From 5cbf4f8b3dc44a715903b0b083e796bb394992a9 Mon Sep 17 00:00:00 2001 From: Yoilun Date: Mon, 25 May 2026 20:28:23 +0800 Subject: [PATCH] feat: connect frontend readonly apis --- docs/project.md | 8 +- findings.md | 3 + progress.md | 14 ++ task_plan.md | 2 +- web/package.json | 1 + web/src/App.vue | 9 +- web/src/api/client.js | 59 ++++++ web/src/api/normalizers.js | 318 +++++++++++++++++++++++++++++ web/src/api/normalizers.test.mjs | 90 ++++++++ web/src/components/StatusBadge.vue | 15 +- web/src/data.js | 42 ++-- web/src/styles.css | 27 +++ web/src/views/AgentView.vue | 76 +++++-- web/src/views/DraftsView.vue | 4 +- web/src/views/ProjectView.vue | 151 +++++++++++--- web/src/views/SettingsView.vue | 6 +- web/src/views/WorkflowView.vue | 82 ++++++-- 17 files changed, 805 insertions(+), 102 deletions(-) create mode 100644 web/src/api/client.js create mode 100644 web/src/api/normalizers.js create mode 100644 web/src/api/normalizers.test.mjs diff --git a/docs/project.md b/docs/project.md index 7655fe9..aa3182c 100644 --- a/docs/project.md +++ b/docs/project.md @@ -6,7 +6,7 @@ ## Target Architecture -计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`。 +计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;前端已通过集中 API client 接入这些只读端点并显示加载中、连接失败和空数据状态。目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`。 ## Configuration @@ -60,6 +60,7 @@ curl http://127.0.0.1:18083/api/workflow/events ```bash cd web pnpm install +pnpm test pnpm build ``` @@ -70,7 +71,7 @@ cd web pnpm dev ``` -前端开发地址为 `http://127.0.0.1:13083/`。Phase 4 前端仅使用本地静态示例数据,页面文案会标明“示例数据”或“等待连接后端 API”;Phase 5 才接入真实只读 API。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。 +前端开发地址为 `http://127.0.0.1:13083/`。Phase 5 前端按视图调用只读 API:项目视图读取 `/api/projects` 和 `/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。 ## Security Boundaries @@ -84,6 +85,8 @@ pnpm dev - 当前 `/api/projects` 只读解析 `.codex/config.toml` 中的 `[projects."..."]`,展示路径、显示名、信任等级和目录存在性。 - 当前 `/api/workflow/events` 从运行线程、spawn edges、goals 和计划文件证据生成事件流/交接边/阶段状态,不写死固定流程。 - Phase 4 前端不调用真实 API、不保存草稿、不写回 `.codex/agents/*.toml`;所有写回相关控件仅作为只读步骤展示。 +- Phase 5 前端只调用 GET 只读端点,不新增保存、草稿写入或后端写入逻辑。 +- Phase 5 前端 normalizer 负责把 source kind、confidence、状态和 parseStatus 转成中文显示,并过滤工作流中非阶段表格行。 ## Known Risks @@ -91,3 +94,4 @@ pnpm dev - 运行状态由多来源推断,必须显示置信度。 - Phase 3 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 新增字段或缺少可选字段,应继续走 schema-aware 查询和来源证据,而不是让 API 500。 - Phase 4 只读工作台是静态壳;如果用户误以为它展示真实状态,应优先检查界面是否仍清楚显示来源、置信度和“等待连接 API”提示。 +- Phase 5 agent 接口当前不返回原始 TOML 文本;智能体只读编辑区展示的是接口返回的已解析字段,并在代码区说明“接口未返回原始 TOML”。 diff --git a/findings.md b/findings.md index a4ece28..cecd1e4 100644 --- a/findings.md +++ b/findings.md @@ -33,3 +33,6 @@ - Workflow store 未配置 runtime reader 时返回空视图和 `runtime_missing`/`low` 证据,不 panic、不让 API 500。 - 动态工作流事件从 threads、spawn edges、goals、`task_plan.md` 证据构建,不假设固定角色顺序。 - Phase 4 前端使用 Vue 3 + Vite 和本地静态示例数据实现中文只读工作台;所有示例状态显示来源和置信度,不调用真实 API,不提供保存或写回按钮。 +- Phase 5 前端新增集中 API client,仅调用 `/api/agents`、`/api/projects`、`/api/runtime/threads`、`/api/workflow/events` 四个 GET 只读端点。 +- Phase 5 前端 normalizer 统一把后端 source kind、confidence、状态、parseStatus 转为中文展示;空 runtime/workflow 不回退到示例数据,连接失败时示例数据必须标注“示例/等待连接”。 +- 工作流 phases 需要在前端过滤计划文件中误解析出的非阶段表格行,并把数字阶段名展示为“阶段 N”,避免出现内部英文或无效状态。 diff --git a/progress.md b/progress.md index f11ba21..2729e82 100644 --- a/progress.md +++ b/progress.md @@ -21,6 +21,7 @@ | 2026-05-25 | 4 | coding agent | 实现 Vue 中文只读工作台外壳 | 完成;提交 `feat: add chinese vue workbench shell` | | 2026-05-25 | 4 | spec review | 规格审查未通过:状态徽标和部分视图直接展示 `local_sample`、`low` 等内部英文值 | 已修复为中文来源和置信度展示 | | 2026-05-25 | 4 | coding agent | 创建 Vue 3 + Vite 中文只读前端工作台,包含五个 tabs、静态示例数据、来源/置信度和空状态 | 完成;未接入真实 API,未提供写回入口 | +| 2026-05-25 | 5 | coding agent | TDD 接入前端只读 API client、normalizer 和项目/工作流/智能体真实数据视图 | 完成;提交前已通过测试、构建和本地接口 smoke 验证 | ## Test Results @@ -106,6 +107,18 @@ | 2026-05-25 | `git diff --check` | PASS | Phase 4 whitespace 检查通过 | | 2026-05-25 | `pnpm build` | PASS | Phase 4 规格修复后前端构建通过 | | 2026-05-25 | `git diff --check` | PASS | Phase 4 规格修复 whitespace 检查通过 | +| 2026-05-25 | `pnpm test` | FAIL | TDD 红灯:`normalizers.js` 尚未实现,新增 normalizer 测试无法导入模块 | +| 2026-05-25 | `pnpm test` | PASS | source/confidence 中文映射、invalid agent TOML、空 runtime/workflow 测试通过 | +| 2026-05-25 | `pnpm build` | PASS | Phase 5 首轮 Vue/Vite 构建通过 | +| 2026-05-25 | `curl --max-time 5 -sS http://127.0.0.1:18083/api/agents` | PASS | 后端真实 agents 只读接口可达 | +| 2026-05-25 | `curl --max-time 5 -sS http://127.0.0.1:18083/api/projects` | PASS | 后端真实 projects 只读接口可达 | +| 2026-05-25 | `curl --max-time 5 -sS http://127.0.0.1:13083/` | PASS | Vite 前端页面可达 | +| 2026-05-25 | `curl --max-time 5 -sS http://127.0.0.1:13083/api/workflow/events` | PASS | 前端代理到后端 workflow 只读接口可达 | +| 2026-05-25 | `pnpm test` | FAIL | TDD 红灯:真实 workflow 返回的非阶段表格行未被 normalizer 过滤 | +| 2026-05-25 | `pnpm test` | PASS | workflow phase 过滤测试通过;共 5 个前端单测通过 | +| 2026-05-25 | `pnpm build` | PASS | Phase 5 修复后前端构建通过 | +| 2026-05-25 | `git diff --check` | PASS | Phase 5 whitespace 检查通过 | +| 2026-05-25 | `go test ./...` | PASS | Phase 5 未改 Go 行为;全量 Go 回归通过 | ## Bug Loop @@ -126,3 +139,4 @@ | 3 | `SourceEvidence.Confidence` 出现设计外值 `partial` | 保留 `Kind: sqlite_partial`,将 `Confidence` 改为 `medium` | `go test ./internal/runtime ./internal/server` PASS | | 3 | `workflow.Store` 未配置 Runtime 会 panic | nil Runtime 返回空 view 和 `runtime_missing`/`low` 证据 | `go test ./internal/workflow` PASS | | 4 | UI 直接展示 `local_sample`、`api_missing`、`low`、`medium` 等内部英文值 | `StatusBadge` 增加中文映射,并将示例数据来源/置信度改为中文展示值 | `pnpm build` PASS | +| 5 | workflow phases 会把 `task_plan.md` 里错误记录表的 `Time/Phase` 行显示到 UI | normalizer 过滤非阶段状态,并把数字阶段名转为“阶段 N” | `pnpm test` PASS | diff --git a/task_plan.md b/task_plan.md index 6c5e43f..c5c4018 100644 --- a/task_plan.md +++ b/task_plan.md @@ -22,7 +22,7 @@ | 2 | complete | Agent TOML 只读读取 | 能安全读取 `.codex/agents/*.toml`;坏 TOML 不导致崩溃;提供 `/api/agents` | | 3 | complete | 项目配置、运行线程和工作流模型 | 能读取 projects、threads、spawn edges、goals;状态含来源/置信度;工作流不写死固定流程 | | 4 | complete | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 | -| 5 | pending | API 集成和只读数据显示 | 前端连接只读 API;显示真实 agent 数据和错误状态 | +| 5 | complete | API 集成和只读数据显示 | 前端连接只读 API;显示真实 agent 数据和错误状态 | | 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 | | 7 | pending | 集成验证与文档 | 测试/构建/浏览器验证通过;文档完整 | diff --git a/web/package.json b/web/package.json index 65115bb..0ae3655 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 127.0.0.1 --port 13083", "build": "vite build", + "test": "node --test src/**/*.test.mjs", "preview": "vite preview --host 127.0.0.1 --port 13084" }, "dependencies": { diff --git a/web/src/App.vue b/web/src/App.vue index a7b379d..3910c49 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -6,7 +6,6 @@ import AgentView from './views/AgentView.vue' import DraftsView from './views/DraftsView.vue' import SettingsView from './views/SettingsView.vue' import StatusBadge from './components/StatusBadge.vue' -import { connection } from './data' const tabs = [ { id: 'projects', label: '项目视图', component: ProjectView }, @@ -26,12 +25,12 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v

