Files
beaver_project/app-instance/frontend/app/(app)/page.tsx
steven_li 030bce8a60 feat(litellm): 添加 reasoning_content 支持并强制禁用思考模式
- 在 LiteLLMProvider 中添加 "reasoning_content" 到允许的消息键集合中
- 修改 _apply_thinking_mode 方法以强制禁用思考模式,不再基于模型名称判断
- 总是设置 enable_thinking 为 False 并添加 thinking.type: disabled 配置
- 更新相关测试用例验证新的思考模式行为

fix(web): 修复非运行状态下的直接处理逻辑

- 创建 _run_web_direct 辅助函数来处理代理服务的直接提交/处理逻辑
- 当代理服务未运行时使用 process_direct 而不是 submit_direct
- 更新 REST 和 WebSocket 接口以使用新的处理逻辑
- 添加相应的单元测试验证非运行状态下使用直接处理

test(config): 添加代理配置重载功能的测试

- 添加 test_reload_agent_config_updates_booted_loop_config 测试函数
- 验证配置文件更新后代理循环能够正确加载新配置
- 测试模型、API 基础地址和 API 密钥的更新

chore(frontend): 默认禁用前端思考模式偏好

- 将前端思考模式存储的默认值从 true 改为 false
- 确保窗口未定义时返回 false 而不是 true
- 更新本地存储缺失时的默认行为为禁用思考模式
2026-05-22 17:43:21 +08:00

713 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, 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_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 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 shouldMergePending = shouldMergePendingUsers(detail.messages, localSnapshot, waitingForReply);
const nextMessages = shouldMergePending
? 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);
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') {
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('');
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;
}
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, clearInputDraft, 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);
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>
);
}