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 @@
diff --git a/web/src/data.js b/web/src/data.js
index 838fe96..a2c6d27 100644
--- a/web/src/data.js
+++ b/web/src/data.js
@@ -1,6 +1,6 @@
export const connection = {
- label: '等待连接后端 API',
- detail: 'Phase 4 仅展示只读工作台外壳;以下内容均为本地示例数据。',
+ label: '等待连接后端接口',
+ detail: '示例数据仅在接口未连接时显示,并会明确标注。',
source: '本地示例',
confidence: '低',
}
@@ -30,7 +30,7 @@ export const projects = [
activeAgents: 0,
uncertain: true,
drafts: 0,
- lastActivity: '等待 API',
+ lastActivity: '等待接口',
source: '未连接',
confidence: '低',
},
@@ -57,7 +57,7 @@ export const agentMatrix = [
role: '规划与监管',
status: 'recent',
statusZh: '最近活跃',
- goal: 'Phase 4 前端工作台',
+ goal: '阶段 4 前端工作台',
process: '等待进程表',
source: '本地示例',
confidence: '低',
@@ -70,7 +70,7 @@ export const agentMatrix = [
status: 'running',
statusZh: '运行中',
goal: '搭建中文工作台',
- process: '等待 API 连接',
+ process: '等待接口连接',
source: '本地示例',
confidence: '低',
lastActivity: '示例:刚刚',
@@ -83,7 +83,7 @@ export const agentMatrix = [
statusZh: '未知',
goal: '等待阶段 5 数据',
process: '未连接',
- source: '等待 API',
+ source: '等待接口',
confidence: '低',
lastActivity: '等待连接',
},
@@ -91,19 +91,19 @@ export const agentMatrix = [
export const workflow = {
phases: [
- { name: 'Phase 0-3', status: 'complete', label: '已完成', gate: 'Go 后端只读模型', evidence: 'task_plan.md / progress.md', confidence: '中' },
- { name: 'Phase 4', status: 'running', label: '进行中', gate: '中文只读前端工作台', evidence: '本地示例视图', confidence: '低' },
- { name: 'Phase 5', status: 'pending', label: '未开始', gate: '连接只读 API', evidence: '等待后续阶段', confidence: '低' },
- { name: 'Phase 6', status: 'pending', label: '未开始', gate: '草稿校验与写回', evidence: '当前禁用写回', confidence: '低' },
+ { name: '阶段 0-3', status: 'complete', label: '已完成', gate: 'Go 后端只读模型', evidence: 'task_plan.md / progress.md', confidence: '中' },
+ { name: '阶段 4', status: 'running', label: '进行中', gate: '中文只读前端工作台', evidence: '本地示例视图', confidence: '低' },
+ { name: '阶段 5', status: 'pending', label: '未开始', gate: '连接只读接口', evidence: '等待后续阶段', confidence: '低' },
+ { name: '阶段 6', status: 'pending', label: '未开始', gate: '草稿校验与写回', evidence: '当前禁用写回', confidence: '低' },
],
handoffs: [
- { from: '主智能体', to: '前端实现智能体', summary: '派发 Phase 4:构建只读工作台外壳', time: '示例:18:45', source: '本地示例', confidence: '低' },
- { from: '前端实现智能体', to: '审查智能体', summary: '等待构建和界面验证证据', time: '等待连接', source: '等待 API', confidence: '低' },
- { from: '审查智能体', to: '主智能体', summary: '阶段 5 才接入真实 API 和错误态', time: '计划中', source: '计划文件', confidence: '中' },
+ { from: '主智能体', to: '前端实现智能体', summary: '派发阶段 4:构建只读工作台外壳', time: '示例:18:45', source: '本地示例', confidence: '低' },
+ { from: '前端实现智能体', to: '审查智能体', summary: '等待构建和界面验证证据', time: '等待连接', source: '等待接口', confidence: '低' },
+ { from: '审查智能体', to: '主智能体', summary: '阶段 5 接入真实接口和错误态', time: '计划中', source: '计划文件', confidence: '中' },
],
edges: [
{ parent: '主线程', child: '前端实现', status: '示例运行', source: '本地示例', confidence: '低' },
- { parent: '前端实现', child: '界面审查', status: '等待', source: '等待 API', confidence: '低' },
+ { parent: '前端实现', child: '界面审查', status: '等待', source: '等待接口', confidence: '低' },
{ parent: '界面审查', child: '修复回路', status: '未开始', source: '计划文件', confidence: '中' },
],
}
@@ -127,9 +127,9 @@ export const agents = [
description: '优先发现行为回归、安全边界和遗漏测试。',
role: '审查 / 风险 / 验证',
status: '等待读取',
- source: '等待 API',
+ source: '等待接口',
confidence: '低',
- toml: '# 等待 Phase 5 从 /api/agents 读取真实 TOML',
+ toml: '# 等待只读接口从 /api/agents 读取真实 TOML',
},
]
@@ -138,7 +138,7 @@ export const drafts = [
file: '~/.codex/agents/frontend-developer.toml',
changedFields: ['描述', '角色设定'],
validation: '示例:未校验',
- backup: '等待 Phase 6',
+ backup: '等待阶段 6',
source: '本地示例',
confidence: '低',
steps: ['草稿'],
@@ -148,16 +148,16 @@ export const drafts = [
changedFields: [],
validation: '无草稿',
backup: '无',
- source: '等待 API',
+ source: '等待接口',
confidence: '低',
steps: [],
},
]
export const settings = [
- { name: 'Codex home', value: '/Users/yoilun/.codex', detail: '阶段 5 后由后端配置返回', enabled: true },
- { name: 'SQLite 状态读取', value: '只读 mode=ro&immutable=1', detail: '当前界面未连接 API', enabled: true },
+ { name: 'Codex home', value: '/Users/yoilun/.codex', detail: '由后端配置提供', enabled: true },
+ { name: 'SQLite 状态读取', value: '只读 mode=ro&immutable=1', detail: '通过只读接口展示', enabled: true },
{ name: '本机进程辅助判断', value: '等待后端聚合', detail: '只显示来源与置信度,不伪装确定状态', enabled: true },
- { name: '写回能力', value: 'Phase 6 才启用', detail: '当前没有保存、写入或批量写回按钮', enabled: false },
+ { name: '写回能力', value: '阶段 6 才启用', detail: '当前没有保存、写入或批量写回按钮', enabled: false },
{ name: '敏感文件黑名单', value: 'auth.json', detail: '前端只展示规则摘要,不读取内容', enabled: true },
]
diff --git a/web/src/styles.css b/web/src/styles.css
index aa2bc1d..b116588 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -393,6 +393,33 @@ button {
padding: 14px;
}
+.load-state {
+ display: grid;
+ min-height: 76px;
+ place-items: center;
+ color: var(--muted);
+ background: var(--panel-muted);
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ font-weight: 700;
+}
+
+.error-state {
+ border-color: #d8a08a;
+ background: #f8e7dd;
+}
+
+.sample-fallback {
+ margin-top: 14px;
+}
+
+.project-item[role="button"]:focus-visible,
+.agent-list-item:focus-visible,
+.tab-button:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--terracotta) 48%, transparent);
+ outline-offset: 2px;
+}
+
.workflow-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue
index 8add27c..38a5eff 100644
--- a/web/src/views/AgentView.vue
+++ b/web/src/views/AgentView.vue
@@ -1,10 +1,34 @@
@@ -12,14 +36,24 @@ const selectedAgent = computed(() => agents.find((agent) => agent.id === selecte