feat(mcp): 客户端配置代码块添加语法高亮
将配置片段 DTO 从扁平字段重构为 snippets Vec(含 content/content_html), 高亮在服务端经 highlight_code 完成(JSON 配置用 json 语法,CLI 用 bash), 前端以 dangerous_inner_html 渲染并包 .md-content 匹配 highlight.css 作用域。 补 json/bash 语法解析测试,确认两者均产出彩色 span 而非纯文本回退。
This commit is contained in:
parent
53376a0373
commit
1b772f4534
@ -262,50 +262,66 @@ fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// 4 种客户端配置 + CLI 一行命令的序列化 DTO。
|
||||
/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML(供展示)。
|
||||
///
|
||||
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
|
||||
/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控),这里复制字段
|
||||
/// 为可两端共享的 DTO,让 WASM 前端能经 server fn 拿到配置字符串。
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct McpClientConfigs {
|
||||
/// Claude Code(`.mcp.json` / `~/.claude.json`)。`type: "http"`(非 `streamable-http`)。
|
||||
pub claude_code_json: String,
|
||||
/// Cursor(`~/.cursor/mcp.json`)。不带 `type` 字段,按 URL 自动识别 streamable-http。
|
||||
pub cursor_json: String,
|
||||
/// Cline(`cline_mcp_settings.json`)。
|
||||
pub cline_json: String,
|
||||
/// Oh-My-Pi(`~/.pi/agent/mcp.json` / `.pi/mcp.json`)。字段名 `transport`,非 `type`。
|
||||
pub omp_json: String,
|
||||
/// 通用原始 JSON(单 server entry)。
|
||||
pub generic_json: String,
|
||||
/// Claude Code CLI 一行命令。
|
||||
pub claude_cli: String,
|
||||
/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成(spaced CSS class 风格,
|
||||
/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下,
|
||||
/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct McpConfigSnippet {
|
||||
/// 显示标题(含客户端名与目标文件路径)。
|
||||
pub title: String,
|
||||
/// 原始配置文本(供「复制」按钮复制,未高亮)。
|
||||
pub content: String,
|
||||
/// syntect 高亮后的 HTML(`<span>` 序列,无 `<pre>/<code>` 外壳)。
|
||||
pub content_html: String,
|
||||
}
|
||||
|
||||
/// 根据明文令牌生成 4 种客户端配置 + CLI 一行命令。
|
||||
/// 各客户端 MCP 配置片段集合。
|
||||
///
|
||||
/// 配置生成在服务端完成(`crate::mcp::config` 是 server-only 模块),返回给前端展示。
|
||||
/// `APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
|
||||
/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
|
||||
/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
|
||||
/// 原始文本与高亮 HTML 打包为可两端共享的 DTO,让 WASM 前端单次请求即可渲染带高亮的配置块。
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct McpClientConfigs {
|
||||
/// 有序配置片段(标题、原始文本、高亮 HTML)。
|
||||
pub snippets: Vec<McpConfigSnippet>,
|
||||
}
|
||||
|
||||
/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML)。
|
||||
///
|
||||
/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
|
||||
/// server-only),返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
|
||||
#[server]
|
||||
pub async fn get_mcp_client_configs(token: String) -> Result<McpClientConfigs, ServerFnError> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::highlight::server::highlight_code;
|
||||
|
||||
let _admin = get_current_admin_user().await?;
|
||||
let configs = crate::mcp::config::generate_client_configs(
|
||||
let c = crate::mcp::config::generate_client_configs(
|
||||
&crate::mcp::config::base_url_from_env(),
|
||||
&token,
|
||||
);
|
||||
Ok(McpClientConfigs {
|
||||
claude_code_json: configs.claude_code_json,
|
||||
cursor_json: configs.cursor_json,
|
||||
cline_json: configs.cline_json,
|
||||
omp_json: configs.omp_json,
|
||||
generic_json: configs.generic_json,
|
||||
claude_cli: configs.claude_cli,
|
||||
})
|
||||
// (标题, 内容, 语言):JSON 配置用 json 语法高亮,CLI 一行命令用 bash。
|
||||
let entries: [(&str, String, &str); 6] = [
|
||||
("Claude Code(.mcp.json / ~/.claude.json)", c.claude_code_json, "json"),
|
||||
("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
|
||||
("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
|
||||
("Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)", c.omp_json, "json"),
|
||||
("通用(单 server entry)", c.generic_json, "json"),
|
||||
("Claude Code CLI", c.claude_cli, "bash"),
|
||||
];
|
||||
let snippets = entries
|
||||
.into_iter()
|
||||
.map(|(title, content, lang)| McpConfigSnippet {
|
||||
title: title.to_string(),
|
||||
content_html: highlight_code(&content, Some(lang)),
|
||||
content,
|
||||
})
|
||||
.collect();
|
||||
Ok(McpClientConfigs { snippets })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
unreachable!()
|
||||
|
||||
@ -508,4 +508,36 @@ const message = ref('Hello Vue!')
|
||||
let by_ts = highlight_code(code, Some("ts"));
|
||||
assert_eq!(result, by_ts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_code_json_produces_spans() {
|
||||
// MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
|
||||
let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
|
||||
let result = highlight_code(code, Some("json"));
|
||||
assert!(
|
||||
result.contains("<span"),
|
||||
"json 应触发语法高亮, got: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!result.contains(r#"<span class="text plain">"#),
|
||||
"json 不应回退纯文本: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_code_bash_alias_produces_spans() {
|
||||
// MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
|
||||
let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
|
||||
let result = highlight_code(code, Some("bash"));
|
||||
assert!(
|
||||
result.contains("<span"),
|
||||
"bash 别名应触发语法高亮, got: {}",
|
||||
result
|
||||
);
|
||||
// bash 别名与直接传 sh 的输出须一致(别名表 "bash" -> "sh")。
|
||||
let by_sh = highlight_code(code, Some("sh"));
|
||||
assert_eq!(result, by_sh);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ use dioxus::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::mcp_tokens::{
|
||||
create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
|
||||
McpClientConfigs, TokenLifetime,
|
||||
McpClientConfigs, McpConfigSnippet, TokenLifetime,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::components::forms::{FormSelect, INPUT_CLASS};
|
||||
@ -503,12 +503,9 @@ fn ConfigCard() -> Element {
|
||||
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" }
|
||||
} else if let Some(c) = configs() {
|
||||
div { class: "flex flex-col gap-4",
|
||||
ConfigSnippet { title: "Claude Code(.mcp.json / ~/.claude.json)".to_string(), content: c.claude_code_json.clone() }
|
||||
ConfigSnippet { title: "Cursor(~/.cursor/mcp.json)".to_string(), content: c.cursor_json.clone() }
|
||||
ConfigSnippet { title: "Cline(cline_mcp_settings.json)".to_string(), content: c.cline_json.clone() }
|
||||
ConfigSnippet { title: "Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)".to_string(), content: c.omp_json.clone() }
|
||||
ConfigSnippet { title: "通用(单 server entry)".to_string(), content: c.generic_json.clone() }
|
||||
ConfigSnippet { title: "Claude Code CLI".to_string(), content: c.claude_cli.clone() }
|
||||
for snippet in c.snippets.iter() {
|
||||
ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
|
||||
@ -522,17 +519,18 @@ fn ConfigCard() -> Element {
|
||||
/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[component]
|
||||
fn ConfigSnippet(title: String, content: String) -> Element {
|
||||
fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
|
||||
let state: McpPageState = use_context();
|
||||
let mut toast = state.toast;
|
||||
let title = snippet.title.clone();
|
||||
rsx! {
|
||||
div { class: "flex flex-col gap-2",
|
||||
div { class: "flex items-center justify-between",
|
||||
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
|
||||
span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{snippet.title}" }
|
||||
button {
|
||||
class: "{BTN_PRIMARY_SM}",
|
||||
onclick: move |_| {
|
||||
let cc = content.clone();
|
||||
let cc = snippet.content.clone();
|
||||
let tt = title.clone();
|
||||
spawn(async move {
|
||||
copy_clipboard_wasm(&cc).await;
|
||||
@ -542,9 +540,13 @@ fn ConfigSnippet(title: String, content: String) -> Element {
|
||||
"复制"
|
||||
}
|
||||
}
|
||||
pre {
|
||||
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
|
||||
code { "{content}" }
|
||||
// .md-content 是 highlight.css 的作用域钩子(.md-content pre code …);
|
||||
// 仅包裹 pre,不触及标题/按钮,避免 prose 排式泄漏。
|
||||
div { class: "md-content",
|
||||
pre {
|
||||
class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
|
||||
code { dangerous_inner_html: "{snippet.content_html}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user