feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -2,15 +2,24 @@
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 { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
archiveSession,
createSession,
getActiveTask,
getBackendTask,
getSession,
getSessionProcess,
listSessions,
@ -27,9 +36,9 @@ import {
} 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';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
@ -86,13 +95,17 @@ export default function ChatPage() {
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [activeTaskDetail, setActiveTaskDetail] = useState<BackendTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const [archiveTargetSessionId, setArchiveTargetSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const loadActiveTaskReqSeq = useRef(0);
const loadedSessionIdRef = useRef<string | null>(null);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
@ -120,16 +133,15 @@ export default function ChatPage() {
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const sessionProgressView = useMemo(
const activeTaskTimelineView = useMemo(
() =>
buildSessionProgressView({
sessionId,
processRuns,
processEvents,
processArtifacts,
locale,
buildTaskTimelineView({
task: activeTaskDetail,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[locale, processArtifacts, processEvents, processRuns, sessionId]
[activeTaskDetail, processArtifacts, processEvents, processRuns]
);
const loadSessions = useCallback(async () => {
@ -142,12 +154,34 @@ export default function ChatPage() {
}, []);
const loadActiveTask = useCallback(async (key: string) => {
const reqSeq = ++loadActiveTaskReqSeq.current;
try {
if (useChatStore.getState().sessionId !== key) return;
setActiveTask(await getActiveTask(key));
const nextActiveTask = await getActiveTask(key);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
setActiveTask(nextActiveTask);
if (!nextActiveTask) {
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null));
try {
const detail = await getBackendTask(nextActiveTask.task_id);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
if (detail.is_open === false) {
setActiveTask(null);
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail(detail);
} catch {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTaskDetail(null);
}
}
} catch {
if (useChatStore.getState().sessionId === key) {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTask(null);
setActiveTaskDetail(null);
}
}
}, []);
@ -194,6 +228,7 @@ export default function ChatPage() {
setIsThinking(false);
}
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(sessionId));
void loadSessionMessages(sessionId);
@ -299,6 +334,7 @@ export default function ChatPage() {
useEffect(() => {
shouldSnapToLatestRef.current = true;
setSessionDrawerOpen(false);
}, [sessionId]);
useLayoutEffect(() => {
@ -474,6 +510,7 @@ export default function ChatPage() {
setSessionId(id);
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(id);
setInput('');
@ -487,14 +524,15 @@ export default function ChatPage() {
void loadSessions();
};
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
const handleArchiveSession = async (key: string) => {
try {
await archiveSession(key);
setArchiveTargetSessionId(null);
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
if (key === sessionId) {
setSessionId('web:default');
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(key);
setInput(useChatStore.getState().getInputDraft('web:default'));
@ -514,9 +552,11 @@ export default function ChatPage() {
const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(key));
setSessionId(key);
setSessionDrawerOpen(false);
};
const removePendingFile = useCallback((file: File) => {
@ -551,53 +591,101 @@ export default function ChatPage() {
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) => (
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
<>
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={() => {
setSessionDrawerOpen(false);
void handleNewSession();
}}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
>
<Plus className="h-4 w-4" />
{pickAppText(locale, '新对话', 'New chat')}
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 px-3 pb-6">
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
{sessions.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => {
const sessionName = formatSessionName(session.key);
const isCurrent = session.key === sessionId;
return (
<div
key={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
key={`${variant}:${session.key}`}
className={`group flex items-center gap-1 rounded-xl px-2 py-1 text-[15px] transition-colors ${
isCurrent
? 'bg-[#EFEEED] text-foreground'
: 'text-foreground hover:bg-[#EFEEED]/70'
: 'text-foreground hover:bg-[#EFEEED]/70 focus-within: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')}
<button
type="button"
onClick={() => handleSelectSession(session.key)}
className="flex h-11 min-w-0 flex-1 items-center rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-current={isCurrent ? 'true' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="truncate">{sessionName}</span>
</button>
<button
type="button"
onClick={() => setArchiveTargetSessionId(session.key)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground opacity-100 transition-colors hover:bg-white hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, `归档会话 ${sessionName}`, `Archive session ${sessionName}`)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</ScrollArea>
);
})}
</div>
</ScrollArea>
</>
);
return (
<div className="relative flex h-[calc(100dvh-4rem)] overflow-hidden bg-background">
<aside className="hidden w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] md:flex">
{renderSessionSidebar('desktop')}
</aside>
{sessionDrawerOpen && (
<div className="fixed inset-x-0 bottom-0 top-16 z-40 md:hidden">
<button
type="button"
className="absolute inset-0 bg-black/30"
aria-label={pickAppText(locale, '关闭最近对话', 'Close recent chats')}
onClick={() => setSessionDrawerOpen(false)}
/>
<aside className="absolute bottom-0 left-0 top-0 flex w-[min(86vw,320px)] flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] shadow-2xl">
{renderSessionSidebar('drawer')}
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex min-h-14 items-center gap-2 border-b border-[#E6E1DE] bg-[#F7F6F5] px-3 md:hidden">
<button
type="button"
onClick={() => setSessionDrawerOpen(true)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715]"
aria-label={pickAppText(locale, '打开最近对话', 'Open recent chats')}
>
<Menu className="h-5 w-5" />
</button>
<div className="min-w-0 text-sm font-medium text-foreground">
<span className="block truncate">{formatSessionName(sessionId)}</span>
</div>
</div>
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
@ -614,14 +702,14 @@ export default function ChatPage() {
/>
</div>
<div className="bg-background px-8 pb-8 pt-4">
<div className="bg-background px-3 pb-4 pt-3 sm:px-5 sm:pb-6 md:px-8 md:pb-8 md:pt-4">
<div className="mx-auto max-w-5xl">
{(activeTask || revisionTargetRunId) && (
<div className="mb-2 flex">
{activeTask ? (
<Link
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
className="inline-flex 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]"
className="inline-flex h-11 max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
title={activeTask.description}
>
<span className="shrink-0 text-muted-foreground">
@ -638,7 +726,7 @@ export default function ChatPage() {
{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">
<div key={`${item.file.name}:${index}`} className="flex min-h-11 items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
@ -652,8 +740,13 @@ export default function ChatPage() {
) : (
<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
type="button"
onClick={() => removePendingFile(item.file)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground"
aria-label={pickAppText(locale, `移除附件 ${item.file.name}`, `Remove attachment ${item.file.name}`)}
>
<X className="h-4 w-4" />
</button>
</div>
))}
@ -662,8 +755,14 @@ export default function ChatPage() {
<div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<label htmlFor="chat-composer" className="sr-only">
{revisionTargetRunId
? pickAppText(locale, '修改要求', 'Revision request')
: pickAppText(locale, '消息内容', 'Message content')}
</label>
<textarea
id="chat-composer"
ref={textareaRef}
value={input}
onChange={(e) => {
@ -677,7 +776,7 @@ export default function ChatPage() {
: 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"
className="block w-full resize-none border-0 bg-transparent px-1 pb-8 pt-1 text-[16px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:px-2 sm:text-[17px]"
style={{ minHeight: '72px', maxHeight: '200px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
@ -687,18 +786,20 @@ export default function ChatPage() {
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
<div className="flex items-center gap-2 text-[15px] text-muted-foreground sm:gap-5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-[#F7F5F4] hover:text-muted-foreground"
title={pickAppText(locale, '添加附件', 'Add attachment')}
aria-label={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Plus className="h-5 w-5" />
</button>
<button
type="button"
onClick={toggleThinkingMode}
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
className={`inline-flex h-11 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
thinkingModeEnabled
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
@ -729,7 +830,43 @@ export default function ChatPage() {
</div>
</div>
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
<Dialog open={Boolean(archiveTargetSessionId)} onOpenChange={(open) => !open && setArchiveTargetSessionId(null)}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{pickAppText(locale, '归档此会话?', 'Archive this chat?')}</DialogTitle>
<DialogDescription>
{pickAppText(
locale,
archiveTargetSessionName ? `会话「${archiveTargetSessionName}」会从最近对话中移除。` : '此会话会从最近对话中移除。',
archiveTargetSessionName ? `Chat "${archiveTargetSessionName}" will be removed from recent chats.` : 'This chat will be removed from recent chats.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<button
type="button"
onClick={() => setArchiveTargetSessionId(null)}
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => archiveTargetSessionId && void handleArchiveSession(archiveTargetSessionId)}
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{pickAppText(locale, '确认归档', 'Confirm archive')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{activeTaskDetail ? (
<CurrentSessionProgressSidebar
cards={activeTaskTimelineView?.cards ?? []}
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
/>
) : null}
</div>
);
}