feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
@ -31,6 +31,7 @@ import type {
|
||||
UiMcpServerDescriptor,
|
||||
WsEvent,
|
||||
} from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
|
||||
@ -90,9 +91,10 @@ function withTimeout(
|
||||
signal?: AbortSignal,
|
||||
timeoutMs: number = REQUEST_TIMEOUT_MS
|
||||
): { signal: AbortSignal; cleanup: () => void } {
|
||||
const locale = getCurrentAppLocale();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
controller.abort(new DOMException('请求超时', 'AbortError'));
|
||||
controller.abort(new DOMException(pickAppText(locale, '请求超时', 'Request timed out'), 'AbortError'));
|
||||
}, timeoutMs);
|
||||
|
||||
const forwardAbort = () => controller.abort(signal?.reason);
|
||||
@ -154,6 +156,7 @@ function authHeaders(includeJsonContentType: boolean = true): Record<string, str
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T> {
|
||||
const locale = getCurrentAppLocale();
|
||||
const mergedHeaders = {
|
||||
...authHeaders(),
|
||||
...(options?.headers as Record<string, string> | undefined),
|
||||
@ -170,7 +173,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
throw new Error(pickAppText(locale, '请求超时', 'Request timed out'));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -191,7 +194,7 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`接口错误 ${res.status}: ${detail}`);
|
||||
throw new Error(`${pickAppText(locale, '接口错误', 'API error')} ${res.status}: ${detail}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@ -262,6 +265,7 @@ export function streamMessage(
|
||||
onError: (error: string) => void
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
const locale = getCurrentAppLocale();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@ -273,7 +277,7 @@ export function streamMessage(
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
onError(`HTTP 错误 ${res.status}`);
|
||||
onError(`${pickAppText(locale, 'HTTP 错误', 'HTTP error')} ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -308,7 +312,7 @@ export function streamMessage(
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
onError(err.message || '流式请求失败');
|
||||
onError(err.message || pickAppText(locale, '流式请求失败', 'Streaming request failed'));
|
||||
}
|
||||
}
|
||||
})();
|
||||
@ -1059,8 +1063,9 @@ export async function uploadFile(
|
||||
sessionId: string = 'web:default',
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<FileAttachment> {
|
||||
const locale = getCurrentAppLocale();
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error('文件过大(最大 50MB)');
|
||||
throw new Error(pickAppText(locale, '文件过大(最大 50MB)', 'File is too large (max 50MB)'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@ -1086,11 +1091,11 @@ export async function uploadFile(
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(`上传失败:${xhr.status}`));
|
||||
reject(new Error(`${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('上传失败'));
|
||||
xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
@ -1142,8 +1147,9 @@ export async function uploadToWorkspace(
|
||||
dirPath: string = '',
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<WorkspaceItem> {
|
||||
const locale = getCurrentAppLocale();
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error('文件过大(最大 50MB)');
|
||||
throw new Error(pickAppText(locale, '文件过大(最大 50MB)', 'File is too large (max 50MB)'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@ -1168,11 +1174,11 @@ export async function uploadToWorkspace(
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(`上传失败:${xhr.status}`));
|
||||
reject(new Error(`${pickAppText(locale, '上传失败', 'Upload failed')}: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('上传失败'));
|
||||
xhr.onerror = () => reject(new Error(pickAppText(locale, '上传失败', 'Upload failed')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
78
app-instance/frontend/lib/i18n/common.ts
Normal file
78
app-instance/frontend/lib/i18n/common.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { OfficeTaskStatus } from '@/lib/office';
|
||||
import type { ProcessArtifact, ProcessRun } from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
import type { WsStatus } from '@/lib/api';
|
||||
|
||||
export function appStatusLabel(
|
||||
status: ProcessRun['status'] | OfficeTaskStatus | string,
|
||||
locale: AppLocale = getCurrentAppLocale()
|
||||
): string {
|
||||
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
|
||||
if (status === 'running') return pickAppText(locale, '运行中', 'Running');
|
||||
if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
|
||||
if (status === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
|
||||
if (status === 'done') return pickAppText(locale, '已完成', 'Done');
|
||||
if (status === 'error') return pickAppText(locale, '失败', 'Error');
|
||||
if (status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return status;
|
||||
}
|
||||
|
||||
export function appActorTypeLabel(actorType: string, locale: AppLocale = getCurrentAppLocale()): string {
|
||||
if (actorType === 'mcp') return 'MCP';
|
||||
if (actorType === 'system') return pickAppText(locale, '系统', 'System');
|
||||
if (actorType === 'agent') return pickAppText(locale, '智能体', 'Agent');
|
||||
return actorType;
|
||||
}
|
||||
|
||||
export function appEventKindLabel(kind: string, locale: AppLocale = getCurrentAppLocale()): string {
|
||||
if (kind === 'run_started') return pickAppText(locale, '已启动', 'Started');
|
||||
if (kind === 'run_progress') return pickAppText(locale, '进行中', 'In Progress');
|
||||
if (kind === 'run_status') return pickAppText(locale, '状态更新', 'Status');
|
||||
if (kind === 'run_message') return pickAppText(locale, '消息', 'Message');
|
||||
if (kind === 'run_artifact') return pickAppText(locale, '产物', 'Artifact');
|
||||
if (kind === 'run_finished') return pickAppText(locale, '已结束', 'Finished');
|
||||
if (kind === 'run_cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return kind;
|
||||
}
|
||||
|
||||
export function appFeedRoleLabel(
|
||||
role: 'user' | 'assistant' | 'system' | 'tool',
|
||||
locale: AppLocale = getCurrentAppLocale()
|
||||
): string {
|
||||
if (role === 'user') return pickAppText(locale, '主 agent', 'Lead agent');
|
||||
if (role === 'tool') return pickAppText(locale, '工具输出', 'Tool output');
|
||||
if (role === 'system') return pickAppText(locale, '状态', 'Status');
|
||||
return pickAppText(locale, '子 agent', 'Sub-agent');
|
||||
}
|
||||
|
||||
export function appArtifactPreview(artifact: ProcessArtifact, locale: AppLocale = getCurrentAppLocale()): string {
|
||||
if (artifact.artifact_type === 'link' && artifact.url) {
|
||||
return `${artifact.title}\n${artifact.url}`;
|
||||
}
|
||||
if ((artifact.artifact_type === 'text' || artifact.artifact_type === 'markdown') && artifact.content) {
|
||||
return `${artifact.title}\n${artifact.content}`;
|
||||
}
|
||||
if (artifact.artifact_type === 'json') {
|
||||
return `${artifact.title}\n${pickAppText(locale, '已生成结构化结果', 'Structured output generated')}`;
|
||||
}
|
||||
if (artifact.file_id) {
|
||||
return `${artifact.title}\n${pickAppText(locale, '已生成文件输出', 'File output generated')}`;
|
||||
}
|
||||
return artifact.title;
|
||||
}
|
||||
|
||||
export function appConnectionStatusLabel(
|
||||
wsStatus: WsStatus,
|
||||
nanobotReady: boolean | null,
|
||||
locale: AppLocale = getCurrentAppLocale()
|
||||
): string {
|
||||
const isOnline = wsStatus === 'connected' && nanobotReady === true;
|
||||
const isChecking = wsStatus === 'connected' && nanobotReady === null;
|
||||
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
|
||||
|
||||
if (isOnline) return pickAppText(locale, '已连接', 'Connected');
|
||||
if (isChecking) return pickAppText(locale, '检查中', 'Checking');
|
||||
if (wsStatus === 'connecting') return pickAppText(locale, '连接中', 'Connecting');
|
||||
if (isOffline && wsStatus === 'connected') return pickAppText(locale, '服务离线', 'Service offline');
|
||||
return pickAppText(locale, '未连接', 'Disconnected');
|
||||
}
|
||||
76
app-instance/frontend/lib/i18n/core.ts
Normal file
76
app-instance/frontend/lib/i18n/core.ts
Normal file
@ -0,0 +1,76 @@
|
||||
export const APP_LOCALE_COOKIE = 'nanobot_locale';
|
||||
export const APP_LOCALE_STORAGE_KEY = 'nanobot_locale';
|
||||
|
||||
export const APP_LOCALES = ['zh-CN', 'en-US'] as const;
|
||||
|
||||
export type AppLocale = (typeof APP_LOCALES)[number];
|
||||
|
||||
export function isAppLocale(value: string | null | undefined): value is AppLocale {
|
||||
return value === 'zh-CN' || value === 'en-US';
|
||||
}
|
||||
|
||||
export function normalizeAppLocale(value?: string | null): AppLocale {
|
||||
const probe = value?.trim().toLowerCase() || '';
|
||||
if (probe.startsWith('en')) {
|
||||
return 'en-US';
|
||||
}
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
function readCookieLocale(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((item) => item.startsWith(`${APP_LOCALE_COOKIE}=`));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return decodeURIComponent(match.slice(APP_LOCALE_COOKIE.length + 1));
|
||||
}
|
||||
|
||||
export function readBrowserAppLocale(): AppLocale {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
const fromDocument = document.documentElement.lang;
|
||||
if (fromDocument) {
|
||||
return normalizeAppLocale(fromDocument);
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(APP_LOCALE_STORAGE_KEY);
|
||||
if (fromStorage) {
|
||||
return normalizeAppLocale(fromStorage);
|
||||
}
|
||||
|
||||
const fromCookie = readCookieLocale();
|
||||
if (fromCookie) {
|
||||
return normalizeAppLocale(fromCookie);
|
||||
}
|
||||
|
||||
return normalizeAppLocale(window.navigator.language);
|
||||
}
|
||||
|
||||
export function persistAppLocale(locale: AppLocale): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
window.localStorage.setItem(APP_LOCALE_STORAGE_KEY, locale);
|
||||
document.cookie = `${APP_LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
|
||||
}
|
||||
|
||||
export function getCurrentAppLocale(): AppLocale {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
return readBrowserAppLocale();
|
||||
}
|
||||
|
||||
export function pickAppText<T>(locale: AppLocale, zhValue: T, enValue: T): T {
|
||||
return locale === 'en-US' ? enValue : zhValue;
|
||||
}
|
||||
57
app-instance/frontend/lib/i18n/provider.tsx
Normal file
57
app-instance/frontend/lib/i18n/provider.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
type AppLocale,
|
||||
persistAppLocale,
|
||||
readBrowserAppLocale,
|
||||
} from '@/lib/i18n/core';
|
||||
|
||||
type AppI18nContextValue = {
|
||||
locale: AppLocale;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
};
|
||||
|
||||
const AppI18nContext = React.createContext<AppI18nContextValue | null>(null);
|
||||
|
||||
export function AppI18nProvider({
|
||||
initialLocale,
|
||||
children,
|
||||
}: {
|
||||
initialLocale: AppLocale;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [locale, setLocaleState] = React.useState<AppLocale>(initialLocale);
|
||||
|
||||
React.useEffect(() => {
|
||||
const browserLocale = readBrowserAppLocale();
|
||||
if (browserLocale !== locale) {
|
||||
setLocaleState(browserLocale);
|
||||
return;
|
||||
}
|
||||
persistAppLocale(locale);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
persistAppLocale(locale);
|
||||
}, [locale]);
|
||||
|
||||
const value = React.useMemo<AppI18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale: setLocaleState,
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
return <AppI18nContext.Provider value={value}>{children}</AppI18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAppI18n(): AppI18nContextValue {
|
||||
const value = React.useContext(AppI18nContext);
|
||||
if (!value) {
|
||||
throw new Error('useAppI18n must be used within AppI18nProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
17
app-instance/frontend/lib/i18n/server.ts
Normal file
17
app-instance/frontend/lib/i18n/server.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
import { APP_LOCALE_COOKIE, normalizeAppLocale, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
export function getServerAppLocale(): AppLocale {
|
||||
const cookieLocale = cookies().get(APP_LOCALE_COOKIE)?.value;
|
||||
if (cookieLocale) {
|
||||
return normalizeAppLocale(cookieLocale);
|
||||
}
|
||||
|
||||
const acceptLanguage = headers().get('accept-language');
|
||||
if (acceptLanguage) {
|
||||
return normalizeAppLocale(acceptLanguage);
|
||||
}
|
||||
|
||||
return 'zh-CN';
|
||||
}
|
||||
@ -224,4 +224,40 @@ describe('office view builders', () => {
|
||||
expect(tasks[1].taskId).toBe('run-done');
|
||||
expect(tasks[0].sessionLabel).toBe('Alpha Session');
|
||||
});
|
||||
|
||||
it('keeps office tasks visible when the root run inherits session from descendants', () => {
|
||||
const tasks = buildOfficeTaskList({
|
||||
sessionId: 'web:alpha',
|
||||
sessions: [{ key: 'web:alpha', path: 'Alpha Session' }],
|
||||
processRuns: [
|
||||
{
|
||||
run_id: 'run-root-no-session',
|
||||
parent_run_id: null,
|
||||
actor_type: 'agent',
|
||||
actor_id: 'agent-a',
|
||||
actor_name: 'Agent A',
|
||||
title: '根任务缺少会话字段',
|
||||
status: 'running',
|
||||
started_at: '2026-03-24T11:20:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'run-child-with-session',
|
||||
parent_run_id: 'run-root-no-session',
|
||||
session_id: 'web:alpha',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'agent-b',
|
||||
actor_name: 'Agent B',
|
||||
title: '子任务仍带着会话字段',
|
||||
status: 'running',
|
||||
started_at: '2026-03-24T11:21:00.000Z',
|
||||
},
|
||||
],
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
});
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].taskId).toBe('run-root-no-session');
|
||||
expect(tasks[0].sessionId).toBe('web:alpha');
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ProcessRunStatus,
|
||||
Session,
|
||||
} from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
const TERMINAL_STATUSES = new Set<OfficeTaskStatus>(['done', 'error', 'cancelled']);
|
||||
const STALE_WAITING_MS = 2 * 60 * 1000;
|
||||
@ -200,8 +201,8 @@ function latestTimestamp(values: Array<string | null | undefined>): string | nul
|
||||
return selected;
|
||||
}
|
||||
|
||||
function getSessionLabel(sessions: Session[], sessionId: string | null): string {
|
||||
if (!sessionId) return '未关联会话';
|
||||
function getSessionLabel(sessions: Session[], sessionId: string | null, locale: AppLocale): string {
|
||||
if (!sessionId) return pickAppText(locale, '未关联会话', 'No session linked');
|
||||
const session = sessions.find((item) => item.key === sessionId);
|
||||
if (!session) return sessionId;
|
||||
return session.path?.trim() || session.key;
|
||||
@ -279,6 +280,7 @@ function deriveStageLabel(
|
||||
run: ProcessRun,
|
||||
runEvents: ProcessEvent[],
|
||||
fallbackStatus: OfficeTaskStatus,
|
||||
locale: AppLocale,
|
||||
): string | null {
|
||||
const runMetadataLabel = readMetadataString(run.metadata, [
|
||||
'stage_label',
|
||||
@ -299,13 +301,13 @@ function deriveStageLabel(
|
||||
if (label) return label;
|
||||
}
|
||||
|
||||
if (fallbackStatus === 'running') return '执行中';
|
||||
if (fallbackStatus === 'waiting') return '等待中';
|
||||
if (fallbackStatus === 'queued') return '排队中';
|
||||
if (fallbackStatus === 'done') return '已完成';
|
||||
if (fallbackStatus === 'error') return '失败';
|
||||
if (fallbackStatus === 'cancelled') return '已取消';
|
||||
if (fallbackStatus === 'blocked') return '阻塞';
|
||||
if (fallbackStatus === 'running') return pickAppText(locale, '执行中', 'Running');
|
||||
if (fallbackStatus === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
|
||||
if (fallbackStatus === 'queued') return pickAppText(locale, '排队中', 'Queued');
|
||||
if (fallbackStatus === 'done') return pickAppText(locale, '已完成', 'Done');
|
||||
if (fallbackStatus === 'error') return pickAppText(locale, '失败', 'Error');
|
||||
if (fallbackStatus === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
if (fallbackStatus === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -330,13 +332,13 @@ function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): Offic
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
function zoneLabel(zoneId: OfficeZoneId): string {
|
||||
if (zoneId === 'reception') return '接待区';
|
||||
if (zoneId === 'workspace') return '工位区';
|
||||
if (zoneId === 'collab') return '协作区';
|
||||
if (zoneId === 'research') return '研究区';
|
||||
if (zoneId === 'alert') return '异常区';
|
||||
return '完成区';
|
||||
function zoneLabel(zoneId: OfficeZoneId, locale: AppLocale): string {
|
||||
if (zoneId === 'reception') return pickAppText(locale, '接待区', 'Reception');
|
||||
if (zoneId === 'workspace') return pickAppText(locale, '工位区', 'Workspace');
|
||||
if (zoneId === 'collab') return pickAppText(locale, '协作区', 'Collaboration');
|
||||
if (zoneId === 'research') return pickAppText(locale, '研究区', 'Research');
|
||||
if (zoneId === 'alert') return pickAppText(locale, '异常区', 'Alerts');
|
||||
return pickAppText(locale, '完成区', 'Completed');
|
||||
}
|
||||
|
||||
function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] {
|
||||
@ -378,7 +380,7 @@ function selectDisplayRun(
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | null {
|
||||
function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[], locale: AppLocale): string | null {
|
||||
if (run.status !== 'error') return null;
|
||||
const direct = firstString(run.summary);
|
||||
if (direct) return direct;
|
||||
@ -388,13 +390,14 @@ function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[]): string | n
|
||||
return event.text!.trim();
|
||||
}
|
||||
}
|
||||
return '任务执行失败';
|
||||
return pickAppText(locale, '任务执行失败', 'Task execution failed');
|
||||
}
|
||||
|
||||
function deriveProgress(
|
||||
rootRun: ProcessRun,
|
||||
taskRuns: ProcessRun[],
|
||||
taskViews: OfficeTaskView[],
|
||||
locale: AppLocale,
|
||||
): OfficeProgressView {
|
||||
const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']);
|
||||
const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']);
|
||||
@ -403,7 +406,11 @@ function deriveProgress(
|
||||
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||
return {
|
||||
mode: 'ratio',
|
||||
label: `阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
|
||||
label: pickAppText(
|
||||
locale,
|
||||
`阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`,
|
||||
`Stage ${Math.min(stageValue, stageMax)} / ${stageMax}`
|
||||
),
|
||||
value: stageValue,
|
||||
max: stageMax,
|
||||
stageLabel,
|
||||
@ -414,7 +421,11 @@ function deriveProgress(
|
||||
if (taskRuns.length > 0) {
|
||||
return {
|
||||
mode: 'ratio',
|
||||
label: `已完成子任务 ${doneRuns} / ${taskRuns.length}`,
|
||||
label: pickAppText(
|
||||
locale,
|
||||
`已完成子任务 ${doneRuns} / ${taskRuns.length}`,
|
||||
`Subtasks completed ${doneRuns} / ${taskRuns.length}`
|
||||
),
|
||||
value: doneRuns,
|
||||
max: taskRuns.length,
|
||||
stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null,
|
||||
@ -423,7 +434,7 @@ function deriveProgress(
|
||||
|
||||
return {
|
||||
mode: 'status',
|
||||
label: '等待任务数据',
|
||||
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||
value: null,
|
||||
max: null,
|
||||
stageLabel,
|
||||
@ -433,6 +444,7 @@ function deriveProgress(
|
||||
function buildAlerts(
|
||||
taskViews: OfficeTaskView[],
|
||||
now: number,
|
||||
locale: AppLocale,
|
||||
): OfficeAlertView[] {
|
||||
const alerts: OfficeAlertView[] = [];
|
||||
|
||||
@ -441,7 +453,7 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `error:${task.runId}`,
|
||||
level: 'error',
|
||||
title: `${task.actorName} 执行失败`,
|
||||
title: pickAppText(locale, `${task.actorName} 执行失败`, `${task.actorName} failed`),
|
||||
description: task.errorText,
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
@ -451,8 +463,8 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `blocked:${task.runId}`,
|
||||
level: 'warn',
|
||||
title: `${task.actorName} 长时间等待`,
|
||||
description: '该任务长时间无更新,可能存在阻塞。',
|
||||
title: pickAppText(locale, `${task.actorName} 长时间等待`, `${task.actorName} has been waiting for a while`),
|
||||
description: pickAppText(locale, '该任务长时间无更新,可能存在阻塞。', 'This task has not updated for a while and may be blocked.'),
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
createdAt: task.updatedAt,
|
||||
@ -463,8 +475,8 @@ function buildAlerts(
|
||||
alerts.push({
|
||||
id: `stale:${task.runId}`,
|
||||
level: 'warn',
|
||||
title: `${task.actorName} 等待时间偏长`,
|
||||
description: '该任务仍处于等待态,建议查看详情确认依赖是否卡住。',
|
||||
title: pickAppText(locale, `${task.actorName} 等待时间偏长`, `${task.actorName} has been waiting longer than expected`),
|
||||
description: pickAppText(locale, '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', 'This task is still waiting. Check the details to confirm whether a dependency is stuck.'),
|
||||
runId: task.runId,
|
||||
actorId: task.actorId,
|
||||
createdAt: task.updatedAt,
|
||||
@ -476,18 +488,18 @@ function buildAlerts(
|
||||
return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt));
|
||||
}
|
||||
|
||||
function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[]): OfficeZoneView[] {
|
||||
function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[], locale: AppLocale): OfficeZoneView[] {
|
||||
const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done'];
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
label: zoneLabel(id),
|
||||
label: zoneLabel(id, locale),
|
||||
memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId),
|
||||
taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId),
|
||||
tone: zoneTone(id),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>): OfficeAssignmentView[] {
|
||||
function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, ProcessRun[]>, locale: AppLocale): OfficeAssignmentView[] {
|
||||
return taskRuns
|
||||
.filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0)
|
||||
.map((run) => {
|
||||
@ -497,7 +509,7 @@ function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map<string, Proce
|
||||
ownerActorName: run.actor_name,
|
||||
assigneeRunIds: children.map((item) => item.run_id),
|
||||
assigneeActorNames: children.map((item) => item.actor_name),
|
||||
label: `${run.actor_name} 分派了 ${children.length} 个子任务`,
|
||||
label: pickAppText(locale, `${run.actor_name} 分派了 ${children.length} 个子任务`, `${run.actor_name} assigned ${children.length} subtasks`),
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -506,19 +518,20 @@ export function isOfficeTaskTerminal(status: OfficeTaskStatus): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function officeTaskStatusLabel(status: OfficeTaskStatus): string {
|
||||
if (status === 'queued') return '排队中';
|
||||
if (status === 'running') return '进行中';
|
||||
if (status === 'waiting') return '等待中';
|
||||
if (status === 'blocked') return '阻塞';
|
||||
if (status === 'done') return '已完成';
|
||||
if (status === 'error') return '失败';
|
||||
return '已取消';
|
||||
export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocale = getCurrentAppLocale()): string {
|
||||
if (status === 'queued') return pickAppText(locale, '排队中', 'Queued');
|
||||
if (status === 'running') return pickAppText(locale, '进行中', 'In Progress');
|
||||
if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting');
|
||||
if (status === 'blocked') return pickAppText(locale, '阻塞', 'Blocked');
|
||||
if (status === 'done') return pickAppText(locale, '已完成', 'Done');
|
||||
if (status === 'error') return pickAppText(locale, '失败', 'Error');
|
||||
return pickAppText(locale, '已取消', 'Cancelled');
|
||||
}
|
||||
|
||||
export function buildOfficeView(
|
||||
taskId: string,
|
||||
input: BuildOfficeInput,
|
||||
locale: AppLocale = getCurrentAppLocale(),
|
||||
): OfficeView | null {
|
||||
const { sessions, processRuns, processEvents, processArtifacts } = input;
|
||||
const runById = new Map(processRuns.map((run) => [run.run_id, run]));
|
||||
@ -539,7 +552,7 @@ export function buildOfficeView(
|
||||
const runEvents = eventsByRun.get(run.run_id) ?? [];
|
||||
const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun);
|
||||
const status = deriveRunStatus(run, updatedAt, now);
|
||||
const stageLabel = deriveStageLabel(run, runEvents, status);
|
||||
const stageLabel = deriveStageLabel(run, runEvents, status, locale);
|
||||
const childTaskIds = (childrenMap.get(run.run_id) ?? [])
|
||||
.filter((child) => taskRunIds.has(child.run_id))
|
||||
.map((child) => child.run_id);
|
||||
@ -560,7 +573,7 @@ export function buildOfficeView(
|
||||
finishedAt: run.finished_at ?? null,
|
||||
childTaskIds,
|
||||
artifactCount: (artifactsByRun.get(run.run_id) ?? []).length,
|
||||
errorText: deriveErrorText(run, runEvents),
|
||||
errorText: deriveErrorText(run, runEvents, locale),
|
||||
isRoot: run.run_id === rootRun.run_id,
|
||||
};
|
||||
})
|
||||
@ -620,9 +633,9 @@ export function buildOfficeView(
|
||||
rootRun.started_at,
|
||||
]) ?? rootRun.started_at;
|
||||
const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now);
|
||||
const alerts = buildAlerts(taskViews, now);
|
||||
const progress = deriveProgress(rootRun, taskRuns, taskViews);
|
||||
const sourceSessionLabel = getSessionLabel(sessions, sessionId);
|
||||
const alerts = buildAlerts(taskViews, now, locale);
|
||||
const progress = deriveProgress(rootRun, taskRuns, taskViews, locale);
|
||||
const sourceSessionLabel = getSessionLabel(sessions, sessionId, locale);
|
||||
const createdAt = rootRun.started_at;
|
||||
const finishedAt = rootRun.finished_at ?? null;
|
||||
const durationStart = toTime(createdAt);
|
||||
@ -636,7 +649,7 @@ export function buildOfficeView(
|
||||
officeId: rootRun.run_id,
|
||||
taskId: rootRun.run_id,
|
||||
sessionId,
|
||||
title: rootRun.title || `Task ${rootRun.run_id.slice(0, 8)}`,
|
||||
title: rootRun.title || pickAppText(locale, `任务 ${rootRun.run_id.slice(0, 8)}`, `Task ${rootRun.run_id.slice(0, 8)}`),
|
||||
status: derivedRootStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@ -645,7 +658,7 @@ export function buildOfficeView(
|
||||
sourceSessionLabel,
|
||||
rootRunId: rootRun.run_id,
|
||||
rootActorName: rootRun.actor_name,
|
||||
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus),
|
||||
currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus, locale),
|
||||
progress,
|
||||
stats: {
|
||||
totalRuns: taskRuns.length,
|
||||
@ -657,25 +670,25 @@ export function buildOfficeView(
|
||||
artifactCount: taskArtifacts.length,
|
||||
},
|
||||
alerts,
|
||||
zones: buildZones(members, taskViews),
|
||||
zones: buildZones(members, taskViews, locale),
|
||||
members,
|
||||
tasks: taskViews,
|
||||
assignments: buildAssignments(taskRuns, childrenMap),
|
||||
assignments: buildAssignments(taskRuns, childrenMap, locale),
|
||||
detailRunIds: taskViews.map((task) => task.runId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOfficeTaskList(
|
||||
input: BuildOfficeInput & { sessionId?: string | null },
|
||||
locale: AppLocale = getCurrentAppLocale(),
|
||||
): OfficeTaskListItem[] {
|
||||
const rootRuns = findRootRuns(input.processRuns);
|
||||
const filteredRoots = input.sessionId
|
||||
? rootRuns.filter((run) => run.session_id === input.sessionId)
|
||||
: rootRuns;
|
||||
|
||||
return filteredRoots
|
||||
.map((rootRun) => buildOfficeView(rootRun.run_id, input))
|
||||
const offices = rootRuns
|
||||
.map((rootRun) => buildOfficeView(rootRun.run_id, input, locale))
|
||||
.filter((office): office is OfficeView => office !== null)
|
||||
.filter((office) => !input.sessionId || office.sessionId === input.sessionId);
|
||||
|
||||
return offices
|
||||
.map((office) => ({
|
||||
officeId: office.officeId,
|
||||
taskId: office.taskId,
|
||||
|
||||
52
app-instance/frontend/lib/store.test.ts
Normal file
52
app-instance/frontend/lib/store.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
describe('chat store process event ingestion', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
sessionId: 'web:alpha',
|
||||
processRuns: [],
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
selectedRunId: null,
|
||||
selectedArtifactId: null,
|
||||
lastCancelAck: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useChatStore.setState({
|
||||
sessionId: 'web:default',
|
||||
processRuns: [],
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
selectedRunId: null,
|
||||
selectedArtifactId: null,
|
||||
lastCancelAck: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns session_id when the first observed event is progress', () => {
|
||||
useChatStore.getState().ingestProcessEvent({
|
||||
type: 'process_run_progress',
|
||||
session_id: 'web:alpha',
|
||||
run_id: 'run-progress-only',
|
||||
parent_run_id: null,
|
||||
actor_type: 'agent',
|
||||
actor_id: 'agent-a',
|
||||
actor_name: 'Agent A',
|
||||
text: 'still working',
|
||||
created_at: '2026-03-24T11:20:00.000Z',
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().processRuns).toEqual([
|
||||
expect.objectContaining({
|
||||
run_id: 'run-progress-only',
|
||||
session_id: 'web:alpha',
|
||||
status: 'running',
|
||||
title: 'Agent A',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -231,18 +231,21 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
}
|
||||
|
||||
if (event.type === 'process_run_status') {
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title:
|
||||
nextRuns.find((item) => item.run_id === event.run_id)?.title ||
|
||||
event.actor_name,
|
||||
current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: event.status,
|
||||
started_at:
|
||||
nextRuns.find((item) => item.run_id === event.run_id)?.started_at ||
|
||||
event.created_at,
|
||||
current?.started_at || event.created_at,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@ -250,12 +253,16 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: current?.status || 'running',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@ -264,12 +271,15 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? event.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: current?.status || 'running',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@ -295,10 +305,13 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: event.status,
|
||||
started_at: current?.started_at || event.created_at,
|
||||
finished_at: event.created_at,
|
||||
@ -311,14 +324,17 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
const current = nextRuns.find((item) => item.run_id === event.run_id);
|
||||
nextRuns = upsertRun(nextRuns, {
|
||||
run_id: event.run_id,
|
||||
parent_run_id: current?.parent_run_id ?? null,
|
||||
session_id: current?.session_id ?? event.session_id ?? state.sessionId,
|
||||
actor_type: event.actor_type,
|
||||
actor_id: event.actor_id,
|
||||
actor_name: event.actor_name,
|
||||
title: current?.title || event.actor_name,
|
||||
source: current?.source ?? null,
|
||||
status: 'cancelled',
|
||||
started_at: current?.started_at || event.created_at,
|
||||
finished_at: event.created_at,
|
||||
summary: current?.summary ?? '已取消',
|
||||
summary: current?.summary ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user