oudecheng 6901659849 fix(mcp): 限制 server 名称字符并兜底 tool_name 清洗
OpenAI 要求 function name 匹配 ^[a-zA-Z0-9_-]+$,否则整个请求 400。MCP 工具名由 mcp_{server_key}_{tool_name} 拼成,两个输入源:server_key 用户可控(前端 addMcp/renameMcp 正则校验 + toast),tool_name 由 MCP server 上报(后端 sanitize_tool_name 替换非法字符为 _)。

前端:MCP 卡片头部改用 MapEntryHeader 支持点击重命名,状态指示灯移到上方独立行。顺手修了 MapEntryHeader 进入编辑时 val 未同步当前 name 的 bug。

后端:保留 server_key 和 tool_name 原值用于路由,仅清洗 LLM 可见的 full_name,并在发生清洗时打 warn 日志便于定位。
2026-08-04 11:17:41 +08:00

411 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Shared UI primitives extracted from ConfigPage.tsx
import { useState, type ReactNode } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { inputCls } from './constants';
import type { KnownSource } from './types';
export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
);
}
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button>
);
}
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('');
const add = () => {
const v = input.trim();
if (v && !tags.includes(v)) {
onChange([...tags, v]);
setInput('');
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={t}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]"
>
{t}
<button
onClick={() => onChange(tags.filter((_, j) => j !== i))}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
placeholder="输入后按 Enter"
className={inputCls + ' !text-xs'}
/>
<button
onClick={add}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export function SectionCard({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && (
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">
{subtitle}
</span>
)}
</div>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
);
}
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
export function ModalHeader({
icon,
title,
onClose,
}: {
icon: ReactNode;
title: string;
onClose: () => void;
}) {
return (
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
{icon}
<span className="text-base font-semibold text-[var(--text-primary)]">{title}</span>
<button
onClick={onClose}
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
aria-label="关闭"
title="关闭 (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
);
}
/** 模态框底部按钮区,与项目模态框惯例一致 */
export function ModalFooter({ children }: { children: ReactNode }) {
return (
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
{children}
</div>
);
}
export function SourceEditor({
sources,
onChange,
knownSources,
examplePaths,
showCustom = true,
}: {
sources: string[];
onChange: (s: string[]) => void;
knownSources: KnownSource[];
examplePaths?: string[];
showCustom?: boolean;
}) {
const [customInput, setCustomInput] = useState('');
const knownKeys = new Set(knownSources.map((k) => k.key));
const customPaths = sources.filter((s) => !knownKeys.has(s));
const toggleKnown = (key: string) => {
if (sources.includes(key)) {
onChange(sources.filter((s) => s !== key));
} else {
onChange([...sources, key]);
}
};
const addCustom = () => {
const v = customInput.trim();
if (v && !sources.includes(v)) {
onChange([...sources, v]);
setCustomInput('');
}
};
const removeCustom = (path: string) => {
onChange(sources.filter((s) => s !== path));
};
return (
<div className="space-y-4">
{/* Known sources as toggles */}
<div className="space-y-2">
{knownSources.map((src) => (
<div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
</div>
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
</div>
))}
</div>
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
</div>
{customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{customPaths.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{p}
<button
onClick={() => removeCustom(p)}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'}
/>
<button
onClick={addCustom}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">: {examplePaths.join('、')}</p>
)}
</div>
)}
</div>
);
}
export interface CheckboxListOption {
key: string;
label: string;
description?: string;
/** 可选分组标识,配合 groupBy 使用 */
group?: string;
}
interface CheckboxListProps {
options: CheckboxListOption[];
selected: string[];
onChange: (selected: string[]) => void;
/** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */
extraSelected?: string[];
emptyHint?: string;
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */
groupBy?: (option: CheckboxListOption) => string;
}
/**
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
*/
export function CheckboxList({
options,
selected,
onChange,
extraSelected = [],
emptyHint,
groupBy,
}: CheckboxListProps) {
const toggle = (key: string) =>
onChange(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
const selectedSet = new Set(selected);
const optionsInList = new Set(options.map((o) => o.key));
// 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter((k) => !optionsInList.has(k));
// 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => (
<div className="space-y-1">
{opts.map((option) => (
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
{option.description && (
<div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>
)}
</div>
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
</div>
))}
</div>
);
let body: ReactNode;
if (options.length === 0) {
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>;
} else if (groupBy) {
const groups = new Map<string, CheckboxListOption[]>();
for (const opt of options) {
const g = groupBy(opt);
const arr = groups.get(g) ?? [];
arr.push(opt);
groups.set(g, arr);
}
body = (
<div className="space-y-3">
{Array.from(groups.entries()).map(([g, opts]) => (
<div key={g}>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
{g}
</div>
{renderOptions(opts)}
</div>
))}
</div>
);
} else {
body = renderOptions(options);
}
return (
<div className="space-y-2">
{body}
{extra.length > 0 && (
<div>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
</div>
<div className="flex flex-wrap gap-1.5">
{extra.map((key) => (
<span
key={key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{key}
<button onClick={() => toggle(key)} className="hover:text-white transition-colors">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
</div>
)}
</div>
);
}
export function MapEntryHeader({
name,
onDelete,
onRename,
}: {
name: string;
onDelete: () => void;
onRename?: (n: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={() => {
setEditing(false);
onRename?.(val.trim() || name);
}}
onKeyDown={(e) =>
e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))
}
className={inputCls + ' !py-1 !text-xs max-w-[200px]'}
autoFocus
/>
) : (
<span
className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer"
onClick={() => {
if (onRename) {
setVal(name);
setEditing(true);
}
}}
>
{name}
</span>
)}
<div className="flex-1" />
<button
onClick={onDelete}
className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
}