yggdrasil/pages/
writing_guide.rs1use leptos::prelude::*;
4use leptos_meta::Title;
5use leptos_router::{lazy_route, LazyRoute};
6
7use crate::api::writing_guide::{get_writing_guide, WritingGuideData, SKILL_URL};
8use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
9use crate::components::skeletons::writing_guide_skeleton::WritingGuideSkeleton;
10use crate::components::ui::{Icon, IconName, SegmentedControl, SegmentedItem};
11use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
12use crate::pages::not_found::{NotFound, PageLoadFailed};
13use crate::routes::Route;
14use crate::utils::error::is_not_found;
15
16const CLIENTS: [(&str, &str, &str); 3] = [
18 (
19 "Codex",
20 "~/.codex/config.toml",
21 "https://developers.openai.com/codex/mcp",
22 ),
23 (
24 "OMP",
25 "~/.omp/agent/mcp.json",
26 "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md",
27 ),
28 (
29 "OpenCode",
30 "~/.config/opencode/opencode.json",
31 "https://opencode.ai/docs/mcp-servers/",
32 ),
33];
34
35const FEATURES: [(&str, &str, &str); 4] = [
37 (
38 "可运行代码",
39 "围栏语言名后加 runnable,阅读页提供运行按钮。",
40 "```python runnable\nprint(sum([2, 4, 6]))\n```",
41 ),
42 (
43 "Mermaid 图表",
44 "流程图、时序图与关系图以代码形式书写,跟随明暗主题。",
45 "```mermaid\nflowchart LR\n 草稿 --> 审阅 --> 发布\n```",
46 ),
47 (
48 "数学与化学公式",
49 "行内与独立公式使用 KaTeX 排版,化学式使用 \\ce{}。",
50 "$$E = mc^2$$\n\n$\\ce{2H2 + O2 -> 2H2O}$",
51 ),
52 (
53 "脚注、表格与折叠",
54 "支持脚注、表格、任务列表与折叠内容。",
55 "适用条件见脚注[^note]。\n\n[^note]: 输入在计算期间不变。",
56 ),
57];
58
59pub struct WritingGuideRoute {
61 guide: RouteData<WritingGuideData>,
62}
63
64#[leptos::lazy]
65async fn fetch_guide() -> PageJson<WritingGuideData> {
66 PageJson::new(get_writing_guide().await)
67}
68
69#[lazy_route]
70impl LazyRoute for WritingGuideRoute {
71 fn data() -> Self {
72 Self {
73 guide: route_data(|| (), unit(fetch_guide)),
74 }
75 }
76
77 fn view(this: Self) -> AnyView {
78 let guide = this.guide;
79 view! {
80 <Title text="写作指南 · Yggdrasil"/>
81 <Suspense fallback=|| {
82 view! {
83 <DelayedSkeleton>
84 <WritingGuideSkeleton/>
85 </DelayedSkeleton>
86 }
87 }>
88 {move || Suspend::new(async move {
89 match guide.await {
90 Ok(data) => view! { <WritingGuide data=data/> }.into_any(),
91 Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
92 Err(_) => view! { <PageLoadFailed/> }.into_any(),
93 }
94 })}
95 </Suspense>
96 }
97 .into_any()
98 }
99}
100
101impl WritingGuideRoute {
102 pub fn prefetch_data() {
104 prefetch_route_data((), unit(fetch_guide));
105 }
106}
107
108#[component]
109fn WritingGuide(data: WritingGuideData) -> impl IntoView {
110 let features = FEATURES
111 .iter()
112 .map(|(title, description, code)| {
113 view! {
114 <div class="writing-feature">
115 <p class="writing-feature-title">{*title}</p>
116 <p class="writing-feature-description">{*description}</p>
117 <pre class="writing-feature-code">
118 <code>{*code}</code>
119 </pre>
120 </div>
121 }
122 })
123 .collect_view();
124
125 view! {
126 <article class="writing-page">
127 <WritingHeader/>
128 <ol class="writing-steps page-enter" style="--enter-index: 1">
129 <InstallStep prompt=data.install_prompt.clone()/>
130 <ConnectStep data=data.clone()/>
131 <li class="writing-step">
132 <div class="writing-step-rail">
133 <span class="writing-step-number is-last tabular-nums">"3"</span>
134 </div>
135 <div class="writing-step-body">
136 <h2 class="writing-step-title">"开始写作"</h2>
137 <p class="writing-step-description">
138 "AI 只会保存草稿,发布仍由你在后台确认。可以这样开头:"
139 </p>
140 <blockquote class="writing-example">
141 "让 Agent 使用 yggdrasil-writing,写一篇文章,先保存为草稿。"
142 </blockquote>
143 </div>
144 </li>
145 </ol>
146 <section class="writing-features" aria-labelledby="writing-features-title">
147 <h2 id="writing-features-title" class="writing-section-title">
148 "正文支持的功能"
149 </h2>
150 <div class="writing-feature-grid">{features}</div>
151 </section>
152 <SkillDocument data=data/>
153 </article>
154 }
155}
156
157#[component]
159pub fn WritingHeader() -> impl IntoView {
160 view! {
161 <a class="writing-back" href=Route::About.to_string()>
162 <Icon name=IconName::ArrowLeft size=14/>
163 "关于"
164 </a>
165 <header class="writing-header page-enter">
166 <h1 class="writing-title">"写作指南"</h1>
167 <p class="writing-lead">
168 "把这里的写作方式交给你的 AI:一份技能文件告诉它怎么排版,一条 MCP 连接让它能直接保存草稿。"
169 </p>
170 </header>
171 }
172}
173
174#[component]
176fn InstallStep(prompt: String) -> impl IntoView {
177 view! {
178 <li class="writing-step">
179 <div class="writing-step-rail">
180 <span class="writing-step-number tabular-nums">"1"</span>
181 <span class="writing-step-line" aria-hidden="true"></span>
182 </div>
183 <div class="writing-step-body">
184 <h2 class="writing-step-title">"安装写作技能"</h2>
185 <p class="writing-step-description">
186 "把这句话发给你的 Agent。它会把 SKILL.md 安装到用户目录,之后在任何项目里都能用;Codex、Oh-My-Pi 与 OpenCode 都支持这个目录。"
187 </p>
188 <div class="writing-install">
189 <p class="writing-install-prompt">{prompt.clone()}</p>
190 <div class="writing-install-actions">
191 <CopyButton text=prompt label="复制安装指令" primary=true/>
192 <a
193 class="btn btn-ghost btn-sm"
194 href=SKILL_URL
195 download="SKILL.md"
196 rel="external"
197 >
198 <Icon name=IconName::Download size=15/>
199 "下载 SKILL.md"
200 </a>
201 <a class="btn btn-ghost btn-sm" href="#skill">
202 "先读全文"
203 </a>
204 </div>
205 </div>
206 </div>
207 </li>
208 }
209}
210
211#[component]
213fn ConnectStep(data: WritingGuideData) -> impl IntoView {
214 let selected = RwSignal::new(0usize);
215 let tabs: Vec<SegmentedItem<String>> = CLIENTS
216 .iter()
217 .enumerate()
218 .map(|(i, (name, _, _))| SegmentedItem::new(i.to_string(), *name))
219 .collect();
220 let configs = data.configs;
221 let copy_configs = configs.clone();
222 view! {
223 <li class="writing-step">
224 <div class="writing-step-rail">
225 <span class="writing-step-number tabular-nums">"2"</span>
226 <span class="writing-step-line" aria-hidden="true"></span>
227 </div>
228 <div class="writing-step-body writing-connect">
229 <div class="writing-step-head">
230 <h2 class="writing-step-title">"连接站点"</h2>
231 <McpTokenLink/>
232 </div>
233 <p class="writing-step-description">
234 "选择客户端,把配置合并到它的配置文件,再把令牌占位符换成后台签发的令牌。"
235 </p>
236 <figure class="writing-config">
237 <div class="writing-config-bar">
238 <SegmentedControl
239 items=tabs
240 value=Signal::derive(move || selected.get().to_string())
241 on_change=move |value: String| selected.set(value.parse().unwrap_or(0))
242 aria_label="Agent 客户端"
243 class="writing-config-tabs"
244 />
245 <div class="writing-config-meta">
246 <code>{move || CLIENTS[selected.get()].1}</code>
247 {move || {
249 let current = selected.get();
250 view! {
251 <CopyButton text=copy_configs[current].clone() label="复制配置"/>
252 }
253 }}
254 </div>
255 </div>
256 <pre
257 class="writing-config-code"
258 aria-label=move || format!("{} MCP 配置", CLIENTS[selected.get()].0)
259 >
260 <code>{move || configs[selected.get()].clone()}</code>
261 </pre>
262 <figcaption class="writing-config-foot">
263 <span>
264 "将 YOUR_YGGDRASIL_TOKEN 替换为站点签发的令牌;已有配置请合并,保留其他服务。"
265 </span>
266 <a
267 href=move || CLIENTS[selected.get()].2
268 target="_blank"
269 rel="noopener noreferrer"
270 >
271 {move || format!("{} 文档", CLIENTS[selected.get()].0)}
272 <Icon name=IconName::ArrowUpRight size=13/>
273 </a>
274 </figcaption>
275 </figure>
276 <p class="writing-permissions">
277 "技能可以公开阅读;通过 MCP 保存文章需要 write 令牌,执行代码验证需要 admin 权限。"
278 </p>
279 </div>
280 </li>
281 }
282}
283
284#[component]
286fn SkillDocument(data: WritingGuideData) -> impl IntoView {
287 let expanded = RwSignal::new(false);
288 view! {
289 <section id="skill" class="writing-skill" aria-labelledby="skill-title">
290 <div class="writing-skill-head">
291 <h2 id="skill-title" class="writing-section-title">
292 "技能全文"
293 </h2>
294 <div class="writing-actions">
295 <span class="writing-file-label">"SKILL.md"</span>
296 <CopyButton text=data.source.clone() label="复制全文"/>
297 </div>
298 </div>
299 <div class="writing-skill-layout">
300 <nav
303 class="writing-toc"
304 aria-label="技能目录"
305 on:pointerdown=move |_| expanded.set(true)
306 on:keydown=move |event| {
307 if event.key() == "Enter" {
308 expanded.set(true);
309 }
310 }
311 inner_html=data.toc_html
312 ></nav>
313 <div class="writing-skill-body">
314 <div
315 id="skill-content"
316 class="md-content post-content writing-content"
317 data-expanded=move || expanded.get().to_string()
318 inner_html=data.html
319 ></div>
320 {move || {
321 (!expanded.get())
322 .then(|| {
323 view! {
324 <div class="writing-expand">
325 <button
326 type="button"
327 class="btn btn-secondary"
328 aria-controls="skill-content"
329 aria-expanded="false"
330 on:click=move |_| expanded.set(true)
331 >
332 "展开全文"
333 </button>
334 </div>
335 }
336 })
337 }}
338 </div>
339 </div>
340 </section>
341 }
342}
343
344#[component]
346fn CopyButton(
347 #[prop(into)] text: String,
348 label: &'static str,
349 #[prop(optional)] primary: bool,
350) -> impl IntoView {
351 let result = RwSignal::new(None::<bool>);
352 let on_click = move |_| {
353 #[cfg(target_arch = "wasm32")]
354 {
355 let text = text.clone();
356 leptos::task::spawn_local(async move {
357 let ok = crate::utils::js::copy_text(&text).await;
358 let _ = result.try_set(Some(ok));
359 });
360 }
361 #[cfg(not(target_arch = "wasm32"))]
362 let _ = &text;
363 };
364 view! {
365 <span class="writing-copy-wrap">
366 <button
367 type="button"
368 class=if primary { "btn btn-primary btn-sm" } else { "btn btn-ghost btn-sm" }
369 on:click=on_click
370 >
371 {move || {
372 let icon = if result.get() == Some(true) { IconName::Check } else { IconName::Copy };
373 view! { <Icon name=icon size=15/> }
374 }}
375 {label}
376 </button>
377 <span class="writing-copy-status" role="status" aria-live="polite">
378 {move || match result.get() {
379 Some(true) => "已复制",
380 Some(false) => "复制失败,请下载或手动复制",
381 None => "",
382 }}
383 </span>
384 </span>
385 }
386}
387
388#[component]
390fn McpTokenLink() -> impl IntoView {
391 view! {
392 <a href=Route::Mcp.to_string() class="writing-link">
393 "管理 MCP 令牌"
394 <Icon name=IconName::ArrowUpRight size=14/>
395 </a>
396 }
397}