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 日志便于定位。
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
// 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>
|
||
);
|
||
}
|