Skip to main content

yggdrasil/pages/admin/mcp/
configs.rs

1//! 令牌对话框:明文(默认遮罩)、各客户端接入配置与一句话接入指令。
2use super::token_editor::use_close_after_exit;
3use super::{notes_desc, OpenToken};
4use crate::api::mcp_tokens::{get_mcp_client_configs, reveal_mcp_token, McpClientConfigs};
5use crate::components::forms::FormInput;
6use crate::components::ui::{
7    button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconButton, IconName, ModalShell,
8};
9use crate::utils::error::failure_message;
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12use std::sync::Arc;
13
14/// 写入剪贴板;非浏览器目标或被拒绝时返回 `false`。
15pub(super) async fn copy(text: &str) -> bool {
16    #[cfg(target_arch = "wasm32")]
17    {
18        crate::utils::js::copy_text(text).await
19    }
20    #[cfg(not(target_arch = "wasm32"))]
21    {
22        let _ = text;
23        false
24    }
25}
26
27/// 一句话接入:取回令牌明文、生成指令并复制。
28pub(super) async fn copy_agent_prompt(id: String) -> Result<(), String> {
29    let token = reveal_mcp_token(id)
30        .await
31        .map_err(|e| failure_message("读取令牌失败", &e))?
32        .ok_or("这个令牌已失效,请换一个或新建")?;
33    let configs = get_mcp_client_configs(token)
34        .await
35        .map_err(|e| failure_message("生成接入指令失败", &e))?;
36    if copy(&configs.agent_prompt).await {
37        Ok(())
38    } else {
39        Err("浏览器拒绝了剪贴板写入,请在令牌的接入配置里手动复制".into())
40    }
41}
42
43/// 配置标题「Claude Code(.mcp.json / ~/.claude.json)」拆成客户端名与路径。
44fn split_title(title: &str) -> (String, String) {
45    match title.split_once('(') {
46        Some((name, rest)) => (
47            name.trim().to_string(),
48            rest.trim_end_matches(')').trim().to_string(),
49        ),
50        None => (title.to_string(), String::new()),
51    }
52}
53
54/// 令牌明文遮罩:保留前缀与末 4 位。
55fn mask(token: &str) -> String {
56    let tail: String = token
57        .chars()
58        .rev()
59        .take(4)
60        .collect::<Vec<_>>()
61        .into_iter()
62        .rev()
63        .collect();
64    format!("ygg_{}{tail}", "•".repeat(20))
65}
66
67/// 令牌对话框。刚创建时标题为「令牌已创建」,从列表打开时为「接入配置」。
68#[component]
69pub(super) fn TokenDialog(token: OpenToken, #[prop(into)] on_close: Callback<()>) -> impl IntoView {
70    let toast = use_toast();
71    let visible = RwSignal::new(true);
72    let closing = RwSignal::new(false);
73    let shown = RwSignal::new(false);
74    let client = RwSignal::new(0_usize);
75    let configs = RwSignal::new(None::<Result<Arc<McpClientConfigs>, String>>);
76    let plaintext = StoredValue::new(token.plaintext.clone());
77    {
78        let plaintext = token.plaintext.clone();
79        spawn_local(async move {
80            let result = get_mcp_client_configs(plaintext)
81                .await
82                .map(Arc::new)
83                .map_err(|e| failure_message("配置生成失败", &e));
84            let _ = configs.try_set(Some(result));
85        });
86    }
87    // 播完退出动画再卸载。
88    use_close_after_exit(visible, on_close);
89
90    let summary = &token.summary;
91    let expiry = summary.expires_at.map_or("永不过期".to_string(), |e| {
92        format!(
93            "{} 到期",
94            crate::utils::time::ymd(&crate::utils::time::to_local(e), '-')
95        )
96    });
97    let line = format!(
98        "{} · {} · 笔记{} · {expiry}",
99        summary.name,
100        summary.scope.as_str(),
101        notes_desc(summary)
102    );
103    let created = token.created;
104    let title = if created {
105        "令牌已创建"
106    } else {
107        "接入配置"
108    };
109    let copy_flash = move |text: String, what: &'static str| {
110        spawn_local(async move {
111            if copy(&text).await {
112                toast.success(format!("已复制{what}"));
113            } else {
114                toast.error("复制失败,请手动选中复制");
115            }
116        });
117    };
118    let close = move || {
119        closing.set(true);
120        visible.set(false);
121    };
122    let shown_text = Signal::derive(move || {
123        plaintext.with_value(|p| if shown.get() { p.clone() } else { mask(p) })
124    });
125    let ready = move || configs.with(|c| matches!(c, Some(Ok(_))));
126    // 一句话指令跟随当前选中的客户端。
127    let selected_prompt = move || {
128        configs.with(|c| match c {
129            Some(Ok(c)) => c
130                .snippets
131                .get(client.get().min(c.snippets.len().saturating_sub(1)))
132                .map(|s| s.agent_prompt()),
133            _ => None,
134        })
135    };
136
137    view! {
138        <ModalShell
139            visible=visible
140            closing=closing
141            title=title
142            overlay_padding="p-4"
143            panel_class="mcp-dialog mcp-token-dialog"
144        >
145            <div class="mcp-dialog-head">
146                {created
147                    .then(|| {
148                        view! {
149                            <span class="mcp-ok" aria-hidden="true">
150                                <Icon name=IconName::Check size=18/>
151                            </span>
152                        }
153                    })}
154                <div>
155                    <h2>{title}</h2>
156                    <p>{line.clone()}</p>
157                </div>
158                <IconButton
159                    icon=IconName::X
160                    label="关闭"
161                    size=ButtonSize::Sm
162                    onclick=move |_| close()
163                />
164            </div>
165            <div class="mcp-dialog-body">
166                <div>
167                    <label class="mcp-label" for="mcp-plaintext">
168                        "令牌明文"
169                    </label>
170                    <div class="mcp-secret">
171                        <FormInput
172                            id="mcp-plaintext".to_string()
173                            class="field settings-mono"
174                            readonly=true
175                            value=shown_text
176                        />
177                        <IconButton
178                            icon=Signal::derive(move || {
179                                if shown.get() { IconName::EyeOff } else { IconName::Eye }
180                            })
181                            label=Signal::derive(move || {
182                                if shown.get() { "隐藏令牌" } else { "显示令牌" }.to_string()
183                            })
184                            onclick=move |_| shown.update(|s| *s = !*s)
185                        />
186                        <button
187                            class=button_class(ButtonVariant::Primary, ButtonSize::Md)
188                            type="button"
189                            on:click=move |_| copy_flash(plaintext.get_value(), "令牌")
190                        >
191                            "复制令牌"
192                        </button>
193                    </div>
194                    <p class="mcp-warn">
195                        <Icon name=IconName::TriangleAlert size=14/>
196                        <span>
197                            "只写入本地客户端配置,不要提交到 Git 或贴进对话。之后可以在列表里重新查看。"
198                        </span>
199                    </p>
200                </div>
201                <div class="mcp-configs">
202                    <div class="mcp-configs-head">
203                        <h3>"接入配置"</h3>
204                        <span>"已自动填入服务地址和这个令牌"</span>
205                    </div>
206                    {move || match configs.get() {
207                        None => {
208                            view! { <p class="mcp-muted" role="status">"正在生成配置…"</p> }
209                                .into_any()
210                        }
211                        Some(Err(e)) => view! { <p class="mcp-error" role="alert">{e}</p> }.into_any(),
212                        Some(Ok(c)) => {
213                            let count = c.snippets.len();
214                            let current = move || client.get().min(count.saturating_sub(1));
215                            let tabs = c
216                                .snippets
217                                .iter()
218                                .enumerate()
219                                .map(|(i, s)| {
220                                    view! {
221                                        <button
222                                            type="button"
223                                            role="tab"
224                                            aria-selected=move || (i == current()).to_string()
225                                            on:click=move |_| client.set(i)
226                                        >
227                                            {split_title(&s.title).0}
228                                        </button>
229                                    }
230                                })
231                                .collect_view();
232                            view! {
233                                <div class="mcp-clients" role="tablist" aria-label="客户端">
234                                    {tabs}
235                                </div>
236                                {move || {
237                                    c.snippets
238                                        .get(current())
239                                        .cloned()
240                                        .map(|s| {
241                                            let text = s.content.clone();
242                                            let html = s.content_html.clone();
243                                            view! {
244                                                <figure class="mcp-snippet" role="tabpanel">
245                                                    <figcaption>
246                                                        <span>{split_title(&s.title).1}</span>
247                                                        <button
248                                                            class=button_class(
249                                                                ButtonVariant::Ghost,
250                                                                ButtonSize::Xs,
251                                                            )
252                                                            type="button"
253                                                            on:click=move |_| copy_flash(text.clone(), "配置")
254                                                        >
255                                                            <Icon name=IconName::Copy size=13/>
256                                                            "复制配置"
257                                                        </button>
258                                                    </figcaption>
259                                                    <div class="md-content">
260                                                        // 与明文框一致:未点「显示」时配置里的令牌也遮罩,复制仍是完整配置。
261                                                        <pre>
262                                                            <code inner_html=move || {
263                                                                if shown.get() {
264                                                                    html.clone()
265                                                                } else {
266                                                                    plaintext
267                                                                        .with_value(|p| html.replace(p.as_str(), &mask(p)))
268                                                                }
269                                                            }></code>
270                                                        </pre>
271                                                    </div>
272                                                </figure>
273                                            }
274                                        })
275                                }}
276                            }
277                                .into_any()
278                        }
279                    }}
280                </div>
281            </div>
282            <div class="mcp-dialog-foot">
283                <button
284                    class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
285                    type="button"
286                    disabled=move || !ready()
287                    on:click=move |_| {
288                        if let Some(prompt) = selected_prompt() {
289                            copy_flash(prompt, "接入指令,粘贴给 Agent 即可");
290                        }
291                    }
292                >
293                    "复制一句话接入指令"
294                </button>
295                <button
296                    class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
297                    type="button"
298                    on:click=move |_| close()
299                >
300                    "完成"
301                </button>
302            </div>
303        </ModalShell>
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn split_title_separates_client_and_path() {
313        assert_eq!(
314            split_title("Claude Code(.mcp.json / ~/.claude.json)"),
315            ("Claude Code".into(), ".mcp.json / ~/.claude.json".into())
316        );
317        assert_eq!(
318            split_title("Claude Code CLI"),
319            ("Claude Code CLI".into(), String::new())
320        );
321    }
322
323    #[test]
324    fn mask_keeps_only_the_last_four_characters() {
325        let masked = mask("ygg_0123456789abcdef");
326        assert!(masked.starts_with("ygg_•"));
327        assert!(masked.ends_with("cdef"));
328        assert!(!masked.contains("0123"));
329    }
330}