fix: restore project goals and flow records
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user