Files
beaver_project/app-instance/frontend/app/(app)/page.tsx
steven_li 6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00

714 lines
28 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, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
archiveSession,
createSession,
getActiveTask,
getSession,
getSessionProcess,
listSessions,
sendMessage,
submitChatFeedback,
uploadFile,
wsManager,
} from '@/lib/api';
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress';
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_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';
}
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 [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 sessionProgressView = useMemo(
() =>
buildSessionProgressView({
sessionId,
processRuns,
processEvents,
processArtifacts,
locale,
}),
[locale, processArtifacts, processEvents, processRuns, sessionId]
);
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 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);
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(() => {
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('');
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,
};
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);
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, 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);
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);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(key));
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);
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-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>
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
</div>
);
}