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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user