- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
686 lines
26 KiB
TypeScript
686 lines
26 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||
|
||
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
|
||
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,
|
||
createSession,
|
||
deleteSession,
|
||
getSession,
|
||
listCommands,
|
||
listSessions,
|
||
sendMessage,
|
||
uploadFile,
|
||
wsManager,
|
||
} from '@/lib/api';
|
||
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import { useChatStore } from '@/lib/store';
|
||
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||
|
||
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 isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||
}
|
||
|
||
export default function ChatPage() {
|
||
const { locale } = useAppI18n();
|
||
const {
|
||
sessionId,
|
||
messages,
|
||
isLoading,
|
||
isThinking,
|
||
sessions,
|
||
processRuns,
|
||
processEvents,
|
||
processArtifacts,
|
||
selectedRunId,
|
||
setSessionId,
|
||
setMessages,
|
||
addMessage,
|
||
setIsLoading,
|
||
clearMessages,
|
||
setIsThinking,
|
||
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 shouldSnapToLatestRef = useRef(true);
|
||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||
|
||
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 sessionProcessRuns = useMemo(
|
||
() => processRuns.filter((run) => run.session_id === sessionId),
|
||
[processRuns, sessionId]
|
||
);
|
||
|
||
const sessionRunIds = useMemo(
|
||
() => new Set(sessionProcessRuns.map((run) => run.run_id)),
|
||
[sessionProcessRuns]
|
||
);
|
||
|
||
const sessionProcessEvents = useMemo(
|
||
() => processEvents.filter((event) => sessionRunIds.has(event.run_id)),
|
||
[processEvents, sessionRunIds]
|
||
);
|
||
|
||
const sessionProcessArtifacts = useMemo(
|
||
() => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)),
|
||
[processArtifacts, sessionRunIds]
|
||
);
|
||
|
||
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
||
|
||
const officeTasks = useMemo(
|
||
() => buildOfficeTaskList({
|
||
sessionId,
|
||
sessions,
|
||
processRuns,
|
||
processEvents,
|
||
processArtifacts,
|
||
}, locale),
|
||
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||
);
|
||
|
||
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
|
||
|
||
const loadSessions = useCallback(async () => {
|
||
try {
|
||
const list = await listSessions();
|
||
useChatStore.getState().setSessions(list);
|
||
} catch {
|
||
// backend may be offline during first render
|
||
}
|
||
}, []);
|
||
|
||
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);
|
||
shouldSnapToLatestRef.current = true;
|
||
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;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (input.startsWith('/') && !input.includes(' ')) {
|
||
void loadCommands();
|
||
}
|
||
}, [input, loadCommands]);
|
||
|
||
useEffect(() => {
|
||
setShowCommandPicker(filteredCommands.length > 0);
|
||
setPickerIndex(0);
|
||
}, [filteredCommands]);
|
||
|
||
useEffect(() => {
|
||
clearMessages();
|
||
setIsLoading(false);
|
||
setIsThinking(false);
|
||
void loadSessionMessages(sessionId);
|
||
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||
|
||
useEffect(() => {
|
||
if (wsStatus === 'connected') {
|
||
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
|
||
refreshSessionOnReconnectRef.current = false;
|
||
void loadSessionMessages(useChatStore.getState().sessionId);
|
||
}
|
||
hasConnectedRef.current = true;
|
||
return;
|
||
}
|
||
|
||
if (wsStatus === 'disconnected' && hasConnectedRef.current) {
|
||
refreshSessionOnReconnectRef.current = true;
|
||
}
|
||
}, [loadSessionMessages, wsStatus]);
|
||
|
||
useEffect(() => {
|
||
const unsubMessage = wsManager.onMessage((data) => {
|
||
if (isSessionUpdatedEvent(data)) {
|
||
void loadSessions();
|
||
if (data.session_id === useChatStore.getState().sessionId) {
|
||
void loadSessionMessages(data.session_id);
|
||
}
|
||
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 () => {
|
||
unsubMessage();
|
||
};
|
||
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||
|
||
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;
|
||
messagesEndRef.current?.scrollIntoView({ block: 'end', behavior });
|
||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||
}, []);
|
||
|
||
const scheduleScrollToLatest = useCallback((behavior: ScrollBehavior) => {
|
||
if (typeof window === 'undefined') {
|
||
scrollMessagesToLatest(behavior);
|
||
return;
|
||
}
|
||
window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => {
|
||
scrollMessagesToLatest(behavior);
|
||
});
|
||
});
|
||
}, [scrollMessagesToLatest]);
|
||
|
||
useEffect(() => {
|
||
shouldSnapToLatestRef.current = true;
|
||
}, [sessionId]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) {
|
||
return;
|
||
}
|
||
|
||
scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
|
||
shouldSnapToLatestRef.current = false;
|
||
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
|
||
|
||
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 || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||
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) {
|
||
if (useChatStore.getState().sessionId !== sessionId) {
|
||
await loadSessions();
|
||
return;
|
||
}
|
||
addMessage({
|
||
role: 'assistant',
|
||
content: result.response,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
loadSessions();
|
||
} else {
|
||
await loadSessionMessages(sessionId);
|
||
loadSessions();
|
||
}
|
||
} catch {
|
||
setIsThinking(false);
|
||
setIsLoading(false);
|
||
if (useChatStore.getState().sessionId !== sessionId) {
|
||
return;
|
||
}
|
||
addMessage({
|
||
role: 'assistant',
|
||
content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'),
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
}
|
||
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, 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: pickAppText(locale, '文件过大(最大 50MB)', 'File is too large (max 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 || pickAppText(locale, '上传失败', 'Upload failed') } : item)));
|
||
}
|
||
}
|
||
}, [locale, sessionId]);
|
||
|
||
const handleNewSession = async () => {
|
||
const id = `web:${Date.now()}`;
|
||
setSessionId(id);
|
||
setSelectedRunId(null);
|
||
clearMessages();
|
||
useChatStore.getState().resetProcessState();
|
||
try {
|
||
await createSession(id);
|
||
} catch {
|
||
// ignore transient create failures; first message can still create the session server-side
|
||
}
|
||
void loadSessions();
|
||
};
|
||
|
||
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await deleteSession(key);
|
||
if (key === sessionId) {
|
||
setSessionId('web:default');
|
||
clearMessages();
|
||
useChatStore.getState().resetProcessState();
|
||
}
|
||
loadSessions();
|
||
} catch {
|
||
// ignore transient errors
|
||
}
|
||
};
|
||
|
||
const handleSelectSession = (key: string) => {
|
||
setSelectedRunId(null);
|
||
setSessionId(key);
|
||
};
|
||
|
||
const handleCancelRun = useCallback(async (runId: string) => {
|
||
try {
|
||
await cancelDelegation(runId);
|
||
} catch (err: any) {
|
||
addMessage({
|
||
role: 'assistant',
|
||
content: pickAppText(locale, `取消任务 ${runId} 失败:${err.message || '未知错误'}`, `Failed to cancel task ${runId}: ${err.message || 'Unknown error'}`),
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
}, [addMessage, locale]);
|
||
|
||
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 pickAppText(locale, '默认', 'Default');
|
||
const numeric = Number(id);
|
||
if (!Number.isNaN(numeric)) {
|
||
return new Date(numeric).toLocaleDateString(locale, {
|
||
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" />
|
||
{pickAppText(locale, '新对话', 'New chat')}
|
||
</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">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</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">
|
||
{currentOfficeTask ? (
|
||
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
|
||
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Building2 className="h-4 w-4" />
|
||
{pickAppText(locale, '当前任务现场', 'Current task floor')}
|
||
</div>
|
||
<OfficeStatusBadge status={currentOfficeTask.status} />
|
||
</div>
|
||
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||
{currentOfficeTask.title}
|
||
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
|
||
</Button>
|
||
<Button asChild size="sm">
|
||
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
|
||
{pickAppText(locale, '查看任务现场', 'Open task floor')}
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex-1 min-h-0">
|
||
<ChatWorkbench
|
||
messages={messages}
|
||
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
|
||
messagesEndRef={messagesEndRef}
|
||
messageViewportRef={messageViewportRef}
|
||
processRuns={sessionProcessRuns}
|
||
processEvents={sessionProcessEvents}
|
||
processArtifacts={sessionProcessArtifacts}
|
||
selectedRunId={selectedSessionRunId}
|
||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||
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">{pickAppText(locale, '就绪', 'Ready')}</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' ? pickAppText(locale, '技能', '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={pickAppText(locale, '添加附件', 'Add attachment')}
|
||
>
|
||
<Paperclip className="w-4 h-4" />
|
||
</Button>
|
||
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={pickAppText(locale, '输入消息或 / 呼出命令…(回车发送,Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
|
||
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>
|
||
);
|
||
}
|