import { useEffect, useRef, useState } from 'react'; import { AlertCircle, ChevronDown, ChevronRight, FileText, FolderOpen, Loader2, MessageSquareText, PackagePlus, Save, Wrench, X, Zap } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { useTheme } from '../../contexts/ThemeContext'; import { useExpertAuthBootstrap } from '../../hooks'; import MarkdownRenderer from '../MarkdownRenderer'; import AgentConversationList, { type ConversationMessage, type ConversationStep } from './AgentConversationList'; import ChatInputComposer from './ChatInputComposer'; import SkillCreatorThreadSidebar from './SkillCreatorThreadSidebar'; import { useNavigate } from 'react-router-dom'; import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap'; const MOCK_CONVERSATION_MESSAGES: ConversationMessage[] = []; const MIN_CHAT_WIDTH = 360; const MIN_FILE_SIDEBAR_WIDTH = 420; const DEFAULT_FILE_SIDEBAR_WIDTH = 560; const THREAD_STORAGE_KEY = 'skill_creator_thread_id'; interface FileNode { name: string; path: string; type: 'file' | 'directory'; size: number | null; extension: string | null; children: FileNode[] | null; } interface HistoryMessageApiItem { created_at: string; id: number; role: 'user' | 'assistant'; content: unknown; thread_id: number; } const formatFileSize = (size: number | null) => { if (!size) return ''; if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; }; const getFileLanguage = (extension: string | null) => { const normalized = extension?.replace('.', '').toLowerCase(); if (!normalized) return 'TEXT'; return normalized.toUpperCase(); }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const normalizeHistoryContent = (content: unknown) => { const steps: ConversationStep[] = []; const thinkings: Array<{ duration: number; content: string }> = []; const texts: string[] = []; const visit = (value: unknown) => { if (typeof value === 'string') { if (value.trim()) texts.push(value); return; } if (Array.isArray(value)) { value.forEach(visit); return; } if (!isRecord(value)) return; const type = typeof value.type === 'string' ? value.type : ''; const text = typeof value.text === 'string' ? value.text : ''; if (type === 'thinking-steps' && Array.isArray(value.steps)) { value.steps.forEach((stepItem) => { if (!isRecord(stepItem)) return; const title = typeof stepItem.title === 'string' ? stepItem.title : '处理中'; const status = stepItem.status === 'completed' ? 'done' : 'processing'; const items = Array.isArray(stepItem.items) ? stepItem.items.filter((item): item is string => typeof item === 'string') : []; steps.push({ text: title, subText: items[0]?.replace('正在处理: ', ''), status, }); }); return; } if (type === 'reasoning' && text) { const duration = typeof value.duration === 'number' ? value.duration / 1000 : 0; thinkings.push({ duration, content: text }); return; } if (text) { texts.push(text); return; } if ('content' in value) { visit(value.content); } }; visit(content); return { content: texts.join('\n\n').trim(), steps, thinkings, }; }; const toConversationMessage = (message: HistoryMessageApiItem): ConversationMessage => { const normalized = normalizeHistoryContent(message.content); return { id: String(message.id), role: message.role, content: normalized.content, steps: normalized.steps, thinkings: normalized.thinkings, toolCalls: [], }; }; interface FileTreeItemProps { node: FileNode; depth?: number; isDarkMode: boolean; expandedPaths: Set; selectedPath: string | null; onToggle: (path: string) => void; onSelect: (node: FileNode) => void; } function FileTreeItem({ node, depth = 0, isDarkMode, expandedPaths, selectedPath, onToggle, onSelect, }: FileTreeItemProps) { const isFolder = node.extension === null; const isExpanded = expandedPaths.has(node.path); const isSelected = selectedPath === node.path; const hasChildren = Boolean(node.children?.length); return (
{isFolder && isExpanded && hasChildren && (
{node.children!.map((child) => ( ))}
)}
); } export default function SkillCreatorPage() { const [searchParams, setSearchParams] = useSearchParams(); const { theme } = useTheme(); const { authedFetch } = useExpertAuthBootstrap(); const isDarkMode = theme === 'dark'; const queryThreadId = searchParams.get('id'); const [currentThreadId, setCurrentThreadId] = useState(() => queryThreadId || localStorage.getItem(THREAD_STORAGE_KEY) || ''); const [messages, setMessages] = useState(MOCK_CONVERSATION_MESSAGES); const [inputValue, setInputValue] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isThreadSidebarOpen, setIsThreadSidebarOpen] = useState(false); const [fileList, setFileList] = useState([]); const [fileListThreadId, setFileListThreadId] = useState(null); const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false); const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH); const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); const [selectedFile, setSelectedFile] = useState(null); const [filePreviewContent, setFilePreviewContent] = useState(''); const [isFilePreviewLoading, setIsFilePreviewLoading] = useState(false); const [filePreviewError, setFilePreviewError] = useState(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [skillName, setSkillName] = useState(''); const [skillDesc, setSkillDesc] = useState(''); const scrollContainerRef = useRef(null); const thinkingStartRef = useRef(0); const abortControllerRef = useRef(null); const navigate = useNavigate(); useEffect(() => { const nextThreadId = queryThreadId || localStorage.getItem(THREAD_STORAGE_KEY) || ''; setCurrentThreadId((prev) => (prev === nextThreadId ? prev : nextThreadId)); }, [queryThreadId]); useEffect(() => { if (!scrollContainerRef.current) return; scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: 'smooth', }); }, [messages]); const syncThreadId = (nextThreadId: string) => { setCurrentThreadId(nextThreadId); setSearchParams((prev) => { const nextParams = new URLSearchParams(prev); if (nextThreadId) nextParams.set('id', nextThreadId); else nextParams.delete('id'); return nextParams; }); if (nextThreadId) { localStorage.setItem(THREAD_STORAGE_KEY, nextThreadId); } else { localStorage.removeItem(THREAD_STORAGE_KEY); } }; const fetchThreadFiles = async (id: number) => { if (!id) { setFileList([]); setFileListThreadId(null); return; } try { const filesRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/files/all`, { method: 'GET', }); if (!filesRes.ok) { setFileList([]); setFileListThreadId(null); return; } const filesData: unknown = await filesRes.json(); setFileList(Array.isArray(filesData) ? (filesData as FileNode[]) : []); setFileListThreadId(id); } catch (error) { console.error('获取技能文件列表失败', error); setFileList([]); setFileListThreadId(null); } }; const fetchThreadMessages = async (id: number) => { setIsConnecting(true); try { const response = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/messages`, { method: 'GET', }); if (!response.ok) { throw new Error(`获取会话消息失败: ${response.status}`); } const data = (await response.json()) as HistoryMessageApiItem[]; const nextMessages = Array.isArray(data) ? data.map(toConversationMessage) : []; setMessages(nextMessages); } catch (error) { console.error('获取技能创建会话消息失败', error); setMessages([]); } finally { setIsConnecting(false); } }; useEffect(() => { const id = Number(currentThreadId); setSelectedFile(null); setFilePreviewContent(''); setFilePreviewError(''); setExpandedPaths(new Set()); if (!id) { setMessages([]); setFileList([]); setFileListThreadId(null); return; } void fetchThreadMessages(id); void fetchThreadFiles(id); }, [currentThreadId]); const emptyChange = () => { navigate('/skills-workshop') } useEffect(() => { if (!fileList.length) return; setExpandedPaths((prev) => { if (prev.size > 0) return prev; const firstLevelFolders = fileList.filter((node) => node.extension === null).map((node) => node.path); return new Set(firstLevelFolders); }); }, [fileList]); const clearLoadingStates = () => { setMessages((prev) => prev.map((msg) => { if (msg.role === 'assistant') { return { ...msg, steps: [], thinkings: [], toolCalls: [], }; } return msg; }), ); }; const handleToggleFolder = (path: string) => { setExpandedPaths((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; const handleSelectFile = async (file: FileNode) => { if (!fileListThreadId) { setFilePreviewError('当前文件列表还没有关联到可预览的会话'); return; } setSelectedFile(file); setFilePreviewContent(''); setFilePreviewError(''); setIsFilePreviewLoading(true); try { const previewRes = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${fileListThreadId}/files/preview?path=${encodeURIComponent(file.path)}`, { method: 'GET' }, ); if (!previewRes.ok) { setFilePreviewError(`文件预览获取失败,状态码 ${previewRes.status}`); return; } const contentType = previewRes.headers.get('content-type') ?? ''; if (contentType.includes('application/json')) { const data: unknown = await previewRes.json(); if (typeof data === 'string') { setFilePreviewContent(data); } else if (data && typeof data === 'object' && 'content' in data) { setFilePreviewContent(String((data as { content: unknown }).content ?? '')); } else { setFilePreviewContent(JSON.stringify(data, null, 2)); } } else { setFilePreviewContent(await previewRes.text()); } } catch (error) { console.error('获取技能文件预览失败', error); setFilePreviewError('文件预览获取失败,请稍后重试'); } finally { setIsFilePreviewLoading(false); } }; const handleResizeStart = (event: React.MouseEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = fileSidebarWidth; const handleMouseMove = (moveEvent: MouseEvent) => { const containerWidth = window.innerWidth - 160; const maxWidth = Math.max(MIN_FILE_SIDEBAR_WIDTH, containerWidth - MIN_CHAT_WIDTH); const nextWidth = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), MIN_FILE_SIDEBAR_WIDTH), maxWidth); setFileSidebarWidth(nextWidth); }; const handleMouseUp = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }; const handleSendMessage = async () => { const userQuery = inputValue.trim(); if (!userQuery || !currentThreadId || isStreaming) return; setInputValue(''); setIsStreaming(true); setIsConnecting(true); abortControllerRef.current = new AbortController(); const userMessage: ConversationMessage = { id: `user-${Date.now()}`, role: 'user', content: userQuery, }; const assistantId = `assistant-${Date.now()}`; setMessages((prev) => [ ...prev, userMessage, { id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] }, ]); try { const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/skills-agent`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ chat_id: Number(currentThreadId), user_query: userQuery, }), signal: abortControllerRef.current.signal, }); if (!streamRes.ok || !streamRes.body) { console.error('技能对话流式接口失败', streamRes.status, streamRes.statusText); return; } const reader = streamRes.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const thinkingStepsAcc: Array<{ id: string; title: string; status: string; items: string[] }> = []; const reasoningsAcc: Array<{ text: string; duration: number }> = []; let assistantTextAcc = ''; let reasoningStartAt = 0; while (true) { const { done, value } = await reader.read(); if (done) break; setIsConnecting(false); buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const raw = line.slice(6).trim(); if (raw === '[DONE]') break; if (!raw) continue; let event: { type: string; data?: { id: string; title: string; status: string; items?: string[] }; id?: string; delta?: string; toolCallId?: string; toolName?: string; input?: Record; output?: { result?: string } | unknown; } | null = null; try { event = JSON.parse(raw) as typeof event; } catch { continue; } if (!event) continue; switch (event.type) { case 'data-thinking-step': { const step = event.data!; const stepData = { id: step.id, title: step.title, status: step.status, items: step.items ?? [], }; const existingIdx = thinkingStepsAcc.findIndex((s) => s.id === step.id); if (existingIdx >= 0) thinkingStepsAcc[existingIdx] = stepData; else thinkingStepsAcc.push(stepData); break; } case 'reasoning-start': { reasoningStartAt = Date.now(); reasoningsAcc.push({ text: '', duration: 0 }); break; } case 'reasoning-delta': { if (reasoningsAcc.length === 0) { reasoningsAcc.push({ text: event.delta ?? '', duration: 0 }); } else { reasoningsAcc[reasoningsAcc.length - 1].text += event.delta ?? ''; } break; } case 'reasoning-end': { if (reasoningsAcc.length > 0) { reasoningsAcc[reasoningsAcc.length - 1].duration = Date.now() - reasoningStartAt; } break; } case 'text-delta': { assistantTextAcc += event.delta ?? ''; break; } } setMessages((prev) => prev.map((msg) => { if (msg.id !== assistantId) return msg; switch (event!.type) { case 'data-thinking-step': { const step = event!.data!; const newStep = { text: step.title, subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined, status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing', }; const steps = [...(msg.steps ?? [])]; const stepIdx = steps.findIndex((item) => item.text === step.title); if (stepIdx >= 0) steps[stepIdx] = newStep; else steps.push(newStep); return { ...msg, steps }; } case 'reasoning-start': { thinkingStartRef.current = Date.now(); return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] }; } case 'reasoning-delta': { const thinkings = [...(msg.thinkings ?? [])]; if (thinkings.length === 0) { thinkings.push({ duration: 0, content: event!.delta ?? '' }); } else { const last = thinkings[thinkings.length - 1]; thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') }; } return { ...msg, thinkings }; } case 'reasoning-end': { const duration = (Date.now() - thinkingStartRef.current) / 1000; const thinkings = [...(msg.thinkings ?? [])]; if (thinkings.length > 0) { thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration }; } return { ...msg, thinkings }; } case 'tool-input-available': { const toolCalls = [...(msg.toolCalls ?? [])]; const tcIdx = toolCalls.findIndex((toolCall) => toolCall.id === event!.toolCallId); const newToolCall = { id: event!.toolCallId!, name: event!.toolName!, input: event!.input }; if (tcIdx >= 0) toolCalls[tcIdx] = { ...toolCalls[tcIdx], ...newToolCall }; else toolCalls.push(newToolCall); return { ...msg, toolCalls }; } case 'tool-output-available': { const toolCalls = [...(msg.toolCalls ?? [])]; const tcIdx = toolCalls.findIndex((toolCall) => toolCall.id === event!.toolCallId); if (tcIdx >= 0) { const rawOutput = event!.output; const outputStr = typeof rawOutput === 'object' && rawOutput !== null && 'result' in rawOutput ? String((rawOutput as { result: unknown }).result) : JSON.stringify(rawOutput); toolCalls[tcIdx] = { ...toolCalls[tcIdx], output: outputStr }; } return { ...msg, toolCalls }; } case 'text-delta': { return { ...msg, content: msg.content + (event!.delta ?? '') }; } default: return msg; } }), ); } } const persistedContent: Array> = []; if (thinkingStepsAcc.length > 0) { persistedContent.push({ type: 'thinking-steps', steps: thinkingStepsAcc }); } for (const reasoning of reasoningsAcc) { if (reasoning.text) { persistedContent.push({ type: 'reasoning', text: reasoning.text, duration: reasoning.duration }); } } if (assistantTextAcc) { persistedContent.push({ type: 'text', text: assistantTextAcc }); } if (persistedContent.length > 0) { const persistRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ thread_id: Number(currentThreadId), role: 'assistant', content: persistedContent, }), }); if (!persistRes.ok) { console.error('保存助手消息失败:', persistRes.status, persistRes.statusText); } } await fetchThreadFiles(Number(currentThreadId)); } catch (streamError) { if (streamError instanceof Error && streamError.name === 'AbortError') { console.log('技能对话已手动停止'); } else { console.error('技能对话异常', streamError); } } finally { setIsStreaming(false); setIsConnecting(false); abortControllerRef.current = null; } }; const handleStopStream = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsStreaming(false); setIsConnecting(false); clearLoadingStates(); } }; const handleUploadFile = async (file: File) => { if (!currentThreadId) return; try { const formData = new FormData(); formData.append('file', file); const response = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${currentThreadId}/files/upload`, { method: 'POST', body: formData, }, ); if (!response.ok) { throw new Error(`上传失败: ${response.status} ${response.statusText}`); } await fetchThreadFiles(Number(currentThreadId)); } catch (error) { console.error('上传技能会话文件失败', error); } }; const handleThreadSelect = (threadId: string) => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsStreaming(false); setIsConnecting(false); clearLoadingStates(); } syncThreadId(threadId); }; return ( <>
{/*
skill creator
{currentThreadId && (
会话 ID: {currentThreadId}
)}
*/}
setIsThreadSidebarOpen(false)} onThreadSelect={handleThreadSelect} />
{messages.length === 0 && !isConnecting ? (

skill-creator

专注于 Agent Skills 的创建、重构和生产化落地的专家代理,适合用来创建新技能、优化 SKILL.md、补充脚本素材,或整理技能交付流程。

{[ { icon: , label: '创建一个新技能', prompt: '我想创建一个新技能,请帮我开始。' }, { icon: , label: '优化 SKILL.md 文档', prompt: '请帮我优化当前技能的 SKILL.md 文档质量。' }, { icon: , label: '添加可复用脚本', prompt: '请帮我为当前技能添加可复用的脚本或参考资源。' }, { icon: , label: '打包并发布技能', prompt: '请帮我配置验证和打包流程,准备发布 .skill 产物。' }, ].map(({ icon, label, prompt }) => ( ))}
) : ( )}
void handleSendMessage()} onStop={handleStopStream} onUploadFile={(file) => void handleUploadFile(file)} disabled={!currentThreadId} isSending={isStreaming} isDarkMode={isDarkMode} showBorderTop placeholder={!currentThreadId ? '请先从聊天记录中选择或新建一个会话' : isStreaming ? '正在生成中...' : '输入你的问题...'} />
{isFileSidebarOpen && ( )}
{/* setIsSaveModalOpen(false)} title="保存技能" width={520} body={
setSkillName(e.target.value)} placeholder="例如:自动写周报" className={`h-9 w-full rounded-lg border px-3 text-sm outline-none transition ${ isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100 placeholder:text-slate-500 focus:border-blue-500' : 'border-gray-300 bg-white text-gray-800 placeholder:text-gray-400 focus:border-blue-500' }`} />