Files
deerflow-code/web-code/components/angentchat/AgentEditPage.tsx
T
2026-05-27 19:43:15 +08:00

1236 lines
41 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.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown,
ArrowLeft,
Bot,
CheckCircle2,
History as HistoryIcon,
Plus,
Save,
Search,
Sparkles,
X,
} from 'lucide-react';
import { Input, Textarea } from 'tdesign-react';
import { useTheme } from '../../contexts/ThemeContext';
import ChatInputComposer from '../agent/ChatInputComposer';
import AgentChatMessageList, { type AgentChatMsg } from './AgentChatMessageList';
import SixStepChatHistory, { type SixStepConversationMeta } from './SixStepChatHistory';
interface AgentDetail {
key: string;
type?: string;
deletable?: boolean;
name: string;
description: string;
structured_prompt: string;
chat_prompt: string;
default_prompt?: string;
default_model: string;
schema_hint: string;
bound_skills: string[];
order: number;
}
interface SkillOption {
key: string;
name: string;
description: string;
type: string;
}
interface ModelOption {
id: string;
name: string;
provider: string;
description?: string;
}
interface ModelsResponse {
default_model_id: string;
models: ModelOption[];
}
interface AgentEditPageProps {
agentKey: string;
onBack?: () => void;
}
interface ConversationMessage extends AgentChatMsg {
created_at?: number;
}
interface ConversationApiRecord {
id: string;
agent_key: string;
title: string;
model?: string | null;
created_at: number;
updated_at: number;
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
created_at?: number;
}>;
}
interface Conversation extends SixStepConversationMeta {
createdAt: number;
model?: string;
messages: ConversationMessage[];
}
interface AgentFormState {
name: string;
description: string;
structured_prompt: string;
chat_prompt: string;
default_model: string;
}
const normalizeConversation = (item: ConversationApiRecord): Conversation => ({
id: item.id,
title: item.title || '未命名对话',
model: item.model || '',
createdAt: Number(item.created_at || Date.now()),
updatedAt: Number(item.updated_at || Date.now()),
messages: (item.messages || []).map((message) => ({
id: message.id,
role: message.role,
content: message.content,
created_at: message.created_at,
})),
});
const sortConversations = (items: Conversation[]) =>
[...items].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
export default function AgentEditPage({ agentKey, onBack }: AgentEditPageProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [detail, setDetail] = useState<AgentDetail | null>(null);
const [skills, setSkills] = useState<SkillOption[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
const [globalDefaultModel, setGlobalDefaultModel] = useState('');
const [form, setForm] = useState<AgentFormState>({
name: '',
description: '',
structured_prompt: '',
chat_prompt: '',
default_model: '',
});
const [boundSkills, setBoundSkills] = useState<string[]>([]);
const [chatModel, setChatModel] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [chatInput, setChatInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const messageListRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollRef = useRef(false);
const historyTimerRef = useRef<number | null>(null);
const openHistoryPanel = useCallback(() => {
if (historyTimerRef.current) {
window.clearTimeout(historyTimerRef.current);
historyTimerRef.current = null;
}
setHistoryVisible(true);
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setHistoryOpen(true);
});
});
}, []);
const closeHistoryPanel = useCallback(() => {
if (historyTimerRef.current) {
window.clearTimeout(historyTimerRef.current);
}
setHistoryOpen(false);
historyTimerRef.current = window.setTimeout(() => {
setHistoryVisible(false);
historyTimerRef.current = null;
}, 300);
}, []);
useEffect(() => {
return () => {
if (historyTimerRef.current) {
window.clearTimeout(historyTimerRef.current);
}
};
}, []);
const showTransientMessage = (message: string) => {
setSaveMsg(message);
window.setTimeout(() => {
setSaveMsg((current) => (current === message ? null : current));
}, 2400);
};
const upsertConversationState = (conversation: Conversation) => {
setConversations((prev) => sortConversations([conversation, ...prev.filter((item) => item.id !== conversation.id)]));
};
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const container = messageListRef.current;
if (!container) {
return;
}
programmaticScrollRef.current = true;
container.scrollTo({
top: container.scrollHeight,
behavior,
});
window.setTimeout(() => {
programmaticScrollRef.current = false;
}, behavior === 'smooth' ? 400 : 120);
}, []);
const fetchConversations = useCallback(async (): Promise<Conversation[]> => {
const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`);
if (!res.ok) {
throw new Error(`加载聊天记录失败:${res.status}`);
}
const raw = (await res.json()) as ConversationApiRecord[];
return sortConversations(raw.map(normalizeConversation));
}, [agentKey]);
const createConversationOnServer = async (
title: string,
model?: string,
messages: ConversationMessage[] = [],
): Promise<Conversation> => {
const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
model: model || undefined,
messages,
}),
});
if (!res.ok) {
throw new Error(`创建对话失败:${res.status}`);
}
return normalizeConversation((await res.json()) as ConversationApiRecord);
};
const updateConversationOnServer = async (
conversationId: string,
payload: {
title?: string;
model?: string;
messages?: ConversationMessage[];
},
): Promise<Conversation> => {
const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${conversationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`更新对话失败:${res.status}`);
}
return normalizeConversation((await res.json()) as ConversationApiRecord);
};
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [agentRes, skillRes, modelRes, conversationList] = await Promise.all([
fetch(`${backUrl}/api/agents/${agentKey}`),
fetch(`${backUrl}/api/skills`),
fetch(`${backUrl}/api/models`),
fetchConversations(),
]);
if (!agentRes.ok) {
throw new Error(`加载智能体失败:${agentRes.status}`);
}
const agentData = (await agentRes.json()) as AgentDetail;
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillOption[]) : [];
const modelData = modelRes.ok
? ((await modelRes.json()) as ModelsResponse)
: { default_model_id: '', models: [] };
if (cancelled) {
return;
}
const effectiveModel = agentData.default_model || modelData.default_model_id || '';
setDetail(agentData);
setSkills(skillData);
setModels(modelData.models || []);
setGlobalDefaultModel(modelData.default_model_id || '');
setForm({
name: agentData.name || '',
description: agentData.description || '',
structured_prompt: agentData.structured_prompt || agentData.default_prompt || '',
chat_prompt: agentData.chat_prompt || agentData.default_prompt || '',
default_model: effectiveModel,
});
setBoundSkills(agentData.bound_skills || []);
setConversations(conversationList);
setActiveId((current) => {
if (current && conversationList.some((item) => item.id === current)) {
return current;
}
return conversationList[0]?.id || null;
});
setChatModel((current) => current || effectiveModel);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : '加载失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [agentKey, fetchConversations]);
useEffect(() => {
if (autoScrollEnabled) {
scrollToBottom(isStreaming ? 'auto' : 'smooth');
}
}, [activeId, autoScrollEnabled, conversations, isStreaming, scrollToBottom]);
const skillNameMap = useMemo(() => {
const map: Record<string, string> = {};
skills.forEach((skill) => {
map[skill.key] = skill.name;
});
return map;
}, [skills]);
const activeConversation = useMemo(
() => conversations.find((item) => item.id === activeId) || null,
[activeId, conversations],
);
useEffect(() => {
if (activeConversation?.model) {
setChatModel(activeConversation.model);
return;
}
if (form.default_model) {
setChatModel(form.default_model);
return;
}
if (globalDefaultModel) {
setChatModel(globalDefaultModel);
}
}, [activeConversation?.id, activeConversation?.model, form.default_model, globalDefaultModel]);
useEffect(() => {
setAutoScrollEnabled(true);
window.setTimeout(() => {
scrollToBottom('auto');
}, 0);
}, [activeId, scrollToBottom]);
const activeModelLabel = useMemo(() => {
const current = models.find((item) => item.id === chatModel);
return current?.name || chatModel || '跟随默认模型';
}, [chatModel, models]);
const removeBoundSkill = (key: string) => {
setBoundSkills((prev) => prev.filter((item) => item !== key));
};
const handleSave = async () => {
if (!detail) {
return;
}
setSaving(true);
setSaveMsg(null);
try {
const res = await fetch(`${backUrl}/api/agents/${detail.key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
description: form.description,
structured_prompt: form.structured_prompt,
chat_prompt: form.chat_prompt,
default_model: form.default_model,
bound_skills: boundSkills,
}),
});
if (!res.ok) {
throw new Error(`保存失败:${res.status}`);
}
const updated = (await res.json()) as AgentDetail;
setDetail(updated);
setForm((prev) => ({
...prev,
name: updated.name || '',
description: updated.description || '',
structured_prompt: updated.structured_prompt || '',
chat_prompt: updated.chat_prompt || '',
default_model: updated.default_model || prev.default_model,
}));
setBoundSkills(updated.bound_skills || []);
setChatModel((current) => current || updated.default_model || '');
showTransientMessage('保存成功');
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
const handleNewConversation = async () => {
try {
const created = await createConversationOnServer('新对话', chatModel || form.default_model);
upsertConversationState(created);
setActiveId(created.id);
openHistoryPanel();
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '创建对话失败');
}
};
const handleDeleteConversation = async (id: string) => {
try {
const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error(`删除对话失败:${res.status}`);
}
setConversations((prev) => {
const next = prev.filter((item) => item.id !== id);
setActiveId((current) => (current === id ? next[0]?.id || null : current));
return next;
});
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '删除对话失败');
}
};
const handleRenameConversation = async (id: string, title: string) => {
try {
const updated = await updateConversationOnServer(id, { title });
upsertConversationState(updated);
if (activeId === id) {
setActiveId(updated.id);
}
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '重命名失败');
}
};
const handleClearAllConversations = async () => {
try {
const res = await fetch(`${backUrl}/api/agents/${agentKey}/conversations`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error(`清空聊天记录失败:${res.status}`);
}
setConversations([]);
setActiveId(null);
showTransientMessage('聊天记录已清空');
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '清空聊天记录失败');
}
};
const handleStop = () => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
setStreamingMsgId(null);
};
const handleMessageListScroll = () => {
const container = messageListRef.current;
if (!container || programmaticScrollRef.current) {
return;
}
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceToBottom <= 80;
setAutoScrollEnabled(nearBottom);
};
const handleResumeAutoScroll = () => {
setAutoScrollEnabled(true);
scrollToBottom('smooth');
};
const handleSend = async () => {
const text = chatInput.trim();
if (!text || !detail) {
return;
}
let conversation = activeConversation;
try {
if (!conversation) {
conversation = await createConversationOnServer(
text.slice(0, 16) || '新对话',
chatModel || form.default_model,
);
upsertConversationState(conversation);
setActiveId(conversation.id);
}
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '创建对话失败');
return;
}
const conversationId = conversation.id;
const previousMessages = conversation.messages.slice();
const title =
previousMessages.length === 0 ? text.slice(0, 16) || conversation.title || '新对话' : conversation.title;
const userMsg: ConversationMessage = {
id: `m-${Date.now()}-u`,
role: 'user',
content: text,
created_at: Date.now() / 1000,
};
const assistantMsg: ConversationMessage = {
id: `m-${Date.now()}-a`,
role: 'assistant',
content: '',
created_at: Date.now() / 1000 + 0.001,
};
let assistantContent = '';
const effectiveModel = chatModel || conversation.model || form.default_model || globalDefaultModel;
const optimisticMessages = [...previousMessages, userMsg, assistantMsg];
setAutoScrollEnabled(true);
upsertConversationState({
...conversation,
title,
model: effectiveModel,
updatedAt: Date.now() / 1000,
messages: optimisticMessages,
});
setActiveId(conversationId);
setChatInput('');
setIsStreaming(true);
setStreamingMsgId(assistantMsg.id);
const controller = new AbortController();
abortRef.current = controller;
const appendDelta = (delta: string) => {
assistantContent += delta;
setConversations((prev) =>
prev.map((item) => {
if (item.id !== conversationId) {
return item;
}
const messages = item.messages.slice();
const idx = messages.findIndex((message) => message.id === assistantMsg.id);
if (idx !== -1) {
messages[idx] = {
...messages[idx],
content: messages[idx].content + delta,
};
}
return { ...item, messages, updatedAt: Date.now() / 1000 };
}),
);
};
try {
const res = await fetch(`${backUrl}/api/agents/${detail.key}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
history: previousMessages.map((item) => ({ role: item.role, content: item.content })),
model: effectiveModel || undefined,
output_format: 'markdown',
}),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`流式请求失败:${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let sepIdx = buffer.indexOf('\n\n');
while (sepIdx !== -1) {
const raw = buffer.slice(0, sepIdx).replace(/\r/g, '');
buffer = buffer.slice(sepIdx + 2);
let eventName = 'message';
let dataStr = '';
raw.split('\n').forEach((line) => {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataStr += line.slice(5).trim();
}
});
if (dataStr) {
try {
const data = JSON.parse(dataStr) as { text?: string; message?: string };
if (eventName === 'delta' && typeof data.text === 'string') {
appendDelta(data.text);
} else if (eventName === 'error' && data.message) {
appendDelta(`\n[错误] ${data.message}`);
}
} catch {
// ignore malformed SSE payloads
}
}
sepIdx = buffer.indexOf('\n\n');
}
}
} catch (err) {
if ((err as Error)?.name !== 'AbortError') {
const message = err instanceof Error ? err.message : '请求失败';
if (!assistantContent) {
assistantContent = `[错误] ${message}`;
}
setConversations((prev) =>
prev.map((item) => {
if (item.id !== conversationId) {
return item;
}
const messages = item.messages.slice();
const idx = messages.findIndex((messageItem) => messageItem.id === assistantMsg.id);
if (idx !== -1) {
messages[idx] = {
...messages[idx],
content: messages[idx].content || `[错误] ${message}`,
};
}
return { ...item, messages, updatedAt: Date.now() / 1000 };
}),
);
}
} finally {
setIsStreaming(false);
setStreamingMsgId(null);
abortRef.current = null;
try {
const persisted = await updateConversationOnServer(conversationId, {
title,
model: effectiveModel || undefined,
messages: [
...previousMessages,
userMsg,
{
...assistantMsg,
content: assistantContent,
},
],
});
upsertConversationState(persisted);
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '保存聊天记录失败');
}
}
};
if (loading) {
return <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>;
}
if (error || !detail) {
return <div className="text-sm text-red-500">{error || '智能体不存在'}</div>;
}
return (
<div
className={`flex h-[calc(100vh-110px)] overflow-hidden rounded-2xl border ${
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-200 bg-white'
}`}
>
{pickerOpen && (
<SkillPickerModal
isDark={isDark}
skills={skills}
selectedKeys={boundSkills}
onClose={() => setPickerOpen(false)}
onConfirm={async (keys) => {
setBoundSkills(keys);
setPickerOpen(false);
if (!detail) {
return;
}
try {
const res = await fetch(`${backUrl}/api/agents/${detail.key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bound_skills: keys }),
});
if (!res.ok) {
throw new Error(`更新技能绑定失败:${res.status}`);
}
const updated = (await res.json()) as AgentDetail;
setBoundSkills(updated.bound_skills || []);
showTransientMessage('技能绑定已更新');
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '技能绑定失败');
}
}}
/>
)}
{historyVisible ? (
<div
className={`overflow-hidden transition-[width,opacity,transform,margin] duration-300 ease-out ${
historyOpen ? 'mr-0 w-[300px] opacity-100 translate-x-0' : 'mr-0 w-0 opacity-0 -translate-x-4'
}`}
>
<SixStepChatHistory
isOpen
isDark={isDark}
conversations={conversations.map((item) => ({
id: item.id,
title: item.title,
updatedAt: item.updatedAt,
}))}
activeId={activeId}
onClose={closeHistoryPanel}
onOpen={openHistoryPanel}
onSelect={(id) => setActiveId(id)}
onNew={() => void handleNewConversation()}
onDelete={(id) => void handleDeleteConversation(id)}
onRename={(id, title) => void handleRenameConversation(id, title)}
onClearAll={() => void handleClearAllConversations()}
/>
</div>
) : null}
<div className="flex h-full min-w-0 flex-1 flex-col">
<div
className={`flex h-14 items-center justify-between border-b px-4 ${
isDark ? 'border-slate-700' : 'border-gray-200'
}`}
>
<div className="flex items-center gap-3">
{!historyOpen && (
<button
type="button"
onClick={openHistoryPanel}
className={`flex h-8 w-8 items-center justify-center rounded-lg ${
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
}`}
title="展开聊天记录"
>
<HistoryIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onBack}
className={`rounded-md p-1.5 ${
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
}`}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 text-white">
<Bot className="h-4 w-4" />
</div>
<div>
<h2
className={`text-base font-semibold leading-5 ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{form.name || detail.key}
</h2>
<p className={`font-mono text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{detail.key}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="hidden items-center gap-2 sm:flex">
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
聊天模型
</span>
<select
value={chatModel}
onChange={(e) => setChatModel(e.target.value)}
className={`h-9 rounded-lg border px-3 text-sm outline-none ${
isDark
? 'border-slate-600 bg-slate-800 text-slate-100'
: 'border-gray-200 bg-white text-gray-800'
}`}
title={`当前模型:${activeModelLabel}`}
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
{saveMsg && (
<span
className={`text-xs ${
saveMsg.includes('成功') || saveMsg.includes('已')
? 'text-emerald-500'
: 'text-red-500'
}`}
>
{saveMsg}
</span>
)}
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-slate-900 px-3 text-sm text-white hover:bg-slate-800 disabled:opacity-60"
>
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存修改'}
</button>
</div>
</div>
<div className="relative min-h-0 flex-1">
<div
ref={messageListRef}
onScroll={handleMessageListScroll}
className="min-h-0 h-full overflow-y-auto px-6 py-5"
>
<AgentChatMessageList
messages={activeConversation?.messages ?? []}
streamingMsgId={isStreaming ? streamingMsgId : null}
agentName={form.name || detail.key}
/>
</div>
{!autoScrollEnabled && (
<button
type="button"
onClick={handleResumeAutoScroll}
className={`absolute bottom-6 left-1/2 inline-flex -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-lg backdrop-blur transition ${
isDark
? 'border-slate-600/80 bg-slate-900/70 text-slate-100 hover:bg-slate-800/80'
: 'border-white/80 bg-white/75 text-gray-800 hover:bg-white'
}`}
>
<ArrowDown className="h-4 w-4" />
下滑到底部
</button>
)}
</div>
<ChatInputComposer
value={chatInput}
onChange={setChatInput}
onSend={() => void handleSend()}
onStop={handleStop}
isSending={isStreaming}
isDarkMode={isDark}
showUpload={false}
placeholder={`给 ${form.name || detail.key} 发送消息...`}
showBorderTop
/>
</div>
<aside
className={`hidden h-full w-[420px] shrink-0 overflow-y-auto border-l xl:block ${
isDark ? 'border-slate-700 bg-slate-900/30' : 'border-gray-200 bg-gray-50/40'
}`}
>
<div className="space-y-5 px-5 py-5">
<section>
<h3 className={`mb-3 text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
基本信息
</h3>
<div className="space-y-3">
<div>
<label className={`mb-1 block text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
名称
</label>
<Input
value={form.name}
onChange={(value) => setForm((prev) => ({ ...prev, name: String(value) }))}
/>
</div>
<div>
<label className={`mb-1 block text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
描述
</label>
<Textarea
autosize={{ minRows: 2, maxRows: 4 }}
value={form.description}
onChange={(value) => setForm((prev) => ({ ...prev, description: String(value) }))}
placeholder="一句话描述这个 MCP 智能体的职责"
/>
</div>
<div>
<label className={`mb-1 block text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
默认模型
</label>
<select
value={form.default_model}
onChange={(e) => setForm((prev) => ({ ...prev, default_model: e.target.value }))}
className={`h-10 w-full rounded-lg border px-3 text-sm outline-none ${
isDark
? 'border-slate-600 bg-slate-800 text-slate-100'
: 'border-gray-200 bg-white text-gray-800'
}`}
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
当前全局默认模型:{globalDefaultModel || '未配置'}
</p>
</div>
</div>
</section>
<section>
<h3 className={`mb-1 text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
结构化输出提示词
</h3>
<p className={`mb-2 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
用于 <code>/api/agents/{'{key}'}/invoke</code> 的结构化输出场景,以及
<code> output_format=structured/mixed </code> 的模型调用。
</p>
<Textarea
autosize={{ minRows: 6, maxRows: 14 }}
value={form.structured_prompt}
onChange={(value) => setForm((prev) => ({ ...prev, structured_prompt: String(value) }))}
placeholder="请输入结构化输出提示词"
/>
</section>
<section>
<h3 className={`mb-1 text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
对话提示词
</h3>
<p className={`mb-2 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<code>/api/agents/{'{key}'}/chat/stream</code> 固定使用这套提示词;当模型调用使用
<code> output_format=markdown </code> 时,也会自动走这套提示词。
</p>
<Textarea
autosize={{ minRows: 6, maxRows: 14 }}
value={form.chat_prompt}
onChange={(value) => setForm((prev) => ({ ...prev, chat_prompt: String(value) }))}
placeholder="请输入对话提示词"
/>
</section>
<section>
<div className="mb-2 flex items-center justify-between">
<h3
className={`flex items-center gap-1.5 text-sm font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
<Sparkles className="h-4 w-4" />
绑定技能
</h3>
<button
type="button"
onClick={() => setPickerOpen(true)}
className={`inline-flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs transition ${
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-emerald-200 bg-white text-emerald-600 hover:bg-emerald-50'
}`}
>
<Plus className="h-3.5 w-3.5" />
添加
</button>
</div>
<p className={`mb-3 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
支持系统技能和文件技能。保存后会整体替换当前绑定列表。
</p>
<div className="flex min-h-[32px] flex-wrap gap-2">
{boundSkills.length === 0 ? (
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>暂未绑定</span>
) : (
boundSkills.map((key) => (
<span
key={key}
className="inline-flex h-7 items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 text-xs text-emerald-700"
>
<span className="flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 text-[10px] text-white">
✓
</span>
{skillNameMap[key] || key}
<button
type="button"
onClick={() => removeBoundSkill(key)}
className="text-emerald-600 hover:text-emerald-800"
>
<X className="h-3 w-3" />
</button>
</span>
))
)}
</div>
</section>
{detail.schema_hint && (
<details className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<summary className="cursor-pointer">结构化输出 schema 提示(只读)</summary>
<pre
className={`mt-2 overflow-x-auto rounded p-2 text-[11px] ${
isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-700'
}`}
>
{detail.schema_hint}
</pre>
</details>
)}
</div>
</aside>
</div>
);
}
interface SkillPickerModalProps {
isDark: boolean;
skills: SkillOption[];
selectedKeys: string[];
onClose: () => void;
onConfirm: (keys: string[]) => void;
}
function SkillPickerModal({
isDark,
skills,
selectedKeys,
onClose,
onConfirm,
}: SkillPickerModalProps) {
const [search, setSearch] = useState('');
const [draft, setDraft] = useState<string[]>(selectedKeys);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return skills;
}
return skills.filter((skill) =>
`${skill.name} ${skill.key} ${skill.description}`.toLowerCase().includes(keyword),
);
}, [search, skills]);
const toggle = (key: string) => {
setDraft((prev) => (prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key]));
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
className={`relative max-h-[80vh] w-[560px] overflow-hidden rounded-[24px] border shadow-2xl ${
isDark
? 'border-slate-700 bg-slate-900 text-slate-100'
: 'border-[#f3d6d6] bg-white text-gray-900'
}`}
>
<div
className={`h-1 w-full ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div className="flex items-center justify-between px-6 pb-3 pt-5">
<div>
<h2 className="text-base font-semibold">选择要绑定的技能</h2>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
已选 {draft.length} 项,共 {skills.length} 个可选技能
</p>
</div>
<button
type="button"
onClick={onClose}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
isDark
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-6 pb-3">
<div
className={`flex items-center gap-2 rounded-xl border px-3 py-2 ${
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-gray-200 bg-white'
}`}
>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-gray-400'}`} />
<input
type="text"
placeholder="搜索技能名称 / 描述..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={`flex-1 bg-transparent text-sm outline-none ${
isDark
? 'text-slate-100 placeholder:text-slate-500'
: 'text-gray-800 placeholder:text-gray-400'
}`}
/>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto px-3 pb-2">
{filtered.length === 0 ? (
<div
className={`px-3 py-10 text-center text-sm ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
没有匹配的技能
</div>
) : (
filtered.map((skill) => {
const checked = draft.includes(skill.key);
return (
<button
key={skill.key}
type="button"
onClick={() => toggle(skill.key)}
className={`mx-1 my-1 flex w-[calc(100%-0.5rem)] items-start gap-3 rounded-xl border px-3 py-2.5 text-left transition ${
isDark
? checked
? 'border-emerald-500/50 bg-emerald-500/10'
: 'border-slate-700 bg-slate-900/40 hover:bg-slate-800/60'
: checked
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-white hover:bg-gray-50'
}`}
>
<div className="mt-0.5 shrink-0">
{checked ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<div
className={`h-4 w-4 rounded-full border ${
isDark ? 'border-slate-600' : 'border-gray-300'
}`}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div
className={`text-sm font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{skill.name}
<span
className={`ml-2 font-mono text-[10px] ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
{skill.key} · {skill.type}
</span>
</div>
<div
className={`mt-0.5 line-clamp-2 text-xs ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}
>
{skill.description || '暂无描述'}
</div>
</div>
</button>
);
})
)}
</div>
<div
className={`flex items-center justify-end gap-3 border-t px-6 pb-4 pt-3 ${
isDark ? 'border-slate-700' : 'border-gray-200'
}`}
>
<button
type="button"
onClick={onClose}
className={`rounded-xl px-4 py-2 text-sm transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
}`}
>
取消
</button>
<button
type="button"
onClick={() => onConfirm(draft)}
className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-emerald-600"
>
确认绑定({draft.length})
</button>
</div>
</div>
</div>
);
}