添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。 实现了工具失败指导原则,防止相同类别工具重复失败。 为任务规划器添加了超时处理机制,避免长时间等待。 BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。 fix(api): 修复任务详情API返回完整流程投影 修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息, 并确保时间戳字段正确序列化。 refactor(engine): 优化任务技能解析器摘要节点处理 改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节 点不再分配具体技能,直接使用依赖项输出进行汇总。 test: 增加任务修订和超时处理测试用例 添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及 摘要节点技能解析等新功能。
684 lines
27 KiB
TypeScript
684 lines
27 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
||
|
||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import {
|
||
archiveSession,
|
||
createSession,
|
||
getActiveTask,
|
||
getSession,
|
||
getSessionProcess,
|
||
listSessions,
|
||
sendMessage,
|
||
submitChatFeedback,
|
||
uploadFile,
|
||
wsManager,
|
||
} from '@/lib/api';
|
||
import { mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import { useChatStore } from '@/lib/store';
|
||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||
|
||
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||
}
|
||
|
||
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
|
||
if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback');
|
||
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
|
||
return pickAppText(locale, '进行中', 'Active');
|
||
}
|
||
|
||
const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled';
|
||
|
||
function loadThinkingModePreference(): boolean {
|
||
if (typeof window === 'undefined') {
|
||
return true;
|
||
}
|
||
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
|
||
return stored == null ? true : stored !== 'false';
|
||
}
|
||
|
||
export default function ChatPage() {
|
||
const { locale } = useAppI18n();
|
||
const {
|
||
sessionId,
|
||
messages,
|
||
isLoading,
|
||
isThinking,
|
||
sessions,
|
||
processRuns,
|
||
processEvents,
|
||
processArtifacts,
|
||
selectedRunId,
|
||
setSessionId,
|
||
setMessages,
|
||
addMessage,
|
||
setIsLoading,
|
||
clearMessages,
|
||
setIsThinking,
|
||
setSelectedRunId,
|
||
setSessionProcess,
|
||
updateMessageFeedback,
|
||
} = useChatStore();
|
||
|
||
const [input, setInput] = useState('');
|
||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const loadSessionReqSeq = useRef(0);
|
||
const loadedSessionIdRef = useRef<string | null>(null);
|
||
const refreshSessionOnReconnectRef = useRef(false);
|
||
const hasConnectedRef = useRef(false);
|
||
const shouldSnapToLatestRef = useRef(true);
|
||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||
|
||
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 loadSessions = useCallback(async () => {
|
||
try {
|
||
const list = await listSessions();
|
||
useChatStore.getState().setSessions(list);
|
||
} catch {
|
||
// backend may be offline during first render
|
||
}
|
||
}, []);
|
||
|
||
const loadActiveTask = useCallback(async (key: string) => {
|
||
try {
|
||
if (useChatStore.getState().sessionId !== key) return;
|
||
setActiveTask(await getActiveTask(key));
|
||
} catch {
|
||
if (useChatStore.getState().sessionId === key) {
|
||
setActiveTask(null);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
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, process] = await Promise.all([
|
||
getSession(key),
|
||
getSessionProcess(key).catch(() => null),
|
||
]);
|
||
if (reqSeq !== loadSessionReqSeq.current) return;
|
||
if (useChatStore.getState().sessionId !== key) return;
|
||
if (process) {
|
||
setSessionProcess(key, process);
|
||
}
|
||
void loadActiveTask(key);
|
||
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;
|
||
}
|
||
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
||
|
||
useEffect(() => {
|
||
const didSwitchSession = loadedSessionIdRef.current !== null && loadedSessionIdRef.current !== sessionId;
|
||
loadedSessionIdRef.current = sessionId;
|
||
if (didSwitchSession) {
|
||
clearMessages();
|
||
setIsLoading(false);
|
||
setIsThinking(false);
|
||
}
|
||
setActiveTask(null);
|
||
setRevisionTargetRunId(null);
|
||
void loadSessionMessages(sessionId);
|
||
void loadActiveTask(sessionId);
|
||
}, [clearMessages, loadActiveTask, 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') {
|
||
const validationResult = data.validation_result ?? data.metadata?.validation_result;
|
||
const validationStatus = data.validation_status
|
||
? data.validation_status
|
||
: validationResult
|
||
? ((validationResult as Record<string, unknown>).accepted === true ? 'passed' : 'failed')
|
||
: 'unknown';
|
||
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,
|
||
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
|
||
task_id: data.task_id ?? data.metadata?.task_id ?? null,
|
||
task_status: data.task_status ?? data.metadata?.task_status ?? null,
|
||
validation_status: validationStatus,
|
||
});
|
||
void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||
void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||
loadSessions();
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
unsubMessage();
|
||
};
|
||
}, [addMessage, loadActiveTask, 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]);
|
||
|
||
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,
|
||
}));
|
||
|
||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||
|
||
if (revisionTargetRunId && text) {
|
||
setIsLoading(true);
|
||
setIsThinking(false);
|
||
updateMessageFeedback(revisionTargetRunId, 'revise');
|
||
try {
|
||
await submitChatFeedback({
|
||
sessionId,
|
||
runId: revisionTargetRunId,
|
||
feedbackType: 'revise',
|
||
comment: msgContent,
|
||
});
|
||
} catch (err: any) {
|
||
setIsThinking(false);
|
||
setIsLoading(false);
|
||
updateMessageFeedback(revisionTargetRunId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
|
||
return;
|
||
} finally {
|
||
setRevisionTargetRunId(null);
|
||
}
|
||
} else {
|
||
setRevisionTargetRunId(null);
|
||
}
|
||
|
||
setInput('');
|
||
setPendingFiles([]);
|
||
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,
|
||
thinking_enabled: thinkingModeEnabled,
|
||
};
|
||
if (attachments.length > 0) {
|
||
wsPayload.attachments = attachments;
|
||
}
|
||
wsManager.sendRaw(wsPayload);
|
||
} else {
|
||
try {
|
||
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined, {
|
||
thinkingEnabled: thinkingModeEnabled,
|
||
});
|
||
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(),
|
||
run_id: result.run_id,
|
||
task_id: result.task_id,
|
||
task_status: result.task_status,
|
||
validation_status: result.validation_result
|
||
? (result.validation_result.accepted === true ? 'passed' : 'failed')
|
||
: 'unknown',
|
||
});
|
||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||
void loadActiveTask(sessionId);
|
||
loadSessions();
|
||
} else {
|
||
await loadSessionMessages(sessionId);
|
||
void loadActiveTask(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, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||
|
||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||
updateMessageFeedback(runId, feedbackType);
|
||
try {
|
||
await submitChatFeedback({
|
||
sessionId,
|
||
runId,
|
||
feedbackType,
|
||
comment,
|
||
});
|
||
void loadSessionMessages(sessionId);
|
||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||
void loadActiveTask(sessionId);
|
||
void loadSessions();
|
||
} catch (err: any) {
|
||
updateMessageFeedback(runId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
|
||
}
|
||
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
||
|
||
const handleRequestRevision = useCallback((runId: string) => {
|
||
setRevisionTargetRunId(runId);
|
||
textareaRef.current?.focus();
|
||
}, []);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
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);
|
||
setActiveTask(null);
|
||
setRevisionTargetRunId(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 handleArchiveSession = async (key: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await archiveSession(key);
|
||
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
|
||
if (key === sessionId) {
|
||
setSessionId('web:default');
|
||
setActiveTask(null);
|
||
setRevisionTargetRunId(null);
|
||
clearMessages();
|
||
useChatStore.getState().resetProcessState();
|
||
}
|
||
void loadSessions();
|
||
} catch {
|
||
addMessage({
|
||
role: 'assistant',
|
||
content: pickAppText(locale, '归档会话失败,请稍后重试。', 'Failed to archive the session. Please try again later.'),
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleSelectSession = (key: string) => {
|
||
setSelectedRunId(null);
|
||
setActiveTask(null);
|
||
setRevisionTargetRunId(null);
|
||
setSessionId(key);
|
||
};
|
||
|
||
const removePendingFile = useCallback((file: File) => {
|
||
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
|
||
}, []);
|
||
|
||
const toggleThinkingMode = useCallback(() => {
|
||
setThinkingModeEnabled((current) => {
|
||
const next = !current;
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.setItem(THINKING_MODE_STORAGE_KEY, String(next));
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
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-4rem)] bg-background">
|
||
<aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]">
|
||
<div className="px-5 pb-5 pt-6">
|
||
<button
|
||
type="button"
|
||
onClick={handleNewSession}
|
||
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{pickAppText(locale, '新对话', 'New chat')}
|
||
</button>
|
||
</div>
|
||
<ScrollArea className="flex-1">
|
||
<div className="space-y-3 px-3 pb-6">
|
||
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
|
||
{sessions.length === 0 && (
|
||
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
|
||
)}
|
||
{sessions.map((session) => (
|
||
<div
|
||
key={session.key}
|
||
onClick={() => handleSelectSession(session.key)}
|
||
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
|
||
session.key === sessionId
|
||
? 'bg-[#EFEEED] text-foreground'
|
||
: 'text-foreground hover:bg-[#EFEEED]/70'
|
||
}`}
|
||
>
|
||
<div className="truncate">
|
||
<span className="truncate">{formatSessionName(session.key)}</span>
|
||
</div>
|
||
<button
|
||
onClick={(event) => handleArchiveSession(session.key, event)}
|
||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
|
||
title={pickAppText(locale, '归档会话', 'Archive session')}
|
||
aria-label={pickAppText(locale, '归档会话', 'Archive session')}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</aside>
|
||
|
||
<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={sessionProcessRuns}
|
||
processEvents={sessionProcessEvents}
|
||
processArtifacts={sessionProcessArtifacts}
|
||
selectedRunId={selectedSessionRunId}
|
||
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
|
||
onFeedback={handleFeedback}
|
||
onRequestRevision={handleRequestRevision}
|
||
/>
|
||
</div>
|
||
|
||
<div className="bg-background px-8 pb-8 pt-4">
|
||
<div className="mx-auto max-w-5xl">
|
||
{(activeTask || revisionTargetRunId) && (
|
||
<div className="mb-2 flex">
|
||
{activeTask ? (
|
||
<Link
|
||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||
title={activeTask.description}
|
||
>
|
||
<span className="shrink-0 text-muted-foreground">
|
||
{revisionTargetRunId ? pickAppText(locale, '修改任务', 'Revising task') : pickAppText(locale, '当前任务', 'Current task')}:
|
||
</span>
|
||
<span className="truncate font-medium">{activeTask.short_title}</span>
|
||
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
||
{revisionTargetRunId ? pickAppText(locale, '待输入修改要求', 'Awaiting revision') : activeTaskStatusLabel(activeTask.status, locale)}
|
||
</span>
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{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-[#657162] 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 rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
|
||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={
|
||
revisionTargetRunId
|
||
? pickAppText(locale, '请输入修改要求', 'Describe the requested changes')
|
||
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
|
||
}
|
||
rows={1}
|
||
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||
style={{ minHeight: '72px', maxHeight: '200px' }}
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = 'auto';
|
||
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
|
||
}}
|
||
/>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
|
||
title={pickAppText(locale, '添加附件', 'Add attachment')}
|
||
>
|
||
<Plus className="h-5 w-5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={toggleThinkingMode}
|
||
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
|
||
thinkingModeEnabled
|
||
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
|
||
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
title={
|
||
thinkingModeEnabled
|
||
? pickAppText(locale, '思考模式已开启', 'Thinking mode is on')
|
||
: pickAppText(locale, '思考模式已关闭', 'Thinking mode is off')
|
||
}
|
||
aria-pressed={thinkingModeEnabled}
|
||
>
|
||
<Brain className="h-4 w-4" />
|
||
{pickAppText(locale, '思考', 'Think')}
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleSend}
|
||
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
|
||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#85817E] text-white transition-colors hover:bg-primary disabled:opacity-40"
|
||
aria-label={pickAppText(locale, '发送', 'Send')}
|
||
>
|
||
<Send className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|