From ee0af20e2c4de20ca07754a16ccd83783e5e2e73 Mon Sep 17 00:00:00 2001 From: Yoilun Date: Tue, 26 May 2026 00:02:22 +0800 Subject: [PATCH] fix: restore project goals and flow records --- web/src/api/normalizers.js | 137 ++++++++++++++++++++++++++++++- web/src/api/normalizers.test.mjs | 86 +++++++++++++++++++ web/src/styles.css | 33 ++++++++ web/src/views/ProjectView.vue | 79 ++++++++++-------- 4 files changed, 300 insertions(+), 35 deletions(-) diff --git a/web/src/api/normalizers.js b/web/src/api/normalizers.js index 4a64352..5507dea 100644 --- a/web/src/api/normalizers.js +++ b/web/src/api/normalizers.js @@ -180,6 +180,7 @@ export function normalizeRuntime(payload = {}) { .map((goal) => goal.goal || goal.objective) .filter(Boolean) .join(';') + const taskSummary = summarizeTask(thread.title || thread.preview) return { id: thread.id, name: thread.agentNickname || thread.agentRole || thread.title || thread.role || thread.id || '未命名线程', @@ -188,7 +189,8 @@ export function normalizeRuntime(payload = {}) { projectHints: [thread.title, thread.preview, thread.agentPath].filter(Boolean), status, statusZh: formatStatus(status), - goal: goalText || '没有目标记录', + goal: goalText || taskSummary || '未记录结构化目标', + goalSource: goalText ? '结构化目标' : taskSummary ? '线程任务摘要' : '无目标来源', process: describeRuntimeProcess(thread), source: source.label, confidence: source.confidenceLabel, @@ -210,12 +212,15 @@ export function normalizeRuntime(payload = {}) { } function isRuntimeAgentThread(thread) { + if (thread?.threadSource === 'user') { + return false + } return Boolean( thread?.agentNickname || thread?.agentRole || thread?.agentPath || thread?.threadSource === 'subagent' || - thread?.role, + (!thread?.threadSource && thread?.role), ) } @@ -236,9 +241,18 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath const targetPath = normalizePath(projectPath) const agents = runtime.agents.filter((agent) => belongsToProject(agent, targetPath)) const threadIds = new Set(agents.map((agent) => agent.id)) + const agentByID = new Map(agents.map((agent) => [agent.id, agent])) + const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread])) + const projectThreadIds = new Set(runtime.threads.filter((thread) => threadCanJoinProjectFlow(thread, targetPath)).map((thread) => thread.id)) const threads = runtime.threads.filter((thread) => threadIds.has(thread.id)) const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId)) - const edges = runtime.edges.filter((edge) => threadIds.has(edge.fromThreadId) && threadIds.has(edge.toThreadId)) + const edges = runtime.edges.filter((edge) => + (threadIds.has(edge.fromThreadId) || threadIds.has(edge.toThreadId)) && + projectThreadIds.has(edge.fromThreadId) && + projectThreadIds.has(edge.toThreadId), + ) + const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source })) + const phaseGroups = buildPhaseGroups(agents) return { ...runtime, @@ -246,6 +260,8 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath threads, goals, edges, + handoffs, + phaseGroups, isEmpty: agents.length === 0, emptyTitle: '这个项目没有运行线程', emptyText: projectPath @@ -254,6 +270,91 @@ export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath } } +function normalizeProjectHandoff(edge, context) { + const source = normalizeSource(edge.source, context.source) + const status = normalizeSpawnStatus(edge.reason || edge.status) || 'unknown' + return { + from: runtimeNodeName(edge.fromThreadId, context), + to: runtimeNodeName(edge.toThreadId, context), + summary: formatStatus(status), + status, + time: edge.createdAt || '后端事件', + source: source.label, + confidence: source.confidenceLabel, + } +} + +function runtimeNodeName(threadID, { agentByID, threadByID }) { + if (!threadID) { + return '未知线程' + } + const agent = agentByID.get(threadID) + if (agent) { + return agent.name + } + const thread = threadByID.get(threadID) + if (thread?.threadSource === 'user' || (!thread?.agentNickname && !thread?.agentRole && !thread?.agentPath && !thread?.role)) { + return '主线程' + } + return thread?.agentNickname || thread?.agentRole || thread?.role || '未知线程' +} + +function buildPhaseGroups(agents) { + const groups = new Map() + for (const agent of agents) { + const phaseName = extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段' + if (!groups.has(phaseName)) { + groups.set(phaseName, []) + } + groups.get(phaseName).push(agent) + } + return [...groups.entries()] + .map(([name, phaseAgents]) => { + const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN')) + return { + name, + status: phaseStatus(phaseAgents), + statusZh: formatStatus(phaseStatus(phaseAgents)), + roles, + agents: phaseAgents, + } + }) + .sort((a, b) => phaseSortKey(a.name) - phaseSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN')) +} + +function phaseStatus(agents) { + if (agents.some((agent) => agent.status === 'running' || agent.status === 'active')) { + return 'running' + } + if (agents.length > 0 && agents.every((agent) => agent.status === 'complete' || agent.status === 'done')) { + return 'complete' + } + return 'unknown' +} + +function extractPhaseName(values) { + for (const value of values) { + const text = String(value || '') + const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/) + if (chinese) { + return `阶段 ${chinese[1]}` + } + const english = text.match(/\bphase\s*([0-9]+)\b/i) + if (english) { + return `阶段 ${english[1]}` + } + } + return '' +} + +function phaseSortKey(name) { + const match = String(name).match(/阶段\s*([0-9]+)/) + if (!match) { + return Number.MAX_SAFE_INTEGER + } + return Number(match[1]) +} + export function normalizeWorkflow(payload = {}) { const source = normalizeSource(payload.source) const phases = Array.isArray(payload.phases) @@ -390,6 +491,14 @@ function normalizePath(path) { return String(path || '').replace(/\/+$/, '') } +function summarizeTask(value) { + const firstLine = String(value || '').split('\n').map((line) => line.trim()).find(Boolean) || '' + if (firstLine.length <= 180) { + return firstLine + } + return `${firstLine.slice(0, 177)}...` +} + function belongsToProject(agent, targetPath) { if (!targetPath) { return false @@ -404,6 +513,28 @@ function belongsToProject(agent, targetPath) { return [agent.process, ...(agent.projectHints || [])].some((value) => containsPathToken(value, targetPath)) } +function threadBelongsToProject(thread, targetPath) { + if (!targetPath) { + return false + } + const cwd = normalizePath(thread?.cwd) + if (cwd === targetPath) { + return true + } + if (!cwd || !targetPath.startsWith(`${cwd}/`)) { + return false + } + return [thread?.title, thread?.preview, thread?.agentPath].some((value) => containsPathToken(value, targetPath)) +} + +function threadCanJoinProjectFlow(thread, targetPath) { + if (threadBelongsToProject(thread, targetPath)) { + return true + } + const cwd = normalizePath(thread?.cwd) + return Boolean(cwd && targetPath.startsWith(`${cwd}/`)) +} + function normalizeSpawnStatus(status) { if (status === 'open') { return 'running' diff --git a/web/src/api/normalizers.test.mjs b/web/src/api/normalizers.test.mjs index 7894354..bd79e68 100644 --- a/web/src/api/normalizers.test.mjs +++ b/web/src/api/normalizers.test.mjs @@ -187,6 +187,7 @@ test('does not turn ordinary conversation threads into agents', () => { title: '为什么点 yoilun 这个项目出来的都是对话的信息', preview: '普通用户对话,不是智能体', threadSource: 'user', + role: 'user', source: { kind: 'sqlite_table', confidence: 'high' }, }, { @@ -208,9 +209,94 @@ test('does not turn ordinary conversation threads into agents', () => { assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['reviewer']) assert.equal(projectRuntime.agents[0].name, 'Lorentz') assert.equal(projectRuntime.agents[0].process, '子智能体线程') + assert.equal(projectRuntime.agents[0].goal, '审查 /Users/yoilun 项目') + assert.equal(projectRuntime.agents[0].goalSource, '线程任务摘要') assert.doesNotMatch(projectRuntime.agents[0].process, /审查 \/Users\/yoilun/) }) +test('builds project handoffs and phase groups from runtime edges and agent tasks', () => { + const runtime = normalizeRuntime({ + items: [ + { + id: 'main', + cwd: '/repo/a', + title: '主控对话', + threadSource: 'user', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'coder', + cwd: '/repo/a', + title: '阶段 6:实现安全写回', + agentNickname: 'Averroes', + agentRole: '后端架构师', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'reviewer', + cwd: '/repo/a', + title: '阶段 6:审查安全写回', + agentNickname: 'Rawls', + agentRole: '代码审查员', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'pm', + cwd: '/repo/a', + title: '阶段 7:整理最终文档', + agentNickname: 'Zeno', + agentRole: '高级项目经理', + threadSource: 'subagent', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + { + id: 'other-main', + cwd: '/repo/b', + title: '其他项目主控对话', + threadSource: 'user', + source: { kind: 'sqlite_table', confidence: 'high' }, + }, + ], + edges: [ + { fromThreadId: 'main', toThreadId: 'coder', reason: 'closed' }, + { fromThreadId: 'coder', toThreadId: 'reviewer', reason: 'open' }, + { fromThreadId: 'main', toThreadId: 'pm', reason: 'closed' }, + { fromThreadId: 'other-main', toThreadId: 'coder', reason: 'open' }, + ], + source: { kind: 'sqlite_readonly', confidence: 'high' }, + }) + + const projectRuntime = filterRuntimeByProject(runtime, '/repo/a') + + assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.summary), [ + '已完成', + '运行中', + '已完成', + ]) + assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [ + '主线程 -> Averroes', + 'Averroes -> Rawls', + '主线程 -> Zeno', + ]) + assert.ok(projectRuntime.handoffs.every((handoff) => handoff.time === '后端事件')) + assert.deepEqual(projectRuntime.phaseGroups.map((phase) => phase.name), ['阶段 6', '阶段 7']) + assert.equal(projectRuntime.phaseGroups[0].status, 'running') + assert.deepEqual(projectRuntime.phaseGroups[0].roles, ['代码审查员', '后端架构师']) + assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.name), ['Averroes', 'Rawls']) +}) + +test('project view does not include sample runtime fallbacks', async () => { + const thisFile = fileURLToPath(import.meta.url) + const viewPath = new URL('../views/ProjectView.vue', `file://${thisFile}`) + const source = await readFile(viewPath, 'utf8') + + assert.doesNotMatch(source, /sampleProjects/) + assert.doesNotMatch(source, /sampleAgentMatrix/) + assert.match(source, /goalSource/) +}) + test('keeps project threads when Codex cwd is an ancestor but metadata names the project path', () => { const runtime = normalizeRuntime({ items: [ diff --git a/web/src/styles.css b/web/src/styles.css index 2f3eb0c..57167ec 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -260,6 +260,23 @@ button { border-radius: 8px; } +.project-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + margin-top: 18px; +} + +.flow-section { + min-width: 0; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.compact-heading { + margin-bottom: 12px; +} + .matrix-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) minmax(170px, 1fr) minmax(150px, 0.8fr) minmax(130px, 0.7fr); @@ -374,6 +391,13 @@ button { border-top: 1px solid var(--line); } +.detail-block span { + display: inline-block; + margin-top: 8px; + color: var(--muted); + font-size: 0.82rem; +} + .detail-grid { grid-template-columns: auto minmax(0, 1fr); align-items: center; @@ -448,6 +472,14 @@ button { margin: 0; } +.phase-list.compact li { + grid-template-columns: 18px minmax(0, 1fr); +} + +.phase-list.compact .status-badge { + grid-column: 2; +} + .phase-list li { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; @@ -871,6 +903,7 @@ button { .panel-heading.horizontal, .draft-header, .setting-row, + .project-flow, .form-grid { grid-template-columns: 1fr; } diff --git a/web/src/views/ProjectView.vue b/web/src/views/ProjectView.vue index 3f66bf8..d6c0ea9 100644 --- a/web/src/views/ProjectView.vue +++ b/web/src/views/ProjectView.vue @@ -1,9 +1,9 @@