代码修改
This commit is contained in:
1 parent
8f5af46ec2
commit
8f75e12871
168 files changed
+76575
No files matched your search
@@ -0,0 +1,70 @@
|
||||
@keyframes breathing {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathingGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-breathing {
|
||||
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll,
|
||||
.agent-file-preview-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-track,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.38);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(96, 165, 250, 0.58);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface AgentConversationListProps {
|
||||
messages: ConversationMessage[];
|
||||
isConnecting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
|
||||
return (
|
||||
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
|
||||
{messages.map((message, index) => {
|
||||
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
|
||||
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
|
||||
const hasStartedGenerating =
|
||||
Boolean(message.content) ||
|
||||
Boolean(message.steps?.length) ||
|
||||
Boolean(message.thinkings?.length) ||
|
||||
Boolean(message.toolCalls?.length);
|
||||
const shouldShowConnecting =
|
||||
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
|
||||
|
||||
return (
|
||||
<SkillCreatorConversationItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isConnecting={shouldShowConnecting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
|
||||
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
|
||||
interface ExpertAgentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
model_name: string;
|
||||
is_draft: boolean;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ExpertTab = 'mine' | 'published';
|
||||
|
||||
export default function AgentExpertCenterPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
|
||||
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
|
||||
const [isListLoading, setIsListLoading] = useState(false);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
|
||||
|
||||
const fetchExpertList = useCallback(async () => {
|
||||
setIsListLoading(true);
|
||||
try {
|
||||
const isDraft = activeTab === 'mine';
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: isDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
|
||||
setAgentList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
|
||||
|
||||
if (isDraft) {
|
||||
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
|
||||
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
let mineFromAll: ExpertAgentItem[] = [];
|
||||
if (allRes.ok) {
|
||||
const allData: unknown = await allRes.json();
|
||||
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
|
||||
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
} else {
|
||||
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
|
||||
}
|
||||
|
||||
const mergedMap = new Map<number, ExpertAgentItem>();
|
||||
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
|
||||
setAgentList(Array.from(mergedMap.values()));
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentList(rawList);
|
||||
} catch (error) {
|
||||
console.error('expert-agents/list接口异常:', error);
|
||||
setAgentList([]);
|
||||
} finally {
|
||||
setIsListLoading(false);
|
||||
}
|
||||
}, [activeTab, authedFetch, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mine' && !currentUser) return;
|
||||
void fetchExpertList();
|
||||
}, [activeTab, currentUser, fetchExpertList]);
|
||||
|
||||
const handleStatusToggle = async (nextPublished: boolean) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
const nextIsDraft = !nextPublished;
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: nextIsDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('更新专家状态失败:', res.status, res.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
|
||||
await fetchExpertList();
|
||||
} catch (error) {
|
||||
console.error('更新专家状态异常:', error);
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAgentSearch = async () => {
|
||||
const parmy = {
|
||||
model_name: 'Qwen3.5-27B',
|
||||
name: '未命名专家',
|
||||
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
|
||||
}
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(parmy),
|
||||
});
|
||||
console.log('创建专家', res);
|
||||
const data: any = await res.json()
|
||||
navigate(`/agent-expert-edit?id=${data.id}`)
|
||||
}
|
||||
|
||||
const delAgentSearch = async (id) => {
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
MessagePlugin.success('操作成功')
|
||||
await fetchExpertList();
|
||||
}
|
||||
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
agentList.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: item.name || '未命名专家',
|
||||
desc: item.description || '暂无描述',
|
||||
status: item.is_draft ? '草稿' : '已发布',
|
||||
avatarSvg: item.avatar_url,
|
||||
raw: item,
|
||||
})),
|
||||
[agentList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
|
||||
</div>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
|
||||
|
||||
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab('mine')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
我的智能体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
发布的智能体
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
|
||||
<Plus className="w-4 h-4" /> 创建智能体
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
{isListLoading ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<Loading loading text="加载专家列表中..." />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<BriefcaseBusiness className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
|
||||
</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
创建你的第一个专家助手,开始高效工作
|
||||
</p>
|
||||
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
|
||||
<Plus className="w-4 h-4" /> 创建第一个专家
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cards.map((item, idx) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{item.avatarSvg ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
|
||||
alt={item.title}
|
||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
|
||||
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
|
||||
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeTab === 'mine' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="查看详情"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent(item.raw);
|
||||
setDetailVisible(true);
|
||||
}}
|
||||
>
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
navigate(`/agent-expert-edit?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<Popconfirm
|
||||
content="确认删除该条数据吗?"
|
||||
onConfirm={() => delAgentSearch(item.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
|
||||
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
|
||||
>
|
||||
进行对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
|
||||
开始对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
visible={detailVisible}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
confirmBtn={null}
|
||||
cancelBtn={null}
|
||||
header="专家详情"
|
||||
width={520}
|
||||
>
|
||||
<div className="py-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{selectedAgent?.avatar_url ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
|
||||
alt={selectedAgent?.name || '专家头像'}
|
||||
className="w-20 h-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
|
||||
)}
|
||||
|
||||
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
|
||||
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
|
||||
</div>
|
||||
<Switch
|
||||
value={!selectedAgent?.is_draft}
|
||||
loading={isStatusUpdating}
|
||||
disabled={isStatusUpdating}
|
||||
onChange={(value) => {
|
||||
void handleStatusToggle(value as boolean);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
|
||||
<MessageCircleMore className="w-4 h-4" />
|
||||
开始聊天
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,130 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Send, Square, Upload } from 'lucide-react';
|
||||
import { Textarea } from 'tdesign-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ChatInputComposerProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSend?: () => void;
|
||||
onStop?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
isDarkMode: boolean;
|
||||
trailingActions?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
showBorderTop?: boolean;
|
||||
showUpload?: boolean;
|
||||
onUploadFile?: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ChatInputComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
placeholder = '输入你的问题...',
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
isDarkMode,
|
||||
trailingActions,
|
||||
readOnly = false,
|
||||
className = '',
|
||||
showBorderTop = false,
|
||||
showUpload = true,
|
||||
onUploadFile,
|
||||
}: ChatInputComposerProps) {
|
||||
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const iconButtonClass = `rounded-lg p-2 transition-colors ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
|
||||
}`;
|
||||
|
||||
const handleKeyDown = (val, context) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
// 阻止默认的换行行为(如果是 Textarea)
|
||||
context.e.preventDefault();
|
||||
|
||||
// 模拟发送请求
|
||||
onSend()
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange?.(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 8 }}
|
||||
disabled={disabled}
|
||||
readonly={readOnly}
|
||||
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
|
||||
isDarkMode
|
||||
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
|
||||
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showUpload && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void onUploadFile?.(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || !onUploadFile}
|
||||
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
|
||||
title="上传文件"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
|
||||
|
||||
<button
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={!isSending && !submitEnabled}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
|
||||
isSending
|
||||
? isDarkMode
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: submitEnabled
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-red-500 text-white hover:bg-red-600'
|
||||
: isDarkMode
|
||||
? 'cursor-not-allowed bg-slate-600 text-slate-400'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500'
|
||||
}`}
|
||||
type="button"
|
||||
title={isSending ? '停止' : '发送'}
|
||||
>
|
||||
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
|
||||
{isSending ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileCode2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface FileSkill {
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: 'md' | 'py';
|
||||
filename: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
|
||||
|
||||
export default function FileSkillsWorkshop() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [skills, setSkills] = useState<FileSkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FileSkill | null>(null);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as FileSkill[];
|
||||
setSkills(data);
|
||||
} catch (err) {
|
||||
console.error('加载技能失败', err);
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return skills;
|
||||
return skills.filter((s) =>
|
||||
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [searchTerm, skills]);
|
||||
|
||||
const handleDelete = async (skill: FileSkill) => {
|
||||
setOpenCardMenuId(null);
|
||||
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-[calc(100vh-140px)] ${
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
|
||||
}`}
|
||||
>
|
||||
{createOpen && (
|
||||
<CreateSkillModal
|
||||
isDark={isDark}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditSkillModal
|
||||
isDark={isDark}
|
||||
skill={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className="mb-7 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-[22px] font-bold tracking-tight ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
技能管理
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`mb-8 rounded-[28px] border px-6 py-5 ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称 / 描述..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
|
||||
isDark
|
||||
? 'text-slate-100 placeholder:text-slate-500'
|
||||
: 'text-gray-800 placeholder:text-[#9ca3af]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/60 text-slate-300'
|
||||
: 'border-[#f5d4d4] bg-white text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
加载失败
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
暂无技能
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
点击右上角「新建技能」上传你的第一个技能。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((skill) => {
|
||||
const isPy = skill.kind === 'py';
|
||||
const Icon = isPy ? FileCode2 : FileText;
|
||||
return (
|
||||
<article
|
||||
key={skill.folder}
|
||||
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
|
||||
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="absolute right-4 top-4 z-20"
|
||||
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) =>
|
||||
prev === skill.folder ? null : skill.folder,
|
||||
);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.folder && (
|
||||
<div
|
||||
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId(null);
|
||||
setEditing(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-800'
|
||||
: 'text-gray-700 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-rose-400 hover:bg-slate-800'
|
||||
: 'text-rose-500 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
|
||||
isPy
|
||||
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
|
||||
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`truncate text-[17px] font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between text-xs ${
|
||||
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
|
||||
isPy
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'bg-[#fff2f2] text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{isPy ? 'Python' : 'Markdown'}
|
||||
</span>
|
||||
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateModalProps {
|
||||
isDark: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (next: File | null) => {
|
||||
setFile(next);
|
||||
if (next && !name.trim()) {
|
||||
const stem = next.name.replace(/\.[^.]+$/, '');
|
||||
setName(stem);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) {
|
||||
setErrMsg('请上传 .md 或 .py 文件');
|
||||
return;
|
||||
}
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name.trim()) fd.append('name', name.trim());
|
||||
if (description.trim()) fd.append('description', description.trim());
|
||||
const res = await fetch(API_BASE, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('新建技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 w-[480px] overflow-hidden rounded-[28px] 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">
|
||||
<h2 className="text-base font-semibold">新建技能</h2>
|
||||
<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="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能文件(.md 或 .py)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : '选择文件'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支持 .md 提示词 或 .py 可执行脚本
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="默认使用上传文件名"
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="技能用途、输入输出说明等"
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errMsg && (
|
||||
<p className="text-xs text-rose-500">{errMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<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-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '提交中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
isDark: boolean;
|
||||
skill: FileSkill;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (name.trim() !== skill.name) fd.append('name', name.trim());
|
||||
if (description !== (skill.description ?? '')) fd.append('description', description);
|
||||
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
console.error('保存技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 w-[480px] overflow-hidden rounded-[28px] 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">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<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="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
覆盖文件(可选,选择后将替换原有内容)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : `当前文件:${skill.filename}`}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
仅支持 .md 或 .py
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<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-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface SkillCreatorConversationItemProps {
|
||||
message: ConversationMessage;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${
|
||||
role === 'assistant'
|
||||
? isDarkMode
|
||||
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
|
||||
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
const doneCount = steps.filter((step) => step.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
<span className="font-medium">执行步骤</span>
|
||||
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{steps.map((step, index) => {
|
||||
const done = step.status === 'done';
|
||||
|
||||
return (
|
||||
<div key={`${step.text}-${index}`} className="flex gap-3">
|
||||
<div className="pt-0.5">
|
||||
{done ? (
|
||||
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
) : (
|
||||
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
|
||||
第 {index + 1} 步:{step.text}
|
||||
</div>
|
||||
{step.subText && (
|
||||
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
|
||||
{step.subText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) setOpen(false);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">思考过程</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const details = {
|
||||
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
|
||||
output: toolCall.output ?? '',
|
||||
};
|
||||
const hasDetails = Boolean(details.input || details.output);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">{toolCall.name}</span>
|
||||
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
|
||||
</button>
|
||||
{hasDetails && (
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{details.input && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
|
||||
</div>
|
||||
)}
|
||||
{details.output && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
|
||||
}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
<MessageAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<MessageAvatar role="assistant" active={!message.content} />
|
||||
<div
|
||||
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
|
||||
}`}
|
||||
>
|
||||
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
|
||||
{message.thinkings?.map((thinking, index) => (
|
||||
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
|
||||
))}
|
||||
{message.toolCalls?.map((toolCall) => (
|
||||
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
|
||||
))}
|
||||
{isConnecting && (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span
|
||||
data-text="正在连接技能服务..."
|
||||
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
|
||||
>
|
||||
正在连接技能服务...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message.content && (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,337 @@
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { Archive, Loader2, MessageSquareText, Plus, RefreshCw, Trash2, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export interface SkillCreatorThread {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ThreadListResponse {
|
||||
threads?: SkillCreatorThread[];
|
||||
archived_threads?: SkillCreatorThread[];
|
||||
}
|
||||
|
||||
interface SkillCreatorThreadSidebarProps {
|
||||
authedFetch: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>;
|
||||
currentThreadId: string;
|
||||
isDarkMode: boolean;
|
||||
isOpen: boolean;
|
||||
isBusy?: boolean;
|
||||
createThreadPayload?: Record<string, unknown>;
|
||||
createButtonText?: string;
|
||||
emptyText?: string;
|
||||
subtitle?: string;
|
||||
threadFilter?: (thread: SkillCreatorThread) => boolean;
|
||||
onClose: () => void;
|
||||
onThreadSelect: (threadId: string) => void;
|
||||
}
|
||||
|
||||
const THREADS_API_URL = `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`;
|
||||
|
||||
const sortThreads = (threads: SkillCreatorThread[]) =>
|
||||
[...threads].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
|
||||
export default function SkillCreatorThreadSidebar({
|
||||
authedFetch,
|
||||
currentThreadId,
|
||||
isDarkMode,
|
||||
isOpen,
|
||||
isBusy = false,
|
||||
createThreadPayload,
|
||||
createButtonText = '新建聊天',
|
||||
emptyText = '暂无记录',
|
||||
subtitle = '切换或管理当前会话',
|
||||
threadFilter,
|
||||
onClose,
|
||||
onThreadSelect,
|
||||
}: SkillCreatorThreadSidebarProps) {
|
||||
const [threads, setThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [archivedThreads, setArchivedThreads] = useState<SkillCreatorThread[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(items: SkillCreatorThread[]) => (threadFilter ? items.filter(threadFilter) : items),
|
||||
[threadFilter],
|
||||
);
|
||||
|
||||
const refreshThreads = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取会话列表失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ThreadListResponse;
|
||||
const nextThreads = Array.isArray(data.threads) ? data.threads : [];
|
||||
const nextArchivedThreads = Array.isArray(data.archived_threads) ? data.archived_threads : [];
|
||||
|
||||
setThreads(sortThreads(applyFilter(nextThreads)));
|
||||
setArchivedThreads(sortThreads(applyFilter(nextArchivedThreads)));
|
||||
} catch (fetchError) {
|
||||
console.error('获取会话列表失败', fetchError);
|
||||
setError('会话列表加载失败,请稍后重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
void refreshThreads();
|
||||
}, [isOpen, refreshThreads]);
|
||||
|
||||
const allThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
|
||||
|
||||
const handleCreateThread = async () => {
|
||||
if (isBusy || isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(THREADS_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: false,
|
||||
is_real_chat: true,
|
||||
...createThreadPayload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const newThread = (await response.json()) as SkillCreatorThread;
|
||||
if (!threadFilter || threadFilter(newThread)) {
|
||||
setThreads((prev) => sortThreads([newThread, ...prev]));
|
||||
}
|
||||
onThreadSelect(String(newThread.id));
|
||||
} catch (createError) {
|
||||
console.error('创建会话失败', createError);
|
||||
setError('新建会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteThread = async (thread: SkillCreatorThread) => {
|
||||
if (isBusy || deletingThreadId === thread.id) return;
|
||||
|
||||
const confirmed = window.confirm(`确认删除会话“${thread.title || `会话 ${thread.id}`}”吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeletingThreadId(thread.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await authedFetch(`${THREADS_API_URL}/${thread.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`删除会话失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const nextThreads = threads.filter((item) => item.id !== thread.id);
|
||||
const nextArchivedThreads = archivedThreads.filter((item) => item.id !== thread.id);
|
||||
setThreads(nextThreads);
|
||||
setArchivedThreads(nextArchivedThreads);
|
||||
|
||||
if (currentThreadId === String(thread.id)) {
|
||||
const fallbackThread = [...nextThreads, ...nextArchivedThreads][0];
|
||||
onThreadSelect(fallbackThread ? String(fallbackThread.id) : '');
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error('删除会话失败', deleteError);
|
||||
setError('删除会话失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderThreadSection = (title: string, items: SkillCreatorThread[], archived = false) => (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{archived ? <Archive className="h-3.5 w-3.5" /> : <MessageSquareText className="h-3.5 w-3.5" />}
|
||||
<span className="text-[11px] font-semibold tracking-[0.14em]">{title}</span>
|
||||
</div>
|
||||
<span className={`text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{items.length}</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className={`rounded-xl border border-dashed px-3 py-4 text-center text-xs ${
|
||||
isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{items.map((thread) => {
|
||||
const isActive = currentThreadId === String(thread.id);
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onThreadSelect(String(thread.id))}
|
||||
className={`group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'border-slate-600 bg-slate-800/90 text-slate-50 shadow-sm'
|
||||
: 'border-red-200 bg-red-50/80 text-gray-900 shadow-sm'
|
||||
: isDarkMode
|
||||
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
||||
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isActive
|
||||
? isDarkMode
|
||||
? 'bg-slate-700 text-slate-200'
|
||||
: 'bg-white text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{archived ? <Archive className="h-4 w-4" /> : <MessageSquareText className="h-4 w-4" />}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{thread.title || `会话 ${thread.id}`}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}}
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
} ${
|
||||
isDarkMode
|
||||
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
||||
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
||||
}`}
|
||||
role="button"
|
||||
aria-label={`删除会话 ${thread.title || thread.id}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteThread(thread);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity,transform,border-color] duration-300 ease-out ${
|
||||
isOpen ? 'w-[286px] translate-x-0 opacity-100' : 'w-0 -translate-x-3 opacity-0 pointer-events-none'
|
||||
} ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-950/90 text-slate-100' : 'border-gray-200 bg-[#fafafa] text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`border-b px-4 py-4 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">聊天记录</h3>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
title="关闭聊天记录"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isCreating}
|
||||
onClick={() => void handleCreateThread()}
|
||||
className={`inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
|
||||
} disabled:cursor-not-allowed disabled:opacity-70`}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{createButtonText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshThreads()}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg border transition ${
|
||||
isDarkMode
|
||||
? 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} ${isLoading ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||
title="刷新会话列表"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${isDarkMode ? 'bg-rose-500/10 text-rose-300' : 'bg-rose-50 text-rose-500'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{isLoading && allThreads.length === 0 ? (
|
||||
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载聊天记录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{renderThreadSection('进行中', threads)}
|
||||
{renderThreadSection('已归档', archivedThreads, true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Crown,
|
||||
Edit,
|
||||
FileCode2,
|
||||
Globe,
|
||||
LineChart,
|
||||
MoreVertical,
|
||||
PenTool,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trash2,
|
||||
Upload,
|
||||
Wand2,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
import { MessagePlugin } from 'tdesign-react';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
highlight: string;
|
||||
relatedCount: number;
|
||||
version: string;
|
||||
inputCount: number;
|
||||
outputCount: number;
|
||||
isNew?: boolean;
|
||||
isMine: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ApiSkill {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface SkillsResponse {
|
||||
items: ApiSkill[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
interface CreateSkillThreadResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
expert_agent_id: number | null;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type CategoryKey = 'all' | 'writing' | 'analysis' | 'decision' | 'validation' | 'workflow';
|
||||
|
||||
const CATEGORY_LABELS: Record<CategoryKey, string> = {
|
||||
all: '全部类型',
|
||||
writing: '编辑类',
|
||||
analysis: '分析类',
|
||||
decision: '决策类',
|
||||
validation: '校验类',
|
||||
workflow: '流程类',
|
||||
};
|
||||
|
||||
const categoryOrder: CategoryKey[] = ['all', 'writing', 'analysis', 'decision', 'validation', 'workflow'];
|
||||
|
||||
const AVATAR_STYLES = [
|
||||
{
|
||||
Icon: Bot,
|
||||
wrapper: 'from-rose-100 via-red-50 to-white text-red-500',
|
||||
glow: 'shadow-red-100/80',
|
||||
ring: 'ring-red-100',
|
||||
orb: 'bg-red-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Wand2,
|
||||
wrapper: 'from-violet-100 via-fuchsia-50 to-white text-violet-500',
|
||||
glow: 'shadow-violet-100/80',
|
||||
ring: 'ring-violet-100',
|
||||
orb: 'bg-violet-500/10',
|
||||
},
|
||||
{
|
||||
Icon: LineChart,
|
||||
wrapper: 'from-cyan-100 via-sky-50 to-white text-sky-500',
|
||||
glow: 'shadow-sky-100/80',
|
||||
ring: 'ring-sky-100',
|
||||
orb: 'bg-sky-500/10',
|
||||
},
|
||||
{
|
||||
Icon: ShieldCheck,
|
||||
wrapper: 'from-emerald-100 via-green-50 to-white text-emerald-500',
|
||||
glow: 'shadow-emerald-100/80',
|
||||
ring: 'ring-emerald-100',
|
||||
orb: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Workflow,
|
||||
wrapper: 'from-amber-100 via-yellow-50 to-white text-amber-500',
|
||||
glow: 'shadow-amber-100/80',
|
||||
ring: 'ring-amber-100',
|
||||
orb: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
Icon: FileCode2,
|
||||
wrapper: 'from-indigo-100 via-blue-50 to-white text-indigo-500',
|
||||
glow: 'shadow-indigo-100/80',
|
||||
ring: 'ring-indigo-100',
|
||||
orb: 'bg-indigo-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Target,
|
||||
wrapper: 'from-pink-100 via-rose-50 to-white text-pink-500',
|
||||
glow: 'shadow-pink-100/80',
|
||||
ring: 'ring-pink-100',
|
||||
orb: 'bg-pink-500/10',
|
||||
},
|
||||
{
|
||||
Icon: Compass,
|
||||
wrapper: 'from-orange-100 via-amber-50 to-white text-orange-500',
|
||||
glow: 'shadow-orange-100/80',
|
||||
ring: 'ring-orange-100',
|
||||
orb: 'bg-orange-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string) =>
|
||||
value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const inferCategory = (skill: ApiSkill): CategoryKey => {
|
||||
const text = `${skill.name} ${skill.description}`.toLowerCase();
|
||||
|
||||
if (/(summary|readme|writer|copy|frontend|narrative|edit|document)/.test(text)) return 'writing';
|
||||
if (/(analysis|audience|risk|search|insight|research|data)/.test(text)) return 'analysis';
|
||||
if (/(plan|decision|goal|strategy|logic|outreach)/.test(text)) return 'decision';
|
||||
if (/(check|validate|review|guard|test|lint)/.test(text)) return 'validation';
|
||||
return 'workflow';
|
||||
};
|
||||
|
||||
const buildVersion = (id: number) => `v${1 + (id % 3)}.${(id % 8) + 1}`;
|
||||
const buildCount = (id: number, offset: number) => 2 + ((id + offset) % 4);
|
||||
const buildRelatedCount = (id: number) => 3 + (id % 4);
|
||||
|
||||
const mapApiSkillToUiSkill = (skill: ApiSkill, type: 'official' | 'mine'): Skill => ({
|
||||
id: String(skill.id),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: inferCategory(skill),
|
||||
highlight: type === 'mine' ? '已接入' : '可添加',
|
||||
relatedCount: buildRelatedCount(skill.id),
|
||||
version: buildVersion(skill.id),
|
||||
inputCount: buildCount(skill.id, 1),
|
||||
outputCount: buildCount(skill.id, 2),
|
||||
isNew: skill.id % 3 === 0,
|
||||
isMine: type === 'mine',
|
||||
});
|
||||
|
||||
const getAvatarStyle = (skillId: string) => AVATAR_STYLES[hashString(skillId) % AVATAR_STYLES.length];
|
||||
|
||||
export default function SkillsWorkshop() {
|
||||
const navigate = useNavigate();
|
||||
const { authedFetch } = useExpertAuthBootstrap();
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'mine'>('all');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
||||
const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||
const [addingSkillId, setAddingSkillId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
|
||||
const [isAiEditing, setIsAiEditing] = useState(false);
|
||||
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const createUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
const fetchByType = async (type: 'official' | 'mine') => {
|
||||
const params = new URLSearchParams({
|
||||
type,
|
||||
keyword: searchTerm.trim(),
|
||||
page: '1',
|
||||
page_size: '100',
|
||||
});
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`加载技能失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as SkillsResponse;
|
||||
return data.items.map((item) => mapApiSkillToUiSkill(item, type));
|
||||
};
|
||||
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const list =
|
||||
selectedFilter === 'all'
|
||||
? await Promise.all([fetchByType('official'), fetchByType('mine')])
|
||||
: [await fetchByType('mine')];
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
const merged = selectedFilter === 'all' ? [...list[0], ...list[1]] : list[0];
|
||||
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
||||
setSkills(deduped);
|
||||
} catch {
|
||||
if (canceled) return;
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
if (!canceled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSkills();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [searchTerm, selectedFilter, authedFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (createMenuRef.current && !createMenuRef.current.contains(target)) {
|
||||
setIsCreateMenuOpen(false);
|
||||
}
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => selectedCategory === 'all' || skill.category === selectedCategory),
|
||||
[selectedCategory, skills],
|
||||
);
|
||||
|
||||
const categoryCounts = useMemo(
|
||||
() =>
|
||||
categoryOrder.reduce<Record<CategoryKey, number>>((acc, category) => {
|
||||
acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
|
||||
return acc;
|
||||
}, {} as Record<CategoryKey, number>),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const mySkillCount = skills.filter((skill) => skill.isMine).length;
|
||||
const officialSkillCount = skills.filter((skill) => !skill.isMine).length;
|
||||
|
||||
const handlePublishSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/skills/publish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
skill_id: parseInt(skillId, 10),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`发布失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('发布技能失败', err);
|
||||
setError('发布技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSkill = (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
const skill = skills.find((item) => item.id === skillId) ?? null;
|
||||
setEditingSkill(skill);
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (skillId: string) => {
|
||||
setOpenCardMenuId(null);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/${skillId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`删除失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.filter((item) => item.id !== skillId));
|
||||
MessagePlugin.success('操作成功');
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSkillFile = async (file: File, skillId: string) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/${skillId}/upload`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setEditingSkill(null);
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOfficialSkill = async (skillId: string) => {
|
||||
if (addingSkillId) return;
|
||||
setAddingSkillId(skillId);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ official_skill_id: parseInt(skillId, 10) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`添加失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
setSkills((prev) => prev.map((item) => (item.id === skillId ? { ...item, isMine: true, highlight: '已接入' } : item)));
|
||||
MessagePlugin.success('添加成功');
|
||||
} catch (err) {
|
||||
console.error('添加技能失败', err);
|
||||
setError('添加技能失败,请稍后重试');
|
||||
} finally {
|
||||
setAddingSkillId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiEditSkill = async (skill: Skill) => {
|
||||
if (isAiEditing) return;
|
||||
setIsAiEditing(true);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: `编辑技能:${skill.name}`,
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) throw new Error('创建会话成功,但未返回会话 id');
|
||||
|
||||
const copyRes = await authedFetch(
|
||||
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${threadId}/copy-skill/${skill.id}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!copyRes.ok) {
|
||||
throw new Error(`复制技能失败: ${copyRes.status} ${copyRes.statusText}`);
|
||||
}
|
||||
|
||||
setEditingSkill(null);
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/skill-creator?id=${threadId}`);
|
||||
} catch (err) {
|
||||
console.error('AI 辅助编辑失败', err);
|
||||
setError('AI 辅助编辑失败,请稍后重试');
|
||||
} finally {
|
||||
setIsAiEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByUpload = async (file: File) => {
|
||||
setIsCreatingByUpload(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('上传技能失败', err);
|
||||
setError('上传技能失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingByUpload(false);
|
||||
if (createUploadInputRef.current) createUploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkillByAgent = async () => {
|
||||
if (isCreatingSkillThread) return;
|
||||
setIsCreatingSkillThread(true);
|
||||
setIsCreateMenuOpen(false);
|
||||
try {
|
||||
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: '新建技能',
|
||||
archived: true,
|
||||
is_real_chat: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!threadRes.ok) {
|
||||
throw new Error(`创建技能会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
||||
}
|
||||
|
||||
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
||||
const threadId = threadData?.id;
|
||||
if (!threadId) {
|
||||
throw new Error('创建技能会话成功,但未返回会话 id');
|
||||
}
|
||||
|
||||
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
||||
navigate(`/qa-center/skill-creator?id=${threadId}`);
|
||||
} catch (createError) {
|
||||
console.error('创建技能会话失败', createError);
|
||||
setError('创建技能会话失败,请稍后重试');
|
||||
} finally {
|
||||
setIsCreatingSkillThread(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDarkMode ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'}`}>
|
||||
{editingSkill && (
|
||||
<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) setEditingSkill(null);
|
||||
}}
|
||||
>
|
||||
<div className={`relative w-[440px] overflow-hidden rounded-[28px] border shadow-2xl ${isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-[#f3d6d6] bg-white text-gray-900'}`}>
|
||||
<div className="flex items-center justify-between px-6 pb-4 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${isDarkMode ? '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>
|
||||
|
||||
<p className={`px-6 pb-4 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
选择编辑方式:<span className={`font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>{editingSkill.name}</span>
|
||||
</p>
|
||||
|
||||
<div className={`mx-6 border-t ${isDarkMode ? 'border-slate-700' : 'border-[#f4e4e4]'}`} />
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAiEditing}
|
||||
onClick={() => void handleAiEditSkill(editingSkill)}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isAiEditing ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isAiEditing ? '处理中...' : 'AI 辅助编辑'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>通过对话开启 AI 帮助您优化技能内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && editingSkill) {
|
||||
void handleUploadSkillFile(file, editingSkill.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isUploading ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-lg shadow-orange-400/30 ring-1 ring-white/20">
|
||||
<Upload className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{isUploading ? '上传中...' : '上传新版覆盖'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>上传 `.md` 或 `.zip` 文件替换当前内容</p>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-end gap-3 px-6 pb-5 pt-1 ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSkill(null)}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${isDarkMode ? 'hover:bg-slate-800 hover:text-slate-200' : 'hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className={`mb-7 ${isDarkMode ? '' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1 className={`text-[22px] font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>技能工坊</h1>
|
||||
<p className={`mt-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>统一管理系统中的技能资产,支持发现、接入、发布与编辑。</p>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能体
|
||||
</button>
|
||||
|
||||
{isCreateMenuOpen && (
|
||||
<div className={`absolute right-0 top-[calc(100%+10px)] z-20 w-60 rounded-2xl border py-2 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingSkillThread}
|
||||
onClick={() => void handleCreateSkillByAgent()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingSkillThread ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingSkillThread ? '创建中...' : '通过 Agent 构建'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>通过对话构建和训练 AI 技能</p>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={createUploadInputRef}
|
||||
type="file"
|
||||
accept=".md,.zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void handleCreateSkillByUpload(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingByUpload}
|
||||
onClick={() => createUploadInputRef.current?.click()}
|
||||
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingByUpload ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingByUpload ? '上传中...' : '上传技能文件'}</p>
|
||||
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>导入 `.md` 或 `.zip` 文件</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`mb-8 rounded-[28px] border px-6 py-6 ${isDarkMode ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-3 rounded-2xl border px-4 py-3 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<Search className={`h-4 w-4 ${isDarkMode ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${isDarkMode ? 'text-slate-100 placeholder:text-slate-500' : 'text-gray-800 placeholder:text-[#9ca3af]'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`inline-flex rounded-2xl border p-1 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('all')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'all' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
全部类型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFilter('mine')}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'mine' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
||||
>
|
||||
我的技能
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#475467]'}`}>按类型筛选:</span>
|
||||
{categoryOrder.slice(1).map((category) => {
|
||||
const active = selectedCategory === category;
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(active ? 'all' : category)}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
active
|
||||
? 'border-[#ffb5b5] bg-[#fff5f5] text-[#ef4444]'
|
||||
: isDarkMode
|
||||
? 'border-slate-700 bg-slate-900/40 text-slate-300 hover:border-slate-600'
|
||||
: 'border-[#f3d0d0] bg-white text-[#667085] hover:border-[#ffcccc] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div> */}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${isDarkMode ? 'border-slate-800 bg-slate-900/60 text-slate-300' : 'border-[#f5d4d4] bg-white text-[#667085]'}`}>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>加载失败</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
||||
<div className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${isDarkMode ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'}`}>
|
||||
<Search className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>未找到匹配的技能</h3>
|
||||
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>尝试调整搜索词或切换筛选条件。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const avatar = getAvatarStyle(skill.id);
|
||||
const AvatarIcon = avatar.Icon;
|
||||
const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
|
||||
|
||||
return (
|
||||
<article
|
||||
key={skill.id}
|
||||
className={`group relative rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
{!skill.isMine && selectedFilter === 'all' && (
|
||||
<div className="absolute right-4 top-4 z-20 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
disabled={addingSkillId === skill.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleAddOfficialSkill(skill.id);
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm transition hover:bg-emerald-600 ${addingSkillId === skill.id ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skill.isMine && (
|
||||
<div className="absolute right-4 top-4 z-20" ref={openCardMenuId === skill.id ? cardMenuRef : null}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) => (prev === skill.id ? null : skill.id));
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-lg transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.id && (
|
||||
<div className={`absolute right-0 top-[calc(100%+6px)] z-30 w-40 rounded-2xl border py-1 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handlePublishSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-emerald-400 hover:bg-slate-800' : 'text-emerald-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
发布到全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEditSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-amber-600 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Edit className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteSkill(skill.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-rose-400 hover:bg-slate-800' : 'text-rose-500 hover:bg-[#fff6f6]'}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${avatar.wrapper} shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ${avatar.glow} ring-1 ${avatar.ring}`}>
|
||||
<div className={`absolute inset-0 rounded-2xl ${avatar.orb}`} />
|
||||
<AvatarIcon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className={`text-[17px] font-semibold ${skill.isMine ? 'text-[#111827]' : isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>{skill.name}</h3>
|
||||
<span className="inline-flex items-center rounded-full bg-[#fff2f2] px-2.5 py-1 text-xs font-medium text-[#ef4444]">
|
||||
{ skill.isMine ? '我的' : '官方' }
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 line-clamp-3 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
export interface AgentStepItem {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AgentParsed {
|
||||
steps: AgentStepItem[];
|
||||
thinkingMarkdown: string;
|
||||
answerMarkdown: string;
|
||||
thinkingComplete: boolean;
|
||||
}
|
||||
|
||||
const STEP_RE = /<step\s+title="([^"]+)">([\s\S]*?)<\/step>/g;
|
||||
const ANSWER_RE = /<answer>([\s\S]*?)<\/answer>/;
|
||||
const THINKING_COMMENT_RE = /<!--\s*thinking:\s*([\s\S]*?)\s*-->/i;
|
||||
const WAITING_PREFIX = '仍在等待模型首个 token...';
|
||||
|
||||
const stripCodeFence = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
|
||||
if (match) return match[1].trim();
|
||||
return trimmed.replace(/^```(?:json)?\s*/i, '').trim();
|
||||
};
|
||||
|
||||
const stripThinkTags = (
|
||||
text: string,
|
||||
): { remainder: string; thinkBlock: string; closed: boolean } => {
|
||||
const start = text.indexOf('<think>');
|
||||
if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
|
||||
const after = text.slice(start + '<think>'.length);
|
||||
const endIdx = after.indexOf('</think>');
|
||||
if (endIdx === -1) {
|
||||
return {
|
||||
remainder: text.slice(0, start),
|
||||
thinkBlock: after,
|
||||
closed: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
remainder: text.slice(0, start) + after.slice(endIdx + '</think>'.length),
|
||||
thinkBlock: after.slice(0, endIdx),
|
||||
closed: true,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeStep = (steps: AgentStepItem[], nextStep: AgentStepItem) => {
|
||||
const existingIndex = steps.findIndex((step) => step.title === nextStep.title);
|
||||
if (existingIndex === -1) {
|
||||
steps.push(nextStep);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = steps[existingIndex];
|
||||
const nextIsWaiting = nextStep.content.startsWith(WAITING_PREFIX);
|
||||
const existingLines = existing.content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const stableLines = existingLines.filter((line) => !line.startsWith(WAITING_PREFIX));
|
||||
|
||||
if (nextIsWaiting) {
|
||||
const mergedLines = stableLines.length > 0 ? [...stableLines, nextStep.content] : [nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: mergedLines.join('\n'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLines = stableLines.includes(nextStep.content)
|
||||
? stableLines
|
||||
: [...stableLines, nextStep.content];
|
||||
steps[existingIndex] = {
|
||||
...existing,
|
||||
content: normalizedLines.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
const extractSteps = (text: string): { steps: AgentStepItem[]; remainder: string } => {
|
||||
const steps: AgentStepItem[] = [];
|
||||
const remainder = text.replace(STEP_RE, (_, title: string, content: string) => {
|
||||
mergeStep(steps, { title: title.trim(), content: content.trim() });
|
||||
return '';
|
||||
});
|
||||
return { steps, remainder };
|
||||
};
|
||||
|
||||
const extractTaggedSection = (
|
||||
text: string,
|
||||
re: RegExp,
|
||||
): { value: string; remainder: string } => {
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', remainder: text };
|
||||
return {
|
||||
value: match[1].trim(),
|
||||
remainder: text.replace(match[0], '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const extractJsonStringField = (
|
||||
text: string,
|
||||
field: string,
|
||||
): { value: string; complete: boolean; matched: boolean } => {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*"`);
|
||||
const match = re.exec(text);
|
||||
if (!match) return { value: '', complete: false, matched: false };
|
||||
let i = match.index + match[0].length;
|
||||
let value = '';
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === '\\') {
|
||||
if (i + 1 >= text.length) return { value, complete: false, matched: true };
|
||||
const next = text[i + 1];
|
||||
switch (next) {
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case '"':
|
||||
value += '"';
|
||||
break;
|
||||
case '\\':
|
||||
value += '\\';
|
||||
break;
|
||||
case '/':
|
||||
value += '/';
|
||||
break;
|
||||
case 'b':
|
||||
value += '\b';
|
||||
break;
|
||||
case 'f':
|
||||
value += '\f';
|
||||
break;
|
||||
case 'u':
|
||||
if (i + 5 < text.length) {
|
||||
const hex = text.slice(i + 2, i + 6);
|
||||
value += String.fromCharCode(parseInt(hex, 16));
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
default:
|
||||
value += next;
|
||||
}
|
||||
i += 2;
|
||||
} else if (ch === '"') {
|
||||
return { value, complete: true, matched: true };
|
||||
} else {
|
||||
value += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { value, complete: false, matched: true };
|
||||
};
|
||||
|
||||
const extractItems = (text: string): unknown[] => {
|
||||
const re = /"items"\s*:\s*\[/;
|
||||
const match = re.exec(text);
|
||||
if (!match) return [];
|
||||
let i = match.index + match[0].length;
|
||||
const out: unknown[] = [];
|
||||
while (i < text.length) {
|
||||
while (i < text.length && /[\s,]/.test(text[i])) i++;
|
||||
if (i >= text.length) break;
|
||||
if (text[i] === ']') break;
|
||||
if (text[i] !== '{') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let escape = false;
|
||||
let j = i;
|
||||
for (; j < text.length; j++) {
|
||||
const c = text[j];
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (inStr) {
|
||||
if (c === '\\') escape = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) break;
|
||||
try {
|
||||
out.push(JSON.parse(text.slice(i, j)));
|
||||
} catch {
|
||||
// ignore partial item
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const itemToMarkdown = (item: unknown, idx: number): string => {
|
||||
if (item == null) return `${idx + 1}.`;
|
||||
if (typeof item !== 'object') return `${idx + 1}. ${String(item)}`;
|
||||
const o = item as Record<string, unknown>;
|
||||
const title =
|
||||
(typeof o.title === 'string' && o.title) ||
|
||||
(typeof o.expected_behavior === 'string' && o.expected_behavior) ||
|
||||
(typeof o.name === 'string' && o.name) ||
|
||||
`条目 ${idx + 1}`;
|
||||
const body =
|
||||
(typeof o.content === 'string' && o.content) ||
|
||||
(typeof o.reason === 'string' && o.reason) ||
|
||||
'';
|
||||
|
||||
const lines: string[] = [`**${idx + 1}. ${title}**`];
|
||||
if (body) lines.push(body);
|
||||
|
||||
if (typeof o.expected_behavior === 'string' && typeof o.driving_factor === 'string') {
|
||||
if (!o.title) {
|
||||
lines.length = 0;
|
||||
lines.push(`**${idx + 1}. ${o.expected_behavior}**`);
|
||||
}
|
||||
lines.push(`_驱动因素_:${o.driving_factor}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(o.references) && o.references.length > 0) {
|
||||
const refs = (o.references as unknown[])
|
||||
.filter((x) => typeof x === 'string' || typeof x === 'number')
|
||||
.map((x) => `\`${String(x)}\``)
|
||||
.join(' ');
|
||||
if (refs) lines.push(`引用:${refs}`);
|
||||
}
|
||||
|
||||
return lines.join('\n\n');
|
||||
};
|
||||
|
||||
const itemsToMarkdown = (items: unknown[]): string =>
|
||||
items.map((item, idx) => itemToMarkdown(item, idx)).join('\n\n---\n\n');
|
||||
|
||||
export const parseAgentResponse = (raw: string): AgentParsed => {
|
||||
if (!raw) {
|
||||
return {
|
||||
steps: [],
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: '',
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stripped = stripThinkTags(raw);
|
||||
const stepsResult = extractSteps(stripped.remainder);
|
||||
let body = stripCodeFence(stepsResult.remainder);
|
||||
const extraThinking = stripped.thinkBlock;
|
||||
let thinkingComplete = stripped.closed;
|
||||
|
||||
const answerSection = extractTaggedSection(body, ANSWER_RE);
|
||||
body = answerSection.remainder;
|
||||
const thinkingCommentSection = extractTaggedSection(body, THINKING_COMMENT_RE);
|
||||
body = thinkingCommentSection.remainder;
|
||||
|
||||
if (body) {
|
||||
const start = body.indexOf('{');
|
||||
if (start !== -1) body = body.slice(start);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
try {
|
||||
const obj = JSON.parse(body) as Record<string, unknown>;
|
||||
if (obj && typeof obj === 'object') {
|
||||
const thinking = typeof obj.thinking === 'string' ? obj.thinking : '';
|
||||
const items = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
|
||||
const answerMarkdown = items.length > 0 ? itemsToMarkdown(items) : answerSection.value;
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinking, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown,
|
||||
thinkingComplete: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingField = extractJsonStringField(body, 'thinking');
|
||||
const items = extractItems(body);
|
||||
|
||||
if (thinkingField.complete) thinkingComplete = true;
|
||||
|
||||
let fallbackAnswer = answerSection.value || body.trim();
|
||||
if (items.length > 0) {
|
||||
fallbackAnswer = itemsToMarkdown(items);
|
||||
}
|
||||
|
||||
if (!thinkingField.matched && items.length === 0 && !extraThinking) {
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: '',
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
steps: stepsResult.steps,
|
||||
thinkingMarkdown: [extraThinking, thinkingField.value, thinkingCommentSection.value]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
answerMarkdown: fallbackAnswer,
|
||||
thinkingComplete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,736 @@
|
||||
# Agent 组件开发问答(Q&A)
|
||||
|
||||
> 目录范围:`src/components/agent`
|
||||
>
|
||||
> 涵盖文件:
|
||||
> - `AgentExpertCenterPage.tsx`
|
||||
> - `AgentExpertEditPage.tsx`
|
||||
> - `AgentChatPage.tsx`
|
||||
> - `SkillCreatorPage.tsx`
|
||||
> - `SkillsWorkshop.tsx`
|
||||
> - `SkillCreatorThreadSidebar.tsx`
|
||||
> - `AgentConversationList.tsx`
|
||||
> - `SkillCreatorConversationItem.tsx`
|
||||
> - `ChatInputComposer.tsx`
|
||||
> - `AgentChatPage.css`
|
||||
|
||||
---
|
||||
|
||||
## Q1:这个目录整体是干什么的?
|
||||
|
||||
**A:这是“专家 Agent + 技能工坊 + 技能创建对话”的前端 UI 组件集合。**
|
||||
|
||||
- `AgentExpertCenterPage`:专家列表入口(我的专家/发布专家)。
|
||||
- `AgentExpertEditPage`:专家配置编辑页(左侧对话预览 + 右侧配置)。
|
||||
- `AgentChatPage`:某个专家的真实聊天页(含线程、文件上传、流式回答)。
|
||||
- `SkillsWorkshop`:技能资产中心(搜索/筛选/接入/发布/删除/编辑)。
|
||||
- `SkillCreatorPage`:技能创建/改写专用对话页(支持文件树预览和保存弹窗)。
|
||||
- `SkillCreatorThreadSidebar`:复用的会话侧边栏(获取线程、新建、删除、切换)。
|
||||
- `AgentConversationList` + `SkillCreatorConversationItem`:统一消息列表与消息卡片渲染。
|
||||
- `ChatInputComposer`:统一输入框(发送/停止/上传)。
|
||||
- `AgentChatPage.css`:`AgentChatPage` 专用动画和滚动条样式。
|
||||
|
||||
---
|
||||
|
||||
## Q2:核心共享数据模型有哪些?
|
||||
|
||||
**A:对话层主要围绕 `ConversationMessage`。**
|
||||
|
||||
- 定义位置:`AgentConversationList.tsx`
|
||||
- 字段:
|
||||
- `id`: 消息唯一标识。
|
||||
- `role`: `'user' | 'assistant'`
|
||||
- `content`: 文本内容(markdown 渲染)。
|
||||
- `steps`: 思考步骤列表(数据来自 `data-thinking-step`)。
|
||||
- `thinkings`: 推理内容列表(来自 `reasoning-*` 事件)。
|
||||
- `toolCalls`: 工具调用(输入输出)列表。
|
||||
|
||||
**A:文件树使用 `FileNode`。**
|
||||
|
||||
- 在 `AgentChatPage`、`SkillCreatorPage` 内部定义并使用。
|
||||
- 字段:`name/path/type/size/extension/children`。
|
||||
|
||||
**A:线程侧边栏使用 `SkillCreatorThread`。**
|
||||
|
||||
- 定义位置:`SkillCreatorThreadSidebar.tsx`
|
||||
- 字段:`id/title/archived/expert_agent_id/created_at/updated_at`。
|
||||
|
||||
---
|
||||
|
||||
## Q3:整个目录的“数据流主干”怎么走?
|
||||
|
||||
**A:可以看成 3 条主链路。**
|
||||
|
||||
### 1) 专家链路(Center -> Edit -> Chat)
|
||||
|
||||
1. `AgentExpertCenterPage` 拉专家列表(我的/发布)。
|
||||
2. 点击“编辑”跳 `AgentExpertEditPage`,加载专家详情 + 工具 + 技能绑定状态。
|
||||
3. 点击“进行对话”跳 `AgentChatPage`,加载该专家详情和对应会话消息。
|
||||
|
||||
### 2) 技能链路(Workshop -> SkillCreator)
|
||||
|
||||
1. `SkillsWorkshop` 拉技能列表(official/mine),本地映射成 UI `Skill`。
|
||||
2. 新建技能:创建会话后跳 `SkillCreatorPage?id=threadId`。
|
||||
3. AI 辅助编辑已有技能:先创建线程,再 copy skill 到线程,再跳转 `SkillCreatorPage`。
|
||||
|
||||
### 3) 对话链路(输入 -> SSE -> UI -> 持久化)
|
||||
|
||||
1. `ChatInputComposer` 触发 `onSend`。
|
||||
2. 页面组件(`AgentChatPage` 或 `SkillCreatorPage`)执行 `handleSendMessage`。
|
||||
3. 先写入用户消息 + assistant 占位消息。
|
||||
4. 调流式接口读取 SSE:
|
||||
- `data-thinking-step` -> 更新 `steps`
|
||||
- `reasoning-start/delta/end` -> 更新 `thinkings`
|
||||
- `tool-input-available/tool-output-available` -> 更新 `toolCalls`
|
||||
- `text-delta` -> 追加 `content`
|
||||
5. 完成后将 assistant 结果再次 POST 到消息接口持久化。
|
||||
|
||||
---
|
||||
|
||||
## Q4:`AgentExpertCenterPage` 组件是干什么的?
|
||||
|
||||
**A:专家资产“总览入口”,负责展示列表和基础操作入口。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 展示“我的专家”和“发布专家”两种视图。
|
||||
- 打开专家详情弹窗(支持发布状态切换)。
|
||||
- 提供“编辑”和“进行对话”跳转入口。
|
||||
|
||||
### 数据来源与接口
|
||||
|
||||
- 列表查询:
|
||||
- `POST /api/v1/expert-agents/list`(按 `is_draft`)
|
||||
- 我的专家场景还会额外 `GET /api/v1/expert-agents` 做合并去重。
|
||||
- 状态切换:
|
||||
- `PATCH /api/v1/expert-agents/{id}`(更新 `is_draft`)
|
||||
|
||||
### 数据流
|
||||
|
||||
- `activeTab` 改变 -> `fetchExpertList` 触发 -> 更新 `agentList`。
|
||||
- `agentList` -> `cards(useMemo)` -> 渲染卡片。
|
||||
- 卡片按钮:
|
||||
- 编辑 -> `navigate('/qa-center/agent-expert-edit?id=...')`
|
||||
- 对话 -> `navigate('/qa-center/agent-chat?id=...')`
|
||||
|
||||
### 维护注意
|
||||
|
||||
- “创建专家”按钮目前是 UI 壳子,未接创建接口。
|
||||
- “删除”按钮当前仅 `console.log`,未接真实删除逻辑。
|
||||
|
||||
---
|
||||
|
||||
## Q5:`AgentExpertEditPage` 组件是干什么的?
|
||||
|
||||
**A:专家配置编辑工作台,同时内置一个“测试对话区”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 左侧:和该专家进行流式对话(非真实聊天线程,使用 no_real 线程)。
|
||||
- 右侧:编辑专家基础配置(名称、模型、描述、系统提示词)。
|
||||
- 管理工具绑定、技能绑定(弹窗操作)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 对话相关:`messages/inputValue/threadId/isStreaming`
|
||||
- 配置相关:`formValues`
|
||||
- 工具相关:`toolOptions/selectedTools/pendingTools/boundToolIdMap`
|
||||
- 技能相关:`skillOptions/selectedSkills/boundSkillMap/skillKeyword`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 保存专家:`PUT /api/v1/expert-agents/{agentId}`
|
||||
- 绑定工具:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/tools`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
- 绑定技能:
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 添加:`POST /api/v1/expert-agents/{agentId}/skills`
|
||||
- 删除:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
- 测试对话:
|
||||
- 线程:`GET /api/v1/expert-agent-chat/threads/no_real/{agentId}`
|
||||
- 用户消息:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
### 数据流(对话)
|
||||
|
||||
- 用户发送 -> append user msg -> 保证 threadId -> 保存用户消息 -> append assistant 占位 -> SSE 增量更新。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 右侧 `preview` 区底部 `ChatInputComposer` 是只读展示,不参与真实请求。
|
||||
- 工具与技能弹窗是“前端状态 + 后端同步”双层状态,调试时优先检查 refresh 查询是否成功。
|
||||
|
||||
---
|
||||
|
||||
## Q6:`AgentChatPage` 组件是干什么的?
|
||||
|
||||
**A:专家“真实聊天页”,并带线程管理、文件上传和文件预览。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 根据 URL `id` 读取专家详情。
|
||||
- 管理当前线程(localStorage 持久化)。
|
||||
- 获取历史消息并转换为统一消息结构。
|
||||
- 流式发送消息并渲染思考过程。
|
||||
- 管理会话文件(上传、树形结构、预览、侧栏缩放)。
|
||||
|
||||
### 关键状态
|
||||
|
||||
- 会话:`currentThreadId/messages/inputValue/isStreaming/isLoading`
|
||||
- UI:`isThreadSidebarOpen/autoScrollEnabled/showScrollButton`
|
||||
- 文件:`fileList/fileListThreadId/isFileSidebarOpen/expandedPaths/selectedFile/filePreview*`
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
|
||||
- 会话消息:
|
||||
- 历史:`GET /api/v1/expert-agent-chat/threads/{id}/messages`
|
||||
- 保存用户:`POST /api/v1/expert-agent-chat/messages`
|
||||
- SSE:`POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 保存助手:`POST /api/v1/expert-agent-chat/messages`
|
||||
- 文件:
|
||||
- 列表:`GET /api/v1/expert-agent-chat/threads/{id}/files/all`
|
||||
- 上传:`POST /api/v1/expert-agent-chat/threads/{id}/files/upload`
|
||||
- 预览:`GET /api/v1/expert-agent-chat/threads/{id}/files/preview?path=...`
|
||||
|
||||
### 关键行为细节
|
||||
|
||||
- **发送即出现 assistant 占位**:点击发送后立即插入 assistant 空消息,保证用户立刻看到“连接中”态。
|
||||
- **连接中显示规则**:由 `AgentConversationList` 判断“最后一条 assistant 且还没有任何生成内容”才显示连接状态。
|
||||
- **失败回滚**:发送或流式失败时移除该 assistant 占位,避免残留空卡片。
|
||||
- **停止流式**:`AbortController` 中断请求,并清理 loading 态字段。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `threadStorageKey` 与 `agentId` 强绑定,切换专家会切换独立 thread 记忆。
|
||||
- `normalizeHistoryContent` 是历史消息兼容核心,后端 content 结构变更时先改这里。
|
||||
|
||||
---
|
||||
|
||||
## Q7:`SkillCreatorPage` 组件是干什么的?
|
||||
|
||||
**A:技能创建/重构专用对话页,结构上是 `AgentChatPage` 的“技能场景定制版”。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 从 query/localStorage 同步当前线程 ID。
|
||||
- 加载技能会话历史与会话文件。
|
||||
- 走技能专属流式接口输出思考步骤/推理/tool call/文本。
|
||||
- 提供“保存技能”弹窗(当前主要是 UI + 本地状态)。
|
||||
|
||||
### 与 `AgentChatPage` 的主要差异
|
||||
|
||||
- 线程 ID 同步到 URL(`useSearchParams`)。
|
||||
- 有独立 `isConnecting` 状态,用于连接中阶段展示。
|
||||
- 流式接口是:`POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 页面内注入了 dot/shimmer 动画样式(供消息连接中态使用)。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 会话消息:`GET /threads/{id}/messages`
|
||||
- 流式:`POST /skills-agent`
|
||||
- 保存 assistant:`POST /messages`
|
||||
- 文件:`GET all` / `POST upload` / `GET preview`
|
||||
|
||||
### 数据流
|
||||
|
||||
- 发消息 -> append 用户 + assistant 占位 -> `isConnecting=true` -> 收到首个流块后 `isConnecting=false` -> 增量刷新 assistant -> 持久化。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 保存技能按钮目前仅 `console.log` 输出技能名和描述,未接正式后端保存接口。
|
||||
|
||||
---
|
||||
|
||||
## Q8:`SkillsWorkshop` 组件是干什么的?
|
||||
|
||||
**A:技能资产管理页(发现、筛选、接入、发布、删除、进入编辑)。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 拉取 `official` 与 `mine` 技能,映射成统一 UI 结构。
|
||||
- 支持搜索、类型筛选、分类筛选。
|
||||
- 支持官方技能接入到“我的技能”。
|
||||
- 支持发布、删除、编辑(编辑弹窗支持 AI 编辑和上传覆盖)。
|
||||
- 支持“新建技能体”:Agent 对话创建 / 上传文件创建。
|
||||
|
||||
### 主要接口
|
||||
|
||||
- 列表:`GET /api/v1/skills?type=official|mine`
|
||||
- 发布:`POST /api/v1/expert-agents/skills/publish`
|
||||
- 删除我的技能:`DELETE /api/v1/skills/mine/{skillId}`
|
||||
- 上传覆盖:`PUT /api/v1/skills/{skillId}/upload`
|
||||
- 接入官方技能:`POST /api/v1/skills/mine/add`
|
||||
- 上传创建:`POST /api/v1/skills/upload`
|
||||
- Agent 创建线程:`POST /api/v1/expert-agent-chat/threads`
|
||||
- AI 编辑复制技能:`POST /threads/{threadId}/copy-skill/{skillId}`
|
||||
|
||||
### 数据流
|
||||
|
||||
- filter/search 改变 -> `useEffect` 重新拉取技能 -> 本地去重并缓存到 `skills`。
|
||||
- 卡片编辑 -> `editingSkill` 打开弹窗 -> 选择 AI 编辑时创建线程后跳 `SkillCreatorPage`。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `inferCategory` 是关键词规则映射,新增技能类型时可优先扩展该规则。
|
||||
- “进入”按钮目前使用 `skill.id` 跳 `skill-creator`,若后端语义改成 threadId 需调整。
|
||||
|
||||
---
|
||||
|
||||
## Q9:`SkillCreatorThreadSidebar` 组件是干什么的?
|
||||
|
||||
**A:会话线程管理的复用侧边栏。**
|
||||
|
||||
### 组件职责
|
||||
|
||||
- 打开时加载会话列表。
|
||||
- 新建会话并自动选中。
|
||||
- 删除会话并在当前会话被删时自动回退到其它会话。
|
||||
- 支持线程过滤(如仅展示当前专家线程)。
|
||||
|
||||
### 输入输出(Props 视角)
|
||||
|
||||
- 输入:`authedFetch/currentThreadId/isOpen/isBusy/threadFilter/createThreadPayload...`
|
||||
- 输出:
|
||||
- `onThreadSelect(threadId)`
|
||||
- `onClose()`
|
||||
|
||||
### 数据流
|
||||
|
||||
- `isOpen` 从 false -> true:触发 `refreshThreads`。
|
||||
- 新建成功:更新本地列表并触发 `onThreadSelect`。
|
||||
- 删除成功:本地移除;若删的是当前线程,自动选择 fallback。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- `createThreadPayload` 是高复用扩展点,场景页可注入 `expert_agent_id/title` 等字段。
|
||||
|
||||
---
|
||||
|
||||
## Q10:`AgentConversationList` 组件是干什么的?
|
||||
|
||||
**A:统一遍历消息并决定“连接中状态”是否显示。**
|
||||
|
||||
### 核心逻辑
|
||||
|
||||
- 逐条渲染 `SkillCreatorConversationItem`。
|
||||
- 只在以下条件满足时给最后一条 assistant 传 `isConnecting=true`:
|
||||
- 页面处于连接/流式阶段;
|
||||
- 当前消息是最后一条 assistant;
|
||||
- 且还没有任何实际生成内容(`content/steps/thinkings/toolCalls` 都为空)。
|
||||
|
||||
### 价值
|
||||
|
||||
- 将“连接中占位显示时机”从页面组件抽离,统一控制,减少重复判断代码。
|
||||
|
||||
---
|
||||
|
||||
## Q11:`SkillCreatorConversationItem` 组件是干什么的?
|
||||
|
||||
**A:单条消息卡片渲染器(用户消息 + 助手多面板消息)。**
|
||||
|
||||
### 渲染结构
|
||||
|
||||
- 用户消息:右侧气泡 + user avatar。
|
||||
- 助手消息:左侧 avatar + 多段内容:
|
||||
- `StepsSummary`
|
||||
- `ThinkingPanel[]`
|
||||
- `ToolCallPanel[]`
|
||||
- 连接中动画文案(`正在连接技能服务...`)
|
||||
- Markdown 正文
|
||||
|
||||
### 数据来源
|
||||
|
||||
- 完全由父级传入 `message` 和 `isConnecting` 控制,不直接请求后端。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 此组件是所有对话页的统一呈现层;若改动 message schema,要同步更新这里的各面板。
|
||||
|
||||
---
|
||||
|
||||
## Q12:`ChatInputComposer` 组件是干什么的?
|
||||
|
||||
**A:通用输入组件,封装输入、发送/停止、上传文件。**
|
||||
|
||||
### 输入输出(Props)
|
||||
|
||||
- 输入:`value/disabled/readOnly/isSending/isDarkMode/showUpload/placeholder...`
|
||||
- 回调:
|
||||
- `onChange(value)`
|
||||
- `onSend()`
|
||||
- `onStop()`
|
||||
- `onUploadFile(file)`
|
||||
|
||||
### 关键交互
|
||||
|
||||
- Enter(非 Shift)触发发送(前提:`submitEnabled` 且非 `isSending`)。
|
||||
- 发送按钮在发送中切换为停止按钮(`onClick={isSending ? onStop : onSend}`)。
|
||||
- 文件上传通过隐藏 `input[type=file]` + 按钮触发。
|
||||
|
||||
### 维护注意
|
||||
|
||||
- 当前实现里文本框只受 `disabled` 控制,不再因为 `isSending` 禁止输入。
|
||||
- 上传按钮仍受 `disabled || !onUploadFile` 控制。
|
||||
|
||||
---
|
||||
|
||||
## Q13:`AgentChatPage.css` 主要做了什么?
|
||||
|
||||
**A:提供 `AgentChatPage` 的辅助样式。**
|
||||
|
||||
- `.agent-resizing`:拖拽文件侧栏时全局 cursor/user-select 控制。
|
||||
- `.agent-file-tree-scroll` / `.agent-file-preview-scroll`:滚动条样式统一。
|
||||
- `.agent-file-markdown-preview`:markdown 首尾边距和图片样式收敛。
|
||||
- `.animate-breathing` 等动画类:预留呼吸动效。
|
||||
|
||||
---
|
||||
|
||||
## Q14:典型业务流程:发送一条消息时,数据如何传输?
|
||||
|
||||
**A:以 `AgentChatPage` 为例。**
|
||||
|
||||
1. 用户在 `ChatInputComposer` 输入后点击发送。
|
||||
2. `handleSendMessage` 校验 `text/threadId/isStreaming`。
|
||||
3. 立即写入:
|
||||
- user message
|
||||
- assistant 占位 message(空内容)
|
||||
4. 调 `POST /messages` 落用户消息。
|
||||
5. 调 `POST /run/stream` 开始 SSE。
|
||||
6. 每个 SSE event 增量更新 assistant message:
|
||||
- step -> `steps`
|
||||
- reasoning -> `thinkings`
|
||||
- tool -> `toolCalls`
|
||||
- text -> `content`
|
||||
7. 流结束后将聚合结果组装成 `persistedContent`,再 `POST /messages` 落库。
|
||||
8. 刷新文件列表(对可能新增输出文件做同步)。
|
||||
|
||||
---
|
||||
|
||||
## Q15:典型业务流程:从技能工坊进入技能创建页,数据如何传输?
|
||||
|
||||
**A:两种入口。**
|
||||
|
||||
### 入口 A:新建技能(Agent 构建)
|
||||
|
||||
1. `SkillsWorkshop.handleCreateSkillByAgent` 创建线程。
|
||||
2. 将 `threadId` 写入 localStorage。
|
||||
3. 跳转 `navigate('/qa-center/skill-creator?id=threadId')`。
|
||||
4. `SkillCreatorPage` 用 query/localStorage 同步 `currentThreadId`,然后拉消息和文件。
|
||||
|
||||
### 入口 B:AI 辅助编辑已有技能
|
||||
|
||||
1. 创建线程。
|
||||
2. `POST /threads/{threadId}/copy-skill/{skillId}` 把技能内容复制到线程工作区。
|
||||
3. 跳转 SkillCreator 页面,后续在线程中对话编辑。
|
||||
|
||||
---
|
||||
|
||||
## Q16:如果要新增一个“对话页”,建议复用哪些组件?
|
||||
|
||||
**A:建议最小复用组合如下。**
|
||||
|
||||
- 消息列表:`AgentConversationList`
|
||||
- 单消息卡片:`SkillCreatorConversationItem`(自动包含 steps/thinking/tool 等面板)
|
||||
- 输入框:`ChatInputComposer`
|
||||
- 线程侧边栏:`SkillCreatorThreadSidebar`
|
||||
|
||||
**页面层只负责:**
|
||||
|
||||
- 状态管理(`messages/input/isStreaming/currentThreadId`)
|
||||
- 接口请求(历史 + SSE + 持久化)
|
||||
- 事件映射(把后端事件转为 `ConversationMessage` 结构)
|
||||
|
||||
---
|
||||
|
||||
## Q17:维护时最容易踩坑的点有哪些?
|
||||
|
||||
**A:建议优先关注这 8 个点。**
|
||||
|
||||
1. **SSE 事件兼容性**:后端 event type 或字段名变化会直接导致 UI 不更新。
|
||||
2. **历史内容兼容**:`normalizeHistoryContent` 没跟上 content schema 会出现历史消息空白。
|
||||
3. **线程切换中断**:切换线程时要记得 abort 流并清状态,避免串流到新线程。
|
||||
4. **占位消息回收**:发送失败/流失败需移除 assistant 占位,避免残留空卡片。
|
||||
5. **threadId 语义一致性**:某些页面参数传 skillId/agentId,需避免误当 threadId 使用。
|
||||
6. **工具/技能绑定状态双源**:本地状态和后端状态不同步时,优先以 refresh 查询结果为准。
|
||||
7. **上传入口 accept 限制**:上传文件类型修改时要同步后端校验和 UI 提示。
|
||||
8. **深色主题样式**:该目录深色/浅色分支很多,改样式需双主题回归。
|
||||
|
||||
---
|
||||
|
||||
## Q18:后续文档扩展建议
|
||||
|
||||
**A:当前文档适合开发入门,下一步可补充“接口契约版”。**
|
||||
|
||||
- 补完整 SSE event JSON 示例(每种事件一例)。
|
||||
- 增加错误码到 UI 提示映射表。
|
||||
- 补一份“页面状态机图”(Idle / Connecting / Streaming / Stopped / Error)。
|
||||
- 给每个页面补“可测点清单”(单测/联调/回归)。
|
||||
|
||||
---
|
||||
|
||||
## Q19:能否给一份可联调的“接口契约附录”?
|
||||
|
||||
**A:可以,以下按模块整理。**
|
||||
|
||||
### 1) 专家模块(Expert)
|
||||
|
||||
- 查询专家列表
|
||||
- `POST /api/v1/expert-agents/list`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
- 响应(数组项):
|
||||
- `id/name/avatar_url/description/model_name/is_draft/user_id/created_at`
|
||||
|
||||
- 查询专家详情
|
||||
- `GET /api/v1/expert-agents/{agentId}`
|
||||
- 响应关键字段:
|
||||
- `id/name/description/system_prompt/model_name`
|
||||
|
||||
- 更新专家配置
|
||||
- `PUT /api/v1/expert-agents/{agentId}`
|
||||
- 请求体关键字段:
|
||||
- `name`
|
||||
- `description`
|
||||
- `system_prompt`
|
||||
- `model_name`
|
||||
- `avatar_url`(当前传 `null`)
|
||||
|
||||
- 切换草稿/发布状态
|
||||
- `PATCH /api/v1/expert-agents/{agentId}`
|
||||
- 请求体:
|
||||
- `is_draft: boolean`
|
||||
|
||||
### 2) 线程与消息模块(Chat Thread + Message)
|
||||
|
||||
- 查询线程列表
|
||||
- `GET /api/v1/expert-agent-chat/threads`
|
||||
- 响应:
|
||||
- `threads: SkillCreatorThread[]`
|
||||
- `archived_threads: SkillCreatorThread[]`
|
||||
|
||||
- 新建线程
|
||||
- `POST /api/v1/expert-agent-chat/threads`
|
||||
- 请求体常见字段:
|
||||
- `title`
|
||||
- `archived`
|
||||
- `is_real_chat`
|
||||
- `expert_agent_id`(专家聊天场景)
|
||||
|
||||
- 删除线程
|
||||
- `DELETE /api/v1/expert-agent-chat/threads/{threadId}`
|
||||
|
||||
- 查询线程消息历史
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/messages`
|
||||
- 响应消息项关键字段:
|
||||
- `id/thread_id/role/content/created_at`
|
||||
|
||||
- 保存消息(用户或助手)
|
||||
- `POST /api/v1/expert-agent-chat/messages`
|
||||
- 请求体(用户消息):
|
||||
- `thread_id`
|
||||
- `role: "user"`
|
||||
- `content: { type: "text", text: string }`
|
||||
- 请求体(助手消息):
|
||||
- `thread_id`
|
||||
- `role: "assistant"`
|
||||
- `content: persistedContent[]`
|
||||
|
||||
### 3) 流式回复模块(SSE)
|
||||
|
||||
- 专家聊天流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `expert_agent_id`
|
||||
- `user_query`
|
||||
- `messages`
|
||||
|
||||
- 专家编辑页测试流式
|
||||
- `POST /api/v1/expert-agent-chat/run/stream/help_expert`
|
||||
|
||||
- 技能创建流式
|
||||
- `POST /api/v1/expert-agent-chat/skills-agent`
|
||||
- 请求体关键字段:
|
||||
- `chat_id`
|
||||
- `user_query`
|
||||
|
||||
### 4) 文件模块(线程文件)
|
||||
|
||||
- 查询线程文件树
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/all`
|
||||
|
||||
- 上传文件
|
||||
- `POST /api/v1/expert-agent-chat/threads/{threadId}/files/upload`
|
||||
- form-data:
|
||||
- `file`
|
||||
|
||||
- 文件预览
|
||||
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/preview?path=...`
|
||||
- 兼容响应:
|
||||
- 文本
|
||||
- JSON 字符串
|
||||
- JSON 对象中的 `content`
|
||||
|
||||
### 5) 技能模块(Skills)
|
||||
|
||||
- 查询技能列表
|
||||
- `GET /api/v1/skills?type=official|mine|all&keyword=&page=1&page_size=100`
|
||||
|
||||
- 发布技能
|
||||
- `POST /api/v1/expert-agents/skills/publish`
|
||||
- 请求体:
|
||||
- `skill_id`
|
||||
|
||||
- 添加官方技能到我的技能
|
||||
- `POST /api/v1/skills/mine/add`
|
||||
- 请求体:
|
||||
- `official_skill_id`
|
||||
|
||||
- 删除我的技能
|
||||
- `DELETE /api/v1/skills/mine/{skillId}`
|
||||
|
||||
- 上传技能文件创建
|
||||
- `POST /api/v1/skills/upload`
|
||||
|
||||
- 上传覆盖已有技能
|
||||
- `PUT /api/v1/skills/{skillId}/upload`
|
||||
|
||||
### 6) 专家绑定工具/技能
|
||||
|
||||
- 工具
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/tools`(`tool_name`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
|
||||
|
||||
- 技能
|
||||
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
|
||||
- 绑定:`POST /api/v1/expert-agents/{agentId}/skills`(`skill_id`)
|
||||
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
|
||||
|
||||
---
|
||||
|
||||
## Q20:SSE 事件规范可以补全吗?
|
||||
|
||||
**A:可以,以下是前端当前实现依赖的事件集合。**
|
||||
|
||||
### 1) 事件封包格式
|
||||
|
||||
- 每行以 `data: ` 开头
|
||||
- 正文为 JSON 字符串
|
||||
- 结束标记:`data: [DONE]`
|
||||
|
||||
### 2) 前端识别的事件类型
|
||||
|
||||
- `data-thinking-step`
|
||||
- 用途:更新执行步骤面板
|
||||
- 关键字段:
|
||||
- `data.id`
|
||||
- `data.title`
|
||||
- `data.status`(`completed` 或其它)
|
||||
- `data.items[]`
|
||||
|
||||
- `reasoning-start`
|
||||
- 用途:开始一段思考
|
||||
- 行为:创建新的 thinking 段,记录开始时间
|
||||
|
||||
- `reasoning-delta`
|
||||
- 用途:思考内容增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
- `reasoning-end`
|
||||
- 用途:结束当前思考段
|
||||
- 行为:计算该段耗时并写入 `duration`
|
||||
|
||||
- `tool-input-available`
|
||||
- 用途:记录工具调用入参
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `toolName`
|
||||
- `input`
|
||||
|
||||
- `tool-output-available`
|
||||
- 用途:记录工具调用输出
|
||||
- 关键字段:
|
||||
- `toolCallId`
|
||||
- `output`
|
||||
|
||||
- `text-delta`
|
||||
- 用途:助手正文 token 增量
|
||||
- 关键字段:
|
||||
- `delta`
|
||||
|
||||
### 3) 推荐后端保证
|
||||
|
||||
- 同一次回复里的 `toolCallId` 全局唯一。
|
||||
- `reasoning-start` 与 `reasoning-end` 成对(允许无 `delta`)。
|
||||
- `data-thinking-step` 同一个 `id` 可重复更新状态。
|
||||
- `text-delta` 在结束前允许为空片段,但尽量减少空片段。
|
||||
|
||||
### 4) 前端容错策略(已实现)
|
||||
|
||||
- JSON parse 失败:跳过该行,不中断整流。
|
||||
- 未识别 event.type:忽略。
|
||||
- `output` 非字符串:尝试 `result` 字段,否则 `JSON.stringify`。
|
||||
- `AbortError`:按“用户主动停止”处理,不弹致命错误。
|
||||
|
||||
---
|
||||
|
||||
## Q21:可以给一份页面级测试清单吗?
|
||||
|
||||
**A:可以,以下清单按页面拆分,适合联调和回归。**
|
||||
|
||||
### A. `AgentChatPage` 测试清单
|
||||
|
||||
- 打开页面后,能加载专家信息(名称、描述)。
|
||||
- 未选线程时,空态正确显示;选中线程后历史消息正确渲染。
|
||||
- 点击发送后立即出现 assistant 占位卡片,显示连接中状态。
|
||||
- 首个有效流式事件到达后,连接中占位消失,转为正常内容增量渲染。
|
||||
- 发送失败/流失败时,assistant 占位会被回收,不留空白卡片。
|
||||
- 点击停止后,流式中断,按钮状态恢复,后续不再追加内容。
|
||||
- 上传文件后文件树刷新;文件预览支持 text/json 两类返回。
|
||||
- 切换线程时会中断当前流,且不会把旧线程流内容写到新线程。
|
||||
|
||||
### B. `SkillCreatorPage` 测试清单
|
||||
|
||||
- URL `id` 与 localStorage 线程 ID 同步正确。
|
||||
- 新线程空态和快捷提示按钮显示正常。
|
||||
- 发送后 `isConnecting=true`,收到首包后 `isConnecting=false`。
|
||||
- steps/thinking/tool/text 四类流式内容都能正确出现。
|
||||
- 线程切换、停止流、文件上传/预览路径行为与 `AgentChatPage` 一致。
|
||||
- 保存技能弹窗开关正常(当前为 UI 行为)。
|
||||
|
||||
### C. `AgentExpertEditPage` 测试清单
|
||||
|
||||
- 进入页面后基础信息(name/model/description/system_prompt)能回填。
|
||||
- 保存专家配置后接口成功,按钮 loading 状态恢复。
|
||||
- 工具弹窗:勾选新增 -> 确认添加 -> 列表刷新一致。
|
||||
- 技能弹窗:绑定/解绑/一键绑定后状态与后端一致。
|
||||
- 左侧测试对话能正常流式返回,滚动与发送状态正确。
|
||||
|
||||
### D. `SkillsWorkshop` 测试清单
|
||||
|
||||
- `all`/`mine` 切换结果正确,搜索词可生效。
|
||||
- 分类筛选(writing/analysis/decision/validation/workflow)正确过滤。
|
||||
- 官方技能“+”接入后卡片状态转“已接入”。
|
||||
- 我的技能菜单中的发布/删除操作正确执行。
|
||||
- 编辑弹窗中 AI 编辑路径可成功创建线程并跳转 SkillCreator。
|
||||
- 上传创建、上传覆盖两条路径成功后状态正确恢复(含 input 清空)。
|
||||
|
||||
### E. `AgentExpertCenterPage` 测试清单
|
||||
|
||||
- mine/published 标签切换后列表正确变化。
|
||||
- mine 模式会按当前用户过滤,且与全量接口合并去重后正确展示。
|
||||
- 详情弹窗中的发布开关可切换并刷新列表。
|
||||
- 编辑、对话跳转路径参数正确。
|
||||
|
||||
### F. 主题与可用性回归
|
||||
|
||||
- 深色/浅色主题下对话气泡、按钮、边框、hover 都可读。
|
||||
- 长会话场景滚动性能可接受,自动滚动按钮行为符合预期。
|
||||
- 键盘发送(Enter)与换行(Shift+Enter)行为正确。
|
||||
|
||||
Reference in new issue
Block a user