Files
deerflow-code/web-code/components/agent/SkillCreatorPage.tsx
T
2026-05-28 18:29:37 +08:00

1086 lines
42 KiB
TypeScript

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<string, unknown> => 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<string>;
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 (
<div>
<button
type="button"
onClick={() => (isFolder ? onToggle(node.path) : onSelect(node))}
className={`group flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left text-sm transition-all duration-200 ${
isSelected
? isDarkMode
? 'bg-blue-500/20 text-blue-200 ring-1 ring-blue-400/40 shadow-sm'
: 'bg-blue-50 text-blue-700 ring-1 ring-blue-200 shadow-sm'
: isDarkMode
? 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-950'
}`}
style={{ paddingLeft: `${10 + depth * 18}px` }}
title={node.path}
>
<span className="flex w-4 shrink-0 justify-center">
{isFolder && hasChildren ? (
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />
) : null}
</span>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
isFolder
? isDarkMode ? 'bg-amber-500/15 text-amber-300' : 'bg-amber-100 text-amber-600'
: isDarkMode ? 'bg-slate-700 text-slate-300' : 'bg-white text-gray-500 shadow-sm ring-1 ring-gray-200'
}`}
>
{isFolder ? <FolderOpen className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{node.name}</span>
{!isFolder && node.size !== null && (
<span className={`shrink-0 text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{formatFileSize(node.size)}</span>
)}
</button>
{isFolder && isExpanded && hasChildren && (
<div className="mt-1 space-y-1">
{node.children!.map((child) => (
<FileTreeItem
key={child.path}
node={child}
depth={depth + 1}
isDarkMode={isDarkMode}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
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<ConversationMessage[]>(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<FileNode[]>([]);
const [fileListThreadId, setFileListThreadId] = useState<number | null>(null);
const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false);
const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
const [selectedFile, setSelectedFile] = useState<FileNode | null>(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<HTMLDivElement | null>(null);
const thinkingStartRef = useRef<number>(0);
const abortControllerRef = useRef<AbortController | null>(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<HTMLDivElement>) => {
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<string, unknown>;
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<Record<string, unknown>> = [];
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 (
<>
<style>{`
@keyframes skillCreatorBreathing {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
@keyframes skillCreatorBreathingGlow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.35);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
.skill-creator-breathing {
animation: skillCreatorBreathing 1.5s ease-in-out infinite, skillCreatorBreathingGlow 1.5s ease-in-out infinite;
}
@keyframes skillCreatorDotBounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
.skill-creator-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background-color: currentColor;
display: inline-block;
animation: skillCreatorDotBounce 1.2s ease-in-out infinite;
}
.skill-creator-dot:nth-child(2) {
animation-delay: 0.15s;
}
.skill-creator-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes skillCreatorMarquee {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skill-creator-shimmer-text {
position: relative;
display: inline-block;
color: var(--marquee-base);
}
.skill-creator-shimmer-text::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(
100deg,
transparent 0%,
transparent 42%,
var(--marquee-highlight) 50%,
transparent 58%,
transparent 100%
);
background-size: 220% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: skillCreatorMarquee 2.8s linear infinite;
}
.skill-creator-shimmer-light {
--marquee-base: #6b7280;
--marquee-highlight: #111827;
}
.skill-creator-shimmer-dark {
--marquee-base: #94a3b8;
--marquee-highlight: #f8fafc;
}
`}</style>
<div className={`h-[calc(100vh-120px)] overflow-hidden rounded-2xl border ${isDarkMode ? 'border-slate-700 bg-slate-900/40' : 'border-gray-200 bg-white'}`}>
<div className={`flex h-14 items-center justify-between border-b px-5 ${isDarkMode ? 'border-slate-700' : 'border-gray-200'}`}>
{/* <div>
<div className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>skill creator</div>
{currentThreadId && (
<div className={`text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>会话 ID: {currentThreadId}</div>
)}
</div> */}
<button
type="button"
onClick={emptyChange}
className={`inline-flex h-9 items-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-gray-50 text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
}`}
title="打开聊天记录"
>
返回
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsThreadSidebarOpen((prev) => !prev)}
className={`inline-flex h-9 items-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-gray-50 text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
}`}
title="打开聊天记录"
>
<MessageSquareText className="h-4 w-4" />
聊天记录
</button>
<button
type="button"
onClick={() => setIsFileSidebarOpen(true)}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-400 via-blue-500 to-purple-600 text-white transition-all duration-300 hover:scale-110 hover:shadow-lg hover:shadow-blue-500/50"
title="文件"
>
<FolderOpen className="h-4 w-4 shrink-0" />
</button>
<button
type="button"
onClick={() => setIsSaveModalOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-emerald-500 px-3 text-sm text-white transition hover:bg-emerald-600"
>
<Save className="h-4 w-4" />
保存技能
</button>
</div>
</div>
<div className="flex h-[calc(100%-56px)] min-h-0 overflow-hidden">
<SkillCreatorThreadSidebar
authedFetch={authedFetch}
currentThreadId={currentThreadId}
isDarkMode={isDarkMode}
isOpen={isThreadSidebarOpen}
isBusy={isStreaming}
onClose={() => setIsThreadSidebarOpen(false)}
onThreadSelect={handleThreadSelect}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-6 py-5">
{messages.length === 0 && !isConnecting ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 via-purple-500 to-indigo-600 shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
<Zap className="h-7 w-7 text-white" />
</div>
<h2 className={`mb-3 text-xl font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
skill-creator
</h2>
<p className={`mb-8 max-w-[480px] text-center text-sm leading-relaxed ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
专注于 Agent Skills 的创建、重构和生产化落地的专家代理,适合用来创建新技能、优化 SKILL.md、补充脚本素材,或整理技能交付流程。
</p>
<div className="grid w-full max-w-[560px] grid-cols-2 gap-3">
{[
{ icon: <PackagePlus className="h-4 w-4 shrink-0" />, label: '创建一个新技能', prompt: '我想创建一个新技能,请帮我开始。' },
{ icon: <FileText className="h-4 w-4 shrink-0" />, label: '优化 SKILL.md 文档', prompt: '请帮我优化当前技能的 SKILL.md 文档质量。' },
{ icon: <Wrench className="h-4 w-4 shrink-0" />, label: '添加可复用脚本', prompt: '请帮我为当前技能添加可复用的脚本或参考资源。' },
{ icon: <Save className="h-4 w-4 shrink-0" />, label: '打包并发布技能', prompt: '请帮我配置验证和打包流程,准备发布 .skill 产物。' },
].map(({ icon, label, prompt }) => (
<button
key={label}
type="button"
disabled={!currentThreadId}
onClick={() => setInputValue(prompt)}
className={`group flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left text-sm font-medium transition-all duration-200 ${
isDarkMode
? 'border-slate-700 bg-slate-900/60 text-slate-300 hover:border-violet-500/60 hover:bg-slate-800 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-40'
: 'border-gray-200 bg-gray-50 text-gray-700 hover:border-violet-400 hover:bg-violet-50 hover:text-violet-700 disabled:cursor-not-allowed disabled:opacity-40'
}`}
>
<span className={`transition-colors ${isDarkMode ? 'text-violet-400 group-hover:text-violet-300' : 'text-violet-500 group-hover:text-violet-600'}`}>
{icon}
</span>
{label}
</button>
))}
</div>
</div>
) : (
<AgentConversationList messages={messages} isConnecting={isConnecting} />
)}
</div>
<ChatInputComposer
value={inputValue}
onChange={setInputValue}
onSend={() => void handleSendMessage()}
onStop={handleStopStream}
onUploadFile={(file) => void handleUploadFile(file)}
disabled={!currentThreadId}
isSending={isStreaming}
isDarkMode={isDarkMode}
showBorderTop
placeholder={!currentThreadId ? '请先从聊天记录中选择或新建一个会话' : isStreaming ? '正在生成中...' : '输入你的问题...'}
/>
</div>
{isFileSidebarOpen && (
<aside
className={`relative flex min-h-0 shrink-0 border-l shadow-2xl ${
isDarkMode ? 'border-slate-700 bg-slate-950 text-slate-100 shadow-black/30' : 'border-gray-200 bg-white text-gray-900 shadow-gray-200/70'
}`}
style={{ width: fileSidebarWidth }}
>
<div
onMouseDown={handleResizeStart}
className={`absolute left-0 top-0 z-20 h-full w-2 -translate-x-1/2 cursor-col-resize transition-colors ${
isDarkMode ? 'hover:bg-blue-400/40' : 'hover:bg-blue-500/25'
}`}
title="拖拽调整文件侧边栏宽度"
/>
<div className={`flex w-[46%] min-w-[220px] flex-col border-r ${isDarkMode ? 'border-slate-800' : 'border-gray-200'}`}>
<div className={`flex items-center justify-between border-b px-4 py-3 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-gray-50/80'}`}>
<div>
<h3 className="text-sm font-semibold">文件</h3>
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{fileList.length} 个根节点</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{fileList.length > 0 ? (
<div className="space-y-1">
{fileList.map((node) => (
<FileTreeItem
key={node.path}
node={node}
isDarkMode={isDarkMode}
expandedPaths={expandedPaths}
selectedPath={selectedFile?.path ?? null}
onToggle={handleToggleFolder}
onSelect={(file) => void handleSelectFile(file)}
/>
))}
</div>
) : (
<div className={`rounded-2xl border border-dashed px-4 py-8 text-center text-sm ${isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'}`}>
暂无文件
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className={`flex items-center justify-between gap-3 border-b px-5 py-3 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-gray-50/80'}`}>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold">{selectedFile?.name ?? '文件预览'}</h3>
<p className={`mt-0.5 truncate text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>
{selectedFile ? selectedFile.path : '选择左侧文件查看内容'}
</p>
</div>
<button
type="button"
onClick={() => setIsFileSidebarOpen(false)}
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition ${
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-900'
}`}
title="关闭文件侧边栏"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-5">
{!selectedFile ? (
<div className={`flex h-full flex-col items-center justify-center rounded-3xl border border-dashed text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/40 text-slate-500' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<FileText className="mb-3 h-10 w-10 opacity-60" />
<p className="text-sm font-medium">请选择一个文件</p>
<p className="mt-1 text-xs">文件内容会显示在这里</p>
</div>
) : isFilePreviewLoading ? (
<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>
) : filePreviewError ? (
<div className={`flex h-full flex-col items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-red-300' : 'text-red-500'}`}>
<AlertCircle className="h-8 w-8" />
{filePreviewError}
</div>
) : (
<div className={`overflow-hidden rounded-2xl border ${isDarkMode ? 'border-slate-800 bg-slate-900' : 'border-gray-200 bg-white'}`}>
<div className={`flex items-center justify-between border-b px-4 py-2.5 ${isDarkMode ? 'border-slate-800 bg-slate-950/70' : 'border-gray-100 bg-gray-50'}`}>
<span className={`text-xs font-semibold tracking-wide ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>{getFileLanguage(selectedFile.extension)}</span>
<span className={`text-xs ${isDarkMode ? 'text-slate-600' : 'text-gray-400'}`}>{formatFileSize(selectedFile.size)}</span>
</div>
<div className={`p-5 text-sm leading-7 ${isDarkMode ? 'text-slate-200' : 'text-gray-700'}`}>
<MarkdownRenderer content={filePreviewContent || '文件内容为空'} />
</div>
</div>
)}
</div>
</div>
</aside>
)}
</div>
</div>
{/* <DraggableModal
open={isSaveModalOpen}
onClose={() => setIsSaveModalOpen(false)}
title="保存技能"
width={520}
body={
<div className="flex flex-col gap-4">
<div>
<label className={`mb-1.5 block text-xs font-medium ${isDarkMode ? 'text-slate-300' : 'text-gray-600'}`}>
技能名称
</label>
<input
type="text"
value={skillName}
onChange={(e) => 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'
}`}
/>
</div>
<div>
<label className={`mb-1.5 block text-xs font-medium ${isDarkMode ? 'text-slate-300' : 'text-gray-600'}`}>
描述
</label>
<textarea
value={skillDesc}
onChange={(e) => setSkillDesc(e.target.value)}
placeholder="简单描述一下这个技能的用途..."
rows={4}
className={`w-full resize-none rounded-lg border px-3 py-2 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'
}`}
/>
</div>
<p className={`text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>
提示:按住顶部标题栏可拖动弹窗,按 ESC 或点击遮罩关闭。
</p>
</div>
}
confirmText="保存"
cancelText="取消"
onConfirm={() => {
console.log('保存技能:', { skillName, skillDesc });
setIsSaveModalOpen(false);
}}
/> */}
</>
);
}