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:
@ -8,6 +8,7 @@ import type {
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
ChannelConnectionView,
|
||||
ChannelConfigDetail,
|
||||
ChannelConfigPayload,
|
||||
ChannelConnectorDescriptor,
|
||||
@ -666,6 +667,10 @@ export async function listChannelConnectors(): Promise<ChannelConnectorDescripto
|
||||
return fetchJSON('/api/channel-connectors');
|
||||
}
|
||||
|
||||
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
|
||||
return fetchJSON('/api/channel-connections');
|
||||
}
|
||||
|
||||
export async function startChannelConnectorSession(
|
||||
payload: ConnectorSessionStartPayload
|
||||
): Promise<ConnectorSessionResponse> {
|
||||
|
||||
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal file
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { connectorChannelForKind, runtimeBridgeEnabledForPath, visibleConnectorCards } from '@/lib/channel-connector-state';
|
||||
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
|
||||
|
||||
|
||||
function channel(overrides: Partial<ChannelStatus>): ChannelStatus {
|
||||
return {
|
||||
channel_id: 'weixin-main',
|
||||
kind: 'weixin',
|
||||
mode: 'polling',
|
||||
account_id: 'wx-main',
|
||||
display_name: 'Weixin Main',
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
capabilities: [],
|
||||
connected_peers: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
describe('connector channel cards', () => {
|
||||
it('does not let a disabled static channel block connector onboarding', () => {
|
||||
expect(connectorChannelForKind('weixin', [channel({})])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects a running connector channel', () => {
|
||||
const running = channel({ enabled: true, state: 'running' });
|
||||
|
||||
expect(connectorChannelForKind('weixin', [running])).toEqual(running);
|
||||
});
|
||||
|
||||
it('selects a running terminal channel', () => {
|
||||
const running = channel({
|
||||
channel_id: 'terminal-dev',
|
||||
kind: 'terminal',
|
||||
mode: 'websocket',
|
||||
display_name: 'Terminal Dev',
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
connected_peers: 1,
|
||||
});
|
||||
|
||||
expect(connectorChannelForKind('terminal', [running])).toEqual(running);
|
||||
});
|
||||
|
||||
it('always includes terminal as a local connector card fallback', () => {
|
||||
const connectors: ChannelConnectorDescriptor[] = [{ kind: 'weixin', authType: 'qr' }, { kind: 'feishu', authType: 'plugin' }];
|
||||
|
||||
expect(visibleConnectorCards(connectors).map((connector) => connector.kind)).toEqual(['weixin', 'feishu', 'terminal']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('app runtime bridge', () => {
|
||||
it('stays enabled on settings pages so the global connection status is accurate', () => {
|
||||
expect(runtimeBridgeEnabledForPath('/settings')).toBe(true);
|
||||
});
|
||||
});
|
||||
28
app-instance/frontend/lib/channel-connector-state.ts
Normal file
28
app-instance/frontend/lib/channel-connector-state.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
|
||||
|
||||
|
||||
const CONNECTOR_CARD_KINDS = ['weixin', 'feishu', 'terminal'];
|
||||
|
||||
export function connectorChannelForKind(kind: string, channels: ChannelStatus[]): ChannelStatus | undefined {
|
||||
const matches = channels.filter((channel) => channel.kind === kind);
|
||||
return (
|
||||
matches.find((channel) => channel.state === 'running') ||
|
||||
matches.find((channel) => channel.enabled && channel.state !== 'disabled' && channel.state !== 'stopped')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function visibleConnectorCards(connectors: ChannelConnectorDescriptor[]): ChannelConnectorDescriptor[] {
|
||||
const byKind = new Map<string, ChannelConnectorDescriptor>();
|
||||
connectors.forEach((connector) => {
|
||||
if (CONNECTOR_CARD_KINDS.includes(connector.kind) && !byKind.has(connector.kind)) {
|
||||
byKind.set(connector.kind, connector);
|
||||
}
|
||||
});
|
||||
return CONNECTOR_CARD_KINDS.map((kind) => byKind.get(kind) || { kind });
|
||||
}
|
||||
|
||||
|
||||
export function runtimeBridgeEnabledForPath(_pathname: string): boolean {
|
||||
return true;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
listChannelConnections,
|
||||
getChannelConnectorSession,
|
||||
listChannelConnectors,
|
||||
startChannelConnectorSession,
|
||||
@ -38,6 +39,18 @@ describe('channel connector api', () => {
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
|
||||
});
|
||||
|
||||
it('lists existing channel connections', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse([{ connection_id: 'conn_1', channel_id: 'weixin-1', kind: 'weixin', status: 'connected' }])
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const connections = await listChannelConnections();
|
||||
|
||||
expect(connections[0].status).toBe('connected');
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connections$/);
|
||||
});
|
||||
|
||||
it('starts a connector session with options', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
describe('session progress view builder', () => {
|
||||
it('selects the latest active root run for the current session and builds its run tree', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'old-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '旧任务',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T08:00:00.000Z',
|
||||
finished_at: '2026-05-22T08:05:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'latest-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '销售数据分析报告生成',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
metadata: {
|
||||
step_index: 3,
|
||||
step_total: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
run_id: 'collect-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '收集销售数据',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:03:00.000Z',
|
||||
summary: '已获取 Q1 销售数据',
|
||||
},
|
||||
{
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '数据清洗与预处理',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:04:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'other-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:other',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '其他会话任务',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-clean',
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
text: '清洗缺失值、异常值,统一格式',
|
||||
created_at: '2026-05-22T09:05:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-json',
|
||||
run_id: 'collect-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '销售数据',
|
||||
artifact_type: 'json',
|
||||
data: { rows: 120 },
|
||||
created_at: '2026-05-22T09:03:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-markdown',
|
||||
run_id: 'clean-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '清洗说明',
|
||||
artifact_type: 'markdown',
|
||||
content: '已完成数据标准化。',
|
||||
created_at: '2026-05-22T09:05:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-other-session',
|
||||
run_id: 'other-root',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
title: '其他会话产物',
|
||||
artifact_type: 'text',
|
||||
content: '不应出现',
|
||||
created_at: '2026-05-22T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view).not.toBeNull();
|
||||
expect(view?.rootRunId).toBe('latest-root');
|
||||
expect(view?.title).toBe('销售数据分析报告生成');
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 3,
|
||||
max: 5,
|
||||
percent: 60,
|
||||
label: '运行中:3 / 5 步',
|
||||
});
|
||||
expect(view?.steps.map((step) => step.runId)).toEqual(['collect-data', 'clean-data', 'latest-root']);
|
||||
expect(view?.steps.find((step) => step.runId === 'clean-data')?.description).toBe('清洗缺失值、异常值,统一格式');
|
||||
expect(view?.artifactTypeSummaries).toEqual([
|
||||
{ type: 'json', count: 1, label: 'JSON' },
|
||||
{ type: 'markdown', count: 1, label: 'Markdown' },
|
||||
]);
|
||||
expect(view?.artifacts.map((artifact) => artifact.artifactId)).toEqual(['artifact-markdown', 'artifact-json']);
|
||||
});
|
||||
|
||||
it('falls back to completed child run counts when no explicit progress metadata exists', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '生成总结',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'done-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'writer',
|
||||
actor_name: 'Writer',
|
||||
title: '整理结果',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:02:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'running-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'reviewer',
|
||||
actor_name: 'Reviewer',
|
||||
title: '复核结果',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:03:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 1,
|
||||
max: 2,
|
||||
percent: 50,
|
||||
label: '已完成 1 / 2 步',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,392 +0,0 @@
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
|
||||
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['queued', 'running', 'waiting']);
|
||||
const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [
|
||||
'text',
|
||||
'json',
|
||||
'file',
|
||||
'image',
|
||||
'link',
|
||||
'markdown',
|
||||
];
|
||||
|
||||
export interface SessionProgressValueView {
|
||||
label: string;
|
||||
value: number | null;
|
||||
max: number | null;
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
export interface SessionProgressStepView {
|
||||
runId: string;
|
||||
title: string;
|
||||
actorName: string;
|
||||
status: ProcessRunStatus;
|
||||
description: string | null;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt: string | null;
|
||||
artifactCount: number;
|
||||
isRoot: boolean;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactView {
|
||||
artifactId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
typeLabel: string;
|
||||
actorName: string;
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactTypeSummary {
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressView {
|
||||
rootRunId: string;
|
||||
title: string;
|
||||
status: ProcessRunStatus;
|
||||
summary: string | null;
|
||||
updatedAt: string;
|
||||
progress: SessionProgressValueView;
|
||||
steps: SessionProgressStepView[];
|
||||
artifacts: SessionProgressArtifactView[];
|
||||
artifactTypeSummaries: SessionProgressArtifactTypeSummary[];
|
||||
}
|
||||
|
||||
export type BuildSessionProgressInput = {
|
||||
sessionId: string;
|
||||
processRuns: ProcessRun[];
|
||||
processEvents: ProcessEvent[];
|
||||
processArtifacts: ProcessArtifact[];
|
||||
locale?: AppLocale;
|
||||
};
|
||||
|
||||
function toTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function latestTimestamp(values: Array<string | null | undefined>): string | null {
|
||||
let selected: string | null = null;
|
||||
let selectedTime = -1;
|
||||
for (const value of values) {
|
||||
const time = toTime(value);
|
||||
if (time === null || time <= selectedTime) continue;
|
||||
selected = value ?? null;
|
||||
selectedTime = time;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function compareIsoDesc(a?: string | null, b?: string | null): number {
|
||||
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
|
||||
}
|
||||
|
||||
function firstNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata?.[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
|
||||
const map = new Map<string, ProcessRun[]>();
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
const children = map.get(run.parent_run_id);
|
||||
if (children) {
|
||||
children.push(run);
|
||||
} else {
|
||||
map.set(run.parent_run_id, [run]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
|
||||
const collected: ProcessRun[] = [];
|
||||
const stack = [rootRun];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || seen.has(current.run_id)) continue;
|
||||
seen.add(current.run_id);
|
||||
collected.push(current);
|
||||
const children = childrenMap.get(current.run_id) ?? [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
stack.push(children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.run_id);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
map.set(item.run_id, [item]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getRunUpdatedAt(
|
||||
run: ProcessRun,
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return (
|
||||
latestTimestamp([
|
||||
run.finished_at,
|
||||
run.started_at,
|
||||
...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at),
|
||||
...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at),
|
||||
]) ?? run.started_at
|
||||
);
|
||||
}
|
||||
|
||||
function getTreeUpdatedAt(
|
||||
runs: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? '';
|
||||
}
|
||||
|
||||
function latestEventText(events: ProcessEvent[]): string | null {
|
||||
const event = [...events]
|
||||
.filter((item) => item.text?.trim())
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0];
|
||||
return event?.text?.trim() || null;
|
||||
}
|
||||
|
||||
function percent(value: number, max: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||
}
|
||||
|
||||
function explicitProgress(
|
||||
rootRun: ProcessRun,
|
||||
treeEvents: ProcessEvent[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressValueView | null {
|
||||
const metadataSources = [
|
||||
rootRun.metadata,
|
||||
...[...treeEvents]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((event) => event.metadata),
|
||||
];
|
||||
|
||||
for (const metadata of metadataSources) {
|
||||
const stepValue = firstNumber(metadata, ['step_index']);
|
||||
const stepMax = firstNumber(metadata, ['step_total']);
|
||||
if (stepValue !== null && stepMax !== null && stepMax > 0) {
|
||||
const safeValue = Math.min(stepValue, stepMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stepMax} 步`, `Running: ${safeValue} / ${stepMax} steps`),
|
||||
value: safeValue,
|
||||
max: stepMax,
|
||||
percent: percent(safeValue, stepMax),
|
||||
};
|
||||
}
|
||||
|
||||
const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']);
|
||||
const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']);
|
||||
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||
const safeValue = Math.min(stageValue, stageMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`),
|
||||
value: safeValue,
|
||||
max: stageMax,
|
||||
percent: percent(safeValue, stageMax),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView {
|
||||
const childRuns = taskRuns.filter((run) => run.parent_run_id);
|
||||
const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns;
|
||||
const doneRuns = runsForProgress.filter((run) => run.status === 'done').length;
|
||||
const totalRuns = runsForProgress.length;
|
||||
|
||||
if (totalRuns > 0) {
|
||||
return {
|
||||
label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns} 步`, `Completed ${doneRuns} / ${totalRuns} steps`),
|
||||
value: doneRuns,
|
||||
max: totalRuns,
|
||||
percent: percent(doneRuns, totalRuns),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||
value: null,
|
||||
max: null,
|
||||
percent: null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string {
|
||||
if (type === 'text') return pickAppText(locale, '文本', 'Text');
|
||||
if (type === 'json') return 'JSON';
|
||||
if (type === 'file') return pickAppText(locale, '文件', 'File');
|
||||
if (type === 'image') return pickAppText(locale, '图片', 'Image');
|
||||
if (type === 'link') return pickAppText(locale, '链接', 'Link');
|
||||
return 'Markdown';
|
||||
}
|
||||
|
||||
function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string {
|
||||
if (artifact.content?.trim()) {
|
||||
return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||
}
|
||||
if (artifact.url?.trim()) return artifact.url.trim();
|
||||
if (artifact.data !== undefined) {
|
||||
return JSON.stringify(artifact.data).slice(0, 120);
|
||||
}
|
||||
return pickAppText(locale, '暂无预览', 'No preview');
|
||||
}
|
||||
|
||||
function buildArtifactSummaries(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactTypeSummary[] {
|
||||
const counts = new Map<ProcessArtifact['artifact_type'], number>();
|
||||
for (const artifact of artifacts) {
|
||||
counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1);
|
||||
}
|
||||
return ARTIFACT_TYPE_ORDER
|
||||
.filter((type) => counts.has(type))
|
||||
.map((type) => ({
|
||||
type,
|
||||
count: counts.get(type) ?? 0,
|
||||
label: artifactTypeLabel(type, locale),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildArtifactViews(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactView[] {
|
||||
return [...artifacts]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((artifact) => ({
|
||||
artifactId: artifact.artifact_id,
|
||||
runId: artifact.run_id,
|
||||
title: artifact.title,
|
||||
type: artifact.artifact_type,
|
||||
typeLabel: artifactTypeLabel(artifact.artifact_type, locale),
|
||||
actorName: artifact.actor_name || artifact.actor_id,
|
||||
preview: artifactPreview(artifact, locale),
|
||||
createdAt: artifact.created_at,
|
||||
url: artifact.url,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSteps(
|
||||
rootRun: ProcessRun,
|
||||
taskRuns: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): SessionProgressStepView[] {
|
||||
return [...taskRuns]
|
||||
.sort((a, b) => {
|
||||
if (a.run_id === rootRun.run_id) return 1;
|
||||
if (b.run_id === rootRun.run_id) return -1;
|
||||
return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0);
|
||||
})
|
||||
.map((run) => {
|
||||
const runEvents = eventsByRun.get(run.run_id) ?? [];
|
||||
const runArtifacts = artifactsByRun.get(run.run_id) ?? [];
|
||||
return {
|
||||
runId: run.run_id,
|
||||
title: run.title,
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
description: latestEventText(runEvents) || run.summary?.trim() || null,
|
||||
startedAt: run.started_at,
|
||||
updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun),
|
||||
finishedAt: run.finished_at ?? null,
|
||||
artifactCount: runArtifacts.length,
|
||||
isRoot: run.run_id === rootRun.run_id,
|
||||
isCurrent: !TERMINAL_STATUSES.has(run.status),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSessionProgressView({
|
||||
sessionId,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale = getCurrentAppLocale(),
|
||||
}: BuildSessionProgressInput): SessionProgressView | null {
|
||||
const sessionRuns = processRuns.filter((run) => run.session_id === sessionId);
|
||||
const rootRuns = sessionRuns.filter((run) => !run.parent_run_id);
|
||||
if (rootRuns.length === 0) return null;
|
||||
|
||||
const allChildrenMap = buildChildrenMap(processRuns);
|
||||
const runTreeCache = new Map<string, ProcessRun[]>();
|
||||
const treeForRoot = (root: ProcessRun) => {
|
||||
const cached = runTreeCache.get(root.run_id);
|
||||
if (cached) return cached;
|
||||
const tree = collectRunTree(root, allChildrenMap).filter(
|
||||
(run) => run.session_id === sessionId || run.run_id === root.run_id
|
||||
);
|
||||
runTreeCache.set(root.run_id, tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const allEventsByRun = groupByRunId(processEvents);
|
||||
const allArtifactsByRun = groupByRunId(processArtifacts);
|
||||
const selectedRoot = [...rootRuns].sort((a, b) => {
|
||||
const aActive = ACTIVE_STATUSES.has(a.status);
|
||||
const bActive = ACTIVE_STATUSES.has(b.status);
|
||||
if (aActive !== bActive) return aActive ? -1 : 1;
|
||||
return compareIsoDesc(
|
||||
getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun),
|
||||
getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun)
|
||||
);
|
||||
})[0];
|
||||
|
||||
if (!selectedRoot) return null;
|
||||
|
||||
const taskRuns = treeForRoot(selectedRoot);
|
||||
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
|
||||
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
|
||||
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
|
||||
const eventsByRun = groupByRunId(taskEvents);
|
||||
const artifactsByRun = groupByRunId(taskArtifacts);
|
||||
const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun);
|
||||
const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale);
|
||||
|
||||
return {
|
||||
rootRunId: selectedRoot.run_id,
|
||||
title: selectedRoot.title,
|
||||
status: selectedRoot.status,
|
||||
summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null,
|
||||
updatedAt,
|
||||
progress,
|
||||
steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun),
|
||||
artifacts: buildArtifactViews(taskArtifacts, locale),
|
||||
artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale),
|
||||
};
|
||||
}
|
||||
160
app-instance/frontend/lib/task-process.test.ts
Normal file
160
app-instance/frontend/lib/task-process.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { selectTaskProcess } from '@/lib/task-process';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function task(): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Build report',
|
||||
goal: 'Build report',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-06-04T00:00:00.000Z',
|
||||
updated_at: '2026-06-04T00:01:00.000Z',
|
||||
run_ids: ['main-run'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
process_runs: [
|
||||
{
|
||||
run_id: 'main-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Persisted main run',
|
||||
status: 'waiting',
|
||||
started_at: '2026-06-04T00:00:10.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
],
|
||||
process_events: [
|
||||
{
|
||||
event_id: 'persisted-event',
|
||||
run_id: 'main-run',
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Planner',
|
||||
text: 'Persisted plan',
|
||||
created_at: '2026-06-04T00:00:20.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
],
|
||||
process_artifacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectTaskProcess', () => {
|
||||
it('merges persisted and live task process data while excluding other tasks', () => {
|
||||
const liveRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'main-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Live main run',
|
||||
status: 'running',
|
||||
started_at: '2026-06-04T00:00:10.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
{
|
||||
run_id: 'child-run',
|
||||
parent_run_id: 'main-run',
|
||||
session_id: 'subagent:child',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
title: 'Child work',
|
||||
status: 'done',
|
||||
started_at: '2026-06-04T00:00:30.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'other-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
actor_name: 'Other Agent',
|
||||
title: 'Other task',
|
||||
status: 'running',
|
||||
started_at: '2026-06-04T00:00:40.000Z',
|
||||
metadata: { task_id: 'task-2' },
|
||||
},
|
||||
];
|
||||
const liveEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'child-event',
|
||||
run_id: 'child-run',
|
||||
parent_run_id: 'main-run',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
text: 'Child finished',
|
||||
created_at: '2026-06-04T00:00:50.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'other-event',
|
||||
run_id: 'other-run',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
actor_name: 'Other Agent',
|
||||
text: 'Other task progress',
|
||||
created_at: '2026-06-04T00:00:55.000Z',
|
||||
metadata: { task_id: 'task-2' },
|
||||
},
|
||||
];
|
||||
const liveArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'child-artifact',
|
||||
run_id: 'child-run',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
title: 'Child result',
|
||||
artifact_type: 'text',
|
||||
created_at: '2026-06-04T00:01:00.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'other-artifact',
|
||||
run_id: 'other-run',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
title: 'Other result',
|
||||
artifact_type: 'text',
|
||||
created_at: '2026-06-04T00:01:05.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const selected = selectTaskProcess({
|
||||
task: task(),
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
});
|
||||
|
||||
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run', 'child-run']);
|
||||
expect(selected.runs[0].title).toBe('Live main run');
|
||||
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event', 'child-event']);
|
||||
expect(selected.artifacts.map((artifact) => artifact.artifact_id)).toEqual(['child-artifact']);
|
||||
});
|
||||
|
||||
it('returns persisted task process data when no live data is available', () => {
|
||||
const selected = selectTaskProcess({
|
||||
task: task(),
|
||||
liveRuns: [],
|
||||
liveEvents: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
|
||||
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run']);
|
||||
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event']);
|
||||
expect(selected.artifacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
75
app-instance/frontend/lib/task-process.ts
Normal file
75
app-instance/frontend/lib/task-process.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
export type TaskProcessSelection = {
|
||||
runs: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
artifacts: ProcessArtifact[];
|
||||
};
|
||||
|
||||
export type SelectTaskProcessInput = {
|
||||
task: BackendTask;
|
||||
liveRuns?: ProcessRun[];
|
||||
liveEvents?: ProcessEvent[];
|
||||
liveArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
function mergeById<T>(
|
||||
persisted: T[],
|
||||
live: T[],
|
||||
idFor: (item: T) => string,
|
||||
): T[] {
|
||||
const merged = new Map<string, T>();
|
||||
for (const item of persisted) merged.set(idFor(item), item);
|
||||
for (const item of live) merged.set(idFor(item), item);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function runBelongsToTask(run: ProcessRun, taskId: string, runIds: Set<string>): boolean {
|
||||
return runIds.has(run.run_id) || run.metadata?.task_id === taskId;
|
||||
}
|
||||
|
||||
function expandTaskRunIds(runs: ProcessRun[], taskId: string, seedRunIds: Set<string>): Set<string> {
|
||||
const selected = new Set(seedRunIds);
|
||||
for (const run of runs) {
|
||||
if (run.metadata?.task_id === taskId) selected.add(run.run_id);
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const run of runs) {
|
||||
if (!run.parent_run_id || !selected.has(run.parent_run_id) || selected.has(run.run_id)) continue;
|
||||
selected.add(run.run_id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function selectTaskProcess({
|
||||
task,
|
||||
liveRuns = [],
|
||||
liveEvents = [],
|
||||
liveArtifacts = [],
|
||||
}: SelectTaskProcessInput): TaskProcessSelection {
|
||||
const persistedRuns = task.process_runs ?? [];
|
||||
const persistedEvents = task.process_events ?? [];
|
||||
const persistedArtifacts = task.process_artifacts ?? [];
|
||||
const allRuns = mergeById(persistedRuns, liveRuns, (run) => run.run_id);
|
||||
const seedRunIds = new Set([
|
||||
...task.run_ids.filter(Boolean),
|
||||
...persistedRuns.map((run) => run.run_id),
|
||||
]);
|
||||
const taskRunIds = expandTaskRunIds(allRuns, task.task_id, seedRunIds);
|
||||
const runs = allRuns.filter((run) => runBelongsToTask(run, task.task_id, taskRunIds));
|
||||
|
||||
return {
|
||||
runs,
|
||||
events: mergeById(persistedEvents, liveEvents, (event) => event.event_id).filter(
|
||||
(event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === task.task_id
|
||||
),
|
||||
artifacts: mergeById(persistedArtifacts, liveArtifacts, (artifact) => artifact.artifact_id).filter(
|
||||
(artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === task.task_id
|
||||
),
|
||||
};
|
||||
}
|
||||
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal file
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
|
||||
import type { BackendTask, ProcessEvent } from '@/types';
|
||||
|
||||
function task(): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Build report',
|
||||
goal: 'Build report',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-06-04T00:00:00.000Z',
|
||||
updated_at: '2026-06-04T00:01:00.000Z',
|
||||
run_ids: ['main-run'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTaskTimelineView', () => {
|
||||
it('builds canonical task timeline cards from matching live process data', () => {
|
||||
const liveEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'plan',
|
||||
run_id: 'main-run',
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Planner',
|
||||
text: 'Plan created',
|
||||
created_at: '2026-06-04T00:00:10.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildTaskTimelineView({
|
||||
task: task(),
|
||||
liveEvents,
|
||||
});
|
||||
|
||||
expect(view?.cards.map((card) => card.type)).toEqual(['task_created', 'plan']);
|
||||
expect(view?.process.events.map((event) => event.event_id)).toEqual(['plan']);
|
||||
});
|
||||
|
||||
it('returns null when there is no active task to display', () => {
|
||||
expect(buildTaskTimelineView({ task: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
37
app-instance/frontend/lib/task-timeline-view.ts
Normal file
37
app-instance/frontend/lib/task-timeline-view.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { selectTaskProcess, type SelectTaskProcessInput, type TaskProcessSelection } from '@/lib/task-process';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, TaskTimelineCard } from '@/types';
|
||||
|
||||
export type BuildTaskTimelineViewInput = Omit<SelectTaskProcessInput, 'task'> & {
|
||||
task: BackendTask | null;
|
||||
};
|
||||
|
||||
export type TaskTimelineView = {
|
||||
process: TaskProcessSelection;
|
||||
cards: TaskTimelineCard[];
|
||||
};
|
||||
|
||||
export function buildTaskTimelineView({
|
||||
task,
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
}: BuildTaskTimelineViewInput): TaskTimelineView | null {
|
||||
if (!task) return null;
|
||||
|
||||
const process = selectTaskProcess({
|
||||
task,
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
});
|
||||
return {
|
||||
process,
|
||||
cards: buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns: process.runs,
|
||||
processEvents: process.events,
|
||||
processArtifacts: process.artifacts,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user