代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
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,
};
};
+736
View File
@@ -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)行为正确。