diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 112c038c..f3815133 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,6 +4,14 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-04 图谱历史选择与螺旋密度 + +图谱左上角增加与工作回顾共用的历史选择,默认最新结果,保留显式结果、故事、经验导航及请求代际检查。空图谱与失败状态也保留选择入口。交互规则见 [UI 设计](./ui-design.md#故事线图谱)。 + +半径不变的直接原因是生产 UI 按 `resultId` 请求图谱,而该数据库分支没有返回 `attention`,只有聚合图谱分支返回。结果级分支现按保存的范围和时间区间统计当前消息;螺旋不再用范围内全部故事的跨度代替回顾区间。模型按每小时消息数归一化,修正边界小时桶、末尾半圈和短区间比例,移除把安静圈半径向峰值抬高的传播步骤。连续曲线、圆形圈段和余弦过渡保留;相同密度不制造半径差异。口径与限制见 [3D 密度规则](../story-graph/3d-concept.md)。 + +验证:模型、工作区、真实 SQLite 时间线与历史回归共 51 项通过;`ipc.test.ts -t 'collects supervision within the exact UI timeRange'` 的 2 项通过,覆盖 global/projects 结果图谱的精确区间。设置 `GOODBUDDY_SUPERVISOR_SPIRAL=1` 运行 `tests/supervisor-layout.electron.test.ts` 通过:检查实际 Three.js 曲线的 1,401 个采样点、均匀高度、非均匀消息密度的半径差、结果事件过滤,以及 1440px 浅色、390px 深色布局和历史选择同步,未生成截图。Electron 使用模拟 API 数据,SQLite 与 IPC 另用隔离数据库验证,没有读取生产用户库或调用付费模型。Node/Agent/Web 类型检查、定向 ESLint 和 `git diff --check` 通过;未运行全量测试,未提交或推送。本次修改不涉及 Agent 或远程 Runtime 执行路径。 + ## 2026-10-04 回顾初始化阻塞修复 对应 FR-S4、FR-S6、FR-S10、US-S12、US-S26、US-S27。原全局七天回顾在 Main 的一个 `BEGIN IMMEDIATE` 内扫描 25 页来源,首次 Electron 复现初始化 16.208 秒,IPC 等待 16.221 秒,阻塞发生在模型调用前。初始化现由现有 Worker 入口的独立连接执行,单次扫描后按 200 条写入;取消、未完成清单重建及发布事务的合同见[调度与存储](./review-scheduling-design.md#已接入的调度与存储)。继续前的全清单版本检查同样移出 Main;单批片段读取约 2 ms,保留原路径。候选、摘要和背景复用既有只读查询 Worker。 diff --git a/docs/features/conversation-supervision/storyline-model-design.md b/docs/features/conversation-supervision/storyline-model-design.md index d19a2ad1..bb523f6c 100644 --- a/docs/features/conversation-supervision/storyline-model-design.md +++ b/docs/features/conversation-supervision/storyline-model-design.md @@ -67,6 +67,7 @@ ## 5. 展示与使用 - 故事线图谱新增 3D 视图,与现有平铺视图并列,几何、时间尺度、聚合规则沿用 [3D 概念](../story-graph/3d-concept.md);Three.js 仅在打开 3D 视图时加载,WebGL 不可用时使用平铺视图。 +- 结果级图谱与聚合图谱都返回 `attention`。结果级查询按该结果保存的范围及时间区间统计当前消息,螺旋使用同一时间窗口;故事成员与结果事件 ID 求交后绘制,不截断结果事件数组。故事归属是当前记录,重新提取后已失去当前归属的历史事件仍可在平铺视图和事件列表查看,不补造历史故事成员。 - MCP 增加按故事、按经验查询;经验返回时附“自动归纳 / 用户编辑”标记和依据。 - 心跳建议增加“故事长期无进展”和“已有经验可能适用于当前故事”。两者都由规则选出:前者按设置的天数判断,只提示不改状态;后者要求共享不过于通用的实体。每次心跳各最多 3 条,排在未决事项之前。 - 工作回顾按故事组织:推进了哪些故事、新出现哪些故事、形成或应用了哪些经验。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index 3db21cde..44bbe762 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -27,6 +27,8 @@ schema 42 为实体、实体变化和关系增加 `source_reference_ids_json`, schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、说明、确认状态、关系理由及本次来源引用。后续 run 只更新 `automatic` 的当前实体与关系,已确认、修订或移除状态受保护。历史结果读取自身内容;人工操作带 `resultId` 时先校验成员归属,在事务内更新当前对象及所选结果,其他结果不变。移除关系记录 `revoked`,防止后续相同端点和类型的自动关系恢复它。 +结果级图谱另由 `supervisionAttention` 按所属 run 保存的范围和时间区间查询当前消息,返回小时桶 `attention`;该字段不写入历史快照。既有 `supervisionGraph` IPC 和 Preload 原样传递,Renderer 按选定区间生成螺旋。归一化、小时内估算和历史数据限制见 [3D 密度规则](../story-graph/3d-concept.md),故事与结果事件的对应规则见[故事线模型设计](./storyline-model-design.md#5-展示与使用)。 + 升级从现有事件实体关联、实体变化和来源引用恢复结果成员,不猜测名称匹配;旧数据缺失的归属或已经覆盖的内容无法还原,原对象仍保留。新结果即使实体没有事件或来源,也会保存完整成员。结果内容与运行、事件、来源在同一事务提交。 `overview({ target? })` 返回稳定的 `id`、`storyLineId` 及来源 ID。指定 Conversation/Task 时,Main 读取目标实际项目,查询同时要求目标来源匹配、结果 scope 为 global 或包含该项目;LIMIT 在过滤之后。卡片展示结果真实范围,global 结果不会伪装成单会话摘要。`graph({ resultId, storyLineId? })` 校验二者归属,只返回该结果的事件、对象和来源;显式 ID 无效时不回退到最新结果。继续讨论按结果主键读取,并验证来源属于该结果。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 49f907bb..99a488e8 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -111,6 +111,10 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 ## 故事线图谱 +左上角提供与“工作回顾”共用的“历史结果”选择框,显示生成时间和范围。首次进入默认最新结果;两页之间切换保留所选结果,从侧栏或建议显式打开的结果、故事、经验优先于默认值。切换结果时清除旧来源详情,迟到请求不得覆盖新选择。空图谱及读取失败时仍可切换历史结果。 + +时间螺旋使用所选回顾的时间区间与事件集合,半径含义见 [3D 密度规则](../story-graph/3d-concept.md)。故事与经验列表仍提供该范围的当前完整记录,供查看和调整;画布只绘制属于本次结果的故事事件,避免出现点击后无法在本次事件列表中定位的点。 + 关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,使用上述可编辑正文预览,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。 左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。 diff --git a/docs/features/story-graph/3d-concept.md b/docs/features/story-graph/3d-concept.md index 018206f8..f6ad5d38 100644 --- a/docs/features/story-graph/3d-concept.md +++ b/docs/features/story-graph/3d-concept.md @@ -1,18 +1,22 @@ # 下一版 3D 故事线图谱概念 -状态:已讨论的视觉方向,尚未实现。本文记录空间表达及概念图引用,不替代当前产品需求或技术设计。 +状态:时间螺旋、分层故事与经验连接已接入生产视图;本文同时保留尚未实现的概念方向。当前数据合同与实施记录见[故事线模型设计](../conversation-supervision/storyline-model-design.md)及其进度文档。 ## 时间、事件与故事 内部只有一条共同的时间螺旋。时间均匀推进,同一圈中交错发生不同事情;同一件事也可以在多个圈中间隔出现。 -每圈的时长随查看范围变化,使螺旋保持约 8 圈、最多 14 圈:一天约 3 小时一圈,一周 1 天一圈,一个季度 1 个月一圈,几年 1 季度或 1 年一圈。用户可以手动指定,指定值会超过 14 圈时改用更粗的粒度。注意力密度按当前粒度重新统计,每圈 8 个时段。 +每圈时长从 3、6、12、24、168、720、2160、8760 小时中选择,在不超过 14 圈的候选中取最接近 8 圈的一档。生产界面自动选择粒度;手动指定仍是 Demo 能力。不足一圈时保留实际圈数,时间区间不因显示需要而扩大。 同一故事中屏幕上过于接近的事件合并为一段,显示事件数量。点击聚合段,查看范围缩小到这段时间,事件随之拆开;小于约 6 小时仍无法拆开的事件(同一时刻多次提取),改为在右侧列出。 [3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。 -螺旋半径可以随时间、随事件片段变化,表达注意力处理密度。时间轴不能随半径拉伸。密度的统计口径、归一化和缺失数据表现尚待定义,半径不代表效率、价值或成果。 +生产螺旋只有一条连续曲线,高度按所选回顾的起止时间均匀推进。每圈中间 70% 保持同一半径,相邻圈在边界两侧各 15% 内用余弦插值过渡。 + +半径使用回顾范围和时间区间内的用户、助手消息数,按小时汇总,再按每圈覆盖时长折算每小时消息数。末尾不足一圈也按实际时长计算。每圈密度除以本次窗口的最大密度后开平方,映射到 78~152 的场景半径;相同密度得到相同半径,无记录时为 78。仅边界过渡做平滑,不向安静时段传播高密度,也不加入随机起伏。 + +小时桶跨圈时按重叠时长分配,小时内的精确分布不可恢复。统计读取当前消息及当前会话项目归属,消息删除、迁移或修改后可能改变历史回顾的半径;这不是当时注意力的冻结快照。知识、任务等非消息来源不计入密度。该指标只比较本次回顾内的消息频率,不表示投入时长、效率、价值或成果,也不能跨窗口直接比较绝对半径。 外层以木桶木片般的曲面连接同一故事的事件。木片起止时间、长度各不相同,可以顺着事件位置弯曲;不能为保持规则桶形而强行等长、等径。多年持续的故事可能只有少数活跃阶段,事件之间保留空白,连接只表达同一故事的延续。未结束的故事使用开放末端,不虚构未来事件。 diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 14968ce2..fcd20be1 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -9308,10 +9308,17 @@ export class AssistantDatabase { if (!story) return { storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] } const id = (story as { id: string }).id if (input.resultId) { - const row = database.prepare('SELECT graph_snapshot_json FROM supervision_results WHERE id = ?').get(input.resultId) as { graph_snapshot_json: string } + const row = database.prepare(`SELECT r.graph_snapshot_json, sr.scope_json, sr.time_range_json + FROM supervision_results r JOIN supervision_runs sr ON sr.id = r.run_id WHERE r.id = ?`).get(input.resultId) as { + graph_snapshot_json: string; scope_json: string; time_range_json: string + } const snapshot = JSON.parse(row.graph_snapshot_json) as { entities: unknown[]; relations: Array<{ confirmation_state: string }> } + const scope = JSON.parse(row.scope_json) as SupervisionRunRequest['scope'] + const range = JSON.parse(row.time_range_json) as SupervisionRunRequest['timeRange'] return { storyLine: story, ...snapshot, relations: snapshot.relations.filter((relation) => relation.confirmation_state !== 'revoked'), + attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined, + new Date(range.from).toISOString(), new Date(range.to).toISOString()), events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId), sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId), eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId), diff --git a/src/main/assistant/supervision-timeline.test.ts b/src/main/assistant/supervision-timeline.test.ts index e5912a61..89c96bcf 100644 --- a/src/main/assistant/supervision-timeline.test.ts +++ b/src/main/assistant/supervision-timeline.test.ts @@ -9,6 +9,7 @@ import { ASSISTANT_DATABASE_SCHEMA_VERSION, AssistantDatabase } from './assistan import { SupervisorService, type SupervisorSummarizerRequest } from './supervisor-service' import type { SupervisionRunRequest } from '../../shared/supervision-contracts' import type { ReviewConfiguration } from './supervision-review-store' +import { supervisionGraphViewSchema } from '../../shared/supervision-contracts' const cleanups: Array<() => Promise> = [] afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup() }) @@ -53,6 +54,24 @@ async function fixture(crossProject = false) { const current = (sql: DatabaseSync) => sql.prepare('SELECT * FROM supervision_events WHERE superseded_by IS NULL ORDER BY started_at').all() +it('supplies real message attention on the review-specific graph, bounded by its project and time range', async () => { + const f = await fixture() + await f.run({ kind: 'projects', projectIds: [f.a.id] }) + const result = f.db.listSupervisionResults()[0]! + // Additional messages outside the selected review must not expand its attention span. + f.db.saveLocalConversations([{ header: { id: randomUUID(), projectId: f.a.id, title: 'Outside review', updatedAt: base }, + messages: [ + { id: randomUUID(), role: 'assistant', content: 'Before', createdAt: Date.parse('2026-09-18T08:00:00Z'), state: 'complete' }, + { id: randomUUID(), role: 'assistant', content: 'Reply', createdAt: Date.parse('2026-09-20T08:15:00Z'), state: 'complete' }, + { id: randomUUID(), role: 'assistant', content: 'Outside', createdAt: Date.parse('2026-09-23T08:00:00Z'), state: 'complete' } + ] }]) + const graph = supervisionGraphViewSchema.parse(f.db.getSupervisionGraph({ resultId: result.id, storyLineId: result.storyLineId })) + expect(graph.attention).toEqual([ + { start: '2026-09-20T08:00:00.000Z', turns: 2, characters: 35 }, + { start: '2026-09-20T09:00:00.000Z', turns: 1, characters: 31 } + ]) +}) + it('extracts each source once for every scope: a project review then global reads only the other project', async () => { const f = await fixture() await f.run({ kind: 'projects', projectIds: [f.a.id] }) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index cb62c24a..c94aba0d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -6646,6 +6646,7 @@ describe('registerIpcHandlers agent terminal state', () => { expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId) const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] } expect(graph.sources).toHaveLength(evidence.length) + expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }]) expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配') expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配') const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to)) diff --git a/src/renderer/src/StoryGraph3D.tsx b/src/renderer/src/StoryGraph3D.tsx index 5eec42e6..1ab81956 100644 --- a/src/renderer/src/StoryGraph3D.tsx +++ b/src/renderer/src/StoryGraph3D.tsx @@ -15,6 +15,7 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, type Props = { stories: SupervisionStory[] attention: SupervisionAttentionSlot[] + timeRange?: { from: string; to: string } selectedEventId?: string onSelectEvent: (id: string) => void onSelectStory: (id: string) => void @@ -59,7 +60,7 @@ function webglAvailable(): boolean { catch { return false } } -export default function StoryGraph3D({ stories, attention: attentionProp, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) { +export default function StoryGraph3D({ stories, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) { const { t, i18n } = useTranslation('heartbeat') const canvasRef = useRef(null) const overlayRef = useRef(null) @@ -80,9 +81,10 @@ export default function StoryGraph3D({ stories, attention: attentionProp, select const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 }) const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined) const range = useMemo(() => { + if (timeRange) return storyWindow(Date.parse(timeRange.from), Date.parse(timeRange.to)) const times = tree.events.map(event => event.t) return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined - }, [tree]) + }, [tree, timeRange]) const level = visibleLevel(focus) const shownLinks = useMemo(() => experienceLinks(experiences, level.children), [experiences, level]) const trail: StoryNode[] = [] diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index dd2aab85..7221e590 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -11,6 +11,46 @@ const render = (ui: React.ReactNode) => { } describe('SupervisorWorkspace', () => { + it('defaults the graph history to latest and synchronizes selection in both directions, including empty graphs', async () => { + const old = { ...result, id: 'old', createdAt: '2026-08-01T00:00:00.000Z', summary: 'Old review' } + const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) + window.goodbuddy = { supervision: { overview: async () => [result, old], graph } } as never + const view = render() + await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled()) + expect(screen.getByLabelText('历史结果')).toHaveValue(result.id) + expect(graph).toHaveBeenLastCalledWith({ resultId: result.id, storyLineId: result.storyLineId }) + fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: old.id } }) + await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled()) + view.rerender() + expect(screen.getByLabelText('历史结果')).toHaveValue(old.id) + expect(screen.getByText('Old review')).toBeVisible() + fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } }) + await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled()) + view.rerender() + expect(screen.getByLabelText('历史结果')).toHaveValue(result.id) + expect(graph).toHaveBeenCalledTimes(3) + }) + + it.each(['story', 'experience'] as const)('preserves explicit %s navigation over the latest default and history changes', async kind => { + const old = { ...result, id: 'old' } + const story = { id: 'target', name: 'Target story', projectId: 'p', projectName: 'P', parentId: null, level: 'feature', + description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: null, endedAt: null, events: [] } + const experience = { id: 'target', statement: 'Target experience', conditions: '', boundaries: '', userEdited: false, events: [] } + const graph = vi.fn(async () => ({ storyLine: { id: 'story', scope_json: '{"kind":"global"}' }, + events: [{ id: 'e', title: 'Event', description: '', occurred_at: result.createdAt }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) + window.goodbuddy = { supervision: { overview: async () => [result, old], graph, + stories: async () => ({ stories: [story], experiences: [experience], unassigned: 0, canUndo: false }) } } as never + const navigation = { resultId: old.id, focus: { kind, id: 'target' } } + const view = render() + await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' }) + expect(screen.getByLabelText('历史结果')).toHaveValue(old.id) + fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } }) + await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled()) + view.rerender() + await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' }) + expect(screen.getByLabelText('历史结果')).toHaveValue(old.id) + }) + it('keeps source lookup failures retryable through notifications', async () => { const onNotify = vi.fn() const source = vi.fn().mockRejectedValueOnce(new TypeError('terminated')).mockResolvedValueOnce({ title: 'Source', content: 'Saved source', occurredAt: result.createdAt }) diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index acbf88ff..b9856cc4 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -474,6 +474,11 @@ export function SupervisorWorkspace({ ? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope']) : undefined const storyState = useSupervisionStories(graphScope, tab === 'graph', graph) + // Story membership is current and scope-wide; only plot events selectable in this review. + const spiralStories = useMemo(() => { + const ids = new Set(graph.events.map(event => event.id)) + return storyState.view.stories.map(story => ({ ...story, events: story.events.filter(event => ids.has(event.id)) })) + }, [storyState.view.stories, graph.events]) const graphLayoutRef = useRef(null) // The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat // graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead. @@ -486,6 +491,21 @@ export function SupervisorWorkspace({ const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined const busy = !!api && (loading || pending !== undefined) + const historySelector = results.length > 0 &&
+ + {tab === 'overview' && latest && } +
return (
@@ -586,26 +606,7 @@ export function SupervisorWorkspace({
} - {results.length > 0 &&
- - {latest && } -
} + {historySelector} {latest && recapStories.available && { select(focus); onTabChange?.('graph') }} />} {latest ? ( @@ -653,6 +654,7 @@ export function SupervisorWorkspace({ )} {tab === 'graph' && ( <> + {historySelector} {/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */} {!loading && !loadError && !graph.events.length && (
@@ -827,7 +829,7 @@ export function SupervisorWorkspace({
}> {t('supervisor.loading')}

}> - { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }} onSelectStory={(id) => select({ kind: 'story', id })} diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index 7166c5d8..53a4d06e 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -141,7 +141,7 @@ export const heartbeat = { legendTitle: 'Legend', legendClose: 'Close legend', legendItems: { time: 'Height is time; {{scale}}.', - radius: 'Spiral radius is conversation density at the time and only shows how concentrated the work was.', + radius: 'Radius compares user and assistant messages per hour within this review. Equal rates have equal radii; no recorded messages gives the minimum radius. It does not measure time spent or productivity.', stave: 'A stave spans its story from first to last event.', experience: 'Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.' }, diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index d3998d04..cd8b528d 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -138,7 +138,7 @@ export const heartbeat = { legendTitle: '图例', legendClose: '关闭图例', legendItems: { time: '高度为时间,{{scale}}。', - radius: '螺旋半径为当时的对话密度,只表示投入集中程度。', + radius: '半径比较本次回顾内每小时的用户与助手消息数。密度相同则半径相同,无消息记录时取最小半径,不代表投入时长或工作效率。', stave: '木片上下跨度为故事的起止时间。', experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。' }, diff --git a/src/renderer/src/story-graph-3d-model.test.ts b/src/renderer/src/story-graph-3d-model.test.ts index 4209c469..cf692a1e 100644 --- a/src/renderer/src/story-graph-3d-model.test.ts +++ b/src/renderer/src/story-graph-3d-model.test.ts @@ -38,7 +38,7 @@ describe('story graph 3d model', () => { expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6) }) - it('keeps each turn round: one radius per turn, bounded change between turns', () => { + it('keeps each turn round with continuous transitions and actual density differences', () => { const window = storyWindow(Date.parse(at(0)), Date.parse(at(8))) const attention = [{ start: at(0.5), turns: 100, characters: 1 }, { start: at(4.5), turns: 1, characters: 1 }] const level = radiusLevels(window, attention) @@ -47,7 +47,35 @@ describe('story graph 3d model', () => { expect(level(0.5 / window.turns)).toBe(1) let previous = level(0), largest = 0 for (let i = 1; i <= 800; i++) { const next = level(i / 800); largest = Math.max(largest, Math.abs(next - previous)); previous = next } - expect(largest).toBeLessThan(0.05) + expect(largest).toBeLessThan(0.06) + expect(level(4.5 / window.turns)).toBeCloseTo(0.1) + expect(level(2.5 / window.turns)).toBe(0) + for (let d = 1; d < window.turns; d++) { + expect(level((d - 1e-8) / window.turns)).toBeCloseTo(level((d + 1e-8) / window.turns), 6) + } + }) + + it('normalizes hourly rates including partial turns without inventing variation for equal densities', () => { + const window = storyWindow(Date.parse(at(0)), Date.parse(at(0)) + 7.5 * 3_600_000, 3) + const attention = Array.from({ length: 8 }, (_, hour) => ({ + start: new Date(window.from + hour * 3_600_000).toISOString(), turns: hour === 7 ? 2 : 4, characters: 0 + })) + const level = radiusLevels(window, attention) + for (const u of [0, 0.2, 0.5, 0.8, 1]) expect(level(u)).toBeCloseTo(1) + expect(radiusLevels(window, [])(0.5)).toBe(0) + expect(storyWindow(0, 1_800_000).turns).toBeCloseTo(1 / 6) + }) + + it('retains a clipped boundary bucket and ignores attention outside the selected interval', () => { + const from = Date.parse(at(0)) + 50 * 60_000 + const window = storyWindow(from, from + 6 * 3_600_000, 3) + const level = radiusLevels(window, [ + { start: at(-1), turns: 10000, characters: 0 }, + { start: at(0), turns: 4, characters: 0 }, + { start: new Date(from + 4 * 3_600_000).toISOString(), turns: 1, characters: 0 } + ]) + expect(level(0.25)).toBe(1) + expect(level(0.75)).toBe(0.5) }) it('merges events closer than the span and keeps the rest apart', () => { diff --git a/src/renderer/src/story-graph-3d-model.ts b/src/renderer/src/story-graph-3d-model.ts index 677d6583..8a384ca7 100644 --- a/src/renderer/src/story-graph-3d-model.ts +++ b/src/renderer/src/story-graph-3d-model.ts @@ -26,7 +26,7 @@ export type StoryWindow = { from: number; to: number; turnHours: number; turns: const TAU = Math.PI * 2 export const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760] -const TARGET_TURNS = 8, MAX_TURNS = 14, MAX_STEP = 0.22, TRANSITION = 0.3 +const TARGET_TURNS = 8, MAX_TURNS = 14, TRANSITION = 0.3 /** Project › feature › thread, plus cross stories as root-level staves. Unassigned events are not staves. */ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map): StoryNode { @@ -78,32 +78,38 @@ export function layout(node: StoryNode, from: number, to: number): void { /** Turn length keeps about 8 turns, at most 14, for any span from hours to years. */ export function storyWindow(from: number, to: number, turnHours?: number): StoryWindow { - const span = Math.max(1, (to - from) / 3_600_000) + const span = Math.max(1 / 3_600_000, (to - from) / 3_600_000) // Closest to the target among steps that stay within the maximum (13 days → 1 day per turn, not 1.9 weekly turns). const fitting = TURN_STEPS.filter(step => span / step <= MAX_TURNS) let hours = turnHours ?? fitting.reduce((best, step) => Math.abs(span / step - TARGET_TURNS) < Math.abs(span / best - TARGET_TURNS) ? step : best, fitting[0] ?? TURN_STEPS.at(-1)!) if (span / hours > MAX_TURNS) hours = fitting[0] ?? TURN_STEPS.at(-1)! - return { from, to: Math.max(to, from + 1), turnHours: hours, turns: Math.max(0.5, span / hours) } + return { from, to: Math.max(to, from + 1), turnHours: hours, turns: span / hours } } /** * Radius level 0..1 at position u (0..1) along the window. Each turn holds one radius from its - * attention, so it reads as a circle; neighbouring turns differ by at most MAX_STEP and ease at the boundary. + * messages per hour, so it reads as a circle; neighbouring turns ease at the boundary. + * Hourly buckets are distributed by overlap; their within-hour timing is unavailable. */ export function radiusLevels(window: StoryWindow, attention: SupervisionAttentionSlot[]): (u: number) => number { const n = Math.max(1, Math.ceil(window.turns - 1e-6)) const perTurn = new Array(n).fill(0) + const duration = (window.to - window.from) / window.turns for (const slot of attention) { - const t = Date.parse(slot.start) + 1_800_000 - if (t < window.from || t > window.to) continue - perTurn[Math.min(n - 1, Math.floor((t - window.from) / (window.to - window.from) * window.turns))]! += slot.turns + // The review query includes its upper endpoint, including an exact hourly boundary. + if (Date.parse(slot.start) === window.to) { perTurn[n - 1]! += slot.turns; continue } + const start = Math.max(window.from, Date.parse(slot.start)) + const end = Math.min(window.to, Date.parse(slot.start) + 3_600_000) + if (end <= start) continue + for (let d = 0; d < n; d++) { + const overlap = Math.max(0, Math.min(end, window.from + (d + 1) * duration) - Math.max(start, window.from + d * duration)) + perTurn[d]! += slot.turns * overlap / (end - start) + } } + // Normalize rates, not totals: the last partial turn has a shorter exposure. + for (let d = 0; d < n; d++) perTurn[d]! /= Math.min(duration, window.to - window.from - d * duration) / 3_600_000 const top = Math.max(1e-6, ...perTurn) const level = perTurn.map(value => Math.sqrt(value / top)) - for (let pass = 0; pass < n; pass++) for (let d = 1; d < n; d++) { - if (level[d]! - level[d - 1]! > MAX_STEP) level[d - 1] = level[d]! - MAX_STEP - if (level[d - 1]! - level[d]! > MAX_STEP) level[d] = level[d - 1]! - MAX_STEP - } return (u: number) => { const x = Math.max(0, Math.min(1, u)) * window.turns const d = Math.min(n - 1, Math.floor(x)), f = x - d, half = TRANSITION / 2 @@ -175,4 +181,4 @@ export function experienceLinks(experiences: SupervisionExperience[], staves: St angle: a + delta / 2, t: (formed.t + to.t) / 2 }, weight: new Set(applied.map(item => item.stave)).size }) } return links.sort((x, y) => y.weight - x.weight || x.link.from.t - y.link.from.t).slice(0, limit).map(item => item.link) -} \ No newline at end of file +} diff --git a/src/renderer/src/supervisor-workspace.css b/src/renderer/src/supervisor-workspace.css index 56b5a888..e6bd925a 100644 --- a/src/renderer/src/supervisor-workspace.css +++ b/src/renderer/src/supervisor-workspace.css @@ -367,6 +367,12 @@ width: 100%; min-width: 0; } +.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation { + margin-top: 0; +} +.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation label { + flex: 0 1 480px; +} .supervisor-workspace__recap li { margin: var(--space-2) 0; overflow-wrap: anywhere; diff --git a/tests/support/supervisor-layout-driver.mjs b/tests/support/supervisor-layout-driver.mjs index 7a0dfedf..c00f18c8 100644 --- a/tests/support/supervisor-layout-driver.mjs +++ b/tests/support/supervisor-layout-driver.mjs @@ -61,6 +61,41 @@ app await settle() } const reports = [] + if (process.env.GOODBUDDY_SUPERVISOR_SPIRAL) { + await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&recap=1') + await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"') + await js('document.querySelector("#supervisor-tab-graph").click()') + await wait('[...document.querySelectorAll("button")].some(b => b.textContent === "时间螺旋")') + await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()') + await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 8') + const geometry = await js('JSON.parse(document.documentElement.dataset.helix)') + assert.equal(geometry.points.length, 1401, 'One continuous, full-span helix') + const radii = geometry.points.map(p => p[0]) + assert(Math.max(...radii) - Math.min(...radii) > 40, 'Actual rendered geometry reflects uneven attention') + geometry.points.forEach((point, i) => assert(Math.abs(point[1] - (-215 + 430 * i / 1400)) < 1e-8, 'Height advances uniformly')) + assert(!geometry.events.includes('outside-review'), 'Aggregate story events outside this review are not plotted') + for (const [width, theme] of [[1440, 'light'], [390, 'dark']]) { + win.setContentSize(width, 1000) + await js(`document.documentElement.dataset.theme = '${theme}'`) + await settle() + const boxes = await js(`(() => { + const selector = document.querySelector('.supervisor-workspace__result-navigation select'); + const r = selector.getBoundingClientRect(), gl = document.querySelector('.story-graph-3d__gl').getBoundingClientRect(); + return { value: selector.value, left: r.left, right: r.right, width: innerWidth, canvasWidth: gl.width, canvasHeight: gl.height, pageWidth: document.documentElement.scrollWidth }; + })()`) + assert.equal(boxes.value, 'fixture-result') + assert(boxes.left >= 0 && boxes.right <= boxes.width && boxes.pageWidth <= boxes.width, 'History selector fits without horizontal overflow') + assert(boxes.canvasWidth > 0 && boxes.canvasHeight > 0, 'WebGL canvas is laid out') + } + await js(`(() => { const e = document.querySelector('.supervisor-workspace__result-navigation select'); + e.value = 'older-result'; e.dispatchEvent(new Event('change', {bubbles:true})); })()`) + await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 1') + await js('document.querySelector("#supervisor-tab-overview").click()') + await wait('document.querySelector(".supervisor-workspace__result-navigation select")?.value === "older-result"') + assert.deepEqual(errors, []) + console.log(JSON.stringify({ spiral: 'passed', vertices: geometry.points.length, radiusRange: [Math.min(...radii), Math.max(...radii)], widths: [1440, 390], errors })) + win.destroy(); app.quit(); return + } const checkDiscussion = async () => { const report = await js(`(() => { const editor = document.querySelector('.supervision-discussion-editor'); diff --git a/tests/support/supervisor-layout-regression.tsx b/tests/support/supervisor-layout-regression.tsx index c344d993..3cfa782b 100644 --- a/tests/support/supervisor-layout-regression.tsx +++ b/tests/support/supervisor-layout-regression.tsx @@ -13,6 +13,24 @@ import { installBundledUiFonts } from '../../src/renderer/src/fonts' installBundledUiFonts() const params = new URLSearchParams(location.search) +if (params.has('spiral')) { + const THREE = await import('three') + const add = THREE.Group.prototype.add + let points: number[][] = [] + const events = new Set() + THREE.Group.prototype.add = function (...objects) { + for (const object of objects) { + const path = ((object as import('three').Mesh).geometry as import('three').TubeGeometry)?.parameters?.path as import('three').CatmullRomCurve3 | undefined + if (path?.points?.length === 1401) { + points = path.points.map(point => [Math.hypot(point.x, point.z), point.y]) + events.clear() + } + for (const event of object.userData.node?.events ?? []) events.add(event.id) + } + document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events] }) + return add.apply(this, objects) + } +} const stories = [ { title: '监督者 · 故事图谱', @@ -148,6 +166,13 @@ Object.defineProperty(window, 'goodbuddy', { ? {} : { supervision: { + ...(params.has('spiral') ? { stories: async () => ({ stories: [{ + id: 'feature', projectId: 'p', projectName: 'Fixture project', parentId: null, level: 'feature', name: 'Fixture story', + description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: null, endedAt: null, + events: [...graph.events, { id: 'outside-review', title: 'Outside review', occurred_at: '2026-08-01T00:00:00Z' }].map(event => ({ + id: event.id, title: event.title, startedAt: event.occurred_at, endedAt: event.occurred_at, projectId: 'p', primary: true, userSet: false + })) + }], experiences: [], unassigned: 0, canUndo: false }) } : {}), continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }), continue: async () => { throw new Error('Preview fixture must not send a message') }, execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }), @@ -189,7 +214,7 @@ Object.defineProperty(window, 'goodbuddy', { scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: params.has('recap') ? ['确认外部评审时间,并回填交付清单。', '检查验收材料是否覆盖所有已确认需求。'] : [] }, ...(params.has('recap') ? [{ id: 'older-result', storyLineId: 'fixture', sourceId: 'source-1', summary: '上期结果:交付清单尚缺负责人,需要继续核对。', changeDigest: '', createdAt: '2026-09-15T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-15T00:00:00Z' }, openItems: ['核对负责人。'] }] : [])] }, - graph: async () => + graph: async (input?: { resultId?: string }) => state === 'empty' ? { storyLine: null, @@ -200,7 +225,12 @@ Object.defineProperty(window, 'goodbuddy', { eventEntities: [], eventSources: [] } - : graph, + : params.has('spiral') ? { ...graph, + events: input?.resultId === 'older-result' ? graph.events.slice(0, 1) : graph.events, + attention: [1, 1, 1, 100, 100, 4, 4].map((turns, index) => ({ + start: `2026-09-${String(1 + index * 3).padStart(2, '0')}T01:00:00.000Z`, turns, characters: turns * 20 + })).filter(slot => input?.resultId !== 'older-result' || slot.start < '2026-09-15') + } : graph, run: async () => { if (params.has('recap') && !params.has('fail-run')) return new Promise(() => {}) throw new Error('SIMULATED: 模型暂时不可用。此验证不调用模型。')