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

@ -19,7 +19,7 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { BackendTask } from '@/types';
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
@ -45,6 +45,7 @@ export default function TaskDetailPage() {
const mountedRef = React.useRef(true);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
@ -89,44 +90,17 @@ export default function TaskDetailPage() {
return () => window.clearInterval(id);
}, [backendTask, loadBackendTask]);
const taskRunIds = useMemo(() => {
const ids = new Set<string>();
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
return ids;
}, [backendTask]);
const liveRuns = useMemo(
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
[processRuns, taskId, taskRunIds]
);
const liveEvents = useMemo(
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
[processEvents, taskId, taskRunIds]
);
const liveArtifacts = useMemo(
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
[processArtifacts, taskId, taskRunIds]
);
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
const timelineCards = useMemo(
const timelineView = useMemo(
() =>
backendTask
? buildTaskTimelineCards({
task: backendTask,
processRuns: renderedRuns,
processEvents: renderedEvents,
processArtifacts: renderedArtifacts,
})
: [],
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
buildTaskTimelineView({
task: backendTask,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[backendTask, processArtifacts, processEvents, processRuns]
);
const timelineCards = timelineView?.cards ?? [];
const activeLabel =
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
@ -164,13 +138,13 @@ export default function TaskDetailPage() {
<div className="min-h-screen bg-background">
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<main className="mx-auto grid min-w-0 max-w-7xl gap-6 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0 space-y-4">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionBusy)}
onClick={() => void deleteCurrentBackendTask()}
>
@ -217,7 +191,12 @@ export default function TaskDetailPage() {
/>
</div>
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
<TaskSideRail
task={backendTask}
runs={timelineView?.process.runs ?? []}
artifacts={timelineView?.process.artifacts ?? []}
cards={timelineCards}
/>
</main>
</div>
);
@ -225,7 +204,7 @@ export default function TaskDetailPage() {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Button asChild variant="outline" className="h-11 w-fit">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务列表', 'Back to tasks')}

View File

@ -46,6 +46,7 @@ export default function TasksPage() {
function OrdinaryTasks() {
const { locale } = useAppI18n();
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const visibleTasks = useMemo(
@ -58,17 +59,25 @@ function OrdinaryTasks() {
const loadBackendTasks = React.useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
listBackendTasks()
.then((items) => {
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
})
.catch(() => {
if (!cancelled) setBackendTasks([]);
.catch((err: any) => {
if (!cancelled) {
setBackendTasks([]);
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load tasks'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
}, [locale]);
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
@ -86,7 +95,18 @@ function OrdinaryTasks() {
}
};
if (visibleTasks.length === 0) {
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载任务中', 'Loading tasks')}
</CardContent>
</Card>
);
}
if (visibleTasks.length === 0 && !error) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
@ -113,7 +133,19 @@ function OrdinaryTasks() {
</CardContent>
</Card>
)}
<Card>
{visibleTasks.length > 0 ? (
<>
<div className="grid gap-3 xl:hidden">
{visibleTasks.map((task) => (
<OrdinaryTaskCard
key={task.task_id}
task={task}
locale={locale}
onDelete={() => void handleDeleteBackendTask(task)}
/>
))}
</div>
<Card className="hidden xl:block">
<CardContent className="p-0">
<Table>
<TableHeader>
@ -154,7 +186,7 @@ function OrdinaryTasks() {
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline">
<Button asChild size="sm" variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入', 'Open')}
<ArrowRight className="ml-2 h-3.5 w-3.5" />
@ -163,8 +195,9 @@ function OrdinaryTasks() {
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => void handleDeleteBackendTask(task)}
aria-label={pickAppText(locale, `删除任务 ${task.short_title || task.task_id}`, `Delete task ${task.short_title || task.task_id}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-3.5 w-3.5" />
@ -177,10 +210,80 @@ function OrdinaryTasks() {
</Table>
</CardContent>
</Card>
</>
) : null}
</div>
);
}
function OrdinaryTaskCard({
task,
locale,
onDelete,
}: {
task: BackendTask;
locale: 'zh-CN' | 'en-US';
onDelete: () => void;
}) {
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold">{title}</h2>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{task.description || task.session_id}
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<Badge className="mt-1" variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '来源', 'Source')}</div>
<div className="mt-1 text-sm">{taskSourceLabel(task, locale)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '运行 / 技能', 'Runs / skills')}</div>
<div className="mt-1 text-sm">{task.run_ids.length} / {task.skill_names.length}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '更新时间', 'Updated')}</div>
<div className="mt-1 text-sm">{formatTaskRuntimeTime(task.updated_at, locale)}</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
<Button
size="icon"
variant="ghost"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onDelete}
aria-label={pickAppText(locale, `删除任务 ${title}`, `Delete task ${title}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button asChild variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入任务', 'Open task')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
@ -246,6 +349,13 @@ function ScheduledTasks() {
}
};
const handleRemoveJob = (job: CronJob) => {
if (!window.confirm(pickAppText(locale, `删除定时任务“${job.name}”?`, `Delete scheduled task "${job.name}"?`))) {
return;
}
void runJobAction(() => removeCronJob(job.id));
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -254,11 +364,11 @@ function ScheduledTasks() {
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
</div>
<div className="flex items-center gap-2">
<Button onClick={() => void loadJobs()} variant="outline" size="sm">
<Button onClick={() => void loadJobs()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Button onClick={() => setShowAdd(true)} size="sm" className="h-11">
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
</Button>
@ -287,7 +397,23 @@ function ScheduledTasks() {
/>
)}
<Card>
{!loading && jobs.length > 0 ? (
<div className="grid gap-3 xl:hidden">
{jobs.map((job) => (
<ScheduledJobCard
key={job.id}
job={job}
locale={locale}
formatTime={formatTime}
onToggle={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
onRun={() => void runJobAction(() => runCronJob(job.id))}
onRemove={() => handleRemoveJob(job)}
/>
))}
</div>
) : null}
<Card className={!loading && jobs.length > 0 ? 'hidden xl:block' : undefined}>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
@ -316,7 +442,11 @@ function ScheduledTasks() {
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} />
<Switch
checked={job.enabled}
onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{job.name}</div>
@ -330,7 +460,7 @@ function ScheduledTasks() {
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
</TableCell>
<TableCell>
<Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Button asChild size="sm" variant="outline" className="h-11" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
<FolderDown className="mr-2 h-3.5 w-3.5" />
{formatTime(job.last_run_at_ms)}
@ -348,10 +478,24 @@ function ScheduledTasks() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => void runJobAction(() => runCronJob(job.id))}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => handleRemoveJob(job)}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@ -367,6 +511,112 @@ function ScheduledTasks() {
);
}
function ScheduledJobCard({
job,
locale,
formatTime,
onToggle,
onRun,
onRemove,
}: {
job: CronJob;
locale: 'zh-CN' | 'en-US';
formatTime: (ms: number | null) => string;
onToggle: (checked: boolean) => void;
onRun: () => void;
onRemove: () => void;
}) {
const historyHref = job.last_scheduled_run_id
? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}`
: job.last_task_id
? `/tasks/${encodeURIComponent(job.last_task_id)}`
: '/tasks';
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-base font-semibold">{job.name}</h2>
<p className="mt-1 break-all text-xs text-muted-foreground">{job.id}</p>
</div>
<label className="flex min-h-11 shrink-0 items-center gap-2 text-sm">
<span className="sr-only">{pickAppText(locale, '启用', 'Enabled')}</span>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</label>
</div>
<p className="text-sm leading-6 text-muted-foreground">{job.message}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '计划', 'Schedule')}</div>
<code className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5">{job.schedule_display}</code>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '下次运行', 'Next run')}</div>
<div className="mt-1 text-sm">{formatTime(job.next_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '上次运行', 'Last run')}</div>
<div className="mt-1 text-sm">{formatTime(job.last_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<div className="mt-1">
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<Button
asChild
variant="outline"
className="h-11"
disabled={!job.last_scheduled_run_id && !job.last_task_id}
>
<Link href={historyHref}>
<FolderDown className="mr-2 h-4 w-4" />
{pickAppText(locale, '运行历史', 'History')}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onRun}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onRemove}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
@ -403,7 +653,14 @@ function AddJobForm({
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onCancel}
aria-label={pickAppText(locale, '关闭新建定时任务表单', 'Close new scheduled task form')}
title={pickAppText(locale, '关闭', 'Close')}
>
<X className="h-4 w-4" />
</Button>
</div>
@ -452,8 +709,8 @@ function AddJobForm({
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" className="h-11" disabled={!name.trim() || !message.trim()}>
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '创建', 'Create')}
</Button>