yggdrasil/pages/admin/mcp/
configs.rs1use 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
14pub(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
27pub(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
43fn 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
54fn 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#[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 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 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 <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}