第一次提交

This commit is contained in:
2026-03-13 16:40:08 +08:00
commit 0a49bcfb2d
277 changed files with 61890 additions and 0 deletions

View File

@ -0,0 +1,637 @@
'use client';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
cancelDelegation,
deleteSession,
getSession,
getStatus,
listCommands,
listSessions,
sendMessage,
uploadFile,
wsManager,
} from '@/lib/api';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SlashCommand, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
.join('|');
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
}
function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
const counts = new Map<string, number>();
for (const message of serverMessages) {
const key = messageFingerprint(message);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const pendingUsers: ChatMessage[] = [];
for (const message of localMessages) {
const key = messageFingerprint(message);
const count = counts.get(key) ?? 0;
if (count > 0) {
counts.set(key, count - 1);
continue;
}
if (message.role === 'user') {
pendingUsers.push(message);
}
}
return [...serverMessages, ...pendingUsers];
}
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
export default function ChatPage() {
const {
sessionId,
messages,
isLoading,
isThinking,
sessions,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
setSessionId,
setMessages,
addMessage,
setIsLoading,
setSessions,
clearMessages,
setWsStatus,
setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId,
} = useChatStore();
const [input, setInput] = useState('');
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [showCommandPicker, setShowCommandPicker] = useState(false);
const [pickerIndex, setPickerIndex] = useState(0);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
const statusCheckInFlightRef = useRef(false);
const shouldSnapToLatestRef = useRef(true);
const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
const filter = input.slice(1).toLowerCase();
return commands.filter(
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
);
}, [commands, input]);
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
setSessions(list);
} catch {
// backend may be offline during first render
}
}, [setSessions]);
const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current;
const localSnapshot = useChatStore.getState().messages;
const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking;
try {
const detail = await getSession(key);
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
const nextMessages = waitingForReply
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
setMessages(nextMessages);
const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
}
} catch {
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
}
}, [setIsLoading, setIsThinking, setMessages]);
const loadCommands = useCallback(async () => {
if (commandsLoadedRef.current) return;
commandsLoadedRef.current = true;
try {
const nextCommands = await listCommands();
setCommands(nextCommands);
} catch {
commandsLoadedRef.current = false;
}
}, []);
const scheduleStatusCheck = useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands();
}
}, [input, loadCommands]);
useEffect(() => {
setShowCommandPicker(filteredCommands.length > 0);
setPickerIndex(0);
}, [filteredCommands]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
loadSessionMessages(sessionId);
}, [loadSessionMessages, resetProcessState, sessionId]);
useEffect(() => {
const unsubStatus = wsManager.onStatusChange(async (status) => {
setWsStatus(status);
if (status === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
hasConnectedRef.current = true;
scheduleStatusCheck();
} else {
if (status === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
}
});
const unsubMessage = wsManager.onMessage((data) => {
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
setIsThinking(false);
setIsLoading(false);
addMessage({
role: 'assistant',
content: typeof data.content === 'string' ? data.content : '',
timestamp: new Date().toISOString(),
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
});
loadSessions();
}
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage();
};
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
useEffect(() => {
if (!isLoading && !isThinking) {
return;
}
const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId);
}, 1500);
return () => clearInterval(timer);
}, [isLoading, isThinking, loadSessionMessages]);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
if (!viewport) return;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []);
useEffect(() => {
shouldSnapToLatestRef.current = true;
}, [sessionId]);
useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
return;
}
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [pickerIndex, showCommandPicker]);
const selectCommand = useCallback((command: SlashCommand) => {
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
setShowCommandPicker(false);
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const text = input.trim();
if ((!text && pendingFiles.length === 0) || isLoading) return;
const readyFiles = pendingFiles.filter((p) => p.id && !p.error);
const attachments: FileAttachment[] = readyFiles.map((item) => ({
file_id: item.id!,
name: item.file.name,
content_type: item.file.type || 'application/octet-stream',
size: item.file.size,
}));
setInput('');
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || '(仅附件)';
addMessage({
role: 'user',
content: msgContent,
timestamp: new Date().toISOString(),
attachments: attachments.length > 0 ? attachments : undefined,
});
setIsLoading(true);
setIsThinking(false);
if (wsManager.getStatus() === 'connected') {
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
if (attachments.length > 0) {
wsPayload.attachments = attachments;
}
wsManager.sendRaw(wsPayload);
} else {
try {
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined);
setIsThinking(false);
setIsLoading(false);
if (result.response) {
addMessage({
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
});
loadSessions();
} else {
await loadSessionMessages(sessionId);
loadSessions();
}
} catch {
setIsThinking(false);
setIsLoading(false);
addMessage({
role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。',
timestamp: new Date().toISOString(),
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault();
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
e.preventDefault();
selectCommand(filteredCommands[pickerIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowCommandPicker(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
e.target.value = '';
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]);
continue;
}
setPendingFiles((prev) => [...prev, { file, progress: 0 }]);
try {
const result = await uploadFile(file, sessionId, (pct) => {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, progress: pct } : item)));
});
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
} catch (err: any) {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item)));
}
}
}, [sessionId]);
const handleNewSession = () => {
const id = `web:${Date.now()}`;
setSessionId(id);
clearMessages();
resetProcessState();
};
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(key);
if (key === sessionId) {
setSessionId('web:default');
clearMessages();
resetProcessState();
}
loadSessions();
} catch {
// ignore transient errors
}
};
const handleSelectSession = (key: string) => {
setSessionId(key);
};
const handleCancelRun = async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
timestamp: new Date().toISOString(),
});
}
};
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
}, []);
const formatSessionName = (key: string) => {
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return '默认';
const numeric = Number(id);
if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return id;
}
return key;
};
return (
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="w-64 border-r border-border flex flex-col bg-card">
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
</Button>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{sessions.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4 text-center"></p>
)}
{sessions.map((session) => (
<div
key={session.key}
onClick={() => handleSelectSession(session.key)}
className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
session.key === sessionId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-2 truncate">
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleDeleteSession(session.key, event)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
onCancelRun={handleCancelRun}
/>
</div>
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
<div className="max-w-5xl mx-auto">
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
</span>
{item.error ? (
<span className="text-destructive text-xs">{item.error}</span>
) : item.progress < 100 ? (
<div className="w-20 h-1.5 bg-muted-foreground/20 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs"></span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="relative flex gap-2">
{showCommandPicker && filteredCommands.length > 0 && (
<div
ref={pickerRef}
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
>
{filteredCommands.map((command, index) => (
<button
key={command.name}
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
index === pickerIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-foreground'
}`}
onMouseDown={(event) => {
event.preventDefault();
selectCommand(command);
}}
onMouseEnter={() => setPickerIndex(index)}
>
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
{command.argument_hint && (
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
)}
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
</span>
)}
</button>
))}
</div>
)}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<Button
onClick={() => fileInputRef.current?.click()}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title="添加附件"
>
<Paperclip className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
}}
/>
<Button
onClick={handleSend}
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
size="icon"
className="h-10 w-10 flex-shrink-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}