- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化 - 移除内置 agents 配置以简化系统架构 - 更新 ContextBuilder 使用动态提示词模板而非硬编码内容 - 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数 - 添加输出语言指令确保用户界面内容按指定语言生成 - 扩展前端 LanguageSwitcher 组件支持三种语言选项 - 优化 Header 和侧边栏组件的响应式布局和文本截断处理 - 更新测试用例验证不同语言环境下的提示词正确性
876 lines
35 KiB
TypeScript
876 lines
35 KiB
TypeScript
'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>
|
||
);
|
||
}
|