Skip to main content

yggdrasil/pages/
writing_guide.rs

1//! 写作技能的公开阅读页;复用文章渲染器及 MCP 配置生成器。
2use dioxus::prelude::*;
3
4use crate::api::writing_guide::{get_writing_guide, SKILL_SOURCE, SKILL_URL};
5use crate::router::Route;
6
7const CLIENTS: [(&str, &str, &str); 3] = [
8    (
9        "Codex",
10        "~/.codex/config.toml",
11        "https://developers.openai.com/codex/mcp",
12    ),
13    (
14        "OMP",
15        "~/.omp/agent/mcp.json",
16        "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md",
17    ),
18    (
19        "OpenCode",
20        "~/.config/opencode/opencode.json",
21        "https://opencode.ai/docs/mcp-servers/",
22    ),
23];
24
25#[component]
26pub fn WritingGuide() -> Element {
27    let response = use_server_future(get_writing_guide)?;
28    let mut selected = use_signal(|| 0usize);
29    let data = match response.read().as_ref() {
30        Some(Ok(data)) => data.clone(),
31        Some(Err(error)) => return Err(error.clone().into()),
32        None => return rsx! { p { role: "status", "正在载入写作指南…" } },
33    };
34    let current = selected();
35    let (client, config_path, docs_url) = CLIENTS[current];
36
37    rsx! {
38        document::Title { "Agent 写作指南 · Yggdrasil" }
39        article { class: "writing-page",
40            Link { class: "writing-back", to: Route::About {}, "← 关于这里" }
41            header { class: "writing-hero",
42                div {
43                    p { class: "writing-eyebrow", span {} "A FIELD GUIDE FOR AGENTS" }
44                    h1 { "让灵感生根," br {} em { "让 Agent 懂得书写。" } }
45                    p { class: "writing-lead", "一份写作技能,连接你的 AI 与这棵世界树。" br {} "从一段能运行的代码,到一张讲清思路的图。" }
46                    div { class: "writing-actions",
47                        CopyButton { text: SKILL_SOURCE.to_string(), label: "复制 Skill 源码", primary: true }
48                        a { class: "writing-download", href: SKILL_URL, download: "SKILL.md", "下载 SKILL.md ↓" }
49                    }
50                }
51                div { class: "writing-emblem", aria_hidden: "true",
52                    span { class: "writing-emblem-orbit" }
53                    span { class: "writing-emblem-star", "✳" }
54                    span { class: "writing-emblem-code", "{{ }}" }
55                    span { class: "writing-emblem-caption", "WORDS → WORLDS" }
56                }
57            }
58            div { class: "writing-capabilities", aria_label: "支持的写作功能",
59                for (symbol, label) in [("⌘", "可运行代码"), ("⑂", "Mermaid 图表"), ("∑", "数学与化学公式"), ("↗", "MCP 写作") ] {
60                    span { span { aria_hidden: "true", "{symbol}" } "{label}" }
61                }
62            }
63
64            section { id: "connect", class: "writing-connect", aria_labelledby: "connect-title",
65                div { class: "writing-section-heading",
66                    div { p { class: "writing-eyebrow", "01 / GET CONNECTED" } h2 { id: "connect-title", "给你的 Agent,一份写作地图" } }
67                    a { href: "#skill", class: "writing-text-link", "先读技能全文 ↓" }
68                }
69                section { class: "writing-install", aria_labelledby: "install-title",
70                    div { class: "writing-install-heading",
71                        h3 { id: "install-title", span { aria_hidden: "true", "↓" } "一句话,让 Agent 安装" }
72                        span { class: "writing-install-badge", "用户全局" }
73                    }
74                    p { class: "writing-install-description", "复制后发送给你的 Agent,一次安装,在不同项目中使用。" }
75                    p { class: "writing-install-prompt", "{data.install_prompt}" }
76                    div { class: "writing-install-footer",
77                        span { "安装到用户目录,跨项目可用" }
78                        CopyButton { text: data.install_prompt.clone(), label: "复制安装指令", primary: true }
79                    }
80                }
81                div { class: "writing-steps",
82                    div { span { "01" } h3 { "全局安装" } p { "将上方指令发给 Agent,或手动保存到" } code { "~/.agents/skills/yggdrasil-writing/SKILL.md" } p { "Codex、OMP 与 OpenCode 均支持此用户级目录。" } }
83                    div { span { "02" } h3 { "连接站点" } p { "将下方配置合并到客户端配置文件,并替换令牌占位符。" } Link { to: Route::Mcp {}, class: "writing-text-link", "管理 MCP 令牌 ↗" } }
84                    div { span { "03" } h3 { "开始写作" } p { "让 Agent 使用 yggdrasil-writing,写一篇带可运行示例的文章,先保存为草稿。" } }
85                }
86                div { class: "writing-config",
87                    div { class: "writing-config-tabs", role: "group", aria_label: "选择 Agent 客户端",
88                        for (index, (name, _, _)) in CLIENTS.iter().enumerate() {
89                            button {
90                                r#type: "button",
91                                aria_pressed: "{current == index}",
92                                class: if current == index { "is-selected" } else { "" },
93                                onclick: move |_| selected.set(index),
94                                "{name}"
95                            }
96                        }
97                        span { "MCP / STREAMABLE HTTP" }
98                    }
99                    div { class: "writing-config-meta",
100                        code { "{config_path}" }
101                        CopyButton { key: "{client}", text: data.configs[current].clone(), label: "复制配置" }
102                    }
103                    pre { class: "writing-config-code", aria_label: "{client} MCP 配置", code { "{data.configs[current]}" } }
104                    div { class: "writing-config-footer",
105                        p { "将 YOUR_YGGDRASIL_TOKEN 替换为站点签发的令牌。" }
106                        a { href: docs_url, target: "_blank", rel: "noopener noreferrer", "{client} 官方文档 ↗" }
107                    }
108                }
109                p { class: "writing-permissions", "技能可以公开阅读;通过 MCP 保存文章需要站点管理员签发的 write 令牌,执行代码验证需要 admin 权限。已有配置请合并,保留其他服务。" }
110                details { class: "writing-sources",
111                    summary { "技能安装参考" }
112                    a { href: "https://developers.openai.com/codex/skills", target: "_blank", rel: "noopener noreferrer", "Codex Skills ↗" }
113                    a { href: "https://github.com/can1357/oh-my-pi/blob/main/docs/skills.md", target: "_blank", rel: "noopener noreferrer", "OMP Skills ↗" }
114                    a { href: "https://opencode.ai/docs/skills/", target: "_blank", rel: "noopener noreferrer", "OpenCode Skills ↗" }
115                }
116            }
117
118            section { id: "skill", class: "writing-document", aria_labelledby: "skill-title",
119                div { class: "writing-section-heading",
120                    div { p { class: "writing-eyebrow", "02 / THE SKILL" } h2 { id: "skill-title", "Yggdrasil 文章写作" } }
121                    span { class: "writing-file-label", "SKILL.md" }
122                }
123                details { class: "writing-toc",
124                    summary { "本页技能目录" span { aria_hidden: "true", "+" } }
125                    nav { aria_label: "技能目录", dangerous_inner_html: data.toc_html }
126                }
127                div { class: "md-content writing-content", dangerous_inner_html: data.html }
128                footer { class: "writing-document-footer",
129                    p { "准备好了,就让下一篇文章在这里生长。" }
130                    CopyButton { text: SKILL_SOURCE.to_string(), label: "复制 Skill 源码", primary: true }
131                }
132            }
133        }
134    }
135}
136
137/// 仅在剪贴板写入成功后显示成功;失败时保持原文可选中或下载。
138#[component]
139fn CopyButton(text: String, label: &'static str, #[props(default)] primary: bool) -> Element {
140    #[allow(unused_mut)]
141    let mut result = use_signal(|| None::<bool>);
142    rsx! {
143        span { class: "writing-copy-wrap",
144            button {
145                r#type: "button",
146                class: if primary { "writing-copy writing-copy-primary" } else { "writing-copy" },
147                onclick: move |_| {
148                    let text = text.clone();
149                    async move {
150                        #[cfg(target_arch = "wasm32")]
151                        {
152                            use wasm_bindgen::JsCast;
153                            let clipboard = web_sys::window().and_then(|window| {
154                                js_sys::Reflect::get(window.navigator().as_ref(), &"clipboard".into())
155                                    .ok()?
156                                    .dyn_into::<web_sys::Clipboard>()
157                                    .ok()
158                            });
159                            let success = if let Some(clipboard) = clipboard {
160                                wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await.is_ok()
161                            } else { false };
162                            result.set(Some(success));
163                        }
164                        #[cfg(not(target_arch = "wasm32"))]
165                        let _ = text;
166                    }
167                },
168                span { aria_hidden: "true", if result() == Some(true) { "✓" } else { "⧉" } }
169                "{label}"
170            }
171            span { class: "writing-copy-status", role: "status", aria_live: "polite",
172                match result() {
173                    Some(true) => "已复制",
174                    Some(false) => "复制失败,请下载或手动复制",
175                    None => "",
176                }
177            }
178        }
179    }
180}