1236 lines
41 KiB
TypeScript
1236 lines
41 KiB
TypeScript
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>
|
||
);
|
||
}
|