diff --git a/README-ZH.md b/README-ZH.md index 6b6e88bc..1916c0e0 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -25,7 +25,7 @@ ![Auto Company 看板](presentation/dashboard-showcase.png) -新版看板按轮次展示工作汇报、成果、下一步和历史记录,并提供独立的用量与日志视图。截图使用已完成的 TableDelta 真实运行记录,以只读归档模式展示,不代表各 Agent 的实时活动。用量仅覆盖已记录的数据,缺失值保持未知,不等于完整账单。 +新版看板用纵向时间轴串联各轮次,展示简洁工作记录、检查结果、交付成果、用量与日志。截图使用 Outline Desk 连续三轮的真实记录,以只读归档模式展示。标题和摘要来自模型按固定格式填写的工作记录;运行事实和已接入检查的结果由程序记录。缺失或过期的信息明确标注,已记录用量不等于完整账单。采集约定与支持范围见[运行记录说明](docs/runtime-observability.md)。 ## 这是什么? @@ -232,7 +232,7 @@ Auto-Company 并非简单调用 LLM API,而是一个高度解耦的 **多智 ### 第 5 层:监控与人机交互层 (Observability & HITL) * **基于文件的操纵杆 (File-based Steering)**:人类只需编辑 `memories/consensus.md`,修改 `Next Action`,下一个周期醒来的 AI 团队就会立刻“转舵”,实现极简的宏观控制。 -* **日志与看板 (Dashboard)**:`logs/` 保存引擎实际输出(对已知凭据进行脱敏),以及每轮结果和可用的用量记录。输出详细程度取决于引擎,不保证包含完整思考链。`dashboard/` 按当前与历史轮次组织工作汇报、成果和下一步,并提供运行控制、状态、用量、预算与日志,不跟踪各个 Agent 的活跃度。 +* **日志与看板 (Dashboard)**:`logs/` 保存引擎实际输出(对已知凭据进行脱敏),以及每轮结果和可用的用量记录。输出详细程度取决于引擎,不保证包含完整思考链。`dashboard/` 按当前与历史轮次组织工作汇报和成果,并提供运行控制、状态、用量、预算与日志,不跟踪各个 Agent 的活跃度。 ### 第 4 层:工作流路由层 (Workflow Routing & Teaming) * **动态组队路由 (Dynamic Squad Formation)**:系统利用 Agent Teams 功能,根据当前 `consensus.md` 中的 "Next Action",从 14 人池子中动态挑选 2-5 名最适合的专家,并在当前循环中将它们“实例化”为子代理。 diff --git a/README.md b/README.md index d2aaf236..355e662c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Optional Cursor and OpenAI-compatible adapters require explicit configuration. S ![Auto Company Dashboard](presentation/dashboard-showcase.png) -The current dashboard shows per-cycle work reports, results, next steps, and history, with separate usage and log views. This screenshot uses real records from a completed TableDelta run in read-only archive mode; it does not show live agent activity. Usage covers recorded data only; missing values remain unknown and do not represent a complete bill. +The dashboard connects cycles in a vertical timeline, with concise work records, check results, deliverables, usage, and logs. This screenshot shows three real Outline Desk cycles in read-only archive mode. Titles and summaries are structured model reports; runtime facts and supported check results are recorded by the program. Missing or expired evidence stays explicit, and recorded usage is not a complete bill. See the [collection contract and supported tools](docs/runtime-observability.md). ## What Is This? @@ -231,7 +231,7 @@ Auto-Company is not a simple LLM API wrapper, but a highly decoupled **Multi-Age ### Layer 5: Observability & HITL (Human-In-The-Loop) * **File-based Steering**: Humans only need to edit `memories/consensus.md` and modify the `Next Action`. The AI team waking up in the next cycle will immediately pivot, enabling minimalist macro-control. -* **Logs & Dashboard**: `logs/` saves engine-emitted output after known credential redaction, together with per-cycle results and available usage records. Output detail depends on the engine; complete reasoning traces are not guaranteed. `dashboard/` organizes current and historical cycle reports, results, and next steps, alongside runtime controls, status, usage, budgets, and logs. It does not track individual agents' activity. +* **Logs & Dashboard**: `logs/` saves engine-emitted output after known credential redaction, together with per-cycle results and available usage records. Output detail depends on the engine; complete reasoning traces are not guaranteed. `dashboard/` organizes current and historical cycle reports and results, alongside runtime controls, status, usage, budgets, and logs. It does not track individual agents' activity. ### Layer 4: Workflow Routing & Teaming * **Dynamic Squad Formation**: Powered by Agent Teams, the system dynamically selects 2-5 of the most suitable experts from the 14-person pool based on the "Next Action" in `consensus.md`, instantiating them as sub-agents for the current loop. diff --git a/dashboard/LICENSE-lucide.txt b/dashboard/LICENSE-lucide.txt new file mode 100644 index 00000000..1dd77f7c --- /dev/null +++ b/dashboard/LICENSE-lucide.txt @@ -0,0 +1,20 @@ +Lucide SVG subset from 0.468.0, commit f12b0de177fbc2a6795e99be065887e72b237123 +Source: https://github.com/lucide-icons/lucide/tree/f12b0de177fbc2a6795e99be065887e72b237123/icons +Icons: notebook-pen, chart-no-axes-column, terminal, play, square, sliders-horizontal, refresh-cw, file-text, list-checks, panels-top-left, external-link, chevron-right, x +Stored inline in app.js; original geometry, decorative aria-hidden rendering. + +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/dashboard/app.js b/dashboard/app.js index 43b21780..67f26431 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1,8 +1,33 @@ +/* +Lucide SVG subset from 0.468.0, commit f12b0de177fbc2a6795e99be065887e72b237123 +Source: https://github.com/lucide-icons/lucide/tree/f12b0de177fbc2a6795e99be065887e72b237123/icons +Icons: notebook-pen, chart-no-axes-column, terminal, play, square, sliders-horizontal, refresh-cw, file-text, list-checks, panels-top-left, external-link, chevron-right, x +Stored inline in app.js; original geometry, decorative aria-hidden rendering. + +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +*/ (() => { 'use strict'; + // Lucide 0.468.0, pinned f12b0de; ISC/MIT notices in LICENSE-lucide.txt. + const ICONS = {"notebook-pen": "\n \n \n \n \n ", "chart-no-axes-column": "\n \n ", "terminal": "\n ", "play": "", "square": "", "sliders-horizontal": "\n \n \n \n \n \n \n \n ", "refresh-cw": "\n \n \n ", "file-text": "\n \n \n \n ", "list-checks": "\n \n \n \n ", "panels-top-left": "\n \n ", "external-link": "\n \n ", "chevron-right": "", "x": "\n "}; const $ = (id) => document.getElementById(id); - const state = { data: null, language: 'zh-CN', tab: 'work', expanded: new Set(), older: false, selectedLog: 'runtime', logText: '', logLoadedId: '', logRequest: 0, logPending: null, refreshPending: null, signature: '', statusFailed: true, action: '', languageState: null, languageSaving: false, languageLoading: false, languageRevision: 0, languageError: '', languageSaved: false, timer: null, autoChanged: false, currentCycle: null }; + const state = { data: null, language: 'zh-CN', tab: 'work', expanded: new Set(), older: false, selectedLog: 'runtime', logText: '', logLoadedId: '', logRequest: 0, logPending: null, refreshPending: null, signature: '', statusFailed: true, action: '', languageState: null, languageSaving: false, languageLoading: false, languageRevision: 0, languageError: '', languageSaved: false, timer: null, autoChanged: false, currentCycle: null, receivedAt: 0, elapsedTimer: null }; const message = (key, values = {}) => { const dictionary = window.JOURNAL_MESSAGES[state.language] || window.JOURNAL_MESSAGES.en; return Object.entries(values).reduce((result, [name, value]) => result.replaceAll(`{${name}}`, String(value)), dictionary[key] || key); @@ -57,6 +82,16 @@ if (!Number.isFinite(seconds) || seconds < 0) return ''; return seconds < 60 ? message('seconds', { seconds }) : message('minutes', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }); } + function liveDuration(cycle, now = performance.now()) { + const runtime = state.data?.runtime; + const age = now - state.receivedAt; + if (!cycle.active || cycle.status !== 'running' || state.statusFailed || state.action === 'stop' || state.data?.control?.stopUnconfirmed || runtime?.processState !== 'running' || (runtime?.currentCycleId && runtime.currentCycleId !== cycle.id) || runtime?.elapsedReliable !== true || !knownNumber(runtime.elapsedSeconds) || !Number.isFinite(age) || age < 0 || age > 15000) return message('elapsedUnconfirmed'); + const seconds = Math.floor(runtime.elapsedSeconds + age / 1000); + return message('runningElapsed', { time: seconds < 60 ? message('seconds', { seconds }) : message('minutes', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) }); + } + function updateElapsed() { + document.querySelectorAll('.live-elapsed').forEach((node) => { if (node.dataset.cycleId === state.currentCycle?.id) node.textContent = liveDuration(state.currentCycle); }); + } function cycleTitle(cycle) { if (cycle.workReport) return cycle.workReport.title; if (cycle.status === 'interrupted' && cycle.events?.length) return message('interruptedSummary'); @@ -71,14 +106,20 @@ } function metadata(cycle) { const row = element('div', 'cycle-meta'); - row.append(element('span', cycle.status === 'failed' ? 'status-failed' : '', statusLabel(cycle.status))); + if (cycle.workReport?.phase) row.append(element('span', 'reported-phase', message(`workPhase_${cycle.workReport.phase}`))); + if (['failed', 'interrupted', 'unknown', 'completed_with_timeout'].includes(cycle.status)) row.append(element('span', cycle.status === 'failed' ? 'status-failed' : '', statusLabel(cycle.status))); row.append(element('span', '', message('startAt', { time: formatTime(cycle.startedAt) }))); const elapsed = duration(cycle); if (elapsed) row.append(element('span', '', elapsed)); - if (cycle.durationReliable === false || cycle.status === 'interrupted') row.title = message('recoveredEnd'); + if (cycle.active && cycle.status === 'running') { + const live = element('span', 'live-elapsed'); live.dataset.cycleId = cycle.id; + live.textContent = liveDuration(cycle); row.append(live); + } + if ((!cycle.active && cycle.durationReliable === false) || cycle.status === 'interrupted') { row.title = message('recoveredEnd'); row.append(element('span', '', message('durationUnknown'))); } return row; } function bindDisclosure(details, key) { + details.dataset.disclosureKey = key; details.open = state.expanded.has(key); details.addEventListener('toggle', () => { if (details.open) state.expanded.add(key); @@ -96,6 +137,7 @@ if (!cycle.logAvailable) return element('p', 'sidebar-note cycle-log-link', message('noLog')); const button = element('button', 'text-button cycle-log-link', message('viewLog')); button.type = 'button'; + button.dataset.focusKey = `log:${cycle.id}`; button.addEventListener('click', () => { state.selectedLog = cycle.id; $('logSelect').value = cycle.id; @@ -123,49 +165,109 @@ } return list; } - function fullReport(cycle) { - if (!cycle.report) return null; - const details = bindDisclosure(element('details', 'report-disclosure'), `report:${cycle.id}`); - const summary = element('summary', '', message('fullReport')); - const body = element('div', 'full-report'); - for (const block of String(cycle.report).split(/\n\s*\n/)) { - if (!block.trim()) continue; - if (block.trim().startsWith('```')) { - body.append(element('pre', 'report-code', block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, ''))); - } else if (block.trim().startsWith('|')) { - const rows = reportRows({ report: block }, Infinity); - if (rows.length) body.append(resultList(rows)); - } else if (/^\s*[-*]\s/.test(block)) { - const list = element('ul'); - block.split('\n').filter((line) => line.trim()).forEach((line) => list.append(element('li', '', clean(line)))); - body.append(list); - } else { - body.append(element('p', '', clean(block))); + function checkCounts(tests) { + if (!tests) return {}; + const result = { ...tests }; + const keys = ['tests', 'failures', 'errors', 'skipped']; + if (keys.every((key) => Number.isInteger(tests[key]) && tests[key] >= 0) && tests.failures + tests.errors + tests.skipped <= tests.tests) result.passed = tests.tests - tests.failures - tests.errors - tests.skipped; + return result; + } + function progressState(status) { + if (status === 'completed') return 'completed'; + if (status === 'running') return 'running'; + if (['paused', 'waiting_limit', 'circuit_break', 'interrupted', 'completed_with_timeout', 'stopped'].includes(status)) return 'paused'; + if (status === 'failed') return 'failed'; + if (['pending', 'not_started'].includes(status)) return 'pending'; + return 'unknown'; + } + function progressIcon(status, label = statusLabel(status)) { + const state = progressState(status); + const node = element('span', `progress-node progress-${state}`); + if (state === 'pending') label = message('pendingCycle'); + node.setAttribute('role', 'img'); node.setAttribute('aria-label', label); node.title = label; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + for (const [key, value] of Object.entries({ viewBox: '0 0 32 32', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.7', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(key, value); + // Original status geometry: the sector indicates activity, never a measured percentage. + const marks = { + completed: '', + running: '', + paused: '', + pending: '', + failed: '', + unknown: '', + }; + svg.innerHTML = `${marks[state]}`; + node.append(svg); + return node; + } + function checkPresentation(cycle) { + const check = cycle.latestCheck; + if (!check) return { status: 'unknown', detail: message(cycle.checkStatus === 'invalid' ? 'check_invalid' : 'noRegisteredCheck') }; + const stale = cycle.checkStatus === 'stale' || check.freshness === 'stale' || ['changed', 'missing', 'stale'].includes(check.evidenceStatus); + if (stale) return { status: 'unknown', detail: message('check_stale') }; + const status = cycle.checkStatus || check.state; + if (status === 'running') return { status, detail: message('check_running') }; + if (status === 'interrupted') return { status, detail: message('check_interrupted') }; + if (status === 'invalid') return { status: 'unknown', detail: message('check_invalid') }; + const counts = checkCounts(check.tests); + const valid = Number.isInteger(counts.passed); + const failed = check.exitCode > 0 || counts.failures > 0 || counts.errors > 0; + const completed = check.evidenceStatus === 'completed' && status === 'completed'; + const result = completed && failed ? 'failed' : completed && check.exitCode === 0 && valid && counts.passed > 0 ? 'completed' : 'unknown'; + const parts = []; + if (valid && completed) { + for (const [key, label] of [['passed', 'countPassed'], ['failures', 'countFailed'], ['errors', 'countErrors'], ['skipped', 'countSkipped']]) { + if (counts[key] > 0 || key === 'passed') parts.push(message(label, { count: number(counts[key]) })); } } - details.append(summary, body); - return details; - } - function eventList(cycle) { - const events = (cycle.events || []).filter((event) => event.kind !== 'report'); - if (!events.length) return null; - const section = bindDisclosure(element('details', 'observed-events report-disclosure'), `events:${cycle.id}`); - section.append(element('summary', '', message('observedEvents'))); - section.append(element('p', 'sidebar-note', message('eventTimeNote'))); - const list = element('ol', 'event-list'); - for (const event of events.slice(-12)) { - const row = element('li'); - let label = message(`event_${event.kind}`); - if (event.kind === 'command') { - const ended = event.phase === 'completed'; - label = `${message(ended ? 'commandEnded' : 'commandStarted')} ${event.command || ''}`; - if (ended) label += ` · ${message('exitCode')}: ${Number.isInteger(event.exitCode) ? event.exitCode : message('unknown')}`; - } else if (event.kind === 'files') label = `${message('fileChanges')}: ${(event.paths || []).join(', ')}`; - row.append(element('time', '', formatTime(event.observedAt)), element('span', '', label)); - list.append(row); + if (result === 'failed' && !(counts.failures > 0 || counts.errors > 0)) parts.push(message('checkFailedRun')); + if (!valid) parts.push(message('checkCountsUnknown')); + else if (!completed || result === 'unknown') parts.push(message('checkUnconfirmed')); + return { status: result, detail: parts.join(' · ') }; + } + function cycleRecords(cycle) { + const section = element('section', 'cycle-records'); + section.append(element('h3', 'content-heading', message('cycleRecords'))); + if (cycle.projectStatus === 'other' || cycle.projectStatus === 'unknown') { + section.append(element('p', 'sidebar-note', message(cycle.projectStatus === 'other' ? 'otherProjectCycle' : 'unknownProjectCycle'))); + return section; + } + const rows = []; + const check = cycle.latestCheck; + const presentation = checkPresentation(cycle); + rows.push({ kind: 'check', title: message('latestCheckRecord'), status: presentation.status, detail: presentation.detail, + time: check?.endedAt || check?.recordedAt || check?.startedAt, + path: check?.available === true ? check.path : null }); + for (const artifact of cycle.artifacts || []) { + if (artifact.kind !== 'document') continue; + rows.push({ kind: 'document', time: artifact.recordedAt, title: message(artifact.available === true ? 'documentRecorded' : 'documentUnavailable'), + detail: String(artifact.path || artifact.label || '').split(/[\\/]/).pop() }); + } + if (cycle.workReport && cycle.workReportStatus === 'valid') rows.push({ kind: 'report', time: cycle.workReport.recorded_at, title: message('workRecorded') }); + // Missing timestamps remain unknown, rather than borrowing a cycle or consensus time. + rows.sort((a, b) => (Date.parse(a.time) || Infinity) - (Date.parse(b.time) || Infinity)); + const list = element('ol', 'record-list'); + for (const record of rows) { + const row = element('li', `record-row record-${record.kind}`); + const time = element('time', '', record.time ? formatTime(record.time) : '—'); + if (record.time) { time.dateTime = record.time; time.title = formatTime(record.time, true); } + const body = element('div', 'record-body'); + const title = element('div', 'record-title', record.title); + if (record.status) title.append(progressIcon(record.status, record.detail)); + body.append(title); + if (record.detail || record.path) { + const detail = element('div', `record-detail${record.status === 'failed' ? ' status-failed' : ''}`); + if (record.detail) detail.append(element('span', 'record-counts', record.detail)); + if (record.path) { + const link = element('a', 'check-report', message('checkReport')); + link.href = `/api/journal/document?path=${encodeURIComponent(record.path)}`; link.target = '_blank'; link.rel = 'noopener'; + link.append(icon('external-link')); detail.append(link); + } + body.append(detail); + } + row.append(time, body); list.append(row); } section.append(list); - if (cycle.eventStatus === 'partial') section.append(element('p', 'sidebar-note', message('partialEvents'))); return section; } function workReportDetails(cycle) { @@ -176,9 +278,7 @@ section.append(element('p', 'sidebar-note', message(cycle.workReportStatus === 'invalid' ? 'invalidWorkReport' : 'missingWorkReport'))); return section; } - section.append(element('p', 'report-source', `${message('reportedPhase')} · ${message(`workPhase_${work.phase}`)}`)); if (work.blocker) section.append(element('p', 'work-blocker', `${message('workBlocker')}:${work.blocker}`)); - section.append(element('p', 'report-source', message('structuredReportSource'))); if (!cycle.active && !work.final) section.append(element('p', 'sidebar-note', message('unfinishedWorkReport'))); return section; } @@ -191,8 +291,9 @@ return; } const article = element('article', 'current-cycle'); + article.setAttribute('aria-current', 'step'); + article.setAttribute('aria-labelledby', 'cycleTitle'); const gutter = element('div', 'cycle-gutter'); - gutter.append(element('span', 'cycle-word', 'CYCLE')); const cycleNumber = element('span', 'cycle-number', String(cycle.number ?? '—').padStart(2, '0')); cycleNumber.id = 'cycleNumber'; gutter.append(cycleNumber); @@ -217,8 +318,7 @@ const workDetails = workReportDetails(cycle); if (workDetails) report.append(workDetails); body.append(report); - const observed = eventList(cycle); - if (observed) body.append(observed); + body.append(cycleRecords(cycle)); const rows = cycle.workReport ? [] : reportRows(cycle); if (rows.length) { const results = element('section', 'results-section'); @@ -228,10 +328,8 @@ body.append(results); } if (cycle.report && !cycle.workReport) body.append(element('p', 'report-source', message('reportSource'))); - const disclosure = fullReport(cycle); - if (disclosure) body.append(disclosure); body.append(logButton(cycle)); - article.append(gutter, body); + article.append(gutter, progressIcon(cycle.status), body); container.append(article); } function renderHistory() { @@ -242,15 +340,14 @@ const row = bindDisclosure(element('details', 'history-row'), `cycle:${cycle.id}`); row.dataset.cycleId = cycle.id; const summary = element('summary'); - const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}`; - summary.append(element('span', 'history-number', String(cycle.number ?? '—').padStart(2, '0')), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing)); - const arrow = element('span', 'history-chevron', '›'); + const association = cycle.projectStatus === 'other' ? message('otherProjectCycle') : cycle.projectStatus === 'unknown' ? message('unknownProjectCycle') : ''; + const timing = `${statusLabel(cycle.status)} · ${formatTime(cycle.startedAt)}${association ? ` · ${association}` : ''}`; + summary.append(element('span', 'history-number', String(cycle.number ?? '—').padStart(2, '0')), progressIcon(cycle.status), element('span', 'history-title', cycleTitle(cycle)), element('span', `history-meta${cycle.status === 'failed' ? ' status-failed' : ''}`, timing)); + const arrow = icon('chevron-right'); arrow.classList.add('history-chevron'); arrow.setAttribute('aria-hidden', 'true'); summary.append(arrow); const content = element('div', 'history-content'); content.append(metadata(cycle)); - const observed = eventList(cycle); - if (observed) content.append(observed); let report = cycle.workReport?.summary || clean(cycle.summary || ''); if (/^[\[{]/.test(report)) report = cycleTitle(cycle); content.append(element('p', '', report || message('noSummary'))); @@ -258,8 +355,7 @@ if (workDetails) content.append(workDetails); const results = cycle.workReport ? [] : reportRows(cycle); if (results.length) content.append(resultList(results)); - const disclosure = fullReport(cycle); - if (disclosure) content.append(disclosure); + content.append(cycleRecords(cycle)); content.append(logButton(cycle)); row.append(summary, content); history.append(row); @@ -267,6 +363,7 @@ $('olderButton').hidden = older.length <= 2; $('olderButton').textContent = state.older ? message('fewer') : message('older', { count: older.length - 2 }); document.querySelector('.history-section').hidden = !older.length; + document.querySelector('.journal-layout').classList.toggle('no-history', !older.length); } function aggregate(cycles) { const result = { inputTokens: null, outputTokens: null, totalTokens: null, known: 0, count: cycles.length, partial: false }; @@ -278,19 +375,17 @@ result.partial = result.known < cycles.length || cycles.some((cycle) => cycle.usage?.status === 'partial'); return result; } - function fileIcon() { + function icon(name) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 24'); - svg.setAttribute('class', 'artifact-icon'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('stroke', 'currentColor'); - svg.setAttribute('stroke-width', '1.4'); - svg.setAttribute('aria-hidden', 'true'); - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M4 2h8l5 5v15H4zM12 2v6h5M7 12h7M7 16h7'); - svg.append(path); + for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', class: 'ui-icon', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.7', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' })) svg.setAttribute(key, value); + // Only trusted, pinned library geometry is parsed here; all runtime content uses textContent. + svg.innerHTML = ICONS[name] || ICONS['file-text']; return svg; } + function iconLabel(node, name, text) { + node.replaceChildren(icon(name), element('span', '', text)); + return node; + } function runtimeRows(entries) { const list = element('dl', 'runtime-details'); for (const [label, value] of entries) list.append(element('dt', '', label), element('dd', '', !value || value === 'unknown' ? message('unknown') : value)); @@ -315,8 +410,8 @@ $('runtimeState').dataset.state = unavailable ? 'unavailable' : runtime.state || 'unknown'; $('startButton').disabled = locked || retryStop || !['stopped', 'inactive'].includes(process); $('stopButton').disabled = locked || (!retryStop && process !== 'running'); - $('startButton').textContent = message(action === 'start' ? 'starting' : 'start'); - $('stopButton').textContent = message(action === 'stop' ? 'stopping' : 'stop'); + iconLabel($('startButton'), 'play', message(action === 'start' ? 'starting' : 'start')); + iconLabel($('stopButton'), 'square', message(action === 'stop' ? 'stopping' : 'stop')); $('startButton').title = $('stopButton').title = readOnly() ? message('readOnly') : unavailable ? message('statusUnavailable') : ''; $('refreshButton').disabled = Boolean(state.refreshPending || state.action); $('modeNote').textContent = data ? message(readOnly() ? 'preview' : 'live') : ''; @@ -327,6 +422,7 @@ const paused = ['paused', 'waiting_limit', 'circuit_break'].includes(runtime.state); $('runtimeNotice').hidden = unavailable || (!reason && !paused && !data?.budgetPause); $('runtimeNotice').textContent = reason ? message('pauseReason', { reason: pauseLabel(reason) }) : paused ? message('pauseReview') : data?.budgetPause ? message('budgetPause') : ''; + updateElapsed(); renderDiagnostics(); } function renderDiagnostics() { @@ -398,45 +494,43 @@ } catch (_) { state.languageError = 'languageSaveFailed'; } finally { state.languageSaving = false; renderLanguage(); } } + function unavailableArtifact(artifact) { + if (artifact.kind === 'preview') { + return message(artifact.state === 'stopped' ? 'previewEnded' : artifact.state === 'interrupted' ? 'previewInterrupted' : 'previewUnavailable'); + } + return message(artifact.evidenceStatus === 'stale' ? 'artifactStale' : 'artifactUnavailable'); + } function renderSidebar() { const sidebar = clear($('projectSidebar')); const data = state.data; - const next = element('section', 'sidebar-block'); - const heading = element('div', 'section-heading-row'); - heading.append(element('h2', '', message('nextAction'))); - next.append(heading); - const work = state.currentCycle?.workReport; - const nextTime = work?.recorded_at || data.consensus?.updatedAt; - if (nextTime) heading.append(element('span', 'sidebar-update', message('updated', { time: formatTime(nextTime) }))); - next.append(element('p', 'next-action', work ? (work.next_action || message('noPlannedAction')) : clean(data.consensus?.nextAction) || message('noNextAction'))); - if (work?.next_action_kind === 'human_input') next.append(element('p', 'sidebar-note', message('humanInput'))); - next.append(element('p', 'sidebar-note', message(readOnly() || (!state.statusFailed && data.runtime?.processState === 'stopped') ? 'stoppedNotice' : work ? 'workPlanNotice' : 'planNotice'))); + const overview = element('section', 'sidebar-block project-overview'); + const name = element('h1', '', data.project?.name || message('noProject')); name.id = 'projectName'; + const description = element('p', 'project-description', clean(data.project?.description)); description.id = 'projectDescription'; description.title = description.textContent; + const latest = state.currentCycle; + const date = element('p', 'sidebar-note', latest?.active ? message('currentRun') : latest ? message('latestRun', { date: formatDate(latest.startedAt) }) : message(readOnly() ? 'archived' : 'ready')); date.id = 'runHeading'; + overview.append(name, description, date); const artifacts = element('section', 'sidebar-block'); artifacts.append(element('h2', '', message('artifacts'))); - if (data.artifacts?.length) { + const visibleArtifacts = (data.artifacts || []).filter((artifact) => artifact.kind !== 'check'); + if (visibleArtifacts.length) { const list = element('ul', 'artifact-list'); - for (const artifact of data.artifacts) { + for (const artifact of visibleArtifacts) { const item = element('li'); - const label = artifact.kind === 'preview' ? message('productPreview') : artifact.label || artifact.path; + const label = artifact.kind === 'preview' ? message(artifact.available === false ? 'previewName' : 'productPreview') : artifact.path?.split(/[\\/]/).pop()?.toUpperCase() === 'DELIVERY.MD' ? message('deliveryDocument') : String(artifact.label || artifact.path || '').split(/[\\/]/).pop(); if (artifact.available === false || (!artifact.path && !artifact.url)) { - item.append(element('span', '', `${label} · ${message('artifactUnavailable')}`)); + item.append(element('span', '', `${label} · ${unavailableArtifact(artifact)}`)); } else { const link = element('a', 'artifact-link'); link.href = artifact.url || `/api/journal/document?path=${encodeURIComponent(artifact.path)}`; link.target = '_blank'; link.rel = 'noopener'; link.title = artifact.path || artifact.url; - link.append(fileIcon(), element('span', '', label)); - const arrow = element('span', 'artifact-arrow', '↗'); + link.append(icon(artifact.kind === 'preview' ? 'panels-top-left' : 'file-text'), element('span', '', label)); + const arrow = icon('external-link'); arrow.classList.add('artifact-arrow'); arrow.setAttribute('aria-hidden', 'true'); link.append(arrow); item.append(link); } - if (artifact.kind === 'check') { - const tests = artifact.tests; - const result = `${message('exitCode')}: ${Number.isInteger(artifact.exitCode) ? artifact.exitCode : message('unknown')}`; - item.append(element('p', 'sidebar-note', tests ? `${result} · ${message('testCounts', { tests: tests.tests, failures: tests.failures + tests.errors, skipped: tests.skipped })}` : result)); - } if (artifact.recordedAt) item.append(element('p', 'sidebar-note', message('recordedAt', { time: formatTime(artifact.recordedAt) }))); list.append(item); } @@ -448,16 +542,19 @@ const usage = `${compactNumber(sum.totalTokens)}${knownNumber(sum.totalTokens) ? ' tokens' : ''}${sum.partial && sum.known ? message('usagePartialShort') : ''}`; const config = data.runtime || {}; runtime.append(runtimeRows([[message('engine'), config.engine], [message('model'), config.model], [message('reasoning'), config.reasoning === 'unknown' ? message('unknown') : config.reasoning], [message('language'), languageLabel(config.language || data.language)], [message('recordedUsage'), usage]])); - runtime.append(element('p', 'sidebar-note', message(config.configSource === 'session_context' ? 'observedSession' : 'unconfirmedSession'))); const details = bindDisclosure(element('details', 'runtime-disclosure'), 'runtime'); - details.append(element('summary', '', message('moreRuntime')), runtimeRows([[message('state'), runtimeLabel()], [message('phase'), clean(data.consensus?.phase) || message('phaseUnknown')], [message('source'), data.sourceName]])); + details.append(element('summary', '', message('moreRuntime')), runtimeRows([[message('state'), runtimeLabel()], [message('source'), data.sourceName]])); + details.append(element('p', 'sidebar-note', message(config.configSource === 'session_context' ? 'observedSession' : 'unconfirmedSession'))); runtime.append(details); - sidebar.append(next, artifacts, runtime); + sidebar.append(overview, artifacts, runtime); } function applyLanguage() { document.documentElement.lang = state.language; document.title = `${state.data?.project?.name || 'Auto Company'} · ${message('work')}`; document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = message(node.dataset.i18n); }); + for (const [id, name] of [['tab-work', 'notebook-pen'], ['tab-usage', 'chart-no-axes-column'], ['tab-logs', 'terminal'], ['settingsButton', 'sliders-horizontal']]) iconLabel($(id), name, $(id).textContent); + $('refreshButton').replaceChildren(icon('refresh-cw')); + $('closeSettingsButton').replaceChildren(icon('x')); $('refreshButton').title = message('refresh'); $('refreshButton').setAttribute('aria-label', message('refresh')); $('closeSettingsButton').setAttribute('aria-label', message('close')); @@ -465,21 +562,30 @@ document.querySelector('.table-scroll').setAttribute('aria-label', message('usageDetail')); $('projectSidebar').setAttribute('aria-label', message('artifacts')); } + function rememberFocus() { + const active = document.activeElement; + if (!active || active === document.body) return null; + const disclosure = active.closest('details[data-disclosure-key]'); + return { id: active.id, key: active.dataset.focusKey, disclosure: active.tagName === 'SUMMARY' ? disclosure?.dataset.disclosureKey : null }; + } + function restoreFocus(saved) { + if (!saved) return; + const node = saved.id ? $(saved.id) : saved.key ? [...document.querySelectorAll('[data-focus-key]')].find((item) => item.dataset.focusKey === saved.key) : saved.disclosure ? [...document.querySelectorAll('details[data-disclosure-key]')].find((item) => item.dataset.disclosureKey === saved.disclosure)?.querySelector(':scope > summary') : null; + if (node && !node.disabled) node.focus({ preventScroll: true }); + } + function latestCycle(data) { + const scoped = Boolean(data.project?.id) && Object.hasOwn(data, 'latestProjectCycleId'); + return data.cycles.find((cycle) => cycle.active && (!scoped || cycle.projectStatus === 'current')) + || (scoped ? data.cycles.find((cycle) => cycle.id === data.latestProjectCycleId && cycle.projectStatus === 'current') : data.cycles[0]); + } function render() { + const focus = rememberFocus(); const data = state.data; applyLanguage(); - $('projectName').textContent = data.project?.name || message('noProject'); - const fullDescription = clean(data.project?.description); - let description = fullDescription; - if (data.project?.name && description.startsWith(data.project.name)) { - description = description.slice(data.project.name.length).replace(/^[\s,,::·—-]+/, ''); - } - $('projectDescription').textContent = description.split(/[;;]/)[0]; - $('projectDescription').title = fullDescription; - let latest = data.cycles.find((cycle) => cycle.active) || (liveProcess() ? { id: '__current__', number: data.runtime?.currentCycleNumber, startedAt: data.runtime?.lastRun, status: data.runtime?.state || 'running', active: true, synthetic: true } : data.cycles[0]); + let latest = latestCycle(data); + if (!latest?.active && liveProcess() && data.runtime?.state === 'running') latest = { id: '__current__', number: data.runtime?.currentCycleNumber, startedAt: data.runtime?.lastRun, status: 'running', active: true, synthetic: true }; if (latest?.active && state.statusFailed) latest = { ...latest, active: false, status: 'unknown' }; state.currentCycle = latest; - $('runHeading').textContent = latest?.active ? message('currentRun') : latest ? message('latestRun', { date: formatDate(latest.startedAt) }) : message(readOnly() ? 'archived' : 'ready'); renderCurrent(latest); renderHistory(); renderSidebar(); @@ -498,6 +604,7 @@ renderUsage(); renderLogOptions(); selectTab(state.tab); + restoreFocus(focus); } function filterUsage() { const period = $('usagePeriod').value; @@ -652,7 +759,9 @@ const data = await fetchJSON('/api/journal'); if (!data.ok || !Array.isArray(data.cycles)) throw new Error('invalid journal response'); const signature = JSON.stringify({ ...data, generatedAt: undefined, status: data.status ? { ...data.status, timestamp: undefined, elapsedMs: undefined } : undefined }); + if (state.data && Number.isFinite(Date.parse(data.generatedAt)) && Date.parse(data.generatedAt) < Date.parse(state.data.generatedAt)) throw new Error('Out-of-order journal snapshot'); state.data = data; + state.receivedAt = performance.now(); state.statusFailed = data.runtime?.available === false || data.status?.ok === false || !data.runtime || ['unknown', 'unavailable'].includes(data.runtime.state); if (languageRevision === state.languageRevision && !state.languageSaving && !state.languageLoading) { if (data.languageState) { @@ -680,7 +789,7 @@ $('loadingState').hidden = true; $('connectionError').hidden = false; $('connectionError').textContent = message(state.data ? 'stale' : 'readFailed'); - $('refreshStatus').textContent = ''; + $('refreshStatus').textContent = state.data?.generatedAt ? message('lastUpdated', { time: formatTime(state.data.generatedAt, true) }) : ''; if (state.data) render(); else renderRuntime(); } finally { @@ -730,6 +839,7 @@ if (tabs[next] === 'logs') loadLog(); }); }); + state.elapsedTimer = setInterval(updateElapsed, 1000); $('refreshButton').addEventListener('click', refresh); $('startButton').addEventListener('click', () => runAction('start')); $('stopButton').addEventListener('click', () => runAction('stop')); diff --git a/dashboard/i18n.js b/dashboard/i18n.js index b89471dd..71415dab 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -1,6 +1,19 @@ /* The journal follows the shared preference and the current product language lock. */ window.JOURNAL_MESSAGES = { "zh-CN": { + "pendingCycle": "尚未开始", + "checkUnconfirmed": "检查结果尚未确认", + "cycleRecords": "本轮记录", + "latestCheckRecord": "最近一次检查", + "noRegisteredCheck": "暂无已登记检查", + "documentRecorded": "文档已登记", + "documentUnavailable": "文档记录已失效", + "workRecorded": "更新工作记录", + "deliveryDocument": "查看交付说明", + "countPassed": "{count} 项通过", + "countFailed": "{count} 项失败", + "countErrors": "{count} 项错误", + "countSkipped": "{count} 项跳过", "reportedPhase": "记录的工作阶段", "workPhase_planning": "梳理方案", "workPhase_implementing": "执行任务", @@ -12,9 +25,6 @@ window.JOURNAL_MESSAGES = { "invalidWorkReport": "本轮工作记录格式有误,暂用已有汇报。", "missingWorkReport": "本轮未提交规范工作记录,暂用已有汇报。", "unfinishedWorkReport": "本轮未提交最终工作记录,以上为最后一次有效更新。", - "noPlannedAction": "当前未记录下一步动作。", - "humanInput": "需要人工输入或确认;尚未确认开始执行。", - "workPlanNotice": "这是最近记录的下一步计划,尚未确认开始执行。", "observedEvents": "实际执行记录", "eventTimeNote": "时间为系统接收到事件的时间;命令退出不代表功能验收通过。", "event_process.started": "启动本轮执行", @@ -77,9 +87,6 @@ window.JOURNAL_MESSAGES = { "noReport": "本轮没有可读取的工作汇报。", "viewLog": "查看本轮日志 →", "noLog": "本轮日志未保存或已清理", - "nextAction": "最近记录的下一步", - "noNextAction": "共识文件尚未记录下一步。", - "updated": "更新于 {time}", "artifacts": "项目成果", "noArtifacts": "尚无可读取的成果文档。", "runtime": "运行信息", @@ -92,7 +99,6 @@ window.JOURNAL_MESSAGES = { "phase": "最近记录的阶段", "source": "数据来源", "state": "运行状态", - "stoppedNotice": "本次自主运行已停止。下一步是历史记录,不表示正在执行。", "older": "更早记录 · {count} 轮", "fewer": "收起更早记录", "ledger": "运行用量账本", @@ -187,7 +193,6 @@ window.JOURNAL_MESSAGES = { "runningSummary": "本轮正在进行", "unknownSummary": "本轮状态尚未确认", "runningNoReport": "本轮尚未保存工作汇报。可查看运行日志了解过程。", - "planNotice": "这是最近保存的共识计划,不代表实时任务或子代理活动。", "chooseLog": "选择日志", "runtimeLog": "全局运行日志", "viewRuntimeLog": "查看运行日志 →", @@ -246,9 +251,63 @@ window.JOURNAL_MESSAGES = { "budget_unknown": "未知", "budget_complete": "完整记录", "budget_partial": "部分记录", - "budget_unavailable": "未记录" + "budget_unavailable": "未记录", + "recentExecution": "最近执行", + "recentChecks": "最近检查", + "noEvents": "本轮尚无已采集的执行事件。", + "commandStartedLabel": "运行命令", + "commandEndedLabel": "命令结束", + "fileChangesLabel": "文件变更", + "exactCommand": "查看记录的命令", + "sourceAndScope": "来源与范围", + "elapsedUnconfirmed": "运行耗时待确认", + "runningElapsed": "已运行 {time}", + "lastUpdated": "上次更新 {time}", + "check_unregistered": "本轮暂无已登记检查;不表示通过或失败。", + "check_running": "检查运行中", + "check_completed": "检查已结束", + "check_interrupted": "检查已中断", + "check_invalid": "检查记录无效", + "check_stale": "上次检查结果已过期", + "checkCountsUnknown": "检查计数尚未确认。", + "checkPassed": "通过", + "checkFailed": "失败", + "checkErrors": "错误", + "checkSkipped": "跳过", + "checkTotal": "总计", + "checkReport": "打开检查报告", + "reportAvailability": "报告状态", + "checkScopeNote": "只包含此项目、本轮已登记检查;命令成功不代表业务验收。", + "checkFailedRun": "检查失败", + "report_fresh": "已验证当前报告", + "report_recorded": "已记录报告", + "report_missing_or_stale": "报告缺失或已变更", + "report_unsupported": "尚无此格式的计数解析", + "report_unavailable": "无可用报告", + "otherProjectCycle": "其他项目", + "unknownProjectCycle": "项目归属未记录", + "durationUnknown": "耗时未确认", + "commandTruncated": "命令记录已截断。", + "artifactStale": "文件已变更或缺失", + "previewEnded": "预览已结束", + "previewInterrupted": "预览已中断", + "previewUnavailable": "预览不可用", + "previewName": "产品预览" }, "en": { + "pendingCycle": "Not started", + "checkUnconfirmed": "Check result is not confirmed", + "cycleRecords": "Cycle record", + "latestCheckRecord": "Latest check", + "noRegisteredCheck": "No registered check", + "documentRecorded": "Document registered", + "documentUnavailable": "Document record unavailable", + "workRecorded": "Work record updated", + "deliveryDocument": "View delivery document", + "countPassed": "{count} passed", + "countFailed": "{count} failed", + "countErrors": "{count} errors", + "countSkipped": "{count} skipped", "reportedPhase": "Reported work phase", "workPhase_planning": "Planning", "workPhase_implementing": "Working", @@ -260,9 +319,6 @@ window.JOURNAL_MESSAGES = { "invalidWorkReport": "This cycle's work report is invalid. Showing the existing report.", "missingWorkReport": "No structured work report was submitted for this cycle. Showing the existing report.", "unfinishedWorkReport": "No final work report was submitted; showing the last valid update.", - "noPlannedAction": "No next action is currently recorded.", - "humanInput": "Human input or confirmation is needed; execution has not been confirmed.", - "workPlanNotice": "This is the last recorded next action; execution has not been confirmed.", "observedEvents": "Observed execution", "eventTimeNote": "Times show when events were received; command exit is not feature acceptance.", "event_process.started": "Cycle process started", @@ -325,9 +381,6 @@ window.JOURNAL_MESSAGES = { "noReport": "No readable work report is available for this cycle.", "viewLog": "View cycle log →", "noLog": "The cycle log was not saved or has been cleared", - "nextAction": "Last recorded next step", - "noNextAction": "No next step has been recorded in the consensus file.", - "updated": "Updated {time}", "artifacts": "Project files", "noArtifacts": "No project documents are available yet.", "runtime": "Runtime", @@ -340,7 +393,6 @@ window.JOURNAL_MESSAGES = { "phase": "Last recorded phase", "source": "Data source", "state": "Run state", - "stoppedNotice": "This autonomous run has stopped. The next step is a historical record, not a live task.", "older": "Earlier records · {count} cycles", "fewer": "Hide earlier records", "ledger": "Run usage ledger", @@ -435,7 +487,6 @@ window.JOURNAL_MESSAGES = { "runningSummary": "This cycle is in progress", "unknownSummary": "Cycle state is unconfirmed", "runningNoReport": "No work report has been saved for this cycle yet. Open the runtime log to follow its progress.", - "planNotice": "This is the last saved consensus plan, not a live task or subagent activity feed.", "chooseLog": "Select log", "runtimeLog": "Global runtime log", "viewRuntimeLog": "View runtime log →", @@ -494,6 +545,47 @@ window.JOURNAL_MESSAGES = { "budget_unknown": "Unknown", "budget_complete": "Complete records", "budget_partial": "Partial records", - "budget_unavailable": "Not recorded" + "budget_unavailable": "Not recorded", + "recentExecution": "Recent execution", + "recentChecks": "Recent checks", + "noEvents": "No execution events collected for this cycle.", + "commandStartedLabel": "Command started", + "commandEndedLabel": "Command finished", + "fileChangesLabel": "Files changed", + "exactCommand": "Show recorded command", + "sourceAndScope": "Source and scope", + "elapsedUnconfirmed": "Elapsed time unconfirmed", + "runningElapsed": "Running for {time}", + "lastUpdated": "Last updated {time}", + "check_unregistered": "No registered checks for this cycle; no pass or failure is implied.", + "check_running": "Check running", + "check_completed": "Check finished", + "check_interrupted": "Check interrupted", + "check_invalid": "Check record invalid", + "check_stale": "Previous check result is stale", + "checkCountsUnknown": "Check counts are not confirmed.", + "checkPassed": "Passed", + "checkFailed": "Failed", + "checkErrors": "Errors", + "checkSkipped": "Skipped", + "checkTotal": "Total", + "checkReport": "Open check report", + "reportAvailability": "Report status", + "checkScopeNote": "Only registered checks for this project and cycle; command success is not business acceptance.", + "checkFailedRun": "Check failed", + "report_fresh": "Current report verified", + "report_recorded": "Report recorded", + "report_missing_or_stale": "Report missing or changed", + "report_unsupported": "Count parsing unavailable for this format", + "report_unavailable": "No available report", + "otherProjectCycle": "Other project", + "unknownProjectCycle": "Project not recorded", + "durationUnknown": "Duration unconfirmed", + "commandTruncated": "The recorded command is truncated.", + "artifactStale": "File changed or missing", + "previewEnded": "Preview ended", + "previewInterrupted": "Preview interrupted", + "previewUnavailable": "Preview unavailable", + "previewName": "Product preview" } }; diff --git a/dashboard/index.html b/dashboard/index.html index 04290588..e178f06b 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -15,10 +15,6 @@