feat(engine): 添加MCP连接管理和工具集成功能

- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
This commit is contained in:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

View File

@ -2,31 +2,27 @@
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
cancelDelegation,
archiveSession,
createSession,
deleteSession,
getActiveTask,
getSession,
getSessionProcess,
listCommands,
listSessions,
sendMessage,
submitChatFeedback,
uploadFile,
wsManager,
} from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
@ -62,6 +58,23 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
return data.type === 'session_updated' && typeof data.session_id === 'string';
}
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback');
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
return pickAppText(locale, '进行中', 'Active');
}
const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled';
function loadThinkingModePreference(): boolean {
if (typeof window === 'undefined') {
return true;
}
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
return stored == null ? true : stored !== 'false';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
@ -86,30 +99,19 @@ export default function ChatPage() {
} = useChatStore();
const [input, setInput] = useState('');
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [showCommandPicker, setShowCommandPicker] = useState(false);
const [pickerIndex, setPickerIndex] = useState(0);
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 messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
const shouldSnapToLatestRef = useRef(true);
const wsStatus = useChatStore((state) => state.wsStatus);
const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
const filter = input.slice(1).toLowerCase();
return commands.filter(
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
);
}, [commands, input]);
const sessionProcessRuns = useMemo(
() => processRuns.filter((run) => run.session_id === sessionId),
[processRuns, sessionId]
@ -132,19 +134,6 @@ export default function ChatPage() {
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const officeTasks = useMemo(
() => buildOfficeTaskList({
sessionId,
sessions,
processRuns,
processEvents,
processArtifacts,
}, locale),
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
@ -154,6 +143,17 @@ export default function ChatPage() {
}
}, []);
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;
@ -168,6 +168,7 @@ export default function ChatPage() {
if (process) {
setSessionProcess(key, process);
}
void loadActiveTask(key);
const nextMessages = waitingForReply
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
@ -182,36 +183,16 @@ export default function ChatPage() {
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
}
}, [setIsLoading, setIsThinking, setMessages, setSessionProcess]);
const loadCommands = useCallback(async () => {
if (commandsLoadedRef.current) return;
commandsLoadedRef.current = true;
try {
const nextCommands = await listCommands();
setCommands(nextCommands);
} catch {
commandsLoadedRef.current = false;
}
}, []);
useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands();
}
}, [input, loadCommands]);
useEffect(() => {
setShowCommandPicker(filteredCommands.length > 0);
setPickerIndex(0);
}, [filteredCommands]);
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
useEffect(() => {
clearMessages();
setIsLoading(false);
setIsThinking(false);
setActiveTask(null);
void loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
void loadActiveTask(sessionId);
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
useEffect(() => {
if (wsStatus === 'connected') {
@ -260,6 +241,7 @@ export default function ChatPage() {
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();
}
});
@ -267,7 +249,7 @@ export default function ChatPage() {
return () => {
unsubMessage();
};
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => {
if (!isLoading && !isThinking) {
@ -311,18 +293,6 @@ export default function ChatPage() {
shouldSnapToLatestRef.current = false;
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [pickerIndex, showCommandPicker]);
const selectCommand = useCallback((command: SlashCommand) => {
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
setShowCommandPicker(false);
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const text = input.trim();
if ((!text && pendingFiles.length === 0) || isLoading) return;
@ -337,7 +307,6 @@ export default function ChatPage() {
setInput('');
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
addMessage({
@ -350,14 +319,20 @@ export default function ChatPage() {
setIsThinking(false);
if (wsManager.getStatus() === 'connected') {
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
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);
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined, {
thinkingEnabled: thinkingModeEnabled,
});
setIsThinking(false);
setIsLoading(false);
if (result.response) {
@ -377,9 +352,11 @@ export default function ChatPage() {
: 'unknown',
});
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
void loadActiveTask(sessionId);
loadSessions();
} else {
await loadSessionMessages(sessionId);
void loadActiveTask(sessionId);
loadSessions();
}
} catch {
@ -395,48 +372,27 @@ export default function ChatPage() {
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess]);
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled]);
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => {
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'));
}
}, [loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
if (e.key === 'ArrowUp') {
e.preventDefault();
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
e.preventDefault();
selectCommand(filteredCommands[pickerIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowCommandPicker(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
@ -470,6 +426,7 @@ export default function ChatPage() {
const id = `web:${Date.now()}`;
setSessionId(id);
setSelectedRunId(null);
setActiveTask(null);
clearMessages();
useChatStore.getState().resetProcessState();
try {
@ -480,23 +437,30 @@ export default function ChatPage() {
void loadSessions();
};
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await deleteSession(key);
await archiveSession(key);
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
if (key === sessionId) {
setSessionId('web:default');
setActiveTask(null);
clearMessages();
useChatStore.getState().resetProcessState();
}
loadSessions();
void loadSessions();
} catch {
// ignore transient errors
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);
setSessionId(key);
};
@ -516,6 +480,16 @@ export default function ChatPage() {
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);
@ -535,37 +509,42 @@ export default function ChatPage() {
};
return (
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
<div className="w-64 border-r border-border flex flex-col bg-card">
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
<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>
</button>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="p-2 space-y-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="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
<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 items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
session.key === sessionId
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50'
? 'bg-[#EFEEED] text-foreground'
: 'text-foreground hover:bg-[#EFEEED]/70'
}`}
>
<div className="flex items-center gap-2 truncate">
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
<div className="truncate">
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleDeleteSession(session.key, event)}
<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>
@ -573,40 +552,9 @@ export default function ChatPage() {
))}
</div>
</ScrollArea>
</div>
</aside>
<div className="flex-1 flex flex-col min-w-0">
{currentOfficeTask ? (
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
{pickAppText(locale, '当前任务现场', 'Current task floor')}
</div>
<OfficeStatusBadge status={currentOfficeTask.status} />
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">
{currentOfficeTask.title}
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
</Button>
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
{pickAppText(locale, '查看任务现场', 'Open task floor')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
) : null}
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
@ -623,8 +571,23 @@ export default function ChatPage() {
/>
</div>
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
<div className="max-w-5xl mx-auto">
<div className="bg-background px-8 pb-8 pt-4">
<div className="mx-auto max-w-5xl">
{activeTask && (
<div className="mb-2 flex">
<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">{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">
{activeTaskStatusLabel(activeTask.status, locale)}
</span>
</Link>
</div>
)}
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
@ -640,7 +603,7 @@ export default function ChatPage() {
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
<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" />
@ -650,62 +613,18 @@ export default function ChatPage() {
</div>
)}
<div className="relative flex gap-2">
{showCommandPicker && filteredCommands.length > 0 && (
<div
ref={pickerRef}
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
>
{filteredCommands.map((command, index) => (
<button
key={command.name}
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
index === pickerIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-foreground'
}`}
onMouseDown={(event) => {
event.preventDefault();
selectCommand(command);
}}
onMouseEnter={() => setPickerIndex(index)}
>
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
{command.argument_hint && (
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
)}
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
</span>
)}
</button>
))}
</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} />
<Button
onClick={() => fileInputRef.current?.click()}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Paperclip className="w-4 h-4" />
</Button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={pickAppText(locale, '输入消息或 / 呼出命令…回车发送Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
placeholder={pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')}
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }}
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';
@ -713,14 +632,44 @@ export default function ChatPage() {
}}
/>
<Button
onClick={handleSend}
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
size="icon"
className="h-10 w-10 flex-shrink-0"
>
<Send className="w-4 h-4" />
</Button>
<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>