fix: restore project goals and flow records

This commit is contained in:
Yoilun
2026-05-26 00:02:22 +08:00
parent fcfa824f54
commit ee0af20e2c
4 changed files with 300 additions and 35 deletions

View File

@@ -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'

View File

@@ -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: [

View File

@@ -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;
}

View File

@@ -1,9 +1,9 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import HandoffTimeline from '../components/HandoffTimeline.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { apiClient } from '../api/client'
import { filterRuntimeByProject, normalizeProjects, normalizeRuntime } from '../api/normalizers'
import { agentMatrix as sampleAgentMatrix, projects as sampleProjects } from '../data'
const loading = ref(true)
const error = ref('')
@@ -80,16 +80,6 @@ async function loadReadonlyData() {
</dl>
</article>
</template>
<div v-if="error" class="sample-fallback">
<p class="eyebrow">示例 / 等待连接</p>
<article v-for="project in sampleProjects" :key="project.id" class="project-item">
<div>
<strong>示例{{ project.name }}</strong>
<span>{{ project.path }}</span>
</div>
<StatusBadge :label="project.statusZh" :status="project.status" source="示例数据" confidence="低" />
</article>
</div>
</aside>
<section class="panel matrix-panel" aria-label="项目状态矩阵">
@@ -104,7 +94,7 @@ async function loadReadonlyData() {
<div v-if="loading" class="load-state">加载中</div>
<div v-else-if="error" class="empty-state compact error-state">
<strong>连接失败</strong>
<p>无法读取 `/api/runtime/threads`下面仅显示明确标注的示例状态</p>
<p>无法读取 `/api/runtime/threads`当前不显示示例运行状态</p>
</div>
<div v-else-if="projectRuntime.isEmpty" class="empty-state compact">
<strong>{{ projectRuntime.emptyTitle }}</strong>
@@ -128,33 +118,57 @@ async function loadReadonlyData() {
<span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" />
</span>
<span role="cell">{{ agent.goal }}</span>
<span role="cell">
{{ agent.goal }}
<small>{{ agent.goalSource }}</small>
</span>
<span role="cell">{{ agent.process }}</span>
<span role="cell">{{ agent.lastActivity }}</span>
</div>
</div>
<div v-if="error" class="sample-fallback matrix-table" role="table" aria-label="示例智能体状态矩阵">
<div class="matrix-row head" role="row">
<span role="columnheader">示例智能体</span>
<span role="columnheader">状态</span>
<span role="columnheader">目标</span>
<span role="columnheader">进程</span>
<span role="columnheader">最近活动</span>
<div v-if="!loading && !error && !projectRuntime.isEmpty" class="project-flow">
<div class="flow-section">
<div class="panel-heading horizontal compact-heading">
<div>
<p class="eyebrow">阶段进度</p>
<h3>项目内智能体阶段</h3>
</div>
<span class="read-only-chip">{{ projectRuntime.phaseGroups.length }} 个阶段</span>
</div>
<ol v-if="projectRuntime.phaseGroups.length > 0" class="phase-list compact">
<li v-for="phase in projectRuntime.phaseGroups" :key="phase.name" :data-status="phase.status">
<div class="phase-dot" aria-hidden="true"></div>
<div>
<strong>{{ phase.name }}</strong>
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p>
<span>{{ phase.agents.map((agent) => agent.name).join('、') }}</span>
</div>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
</li>
</ol>
<div v-else class="empty-state compact">
<strong>没有阶段归属</strong>
<p>当前项目智能体线程没有可解析的阶段标记</p>
</div>
</div>
<div v-for="agent in sampleAgentMatrix" :key="agent.id" class="matrix-row" role="row">
<span role="cell">
<strong>示例{{ agent.name }}</strong>
<small>{{ agent.role }}</small>
</span>
<span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" source="示例数据" confidence="低" />
</span>
<span role="cell">{{ agent.goal }}</span>
<span role="cell">{{ agent.process }}</span>
<span role="cell">{{ agent.lastActivity }}</span>
<div class="flow-section">
<div class="panel-heading horizontal compact-heading">
<div>
<p class="eyebrow">交互方向</p>
<h3>项目内智能体流程记录</h3>
</div>
<span class="read-only-chip">{{ projectRuntime.handoffs.length }} 条交接</span>
</div>
<HandoffTimeline v-if="projectRuntime.handoffs.length > 0" :items="projectRuntime.handoffs" />
<div v-else class="empty-state compact">
<strong>没有交接记录</strong>
<p>当前项目没有匹配到主线程与智能体之间的交接边</p>
</div>
</div>
</div>
</section>
<aside class="panel detail-panel" aria-label="详情面板">
@@ -171,10 +185,11 @@ async function loadReadonlyData() {
<div class="detail-block">
<h3>角色摘要</h3>
<p>{{ selectedAgent?.goal || '选择项目或等待运行线程后显示真实目标。' }}</p>
<span v-if="selectedAgent?.goalSource">{{ selectedAgent.goalSource }}</span>
</div>
<div class="detail-block">
<h3>证据说明</h3>
<p>数据来自只读接口连接失败时只显示明确标注的示例不会把示例伪装真实状态</p>
<p>数据来自只读接口连接失败时保持错误状态不使用示例数据伪装真实状态</p>
</div>
<div class="detail-grid">
<span>项目数</span><strong>{{ projectState.projects.length }}</strong>