Files
beaver_project/app-instance/frontend/app/(app)/page.tsx
steven_li fc9fd93c36 feat: 支持多语言提示词本地化和界面优化
- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化
- 移除内置 agents 配置以简化系统架构
- 更新 ContextBuilder 使用动态提示词模板而非硬编码内容
- 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数
- 添加输出语言指令确保用户界面内容按指定语言生成
- 扩展前端 LanguageSwitcher 组件支持三种语言选项
- 优化 Header 和侧边栏组件的响应式布局和文本截断处理
- 更新测试用例验证不同语言环境下的提示词正确性
2026-06-10 16:11:05 +08:00

876 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
archiveSession,
createSession,
getActiveTask,
getBackendTask,
getSession,
getSessionProcess,
listSessions,
promptLocaleForAppLocale,
sendMessage,
submitChatFeedback,
uploadFile,
wsManager,
} from '@/lib/api';
import {
getSessionRefreshIntervalMs,
mergeServerWithPendingUsers,
shouldDisplayChatMessage,
shouldMergePendingUsers,
} from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { ActiveTask, BackendTask, 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: string) {
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
if (status === 'awaiting_acceptance') return pickAppText(locale, '待验收', 'Awaiting acceptance');
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 false;
}
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
return stored == null ? false : stored !== 'false';
}
function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
sessionId,
messages,
isLoading,
isThinking,
sessions,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
setSessionId,
setMessages,
addMessage,
setInputDraft,
getInputDraft,
clearInputDraft,
setIsLoading,
clearMessages,
setIsThinking,
setSelectedRunId,
setSessionProcess,
updateMessageFeedback,
} = useChatStore();
const [input, setInput] = useState(() => useChatStore.getState().getInputDraft(useChatStore.getState().sessionId));
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 [activeTaskDetail, setActiveTaskDetail] = useState<BackendTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const [archiveTargetSessionId, setArchiveTargetSessionId] = 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 loadActiveTaskReqSeq = 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 activeTaskTimelineView = useMemo(
() =>
buildTaskTimelineView({
task: activeTaskDetail,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
locale,
}),
[activeTaskDetail, locale, processArtifacts, processEvents, processRuns]
);
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) => {
const reqSeq = ++loadActiveTaskReqSeq.current;
try {
const nextActiveTask = await getActiveTask(key);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
setActiveTask(nextActiveTask);
if (!nextActiveTask) {
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null));
try {
const detail = await getBackendTask(nextActiveTask.task_id);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
if (detail.is_open === false) {
setActiveTask(null);
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail(detail);
} catch {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTaskDetail(null);
}
}
} catch {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTask(null);
setActiveTaskDetail(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 displayMessages = detail.messages.filter(shouldDisplayChatMessage);
const shouldMergePending = shouldMergePendingUsers(displayMessages, localSnapshot, waitingForReply);
const nextMessages = shouldMergePending
? mergeServerWithPendingUsers(displayMessages, localSnapshot)
: displayMessages;
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);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(sessionId));
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') {
setIsThinking(false);
setIsLoading(false);
const rawEvidenceStatus = data.evidence_status ?? data.metadata?.evidence_status;
const evidenceStatus = rawEvidenceStatus === 'recorded' ? 'recorded' : undefined;
const assistantMessage = {
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,
evidence_status: evidenceStatus,
} as const;
if (shouldDisplayChatMessage(assistantMessage)) {
addMessage(assistantMessage);
}
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(() => {
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
if (intervalMs == null) {
return;
}
const timer = setInterval(() => {
const currentSessionId = useChatStore.getState().sessionId;
void loadSessionMessages(currentSessionId);
void loadSessions();
}, intervalMs);
return () => clearInterval(timer);
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
document.addEventListener('visibilitychange', updateVisibility);
return () => document.removeEventListener('visibilitychange', updateVisibility);
}, []);
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;
setSessionDrawerOpen(false);
}, [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('');
clearInputDraft(sessionId);
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,
prompt_locale: promptLocaleForAppLocale(locale),
};
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;
}
const assistantMessage = {
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
run_id: result.run_id,
task_id: result.task_id,
task_status: result.task_status,
evidence_status: result.evidence_status === 'recorded' ? 'recorded' : undefined,
} as const;
if (shouldDisplayChatMessage(assistantMessage)) {
addMessage(assistantMessage);
}
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, clearInputDraft, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
const handleFeedback = useCallback(async (runId: string, feedbackType: 'accept' | '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);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(id);
setInput('');
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) => {
try {
await archiveSession(key);
setArchiveTargetSessionId(null);
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
if (key === sessionId) {
setSessionId('web:default');
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(key);
setInput(useChatStore.getState().getInputDraft('web:default'));
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);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(key));
setSessionId(key);
setSessionDrawerOpen(false);
};
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;
};
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
<>
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={() => {
setSessionDrawerOpen(false);
void 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) => {
const sessionName = formatSessionName(session.key);
const isCurrent = session.key === sessionId;
return (
<div
key={`${variant}:${session.key}`}
className={`group flex items-center gap-1 rounded-xl px-2 py-1 text-[15px] transition-colors ${
isCurrent
? 'bg-[#EFEEED] text-foreground'
: 'text-foreground hover:bg-[#EFEEED]/70 focus-within:bg-[#EFEEED]/70'
}`}
>
<button
type="button"
onClick={() => handleSelectSession(session.key)}
className="flex h-11 min-w-0 flex-1 items-center rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-current={isCurrent ? 'true' : undefined}
>
<span className="truncate">{sessionName}</span>
</button>
<button
type="button"
onClick={() => setArchiveTargetSessionId(session.key)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground opacity-100 transition-colors hover:bg-white hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, `归档会话 ${sessionName}`, `Archive session ${sessionName}`)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</ScrollArea>
</>
);
return (
<div className="relative flex h-[calc(100dvh-4rem)] overflow-hidden bg-background">
<aside className="hidden w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] md:flex">
{renderSessionSidebar('desktop')}
</aside>
{sessionDrawerOpen && (
<div className="fixed inset-x-0 bottom-0 top-16 z-40 md:hidden">
<button
type="button"
className="absolute inset-0 bg-black/30"
aria-label={pickAppText(locale, '关闭最近对话', 'Close recent chats')}
onClick={() => setSessionDrawerOpen(false)}
/>
<aside className="absolute bottom-0 left-0 top-0 flex w-[min(86vw,320px)] flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] shadow-2xl">
{renderSessionSidebar('drawer')}
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex min-h-14 items-center gap-2 border-b border-[#E6E1DE] bg-[#F7F6F5] px-3 md:hidden">
<button
type="button"
onClick={() => setSessionDrawerOpen(true)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715]"
aria-label={pickAppText(locale, '打开最近对话', 'Open recent chats')}
>
<Menu className="h-5 w-5" />
</button>
<div className="min-w-0 text-sm font-medium text-foreground">
<span className="block truncate">{formatSessionName(sessionId)}</span>
</div>
</div>
<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-3 pb-4 pt-3 sm:px-5 sm:pb-6 md:px-8 md:pb-8 md: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 h-11 max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 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 min-h-11 items-center gap-2 rounded-md bg-muted px-3 py-1.5 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
type="button"
onClick={() => removePendingFile(item.file)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground"
aria-label={pickAppText(locale, `移除附件 ${item.file.name}`, `Remove attachment ${item.file.name}`)}
>
<X className="h-4 w-4" />
</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} />
<label htmlFor="chat-composer" className="sr-only">
{revisionTargetRunId
? pickAppText(locale, '修改要求', 'Revision request')
: pickAppText(locale, '消息内容', 'Message content')}
</label>
<textarea
id="chat-composer"
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
setInputDraft(sessionId, 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-1 pb-8 pt-1 text-[16px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:px-2 sm:text-[17px]"
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-2 text-[15px] text-muted-foreground sm:gap-5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-[#F7F5F4] hover:text-muted-foreground"
title={pickAppText(locale, '添加附件', 'Add attachment')}
aria-label={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Plus className="h-5 w-5" />
</button>
<button
type="button"
onClick={toggleThinkingMode}
className={`inline-flex h-11 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>
<Dialog open={Boolean(archiveTargetSessionId)} onOpenChange={(open) => !open && setArchiveTargetSessionId(null)}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{pickAppText(locale, '归档此会话?', 'Archive this chat?')}</DialogTitle>
<DialogDescription>
{pickAppText(
locale,
archiveTargetSessionName ? `会话「${archiveTargetSessionName}」会从最近对话中移除。` : '此会话会从最近对话中移除。',
archiveTargetSessionName ? `Chat "${archiveTargetSessionName}" will be removed from recent chats.` : 'This chat will be removed from recent chats.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<button
type="button"
onClick={() => setArchiveTargetSessionId(null)}
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => archiveTargetSessionId && void handleArchiveSession(archiveTargetSessionId)}
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{pickAppText(locale, '确认归档', 'Confirm archive')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{activeTaskDetail ? (
<CurrentSessionProgressSidebar
cards={activeTaskTimelineView?.cards ?? []}
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
/>
) : null}
</div>
);
}