本地只读工作台

Codex 智能体管理台

-

中文前端壳已就位,真实 API 接入将在 Phase 5 进行。

+

前端按视图连接后端只读接口,显示真实数据、加载中、连接失败和空数据状态。

- - {{ connection.label }} - {{ connection.detail }} + + 按需读取后端数据 + 项目、运行线程、工作流和智能体视图只调用只读端点;失败时显示“示例/等待连接”标注。
diff --git a/web/src/api/client.js b/web/src/api/client.js new file mode 100644 index 0000000..82fa119 --- /dev/null +++ b/web/src/api/client.js @@ -0,0 +1,59 @@ +async function requestJSON(path, options = {}) { + const response = await fetch(path, { + method: 'GET', + headers: { Accept: 'application/json' }, + ...options, + }) + + const body = await readBody(response) + if (!response.ok) { + throw new APIError(parseErrorMessage(body, response), response.status, body) + } + return body +} + +async function readBody(response) { + const text = await response.text() + if (!text) { + return null + } + try { + return JSON.parse(text) + } catch { + return text + } +} + +function parseErrorMessage(body, response) { + if (body && typeof body === 'object' && typeof body.error === 'string') { + return body.error + } + if (typeof body === 'string' && body.trim() !== '') { + return body + } + return `请求失败:HTTP ${response.status}` +} + +export class APIError extends Error { + constructor(message, status, body) { + super(message) + this.name = 'APIError' + this.status = status + this.body = body + } +} + +export const apiClient = { + getAgents() { + return requestJSON('/api/agents') + }, + getProjects() { + return requestJSON('/api/projects') + }, + getRuntimeThreads() { + return requestJSON('/api/runtime/threads') + }, + getWorkflowEvents() { + return requestJSON('/api/workflow/events') + }, +} diff --git a/web/src/api/normalizers.js b/web/src/api/normalizers.js new file mode 100644 index 0000000..383fbb7 --- /dev/null +++ b/web/src/api/normalizers.js @@ -0,0 +1,318 @@ +const SOURCE_KIND_LABELS = { + api_missing: '等待接口', + config_toml: '配置文件', + local_sample: '示例数据', + plan_file: '计划文件', + runtime_missing: '运行数据缺失', + sqlite_missing: 'SQLite 缺失', + sqlite_missing_table: 'SQLite 表缺失', + sqlite_partial: 'SQLite 部分可用', + sqlite_readonly: 'SQLite 只读', + sqlite_schema_drift: 'SQLite 结构变化', + sqlite_table: 'SQLite 表', + test: '测试数据', +} + +const CONFIDENCE_LABELS = { + high: '高', + medium: '中', + low: '低', + 高: '高', + 中: '中', + 低: '低', +} + +const STATUS_LABELS = { + blocked: '受阻', + clean: '无草稿', + complete: '已完成', + done: '已完成', + failed: '失败', + idle: '空闲', + invalid: '无效', + pending: '待处理', + recent: '最近活跃', + running: '运行中', + unknown: '未知', + valid: '有效', +} + +const PARSE_STATUS_LABELS = { + invalid: '解析失败', + valid: '解析通过', +} + +const TRUST_LABELS = { + trusted: '受信任', + untrusted: '未信任', + unknown: '未知', +} + +const PHASE_STATUSES = new Set(['blocked', 'complete', 'done', 'failed', 'pending', 'running', 'unknown']) + +export function formatSourceKind(kind) { + if (!kind) { + return '来源未知' + } + return SOURCE_KIND_LABELS[kind] ?? kind +} + +export function formatConfidence(confidence) { + if (!confidence) { + return '低' + } + return CONFIDENCE_LABELS[confidence] ?? confidence +} + +export function formatStatus(status) { + if (!status) { + return '未知' + } + return STATUS_LABELS[status] ?? status +} + +export function formatParseStatus(status) { + if (!status) { + return '未知' + } + return PARSE_STATUS_LABELS[status] ?? formatStatus(status) +} + +export function normalizeSource(source, fallback = {}) { + const kind = source?.kind ?? fallback.kind ?? 'api_missing' + const confidence = source?.confidence ?? fallback.confidence ?? 'low' + return { + kind, + label: formatSourceKind(kind), + confidence, + confidenceLabel: formatConfidence(confidence), + path: source?.path ?? fallback.path ?? '', + message: source?.message ?? fallback.message ?? '', + } +} + +export function normalizeProject(project) { + const source = normalizeSource(project?.source, { kind: 'config_toml', confidence: 'high' }) + const path = project?.path ?? '' + return { + id: path || project?.displayName || 'project', + name: project?.displayName || basename(path) || '未命名项目', + path, + status: project?.directoryExists ? 'complete' : 'unknown', + statusZh: project?.directoryExists ? '目录存在' : '目录不可用', + trust: TRUST_LABELS[project?.trustLevel] ?? project?.trustLevel ?? '未知', + directoryExists: Boolean(project?.directoryExists), + source: source.label, + confidence: source.confidenceLabel, + sourceDetail: source, + } +} + +export function normalizeProjects(payload = {}) { + const projects = Array.isArray(payload.items) ? payload.items.map(normalizeProject) : [] + return { + projects, + isEmpty: projects.length === 0, + emptyTitle: '没有项目配置', + emptyText: '后端没有返回项目条目。请确认 .codex/config.toml 中是否存在项目配置;这里不会用示例数据伪装真实结果。', + } +} + +export function normalizeAgent(agent = {}) { + const fileName = agent.fileName || basename(agent.filePath) || agent.id || '未命名智能体' + const parseStatus = agent.parseStatus || 'unknown' + const isInvalid = parseStatus === 'invalid' + const name = agent.name || fileName + const description = isInvalid + ? `TOML 解析失败:${agent.parseError || '后端未返回具体错误'}` + : agent.description || '没有描述' + const source = normalizeSource(null, { + kind: isInvalid ? 'api_missing' : 'config_toml', + confidence: isInvalid ? 'low' : 'high', + }) + + return { + id: agent.id || fileName, + file: agent.filePath || fileName, + fileName, + name, + description, + role: agent.developerInstructions || '没有 developer_instructions 字段', + status: isInvalid ? 'unknown' : 'complete', + statusLabel: isInvalid ? 'TOML 无效' : '已读取', + parseStatus, + parseStatusLabel: formatParseStatus(parseStatus), + parseError: agent.parseError || '', + draftStatus: agent.draftStatus || 'unknown', + draftStatusLabel: formatStatus(agent.draftStatus), + extraFields: agent.extraFields || {}, + modifiedAt: formatDateTime(agent.modifiedAt), + source: source.label, + confidence: source.confidenceLabel, + toml: synthesizeAgentPreview(agent, { isInvalid, description }), + } +} + +export function normalizeAgents(payload = {}) { + const agents = Array.isArray(payload.items) ? payload.items.map(normalizeAgent) : [] + return { + agents, + isEmpty: agents.length === 0, + emptyTitle: '没有智能体定义', + emptyText: '后端没有返回 .codex/agents/*.toml 条目。当前显示为空状态,不会把示例智能体当作真实数据。', + } +} + +export function normalizeRuntime(payload = {}) { + const threads = Array.isArray(payload.items) ? payload.items : [] + const goals = Array.isArray(payload.goals) ? payload.goals : [] + const goalsByThread = groupBy(goals, (goal) => goal.threadId) + const agents = threads.map((thread) => { + const source = normalizeSource(thread.source, payload.source) + const threadGoals = goalsByThread.get(thread.id) ?? [] + return { + id: thread.id, + name: thread.role || thread.id || '未命名线程', + role: thread.id, + status: thread.status || 'unknown', + statusZh: formatStatus(thread.status), + goal: threadGoals.map((goal) => goal.goal).filter(Boolean).join(';') || '没有目标记录', + process: 'SQLite 只读快照', + source: source.label, + confidence: source.confidenceLabel, + lastActivity: thread.updatedAt || thread.createdAt || '没有时间记录', + } + }) + const source = normalizeSource(payload.source) + + return { + agents, + threads, + goals, + edges: Array.isArray(payload.edges) ? payload.edges : [], + source, + isEmpty: threads.length === 0, + emptyTitle: '没有运行线程', + emptyText: source.message || '后端返回了空的运行线程列表;这里保持空状态并展示来源证据。', + } +} + +export function normalizeWorkflow(payload = {}) { + const source = normalizeSource(payload.source) + const phases = Array.isArray(payload.phases) + ? payload.phases.filter(isDisplayPhase).map((phase) => { + const phaseSource = normalizeSource(phase.source, payload.source) + const phaseName = normalizePhaseName(phase.name) + return { + name: phaseName, + status: phase.status || 'unknown', + label: formatStatus(phase.status), + gate: phaseName, + evidence: phaseSource.label, + confidence: phaseSource.confidenceLabel, + source: phaseSource, + } + }) + : [] + const handoffs = Array.isArray(payload.handoffEdges) + ? payload.handoffEdges.map((edge) => { + const edgeSource = normalizeSource(edge.source, payload.source) + return { + from: edge.fromThreadId || '未知线程', + to: edge.toThreadId || '未知线程', + summary: edge.label || '没有交接说明', + time: '后端事件', + source: edgeSource.label, + confidence: edgeSource.confidenceLabel, + } + }) + : [] + const edges = handoffs.map((handoff) => ({ + parent: handoff.from, + child: handoff.to, + status: handoff.summary, + source: handoff.source, + confidence: handoff.confidence, + })) + const events = Array.isArray(payload.items) ? payload.items : [] + + return { + events, + phases, + handoffs, + edges, + source, + isEmpty: events.length === 0 && phases.length === 0 && handoffs.length === 0, + emptyTitle: '没有工作流事件', + emptyText: source.message || '后端返回了空的工作流事件流;这里不会回退到伪装真实的示例关系。', + } +} + +function synthesizeAgentPreview(agent, { isInvalid, description }) { + if (isInvalid) { + return `# TOML 无效,仅只读展示解析错误\n# ${description}` + } + + const lines = [ + '# 接口未返回原始 TOML;以下为只读展示的已解析字段', + `name = ${quote(agent.name || '')}`, + `description = ${quote(agent.description || '')}`, + ] + if (agent.developerInstructions) { + lines.push(`developer_instructions = ${quote(agent.developerInstructions)}`) + } + for (const [key, value] of Object.entries(agent.extraFields || {})) { + lines.push(`${key} = ${quote(value)}`) + } + return lines.join('\n') +} + +function basename(path) { + if (!path) { + return '' + } + return String(path).split('/').filter(Boolean).at(-1) ?? String(path) +} + +function formatDateTime(value) { + if (!value) { + return '没有时间记录' + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +function groupBy(items, keyOf) { + const groups = new Map() + for (const item of items) { + const key = keyOf(item) + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key).push(item) + } + return groups +} + +function quote(value) { + return JSON.stringify(String(value ?? '')) +} + +function isDisplayPhase(phase) { + return PHASE_STATUSES.has(phase?.status || 'unknown') +} + +function normalizePhaseName(name) { + if (!name) { + return '未命名阶段' + } + return /^\d+$/.test(String(name)) ? `阶段 ${name}` : String(name) +} diff --git a/web/src/api/normalizers.test.mjs b/web/src/api/normalizers.test.mjs new file mode 100644 index 0000000..0cd980e --- /dev/null +++ b/web/src/api/normalizers.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + formatConfidence, + formatSourceKind, + normalizeAgent, + normalizeRuntime, + normalizeWorkflow, +} from './normalizers.js' + +test('maps source kind and confidence to Chinese display text', () => { + assert.equal(formatSourceKind('sqlite_readonly'), 'SQLite 只读') + assert.equal(formatSourceKind('sqlite_missing'), 'SQLite 缺失') + assert.equal(formatSourceKind('runtime_missing'), '运行数据缺失') + assert.equal(formatSourceKind('local_sample'), '示例数据') + assert.equal(formatConfidence('high'), '高') + assert.equal(formatConfidence('medium'), '中') + assert.equal(formatConfidence('low'), '低') +}) + +test('normalizes invalid agent TOML as readonly error display', () => { + const agent = normalizeAgent({ + id: 'broken', + filePath: '/Users/yoilun/.codex/agents/broken.toml', + fileName: 'broken.toml', + name: '', + description: '', + developerInstructions: '', + parseStatus: 'invalid', + parseError: 'duplicate key name', + draftStatus: 'none', + }) + + assert.equal(agent.name, 'broken.toml') + assert.equal(agent.statusLabel, 'TOML 无效') + assert.equal(agent.parseStatusLabel, '解析失败') + assert.equal(agent.description, 'TOML 解析失败:duplicate key name') + assert.match(agent.toml, /只读展示/) +}) + +test('normalizes empty runtime without falling back to fake real data', () => { + const runtime = normalizeRuntime({ + items: [], + edges: [], + goals: [], + source: { kind: 'sqlite_missing', confidence: 'low', message: 'state_5.sqlite missing' }, + sources: { + state: { kind: 'sqlite_missing', confidence: 'low' }, + goals: { kind: 'sqlite_missing', confidence: 'low' }, + }, + }) + + assert.equal(runtime.isEmpty, true) + assert.equal(runtime.emptyTitle, '没有运行线程') + assert.equal(runtime.source.label, 'SQLite 缺失') + assert.equal(runtime.source.confidenceLabel, '低') + assert.deepEqual(runtime.agents, []) +}) + +test('normalizes empty workflow with source evidence and no sample edges', () => { + const workflow = normalizeWorkflow({ + items: [], + handoffEdges: [], + phases: [], + source: { kind: 'runtime_missing', confidence: 'low', message: 'runtime reader missing' }, + }) + + assert.equal(workflow.isEmpty, true) + assert.equal(workflow.emptyTitle, '没有工作流事件') + assert.equal(workflow.source.label, '运行数据缺失') + assert.deepEqual(workflow.handoffs, []) + assert.deepEqual(workflow.edges, []) +}) + +test('filters non-phase rows from workflow phase display', () => { + const workflow = normalizeWorkflow({ + items: [], + handoffEdges: [], + phases: [ + { name: '5', status: 'pending', source: { kind: 'plan_file', confidence: 'medium' } }, + { name: 'Time', status: 'Phase', source: { kind: 'plan_file', confidence: 'medium' } }, + ], + source: { kind: 'plan_file', confidence: 'medium' }, + }) + + assert.equal(workflow.phases.length, 1) + assert.equal(workflow.phases[0].name, '阶段 5') + assert.equal(workflow.phases[0].label, '待处理') +}) diff --git a/web/src/components/StatusBadge.vue b/web/src/components/StatusBadge.vue index 7f53cb9..829ec59 100644 --- a/web/src/components/StatusBadge.vue +++ b/web/src/components/StatusBadge.vue @@ -1,15 +1,6 @@