feat: shared supervisor timeline, story assignment and time-spiral view

Each source version is extracted once for every review scope; events carry spans and projects from their sources, and attention density is counted from messages. After publication, events are assigned to project features and sub-threads (cross-project stories only when enabled), with user rename, merge, remove, move and undo. The story graph gains a lazily loaded Three.js time-spiral view. Includes the 3D concept demo, real-data DeepSeek check scripts and docs.
This commit is contained in:
mesalogo
2026-10-02 15:21:48 +08:00
parent 3d4e9577b5
commit 5dc65457d9
53 changed files with 4051 additions and 83 deletions
@@ -20,6 +20,7 @@
| [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 |
| [时态 Story Graph MCP 设计](./story-graph-mcp-design.md) | 只读工具、时间与版本语义、Runtime 接入,以及后续跨范围事实复用和记忆过渡合同 |
| [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 |
| [故事线模型改造设计](./storyline-model-design.md) | 全局 › 项目 › 功能 › 子线索的故事层级、故事归属与经验抽取阶段、用户调整及实施顺序(目标设计) |
| [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 |
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
@@ -4,6 +4,98 @@
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
## 2026-10-02 故事线模型第 3 步:时间螺旋视图
对应[故事线模型改造设计](./storyline-model-design.md)第 3 步与 [3D 概念](../story-graph/3d-concept.md)。
实现:
- 图谱画布标题右侧新增“平铺 / 时间螺旋”切换。时间螺旋视图(`StoryGraph3D.tsx`)读取故事列表与图谱返回的 `attention`:高度为时间,半径为每圈的对话轮次(一圈一个半径,相邻圈差值受限),木片为故事起止,事件按屏幕距离聚合。层级为 全部 › 项目 › 功能 › 子线索,跨项目故事在根层;点击木片进入下一层并在右侧显示故事详情,单个事件选中后显示事件详情。斜俯视/侧视/俯视/自由视角,拖动、滚轮、方向键、数字键、Esc。
- 计算部分在 `story-graph-3d-model.ts`(树、角度、圈长、半径、聚合),渲染只负责显示。圈长从候选档位中选最接近 8 圈且不超过 14 圈的一档。
- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图;画布下方以列表重复当前层木片,供键盘与读屏使用。
验证:
- `story-graph-3d-model.test.ts` 4 项(树与跨度、圈长档位、每圈一个半径且变化有界、聚合);工作区、活动、设置组件测试通过;Node/Web 类型检查、相关 ESLint 通过;`electron-vite build` 确认 Three.js 只在懒加载分块中。
- Electron 实测(第 2 步跨项目运行的真实故事数据副本,生产组件):1440 浅色与 390 深色均无控制台错误、无横向溢出;画布非背景像素约 26%;根层 6 片(5 个项目、2 个跨项目故事中的果蝇神经仿真等),goodbuddy 15 个功能,监督者 9 条子线索;视角切换、Esc 返回、选中故事回调正常;旋转一帧约 1 ms。数据跨 13 天,一圈 1 天共 13.1 圈。
边界:时间切面(第 7 项)、展开视图(第 8 项)、原位分裂动画和桶外经验节点尚未移植;经验(W)要到第 4 步。Three.js 懒加载分块约 1.15 MB。
## 2026-10-02 故事线模型第 2 步:故事归属与用户调整
对应[故事线模型改造设计](./storyline-model-design.md) SL-1~SL-3、SL-5、SL-8 和第 2 步。
实现:
- `supervision-stories.ts`:新表 `supervision_stories`(功能、子线索、跨项目故事,状态、用户调整标记)、`supervision_event_stories`(主故事唯一,另可关联)、`supervision_story_assigned`、`supervision_story_edits`。Schema 54;升级后表为空,已有事件由下一次回顾归属,不重读来源。
- 回顾发布后在同一回顾名额内执行故事归属:只取本范围中尚未归属的当前事件,按项目分块,每块一次模型调用,输入是事件标题、描述和本项目已有故事摘要,不含原文。没有待归属事件时不调用模型。项目来自事件来源;功能、子线索由模型提出;允许“暂不归类”。
- Main 校验每个事件恰好分配一次、主故事属于本项目、子线索有上级功能、结束标记引用本次归入该故事的事件;任一项不符则整块不保存。功能事件数低于“子线索最少事件数”(设置,默认 20)时,子线索并入功能。
- 跨项目关联关闭时,提示词禁止跨项目故事,也不提供已有跨项目故事;开启时项目依次处理,跨项目故事只作关联,不影响主故事的长度和事件数。
- 用户调整:重命名、合并(同项目同层级)、移除(事件回到上级功能或变为暂不归类,不删除事件)、把事件移到同项目其他故事或暂不归类、撤销上一次调整。调整过的故事不会被自动整理改名或标记结束,移除的故事不会因同名再生成。
- 故事归属失败不影响已发布的回顾:活动记录显示“回顾已保存,故事整理失败”并可重新整理;下一次回顾(含无变化)也会补上。
- 界面:图谱左侧新增“故事”页签,按项目 › 功能 › 子线索、跨项目故事列出,显示暂不归类的事件数;选中故事在右侧查看起止、状态、事件,并可重命名、合并、移除、撤销;事件详情可改所属故事。设置新增“功能拆分子线索的最少事件数”。
验证:
- 新增 `supervision-stories.test.ts` 6 项:归属与无变化零调用、长时间空白后接回原故事、不完整/重复/虚构/证据不符的回答整块拒绝、小功能子线索并入、跨项目开关、调整保护与撤销、回顾内执行及失败后重试。`src/main/assistant` 全目录、Story Graph MCP 集成、监督相关 IPC、监督者工作区/活动/设置组件测试均通过;Node/Web 类型检查与相关 ESLint 通过。未运行全量测试。
- 真实数据(第 1 步升级后的本机数据副本,`scripts/stories-deepseek.mjs`,DeepSeek `deepseek-flash`):
- 跨项目关闭:437 个当前事件,11 次调用约 4.3 分钟;39 个故事(35 个功能、4 个子线索),396 个事件归入,41 个暂不归类;无事件有两个主故事、无主故事跨项目;11 个“已结束”均引用本故事内的事件;再次归属 0 次调用。
- 跨项目开启:11 次调用约 6.4 分钟;44 个故事,其中 1 个跨项目故事关联 dgx 与阿里云-国内的 7 个事件;其余检查同上。
观察到的问题(首轮):
- 功能粒度不稳定:goodbuddy 的发布被拆成 0.13.10、0.13.11、0.13.12、0.13.13 多个功能,两次运行切分不同。
- 监督者相关 77~85 个事件的功能只拆出 0~1 个子线索,模型很少主动建子线索。
- goodbuddy 的 fly-brain 仿真与微信 ClawBot 的果蝇开源项目在开启跨项目后仍未关联。
- 故事起止跨度可以从事件计算,但单个事件的跨度仍多为同一时刻(见第 1 步边界)。
针对上述问题的调整:
- 提示词写明粒度:功能是持续数周的模块、子系统或研究主题;发布、版本、单个缺陷、单日任务不是功能,同一项目的发布与版本同步归入一个功能,版本可作子线索;功能名不含版本号和日期;每个项目通常 3~12 个功能;功能达到“子线索最少事件数”后按子线索放置事件。
- 新功能或跨项目故事的名字去掉版本号、日期后与已有或同批故事相同时,并入已有故事(确定性规则);被并入那一方的“已结束”标记不生效,避免一个版本结束让整条发布线结束。
- 子线索拆分:每个项目归属完成后,自身事件数跨过“子线索最少事件数”下一个整数倍的功能(每项目最多 3 个)各做一次拆分调用,输入只有该功能名称、已有子线索和模型放置的事件标题与截断描述。只把事件从功能移到子线索;新子线索至少需要 max(3, 阈值/5) 个事件,否则不建;用户移动过的事件和用户调整过的功能不动;被用户移除的同名子线索不重建。功能没有跨过下一个整数倍时不再调用。
- 跨项目开启时,提示词附带其他项目的功能名称、描述和事件数(最多 60 个,不含事件内容),已有跨项目故事附带它已关联的功能名称;回答可用 `links` 把本项目功能和其他项目功能挂到同一个跨项目故事,关联该功能的全部当前事件(只作关联,不改主故事)。关闭时不提供,且 `links` 会被整块拒绝。
- 新故事键接受 `new_1_t1` 这类写法(真实模型在一次回答中这样写子线索键,原先整块被拒)。
调整后的真实数据(同一数据副本,DeepSeek `deepseek-flash`,每次各自从空故事开始):
| 运行 | 调用 | 功能 / 子线索 / 跨项目 | 归入 / 暂不归类 | 拆分移动 | 版本号命名的功能 |
| --- | --- | --- | --- | --- | --- |
| 首轮 关闭 | 11 | 35 / 4 / 0 | 396 / 41 | — | 有(0.13.10~0.13.13) |
| 首轮 开启 | 11 | 35 / 8 / 1 | 407 / 30 | — | 有 |
| 调整后 关闭 | 12(含 1 次拆分) | 19 / 23 / 0 | 372 / 65 | 18 | 0 |
| 调整后 开启 | 12(含 1 次拆分) | 21 / 23 / 2 | 388 / 49 | 20 | 0 |
- 改善:goodbuddy 发布收敛为一个“发布与版本”/“提交与发布”功能,版本工作成为其子线索;监督者功能拆出 9~11 个子线索(回顾整理与图谱、回顾算法与并发设置、故事线模型与 3D 图谱、活动记录页、自动监督开关与设置等),界面、笔记与画布也拆出子线索;开启跨项目后“果蝇神经仿真”关联了 goodbuddy“本地果蝇神经仿真”与微信 ClawBot“果蝇神经开源项目调研”(16 个事件),另有“Agent 实例生命周期与回收”关联 goodbuddy 与 dgx(24 个事件)。两次运行都无事件有两个主故事、无主故事跨项目、无小功能子线索,再次归属 0 次调用。
- 未改善或变差:暂不归类增多(关闭 41→65,开启 30→49),模型在功能变宽后把更多零散事件留空,未逐条核对是否合理。首轮的 dgx+阿里云跨项目故事这次未出现。子线索大多在归属调用中直接建出(关闭 18、开启 20 个事件由拆分调用移入子线索),拆分调用每次运行只触发 1 次。关闭、开启各只成功跑了一次,多次运行的切分是否一致未比较。
- 单元测试:`supervision-stories.test.ts` 新增 3 项(版本名功能合并且不误结束、拆分调用的触发上限与用户事件保护及整块拒绝、跨项目功能列表与 `links` 关联及关闭时拒绝),共 9 项;与 `supervision-timeline.test.ts` 合计 13 项通过;Node 类型检查与相关 ESLint 通过。
- 失败:另有两次运行中途失败、未完成。一次是开启跨项目时模型把“已结束”标记引用到未归入该故事的事件,整块被拒,导致整次归属失败(已在提示词中强调证据事件须归入同一故事,之后的开启运行通过);一次是上述 `new_1_t1` 键格式,已放宽校验并加单元测试,但放宽后未再用真实模型重跑。严格校验下单块被拒会让整次归属失败,仍需重试才能补上。
## 2026-10-02 故事线模型第 1 步:共享时间线
对应[故事线模型改造设计](./storyline-model-design.md) SL-6、SL-7 和第 1 步。
实现:
- 处理进度改为一条共享时间线:`review_checkpoints` 的监督阶段只用 `scope = 'timeline'`,项目、全局和多项目组合共用。同一来源版本在任一范围成功发布后,其他范围不再提取;范围只决定读取和展示哪些来源。旧的按范围进度在升级时合并,不重读已处理内容。
- 事件新增 `started_at`、`ended_at`、`project_id`、`superseded_by`,来源新增 `source_key`、`source_revision`(`supervision-timeline.ts`)。起止时间和所属项目按事件引用的来源记录计算,不由模型给出;来源跨多个项目时 `project_id` 为空。同一来源版本的重叠区间被后来的结果重新提取时,旧结果中的事件标记为已被替代,图谱和 MCP 只读当前事件,历史结果快照不变。
- 图谱按范围读取共享时间线上的当前事件、实体、关系和来源,并返回 `attention`:按小时统计的消息轮次与正文量,只表示投入集中程度。
- 新增设置“跨项目关联”(`supervisionReview.crossProject`,默认关闭)。关闭时,每批只把本项目已有的实体作为候选,不同项目的内容不会合并为同一实体;开启后候选来自全部项目。运行开始时冻结到本次配置。
- Story Graph MCP:全局范围可以读到各项目的当前事件;来源可从运行范围、全局或包含该来源项目的项目范围读取。
- Schema 53。
验证:
- 新增 `supervision-timeline.test.ts`:项目回顾后全局只提取其他项目的来源,相同项目再次回顾不调用模型;起止时间和项目来自来源;重新整理与项目回顾重叠后当前事件不重复;跨项目开关决定候选;Schema 52 升级合并进度和重复事件。`src/main/assistant` 全目录 28 个文件 324 项、Story Graph MCP 集成、监督相关 IPC 15 项、设置组件均通过。Node/Web 类型检查、相关 ESLint、`git diff --check` 通过。未运行全量测试。
- 真实数据(`scripts/timeline-deepseek.mjs`,用 SQLite 在线备份复制本机 2.2 GB 数据库,原库只读):升级 2.0 秒,`integrity_check` 为 ok。原有 423 个事件全部得到起止时间与项目;13 个两次回顾重复提取的事件被标记替代,当前 410 个(goodbuddy 358、默认项目 28、dgx 17、微信 ClawBot 5、阿里云-国内 2);全局图谱返回 67 个小时段、790 轮对话的密度。
- 真实模型(DeepSeek,`deepseek-flash`):对 goodbuddy 最近 2 小时的 37 个来源做项目回顾,4 个批次加 3 次导航汇总,共 7 次请求,新增 27 个事件,起止时间都在区间内,未关联到其他项目的实体;再次项目回顾和同区间全局回顾均为无变化,0 次请求,全局重新提取的项目来源为 0。
边界:
- 这个 2 小时区间没有其他项目的消息,所以“全局只补其他项目”只在合成测试中验证过。
- 模型通常让一个事件只引用一条消息,所以真实数据里的事件起止时间多数是同一时刻。按故事计算跨度要等第 2 步的故事归属。
- 测试脚本把单次输出上限设为 8,192 token 时,一个批次的输出被截断,导致 JSON 无效。上限调到 32,768 后通过。产品路径不设这个参数,具体由 Runtime 的提供商配置决定。
## 2026-09-30 心跳职责收敛与监督建议
对应 FR-S4、FR-S12、US-S32、US-S33,规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)。
@@ -25,7 +117,7 @@
- 真实模型(DeepSeek,`deepseek-flash`,隔离 SQLite,`scripts/heartbeat-deepseek.mjs`):首次心跳 1 次回顾调用 + 1 次建议调用(建议输入约 1,150 字符,不含原文),生成分歧、修订、未决事项三条建议且依据均指向本次来源;无变化心跳与默认手动回顾均 0 次调用;追加一条消息后只处理 1 个来源,复用全部 5 个既有实体,再调用 1 次回顾、1 次建议。共 4 次付费请求。
- 未运行全量测试。
边界:主动介入(会话级实时)未实现,界面不显示;项目与全局仍各自维护处理进度(US-S31)。
边界:主动介入(会话级实时)未实现,界面不显示;项目与全局仍各自维护处理进度(US-S31),已由 2026-10-02 的共享时间线取代。
## 2026-09-28 Story Graph 只读工具
@@ -40,6 +40,8 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
| `supervisionReview.batchCharacters` | 8000 | 1000..16000 | 每批新正文 UTF-16 单元,不含提示、候选及 JSON |
| `supervisionReview.batchMessages` | 20 | 1..50 | 每批不同消息数;任务等无消息 ID 的来源单独计数 |
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 旧设置兼容字段,执行时忽略,设置页不再展示;恢复旧 run 同样不按此值暂停 |
| `supervisionReview.crossProject` | 关闭 | 开关 | 创建回顾时冻结;关闭时每批只以本项目已有实体为候选,也不生成跨项目故事;开启后候选来自全部项目,故事归属可建立跨项目故事 |
| `supervisionReview.storyThreadEvents` | 20 | 4..500 | 创建回顾时冻结;功能的事件数低于此值时,模型提出的子线索并入功能 |
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后计时 |
| `heartbeatReportTimeoutSeconds` | 240 | 30..600 | 维持报告发起时冻结及既有租约规则 |
| `supervisorModelConcurrency` | 1 | 1..4 | 回顾保存接纳并发;共享池上限仍随应用设置实时变化,实际执行取较小值 |
@@ -0,0 +1,86 @@
# 故事线模型改造设计
状态:2026-10-02 已确认的目标设计。第 1 步(共享时间线、事件起止与密度、跨项目开关)、第 2 步(故事归属与用户调整)和第 3 步(时间螺旋视图)已实现,见[实施进度](./progress.md);第 4、5 步尚未实现。本文定义监督回顾如何产出下一版故事线(时间螺旋、分层故事木片、经验 W),是 [3D 故事线图谱概念](../story-graph/3d-concept.md) 的数据来源合同。现有回顾流程见[回顾分块调度设计](./review-scheduling-design.md),现有图谱工具见 [Story Graph MCP 设计](./story-graph-mcp-design.md)。
## 1. 结构总览
| 层 | 含义 | 来源 |
| --- | --- | --- |
| 全局(根) | 整条时间螺旋本身,即用户的全部经历;不是一片木片 | 所有来源 |
| 项目木片 | 按会话所属项目划分;无项目归属的内容进入“未归属项目” | 会话归属,确定性,不调用模型 |
| 跨项目故事 | 同时涉及多个项目或不属于任何项目的长期事情,例如长期偏好、学习方向 | 故事归属阶段 |
| 功能木片 | 项目内的长期功能或主题 | 故事归属阶段 |
| 子线索 | 事件量足够大的功能再拆分;小功能不拆 | 故事归属阶段 |
| 经验 W | 从故事中形成、可复用到其他故事的经验,默认是全局对象 | 经验抽取阶段 |
全局不作为单独的“全局记忆”重复处理:它是根视图,读取同一套事件、故事和经验。查看某个项目时,只是以该项目为过滤条件。
## 2. 已确认的决定
| 编号 | 决定 |
| --- | --- |
| SL-1 | 层级为 全局 › 项目 › 功能 › 子线索;子线索仅在功能事件量达到阈值时出现,阈值可在设置中调整 |
| SL-2 | 跨项目或无项目的长期事情,作为全局根下的“跨项目故事”,与项目木片并列 |
| SL-3 | 每个事件只归属一个主故事,可另外关联其他故事;关联不影响木片的长度和密度统计 |
| SL-4 | 经验自动生成即可供 Agent 读取,不设确认门槛;标记“自动归纳”,用户可以编辑、合并、删除 |
| SL-5 | 用户对故事和经验的调整(改名、合并、拆分、移动事件、编辑、删除)受保护,后续回顾不覆盖;删除的对象不会因同一证据再次生成 |
| SL-6 | 注意力密度按来源统计:时段内的消息轮次与正文量,不由模型判断;只表示投入集中程度 |
| SL-7 | 每个来源版本只提取一次,项目与全局共用提取结果和增量进度 |
| SL-8 | 设置提供“跨项目关联”,默认关闭。关闭时新内容只关联本项目已有的知识;开启后可关联其他项目中的同一件事,跨项目故事也只在开启时生成 |
## 3. 回顾流程改造
```text
采集来源(按来源版本增量)
↓
批次提取:事件带开始/结束时间(取原始消息时间)、来源引用
↓
故事归属(新增,不读原文)
· 项目:按会话归属确定
· 功能/子线索/跨项目:召回候选故事 → 一次模型调用判断归入已有、新建或暂不归类
↓
经验抽取(新增,低频,不读原文)
· 规则挑候选:有明确结果的决定、多个故事中重复出现的做法、说明原做法问题的修订
· 有候选时一次模型调用:经验内容、形成依据、适用条件、边界
· 记录后续应用及结果
↓
导航摘要 → 发布
```
规则:
- 故事跨回顾保持同一身份,沿用实体候选编号机制;长时间无事件的故事能被接回,不新建。
- 故事状态(进行中、已结束)必须有内容依据;长期无事件只标“暂无进展”,不推断结束。
- 允许“暂不归类”,不为凑层级强行归组;暂不归类的事件在项目下以淡色木片显示。
- 经验的每条依据必须对应到具体事件和来源,否则不保存;没有候选时不调用模型。
- 新增阶段失败时,已发布的提取结果和处理进度保留,可单独重试该阶段。
## 4. 用户调整
图谱是可编辑的结果,不需要逐条审批:
- 故事:改名、合并、拆分、调整层级、把事件移到其他故事。
- 经验:编辑内容与适用条件、合并重复、删除。
- 入口位于故事线图谱的详情面板与 3D 视图选中对象的详情中,共用同一组操作。
- 每次调整可撤销,并记录在活动记录的修改记录中。
## 5. 展示与使用
- 故事线图谱新增 3D 视图,与现有平铺视图并列,几何、时间尺度、聚合规则沿用 [3D 概念](../story-graph/3d-concept.md);Three.js 仅在打开 3D 视图时加载,WebGL 不可用时使用平铺视图。
- MCP 增加按故事、按经验查询;经验返回时附“自动归纳 / 用户编辑”标记和依据。
- 心跳建议增加“故事长期无进展”和“已有经验可能适用于当前故事”。
- 工作回顾按故事组织:推进了哪些故事、新出现哪些故事、形成或应用了哪些经验。
## 6. 存储
新增故事表(含层级与父故事)、事件与故事的归属/关联、经验表、经验依据与应用记录、用户调整记录;对应一次数据库升级。已有回顾结果在升级后按新流程重建,原始来源不变。
## 7. 实施顺序与验收
| 步骤 | 内容 | 验收(真实数据副本 + `.env.deepseek.local` 中的 DeepSeek) |
| --- | --- | --- |
| 1 | 统一时间线(SL-7)、事件时间跨度与密度(SL-6) | 重叠回顾不产生重复事件;项目与全局不重复处理同一来源 |
| 2 | 故事归属与用户调整(SL-1~SL-3、SL-5) | 多次增量回顾后故事不碎片化;间断后的事件接回原故事;调整不被覆盖 |
| 3 | 3D 视图接入真实数据 | 长期数据下时间尺度与聚合正常 |
| 4 | 经验抽取(SL-4) | 每条经验依据可追溯到原文;无候选时零额外调用 |
| 5 | MCP、心跳建议、工作回顾页 | Agent 能按故事接手工作、按经验给出建议 |
+41
View File
@@ -0,0 +1,41 @@
# 下一版 3D 故事线图谱概念
状态:已讨论的视觉方向,尚未实现。本文记录空间表达及概念图引用,不替代当前产品需求或技术设计。
## 时间、事件与故事
内部只有一条共同的时间螺旋。时间均匀推进,同一圈中交错发生不同事情;同一件事也可以在多个圈中间隔出现。
每圈的时长随查看范围变化,使螺旋保持约 8 圈、最多 14 圈:一天约 3 小时一圈,一周 1 天一圈,一个季度 1 个月一圈,几年 1 季度或 1 年一圈。用户可以手动指定,指定值会超过 14 圈时改用更粗的粒度。注意力密度按当前粒度重新统计,每圈 8 个时段。
同一故事中屏幕上过于接近的事件合并为一段,显示事件数量。点击聚合段,查看范围缩小到这段时间,事件随之拆开;小于约 6 小时仍无法拆开的事件(同一时刻多次提取),改为在右侧列出。
[3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。
螺旋半径可以随时间、随事件片段变化,表达注意力处理密度。时间轴不能随半径拉伸。密度的统计口径、归一化和缺失数据表现尚待定义,半径不代表效率、价值或成果。
外层以木桶木片般的曲面连接同一故事的事件。木片起止时间、长度各不相同,可以顺着事件位置弯曲;不能为保持规则桶形而强行等长、等径。多年持续的故事可能只有少数活跃阶段,事件之间保留空白,连接只表达同一故事的延续。未结束的故事使用开放末端,不虚构未来事件。
数据来源与层级(全局 › 项目 › 功能 › 子线索、跨项目故事、经验)由[故事线模型改造设计](../conversation-supervision/storyline-model-design.md)定义。
Demo 底部的“跨项目故事”开关默认关闭。打开后,根层在项目木片旁增加跨项目故事木片(示例:果蝇神经仿真、Agent 运行环境、国内服务器部署),由多个项目中的功能组成;事件仍归属原项目,跨项目木片只做关联。关闭时只显示同一项目内的经验连接。示例中的跨项目分组按功能名称手工对应,产品中应由故事归属阶段提出,并允许用户调整。
## DIKW 与阅读路径
| 层次 | 表达 |
| --- | --- |
| D | 可追溯的原始记录 |
| I | 时间螺旋上的事件片段 |
| K | 木片串联的故事及其形成的认识 |
| W | 从早期故事形成经验及适用条件,关联到后来其他故事中的应用 |
沿螺旋查看某阶段发生的事情;沿木片查看一件事如何跨阶段发展;跨木片查看经验如何被使用、验证或修正。普通相关性不能直接标为智慧。
## 概念图记录
- 基础图片引用:`0d644776-5ed2-42cc-86f9-67f8b0195c86`。
- 本次编辑任务:`b4e13361-acd6-49ba-9aaa-f37be837e257`。
- 会话:`95a52db9-fe02-4559-95de-4488f2163004`。
- 编辑要求:均匀时间轴、变半径单螺旋、不同起止与长度的故事木片、跨故事智慧复用。
提交绘图时任务仍在生成,尚未取得新图片文件。生成结果由会话图片卡片保存;本文只登记任务引用,不表示图片已下载到 Git 仓库。完成后应核验上述空间关系,再将导出的图片纳入设计资产。
File diff suppressed because one or more lines are too long
+608
View File
@@ -0,0 +1,608 @@
/* 3D Story Graph prototype on a real export, rendered with WebGL (Three.js r160, vendor/three.min.js).
* Same model as 3d-demo.js: height = time (uniform), helix radius = attention density, staves on a fixed outer
* cylinder at stable angles, events joined to their stave at the same height. The GPU depth buffer handles
* occlusion; names, counts and experience cards are drawn on a 2D overlay. Falls back to 3d-demo.js without WebGL. */
(() => {
const canvas = document.querySelector('#scene');
let renderer;
try {
if (!window.THREE) throw new Error('three.js missing');
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
} catch {
const fallback = document.createElement('script'); fallback.src = '3d-demo.js'; document.body.append(fallback);
document.querySelector('#renderer-note')?.replaceChildren('Canvas 渲染');
return;
}
const data = window.STORY_DATA;
const overlay = document.querySelector('#overlay');
const ctx = overlay.getContext('2d');
const tau = Math.PI * 2;
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
const totalHours = (range[1] - range[0]) / 3600000;
const zoom = document.querySelector('#zoom');
const showWisdom = document.querySelector('#wisdom');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BG = 0xfcfcf8;
// ---- model ---------------------------------------------------------------
const canonical = new Map();
data.projects.forEach(p => p.features.forEach(f => {
const seen = new Map();
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
}));
const canon = id => canonical.get(id) ?? id;
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
const root = { name: '全部工作', children: data.projects.map(p => ({
name: p.name, detailed: p.detailed, scattered: p.scattered,
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
})) };
const eventIndex = new Map();
function finish(node, parent, depth) {
node.parent = parent; node.depth = depth;
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
if (depth === 2 && !node.ref) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
}
finish(root, undefined, 0);
const projects = root.children;
projects.forEach((p, i) => { p.color = palette[i % palette.length]; });
// Cross-project stories (off by default): grouped from real feature names across projects. Their events still
// belong to the original project features; a cross-project stave only associates them.
const CROSS = [
['果蝇神经仿真', [['goodbuddy', '果蝇脑仿真'], ['微信 ClawBot', '果蝇开源调研'], ['微信 ClawBot', '脉冲脑模型'], ['微信 ClawBot', '视觉模型'], ['微信 ClawBot', '运动仿真']]],
['Agent 运行环境', [['goodbuddy', '远程Agent运行时'], ['goodbuddy', '模型与运行时'], ['dgx', 'Ollama 配置'], ['dgx', 'OCR 显存预留'], ['dgx', 'GBAgent 清理'], ['dgx', 'Agent 运行错误']]],
['国内服务器部署', [['默认项目', '国内节点隐藏IP'], ['阿里云-国内', '阿里云-国内']]],
];
const crossStories = CROSS.map(([name, members], i) => {
const children = members.map(([p, f]) => projects.find(x => x.name === p)?.children.find(x => x.name === f)).filter(Boolean)
.map(ref => ({ name: `${ref.parent.name} · ${ref.name}`, ref, entities: ref.entities, events: ref.events, children: [] }));
return { name, cross: true, color: palette[(projects.length + i * 2) % palette.length], children };
});
const crossToggle = document.querySelector('#cross');
const projectOfEvent = id => eventIndex.get(id)?.feature.parent;
const visibleWisdom = () => data.wisdom.filter(w => crossToggle.checked || projectOfEvent(w.formedIn[0]) === projectOfEvent(w.appliedIn.at(-1)));
function setCrossProject(on) {
root.children = on ? [...projects, ...crossStories] : projects;
root.events = undefined;
finish(root, undefined, 0);
}
let focus = root, selectedEvent, selectedWisdom, selectedCluster, hovered;
function layout(node, from, to) {
node.a0 = from; node.a1 = to;
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
const total = weight.reduce((a, b) => a + b, 0);
let at = from;
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
}
layout(root, 0, tau);
// ---- geometry ----------------------------------------------------------------
const HEIGHT = 430, STAVE_R = 188, WISDOM_R = 262, FLOOR = -HEIGHT / 2 - 34;
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760];
const TARGET_TURNS = 8, MAX_TURNS = 14;
const win = { t0: 0, t1: 1, turnHours: 24 };
let turns = 8, bins = [], binMax = 1, smoothR = [];
const hoursOf = t => t * totalHours;
const clamp01 = t => Math.max(0, Math.min(1, t));
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
const tAt = u => win.t0 + u * (win.t1 - win.t0);
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
function setWindow(t0, t1, turnHours) {
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
const span = hoursOf(win.t1 - win.t0);
win.turnHours = turnHours ?? autoTurnHours(span);
turns = Math.max(.5, span / win.turnHours);
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
const count = Math.max(4, Math.round(turns * 8));
bins = new Array(count).fill(0);
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
binMax = Math.max(1, ...bins);
// One radius per turn, from that turn's attention density, eased between turn centres with a cosine. Each
// turn reads as a circle; the helix swells or narrows only from turn to turn.
const n = Math.max(1, Math.ceil(turns - 1e-6)), perTurn = new Array(n).fill(0);
bins.forEach((v, i) => { perTurn[Math.min(n - 1, Math.floor((i + .5) / bins.length * turns))] += v; });
const top = Math.max(1e-6, ...perTurn), level = perTurn.map(v => Math.sqrt(v / top)); // sqrt keeps quiet turns visible
// Neighbouring turns differ by at most MAX_STEP of the radius range, so the coil never jumps between turns.
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;
}
// Each turn holds its radius (a circle) and changes only in a short eased zone around the turn boundary.
smoothR = Array.from({ length: R_SAMPLES + 1 }, (_, i) => {
const x = i / R_SAMPLES * turns, d = Math.min(n - 1, Math.floor(x)), f = x - d;
const half = TRANSITION / 2;
if (f < half && d > 0) { const k = (f + half) / TRANSITION; return level[d - 1] + (level[d] - level[d - 1]) * (1 - Math.cos(k * Math.PI)) / 2; }
if (f > 1 - half && d < n - 1) { const k = (f - 1 + half) / TRANSITION; return level[d] + (level[d + 1] - level[d]) * (1 - Math.cos(k * Math.PI)) / 2; }
return level[d];
});
}
const MAX_STEP = .22, TRANSITION = .3;
const R_SAMPLES = 600;
function density(u) {
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
return at(i) * (1 - f) + at(i + 1) * f;
}
// Interpolated, never stepped, so the tube stays a smooth curve.
const radius = t => { const x = clamp01(uOf(t)) * R_SAMPLES, i = Math.floor(x), f = x - i; return 78 + 74 * (smoothR[i] * (1 - f) + smoothR[Math.min(R_SAMPLES, i + 1)] * f); };
const yOf = t => (uOf(t) - .5) * HEIGHT;
const helixAngle = t => uOf(t) * turns * tau;
const V = (x, y, z) => new THREE.Vector3(x, y, z);
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return V(r * Math.cos(a), yOf(t), r * Math.sin(a)); };
const cylinder = (t, a, r = STAVE_R) => V(r * Math.cos(a), yOf(t), r * Math.sin(a));
const centerAngle = node => (node.a0 + node.a1) / 2;
// ---- scene -------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 700, 1250); // far geometry fades toward the paper colour instead of gaining outlines
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9cfc4, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(-300, 500, 400); scene.add(sun);
const camera = new THREE.PerspectiveCamera(30, 1, 10, 4000);
let world = new THREE.Group(); scene.add(world);
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
const raycaster = new THREE.Raycaster();
// ---- camera ------------------------------------------------------------------
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 - 1e-3 } };
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
let mode = 'oblique', tween, width = 0, height = 0;
const DIST = 900;
function placeCamera() {
const { yaw, tilt } = cam, ct = Math.cos(tilt), st = Math.sin(tilt);
const target = V(0, cam.pan, 0);
camera.position.copy(target).add(V(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST));
camera.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st);
camera.lookAt(target);
// Same framing as the Canvas version: at zoom 1 the 500 × 560 box fits the viewport.
const visibleHeight = height / Math.min(width / 500, height / 560);
camera.fov = 2 * Math.atan(visibleHeight / 2 / DIST) * 180 / Math.PI;
camera.aspect = width / height; camera.zoom = cam.zoom; camera.updateProjectionMatrix();
}
function project(p) {
const v = p.clone().project(camera);
return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, z: -v.z, behind: v.z > 1 };
}
const viewDir = () => camera.position.clone().sub(V(0, cam.pan, 0)).normalize();
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
function animateTo(target, nextMode = mode) {
mode = nextMode; syncViewButtons();
const goal = { ...cam, ...target };
goal.yaw = shortest(cam.yaw, goal.yaw);
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
const from = { ...cam }, started = performance.now();
tween = { started };
const step = now => {
if (tween?.started !== started) return;
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
syncZoom(); draw();
if (k < 1) requestAnimationFrame(step); else tween = undefined;
};
requestAnimationFrame(step);
}
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
// ---- colour ------------------------------------------------------------------
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
function colorOf(node) {
if (node.depth <= 1) return node.color ?? '#6f807b';
const p = projectOf(node);
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
}
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
function level() { return focus.children?.length ? focus : focus.parent; }
function visibleStaves() { return level().children; }
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
// ---- clustering --------------------------------------------------------------
const CLUSTER_PX = 9;
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
// Quantised to quarter-octaves so zooming re-clusters in steps rather than rebuilding geometry every frame.
function clusterSpan() { const raw = CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); return 2 ** (Math.round(Math.log2(raw) * 4) / 4); }
function clusters(events) {
const span = clusterSpan(), out = [];
events.filter(e => inWindow(e.t)).forEach(e => {
const last = out.at(-1);
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
});
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
return out;
}
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
// ---- building geometry -----------------------------------------------------------
// The scene is rebuilt only when what it shows changes; camera moves just re-render and redraw the overlay.
const mats = new Map();
function mat(key, make) { if (!mats.has(key)) mats.set(key, make()); return mats.get(key); }
const solid = (color, opacity = 1) => mat(`s${color}${opacity}`, () => new THREE.MeshStandardMaterial({ color, roughness: .65, metalness: 0, transparent: opacity < 1, opacity }));
const flat = (color, opacity) => mat(`f${color}${opacity}`, () => new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false }));
const lineMat = (color, opacity) => mat(`l${color}${opacity}`, () => new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity }));
function tube(points, r, material, segments) {
const curve = new THREE.CatmullRomCurve3(points);
return new THREE.Mesh(new THREE.TubeGeometry(curve, segments ?? Math.max(8, points.length * 2), r, 8, false), material);
}
function polyline(points, material) { return new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material); }
let built = '', baseBuilt = '', staveMeshes = [], baseTicks = [], overlayItems = { labels: [], clusters: [], wisdom: [] };
let base = new THREE.Group(); scene.add(base);
let split; // in-place split / merge animation of staves between levels
function signature() {
return JSON.stringify([focus.name, focus.depth, hovered?.name, selectedEvent?.id, selectedWisdom?.lesson, win.t0, win.t1, win.turnHours, showWisdom.checked, clusterSpan(), split ? split.k.toFixed(3) : '']);
}
const dispose = group => group.traverse(o => o.geometry?.dispose());
// Geometry that depends only on the time window: floor, the grey helix and its turn ticks.
function rebuildBase() {
scene.remove(base); dispose(base); base = new THREE.Group(); scene.add(base); baseTicks = [];
const ring = []; for (let i = 0; i <= 96; i++) { const a = i / 96 * tau; ring.push(V(STAVE_R * Math.cos(a), FLOOR, STAVE_R * Math.sin(a))); }
base.add(polyline(ring, lineMat('#9aa69c', .35)));
const shadow = []; for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p.y = FLOOR; shadow.push(p); }
base.add(tube(shadow, 1.4, flat('#8e9a90', .14), 900));
const helix = []; for (let i = 0; i <= 1400; i++) helix.push(helixPoint(tAt(i / 1400)));
base.add(tube(helix, 1.1, solid('#7d8a84'), 2800));
const every = Math.max(1, Math.ceil(turns / 10));
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
const t = tAt(d / turns), p = helixPoint(t);
const tick = new THREE.Mesh(new THREE.SphereGeometry(2.2, 10, 8), solid('#3f514a')); tick.position.copy(p); base.add(tick);
if (d % every === 0 && d / turns < .999) baseTicks.push({ at: p, text: fmtTick(t) });
}
baseBuilt = JSON.stringify([win.t0, win.t1, win.turnHours]);
}
function rebuild() {
if (baseBuilt !== JSON.stringify([win.t0, win.t1, win.turnHours])) rebuildBase();
scene.remove(world); dispose(world);
world = new THREE.Group(); scene.add(world);
staveMeshes = []; overlayItems = { labels: [], clusters: [], wisdom: [] };
const staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t)) && s.a1 - s.a0 > 1e-3), links = split ? [] : connected();
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
// Spans of the helix owned by visible stories are overlaid in the story colour.
const owner = new Array(600).fill(undefined);
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
}));
for (let i = 0; i < 600;) {
const c = owner[i]; let j = i; while (j + 1 < 600 && owner[j + 1] === c) j++;
if (c) { const pts = []; const steps = Math.max(2, (j - i + 1) * 3); for (let k = 0; k <= steps; k++) pts.push(helixPoint(tAt((i + (j + 1 - i) * k / steps) / 600))); world.add(tube(pts, 2.2, solid(c), steps * 2)); }
i = j + 1;
}
// Staves: curved strips of the outer cylinder, from the story's first to last event in the window.
staves.forEach(s => {
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
const a0 = s.a0 + gap, a1 = Math.max(s.a0 + gap + 1e-3, s.a1 - gap);
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
const segs = Math.max(2, Math.ceil((a1 - a0) / .05));
const geo = new THREE.BufferGeometry(), pos = [], idx = [];
for (let j = 0; j <= segs; j++) { const a = a0 + (a1 - a0) * j / segs; const lo = cylinder(t0, a), hi = cylinder(t1, a); pos.push(lo.x, lo.y, lo.z, hi.x, hi.y, hi.z); }
for (let j = 0; j < segs; j++) { const k = j * 2; idx.push(k, k + 2, k + 1, k + 1, k + 2, k + 3); }
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx);
const opacity = em === 'focus' ? .3 : em === 'dim' ? .05 : .15;
const mesh = new THREE.Mesh(geo, flat(color, opacity)); mesh.userData.node = s; mesh.renderOrder = 1;
world.add(mesh); staveMeshes.push(mesh);
if (em !== 'dim') [a0, a1].forEach(a => world.add(polyline([cylinder(t0, a), cylinder(t1, a)], lineMat(color, .7))));
// One band per cluster, thicker for more events; quiet periods stay as gaps.
cl.get(s).forEach(c => {
const arc = []; for (let i = 0; i <= 10; i++) arc.push(cylinder(c.t, a0 + (a1 - a0) * i / 10, STAVE_R + .6));
const active = c.events.includes(selectedEvent);
const r = (Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0)) * .45;
world.add(tube(arc, r, solid(color, em === 'dim' ? .3 : .9), 20));
});
overlayItems.labels.push({ node: s, at: cylinder(t1, centerAngle(s), STAVE_R + 2), angle: centerAngle(s) });
});
// Connectors: helix -> stave at the same height, one per cluster.
links.filter(s => cl.has(s)).forEach(s => {
const color = colorOf(s), a = centerAngle(s);
cl.get(s).forEach(c => {
const p = helixPoint(c.t), q = cylinder(c.t, a), active = c.events.includes(selectedEvent);
world.add(tube([p, q], active ? .9 : .5, solid(color, active ? 1 : .6), 2));
const r = c.n === 1 ? (active ? 5.5 : 3.6) : Math.min(10, 5.5 + Math.log2(c.n) * 1.3);
const dot = new THREE.Mesh(new THREE.SphereGeometry(r * .6, 16, 12), solid(color)); dot.position.copy(p); world.add(dot);
if (active) { const halo = new THREE.Mesh(new THREE.TorusGeometry(r * .6 + 2, .5, 6, 32), solid('#273b36')); halo.position.copy(p); halo.lookAt(camera.position); world.add(halo); }
overlayItems.clusters.push({ cluster: c, node: s, at: p, r, clickable: focus.depth >= 2 || s === focus });
});
});
// Experience: a node on a ring outside the barrel. The forming stave reaches out to it, and an arrow
// runs from it to the applying stave, so the arc never crosses the helix inside.
if (showWisdom.checked && !split) visibleWisdom().forEach((w, i) => {
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return;
const aa = centerAngle(sa), ab = centerAngle(sb), pa = cylinder(a.event.t, aa), pb = cylinder(b.event.t, ab);
const mean = Math.atan2(Math.sin(aa) + Math.sin(ab), Math.cos(aa) + Math.cos(ab));
const node = cylinder((a.event.t + b.event.t) / 2, mean, WISDOM_R), on = selectedWisdom === w;
const leg = (from, to, k) => { const c = from.clone().add(to).multiplyScalar(.5); const out = V(c.x, 0, c.z); if (out.length() > 1) c.add(out.normalize().multiplyScalar(k)); return new THREE.QuadraticBezierCurve3(from, c, to); };
const formed = leg(pa, node, 34), applied = leg(node, pb, 34);
[formed, applied].forEach(curve => {
const dashed = new THREE.Line(new THREE.BufferGeometry().setFromPoints(curve.getPoints(40)), mat('wd', () => new THREE.LineDashedMaterial({ color: 0xa47837, dashSize: 7, gapSize: 5 })));
dashed.computeLineDistances(); world.add(dashed);
if (on) world.add(tube(curve.getPoints(40), .8, solid('#a47837', .5), 40));
});
const tip = applied.getPoint(1), dir = tip.clone().sub(applied.getPoint(.94)).normalize();
const cone = new THREE.Mesh(new THREE.ConeGeometry(3.5, 10, 12), solid('#a47837')); cone.position.copy(tip).sub(dir.clone().multiplyScalar(5));
cone.quaternion.setFromUnitVectors(V(0, 1, 0), dir); world.add(cone);
const gem = new THREE.Mesh(new THREE.OctahedronGeometry(on ? 9 : 7), solid(on ? '#c08a3e' : '#d2a35e')); gem.position.copy(node); world.add(gem);
overlayItems.wisdom.push({ w, i, at: node });
});
built = signature();
}
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
// ---- frame -----------------------------------------------------------------------
let hits = [];
function draw() {
if (!width) return;
placeCamera();
if (signature() !== built) rebuild();
renderer.render(scene, camera);
ctx.clearRect(0, 0, width, height); hits = [];
const v = viewDir();
ctx.font = '10px system-ui'; ctx.fillStyle = '#3f514a';
baseTicks.forEach(t => { const p = project(t.at); if (!p.behind) ctx.fillText(t.text, p.x + 6, p.y + 3); });
overlayItems.clusters.forEach(c => {
const p = project(c.at); if (p.behind) return;
if (c.cluster.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.cluster.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
if (c.clickable) hits.push({ kind: 'cluster', cluster: c.cluster, node: c.node, x: p.x, y: p.y, r: Math.max(9, c.r + 2), z: p.z });
});
drawLabels(v);
overlayItems.wisdom.forEach(w => drawWisdomCard(w));
}
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
function drawLabels(v) {
const placed = [];
overlayItems.labels.map(l => ({ ...l, facing: Math.cos(l.angle) * v.x + Math.sin(l.angle) * v.z, em: emphasisOf(l.node) }))
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.facing - a.facing)
.forEach(({ node: s, at, facing, em }) => {
if (em === 'dim' || (em !== 'focus' && facing < -.05)) return;
const p = project(at); if (p.behind) return;
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
const w = ctx.measureText(s.name).width, x = p.x - w / 2, y = p.y - 8, box = [x - 4, y - 12, w + 8, 16];
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
placed.push(box);
ctx.fillStyle = 'rgba(252,252,248,.85)'; ctx.fillRect(...box);
ctx.fillStyle = colorOf(s); ctx.globalAlpha = em === 'focus' ? 1 : .55 + .45 * Math.max(0, facing); ctx.fillText(s.name, x, y); ctx.globalAlpha = 1;
hits.push({ kind: 'label', node: s, rect: box });
});
}
function drawWisdomCard({ w, i, at }) {
const anchor = project(at), on = selectedWisdom === w, cw = Math.min(176, width - 24), ch = 44;
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
}
// ---- side panel ------------------------------------------------------------
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
const pad2 = n => String(n).padStart(2, '0');
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
// Staves split in place when opening a level and merge back when returning, instead of jumping to a new layout.
function planSplit(oldLevel, newLevel) {
const kids = newLevel.children ?? [], to = new Map(kids.map(k => [k, [k.a0, k.a1]]));
let from;
if (newLevel.parent === oldLevel) { // opening: children start inside the opened stave's old sector
const [c0, c1] = oldAngles.get(newLevel) ?? [0, tau], f = (c1 - c0) / tau;
from = new Map(kids.map(k => [k, [c0 + k.a0 * f, c0 + k.a1 * f]]));
} else if (oldLevel?.parent === newLevel) { // returning: the stave we came from starts as the whole circle
const [x0, x1] = to.get(oldLevel);
from = new Map(kids.map(k => [k, k === oldLevel ? [0, tau] : k.a0 < x0 ? [0, 0] : [tau, tau]]));
}
return from && { from, to };
}
let oldAngles = new Map();
function runSplit(plan) {
if (!plan || reducedMotion) return;
const started = performance.now(), apply = k => plan.to.forEach(([b0, b1], node) => { const [a0, a1] = plan.from.get(node); node.a0 = a0 + (b0 - a0) * k; node.a1 = a1 + (b1 - a1) * k; });
split = { k: 0, started }; apply(0);
const step = now => {
if (split?.started !== started) return;
const t = Math.min(1, (now - started) / 520), k = t < .5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
split.k = k; apply(k);
if (t < 1) { draw(); requestAnimationFrame(step); } else { split = undefined; draw(); }
};
requestAnimationFrame(step);
}
function setFocus(node) {
const oldLevel = level();
oldAngles = new Map((oldLevel.children ?? []).map(k => [k, [k.a0, k.a1]]));
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined;
const plan = level() !== oldLevel ? planSplit(oldLevel, level()) : undefined;
runSplit(plan);
render();
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
}
function render() {
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
const levelName = focus.cross ? '跨项目故事' : focus.ref ? '关联的项目功能' : ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
: focus === root ? `${projects.length} 个项目,${root.events.length} 个事件${crossToggle.checked ? `,${crossStories.length} 条跨项目故事` : ''}。木片越宽,事件越多;选择项目后拆分为功能。`
: focus.cross ? `关联 ${focus.children.length} 个项目功能、${focus.events.length} 个事件,跨越 ${new Set(focus.children.map(c => c.ref.parent.name)).size} 个项目。事件仍归属原项目,这里只做关联。`
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
document.querySelector('#story-description').textContent = desc;
const list = document.querySelector('#stories'); list.replaceChildren();
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
});
const events = document.querySelector('#events'); events.replaceChildren();
const detail = document.querySelector('#detail'); detail.replaceChildren();
if (selectedWisdom) {
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
const s = staveFor(id); if (!s) return;
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
}));
} else if (focus.depth >= 2) {
const detailed = (focus.ref?.parent ?? projectOf(focus)).detailed;
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
}
document.querySelector('#wisdom-list').replaceChildren(...visibleWisdom().map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
draw();
}
function selectEvent(e) {
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
}
function openCluster(c) {
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
const span = c.t1 - c.t0, minSpan = 6 / totalHours;
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
} else { selectedCluster = c; selectedEvent = undefined; render(); }
}
// ---- time window and grain ---------------------------------------------------------
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
function applyWindow(t0, t1) {
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
setWindow(t0, t1, fixed);
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto';
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
}
function zoomWindow(t0, t1) {
const len = t1 - t0;
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
rangeSelect.value = 'custom';
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
}
function applyRange(value) {
rangeSelect.value = value;
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
}
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
applyRange('all');
// ---- input -----------------------------------------------------------------
const viewButtons = [...document.querySelectorAll('[data-view]')];
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
viewButtons.forEach(b => b.addEventListener('click', () => {
if (b.dataset.view === 'free') { freeView(); return; }
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
}));
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
showWisdom.addEventListener('input', draw);
crossToggle.addEventListener('change', () => { setCrossProject(crossToggle.checked); focus = root; hovered = undefined; layout(root, 0, tau); selectedEvent = selectedWisdom = selectedCluster = undefined; render(); });
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
const clampTilt = t => Math.max(-Math.PI / 2 + 1e-3, Math.min(Math.PI / 2 - 1e-3, t));
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
function staveAt(x, y) {
raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - y / height * 2), camera);
const hit = raycaster.intersectObjects(staveMeshes, false)[0];
return hit && { kind: 'stave', node: hit.object.userData.node };
}
function hitAt(x, y) {
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
?? staveAt(x, y);
}
let drag;
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
if (!drag) {
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
if (node !== hovered) { hovered = node; draw(); }
canvas.style.cursor = h ? 'pointer' : '';
return;
}
if (drag.id !== e.pointerId) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
if (mode === 'free') {
if (drag.pan) cam.pan = clampPan(cam.pan + dy / scaleNow());
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
draw();
}
drag.x = e.clientX; drag.y = e.clientY;
});
canvas.addEventListener('pointerup', e => {
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
else if (h?.kind === 'cluster') openCluster(h.cluster);
else if (h) setFocus(h.node);
}
drag = undefined;
});
canvas.addEventListener('pointercancel', () => { drag = undefined; });
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
canvas.addEventListener('wheel', e => {
e.preventDefault(); freeView();
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
syncZoom(); draw();
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
if (!moves[e.key]) return;
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
});
new ResizeObserver(() => {
const r = canvas.getBoundingClientRect(); width = r.width; height = r.height;
if (!width || !height) return;
renderer.setSize(width, height, false);
const dpr = Math.min(devicePixelRatio || 1, 2); overlay.width = Math.round(width * dpr); overlay.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}).observe(canvas);
// Read-only hook for layout checks.
window.storyDemo = { renderer: 'webgl', cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
get splitting() { return !!split; },
// Worst ratio of largest to smallest radius within any single turn: 1 means perfectly round.
roundness: () => { let worst = 1; for (let d = 0; d < Math.floor(turns); d++) { const r = Array.from({ length: 64 }, (_, i) => radius(tAt((d + i / 64) / turns))); worst = Math.max(worst, Math.max(...r) / Math.min(...r)); } return +worst.toFixed(3); },
maxStep: () => { let m = 0, prev = radius(tAt(0)); for (let i = 1; i <= 4000; i++) { const r = radius(tAt(i / 4000)); m = Math.max(m, Math.abs(r - prev)); prev = r; } return +m.toFixed(3); },
wisdomNodes: () => overlayItems.wisdom.map(w => +Math.hypot(w.at.x, w.at.z).toFixed(1)),
angles: () => visibleStaves().map(s => [+s.a0.toFixed(3), +s.a1.toFixed(3)]),
get meshes() { let n = 0; world.traverse(o => { if (o.isMesh || o.isLine) n++; }); return n; },
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
staveAt: (x, y) => staveAt(x, y)?.node.name, labels: () => hits.filter(h => h.kind === 'label').map(h => h.node.name),
setWindow: (t0, t1) => zoomWindow(t0, t1), setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
syncViewButtons(); syncZoom();
render();
})();
+8
View File
@@ -0,0 +1,8 @@
:root{color-scheme:light;--paper:#f5f4ef;--ink:#273b36;--muted:#748078;--border:#dde1d7;--green:#357e6d}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,canvas:focus-visible,a:focus-visible{outline:3px solid #ac7638;outline-offset:3px}.page-header{max-width:1540px;margin:auto;padding:36px 38px 25px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.eyebrow{color:var(--green);font-size:11px;letter-spacing:2px;text-decoration:none}h1{font-size:clamp(23px,2.6vw,35px);letter-spacing:-1px;font-weight:550;margin:9px 0}p{color:var(--muted);margin:0}.badge{white-space:nowrap;border:1px solid var(--border);border-radius:30px;padding:7px 12px;font-size:11px;color:var(--muted);margin-top:8px}main{max-width:1540px;margin:auto;padding:0 38px;display:grid;grid-template-columns:minmax(0,1fr) 306px;gap:22px}.stage{min-width:0;background:#fcfcf8;border:1px solid var(--border);border-radius:15px;overflow:hidden}.stage-header{display:flex;justify-content:space-between;align-items:center;padding:15px 22px;border-bottom:1px solid #e9ebe3;font-size:12px}.stage-header i{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:8px}button{border:1px solid var(--border);background:transparent;color:inherit;border-radius:7px;padding:7px 12px}.viewport{position:relative;height:560px;background:radial-gradient(ellipse at 50% 50%,#eef1e8 0,transparent 70%)}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}.axis-note{position:absolute;left:19px;top:35%;text-align:center;font-size:11px;pointer-events:none;color:#879188}.axis-note span{font-size:10px;line-height:2.5}.canvas-hint{position:absolute;bottom:13px;left:0;right:0;text-align:center;font-size:11px;color:#8a948a;pointer-events:none}.controls{border-top:1px solid #e9ebe3;display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:15px 22px;font-size:12px}.controls label{display:flex;align-items:center;gap:6px}.controls input{accent-color:var(--green)}.range input{width:84px}.legend{display:flex;flex-wrap:wrap;gap:15px;padding:0 22px 17px;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:5px}.legend b{display:inline-block;width:18px;height:0;border-top:2px solid #687b71}.legend .surface{height:9px;background:#65a08a44;border:1px solid #65a08a66}.legend .dashed{border-top:2px dashed #ad7939}aside{padding-top:6px;min-width:0}.section-label{font-size:11px;letter-spacing:1px;color:var(--muted)}.stories{display:grid;gap:8px;margin:13px 0 24px}.stories button{display:flex;text-align:left;align-items:center;gap:12px;padding:13px;background:#fafaf6}.stories button[aria-pressed=true]{background:white;border-color:var(--story-color);box-shadow:0 3px 15px #30433208}.stories i{width:5px;height:29px;border-radius:3px;background:var(--story-color)}.stories span{flex:1}.stories small{color:var(--muted);font-size:10px}.inspector h2{font-size:22px;font-weight:550;margin:6px 0 9px}.inspector p{font-size:12px}.events{display:grid;margin-top:18px;gap:6px}.events button{text-align:left;border-color:transparent;border-left:2px solid #d9e0d5;border-radius:0;padding:8px 12px;font-size:12px}.events button[aria-pressed=true]{border-left-color:var(--green);background:#e7ede3}.events small{color:var(--muted);display:block;font-size:10px}.detail{font-size:12px;padding:14px 0;color:#53665b;min-height:80px}.detail strong{display:block;color:var(--ink);margin-bottom:5px}.wisdom-card{display:grid;gap:7px;text-align:left;width:100%;padding:17px;background:#f0e9db;border-color:#e0d3bc;margin-top:12px}.wisdom-card span{font-size:10px;letter-spacing:1px;color:#996b2c}.wisdom-card strong{font-size:13px;font-weight:550}.wisdom-card small{font-size:11px;color:#817259}footer{max-width:1540px;padding:24px 38px 30px;margin:auto;font-size:11px;color:var(--muted);line-height:1.9}footer strong{font-weight:500;color:#4a6051}
@media(min-width:1500px){.viewport{height:640px}}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr) 250px;gap:15px;padding:0 20px}.page-header{padding:25px 20px}.badge{display:none}.viewport{height:520px}.controls{gap:10px;padding:14px}.legend{padding:0 14px 15px}.axis-note{left:8px}}@media(max-width:680px){main{grid-template-columns:1fr}.viewport{height:460px}.page-header h1{font-size:24px}.page-header p{font-size:12px}aside{padding:14px 0}.stories{grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.stories button{padding:9px 7px;gap:5px;font-size:11px}.stories small{display:none}.controls{font-size:11px}.range input{width:70px}footer{padding:15px 20px}.axis-note{font-size:10px}.canvas-hint{font-size:10px}}
.crumbs{font-size:12px;color:var(--muted);margin-bottom:14px;line-height:2}.crumb{border:0;padding:0 2px;color:var(--green);text-decoration:underline;text-underline-offset:3px}.crumbs strong{color:var(--ink);font-weight:550}.stories{margin:12px 0 10px;max-height:340px;overflow:auto}.stories button{padding:10px 12px}.stories small{white-space:nowrap}.events{max-height:300px;overflow:auto;margin-top:6px}.detail section{margin-bottom:12px}.detail p{margin:0}.detail .jump{display:block;width:100%;text-align:left;margin-top:6px;font-size:12px}.wisdom-list{display:grid;gap:8px}.wisdom-card{margin-top:0;padding:12px 14px}.wisdom-card[aria-pressed=true]{border-color:#ad854b;background:#e9d7b6}
.views{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end}.views button{padding:5px 10px;font-size:12px}.views button[aria-pressed=true]{background:var(--ink);border-color:var(--ink);color:#fcfcf8}.views #reset{margin-left:6px}.legend .link{width:12px;border-top:1px solid #687b71}@media(max-width:680px){.stage-header{flex-direction:column;align-items:flex-start;gap:8px}.views{justify-content:flex-start}}
.controls select{font:inherit;font-size:12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:inherit;padding:3px 6px}#range-reset{padding:4px 10px;font-size:12px}.cluster-head{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:6px}
.overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>时间与故事 · 3D Story Graph</title>
<link rel="stylesheet" href="3d-demo.css">
</head>
<body>
<header class="page-header">
<div><a href="README.md" class="eyebrow">GOODBUDDY / STORY GRAPH LAB</a><h1>时间只有一条,故事交错生长。</h1><p>沿螺旋看一周的经历;木片可以层层拆开:项目 › 功能 › 子线索;跨木片的经验来自真实事件。</p></div>
<span class="badge">真实图谱 · 09.19 – 09.26 · 归组由模型提出,待确认</span>
</header>
<main>
<section class="stage" aria-label="三维故事图谱">
<div class="stage-header"><span><i></i> <span id="scale-note">高度为时间,一圈一天 · 半径为当时的对话密度</span> · <span id="renderer-note">WebGL</span></span>
<div class="views" role="group" aria-label="视角">
<button data-view="oblique" title="看整体结构(1)">斜俯视</button><button data-view="side" title="看起止与间隔(2)">侧视</button><button data-view="top" title="看注意力密度(3)">俯视</button><button data-view="free" title="拖动、滚轮自由查看">自由</button>
<button id="reset">重置</button>
</div>
</div>
<div class="viewport">
<canvas id="scene" tabindex="0" aria-label="三维故事图谱。数字键 1、2、3 切换视角,方向键旋转,PageUp/PageDown 沿时间移动,加减号缩放,Esc 返回上一层。木片也可通过右侧列表选择。"></canvas>
<div class="axis-note"><span id="axis-end">09.26</span><br><span>↑<br>时间等速推进<br>↑</span><br><span id="axis-start">09.19</span></div>
<canvas id="overlay" class="overlay" aria-hidden="true"></canvas>
<div class="canvas-hint">拖动旋转 · 右键或 Shift 拖动沿时间移动 · 滚轮缩放 · 点击木片拆开 · Esc 返回</div>
</div>
<div class="controls">
<label><input id="wisdom" type="checkbox" checked> 跨故事经验</label>
<label title="把多个项目里属于同一件事的功能串成一片全局木片;事件仍归属原项目"><input id="cross" type="checkbox"> 跨项目故事</label>
<label>范围 <select id="range"><option value="all">全部</option><option value="72">最近 3 天</option><option value="24">最近 1 天</option><option value="custom" hidden>所选时段</option></select></label>
<label>一圈 <select id="turn"><option value="auto">自动</option><option value="3">3 小时</option><option value="6">6 小时</option><option value="12">12 小时</option><option value="24">1 天</option><option value="168">1 周</option><option value="720">1 个月</option></select></label>
<button id="range-reset" hidden>返回全部时间</button>
<label class="range">缩放 <input id="zoom" type="range" min="50" max="250" value="100"></label>
</div>
<div class="legend"><span><b class="line"></b>时间:唯一的连续螺旋</span><span><b class="surface"></b>故事:木片,上下跨度为起止时间,短横为同一时段的事件,越粗越多</span><span><b class="link"></b>事件:同一高度连到所属木片,数字为聚合数量,点击放大拆开</span><span><b class="dashed"></b>经验:从形成到应用</span></div>
</section>
<aside>
<nav id="crumbs" class="crumbs" aria-label="木片层级"></nav>
<section class="inspector" aria-live="polite"><span id="period" class="section-label"></span><h2 id="story-title"></h2><p id="story-description"></p></section>
<div id="stories" class="stories" role="group" aria-label="下一层木片"></div>
<div id="events" class="events" aria-label="事件"></div>
<div id="detail" class="detail"></div>
<div id="wisdom-list" class="wisdom-list"></div>
</aside>
</main>
<footer><strong>数据来源:</strong>本机已生成的故事线图谱(2 次回顾,268 个实体;两次回顾重复提取的事件已按时间与类型合并,剩 358 个)的只读导出。项目由事件引用的会话确定;功能与子线索、跨故事经验由 deepseek-flash 根据实体与事件提出,未经确认。半径为每 3 小时的对话轮次,只表示投入集中程度,不代表效率。goodbuddy 以外的项目只显示时间与类型。“跨项目故事”默认关闭;打开后,按功能名称把多个项目中的同一件事(如果蝇神经仿真、Agent 运行环境)串成全局木片,与项目木片并列;事件仍归属原项目。</footer>
<script src="3d-demo-data.js"></script>
<script src="vendor/three.min.js"></script>
<script src="3d-demo-webgl.js"></script>
</body>
</html>
+472
View File
@@ -0,0 +1,472 @@
/* 3D Story Graph prototype on a real export: one time helix, nested story staves (project > feature > sub-thread).
* Geometry: height = time (uniform), helix radius = attention density, staves sit on a fixed outer cylinder at a
* stable angle, and each event joins its stave by a horizontal connector at the same height. */
const data = window.STORY_DATA;
const canvas = document.querySelector('#scene');
const ctx = canvas.getContext('2d');
const tau = Math.PI * 2;
const palette = ['#367e70', '#bb8743', '#8675a6', '#b5604f', '#4f7fa8', '#7a8f3b', '#a35d86', '#3f8f97', '#9a7a55', '#5f6fb0', '#c07a3a', '#6b8d6f'];
const range = data.range.map(d => Date.parse(d + 'T00:00:00+08:00'));
const totalHours = (range[1] - range[0]) / 3600000;
const zoom = document.querySelector('#zoom');
const showWisdom = document.querySelector('#wisdom');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---- model ---------------------------------------------------------------
// The two saved reviews overlap and re-extracted the same moments; show each (time, type) once.
const canonical = new Map();
data.projects.forEach(p => p.features.forEach(f => {
const seen = new Map();
f.events = f.events.filter(e => { const k = e.at + e.type; if (seen.has(k)) { canonical.set(e.id, seen.get(k)); return false; } seen.set(k, e.id); canonical.set(e.id, e.id); return true; });
}));
const canon = id => canonical.get(id) ?? id;
data.wisdom.forEach(w => { w.formedIn = [...new Set(w.formedIn.map(canon))]; w.appliedIn = [...new Set(w.appliedIn.map(canon))]; });
const root = { name: '全部工作', children: data.projects.map(p => ({
name: p.name, detailed: p.detailed, scattered: p.scattered,
children: p.features.map(f => ({ ...f, children: f.subThreads.map(s => ({ ...s,
events: [...new Set(s.eventIds.map(canon))].map(id => f.events.find(e => e.id === id)).filter(Boolean) })) }))
})) };
const eventIndex = new Map();
function finish(node, parent, depth) {
node.parent = parent; node.depth = depth;
(node.children ?? []).forEach(c => finish(c, node, depth + 1));
if (!node.events) node.events = [...new Map(node.children.flatMap(c => c.events).map(e => [e.id, e])).values()].sort((a, b) => a.t - b.t);
node.start = node.events[0]?.t ?? 0; node.end = node.events.at(-1)?.t ?? 0;
if (depth === 2) node.events.forEach(e => eventIndex.set(e.id, { event: e, feature: node }));
}
finish(root, undefined, 0);
root.children.forEach((p, i) => { p.color = palette[i % palette.length]; });
let focus = root, selectedEvent, selectedWisdom, hovered, hits = [];
// Stable angular layout: siblings keep the order of their first event, each takes a sector by sqrt(event count).
function layout(node, from, to) {
node.a0 = from; node.a1 = to;
const kids = (node.children ?? []).slice().sort((a, b) => a.start - b.start);
const weight = kids.map(k => Math.max(1.2, Math.sqrt(k.events.length)));
const total = weight.reduce((a, b) => a + b, 0);
let at = from;
kids.forEach((k, i) => { const w = (to - from) * weight[i] / total; layout(k, at, at + w); at += w; });
}
layout(root, 0, tau);
// ---- geometry ----------------------------------------------------------------
// Event times `t` are normalised over the whole export. The viewed window [win.t0, win.t1] maps to the full height,
// and one turn covers `turnHours`, chosen so the helix keeps a readable number of turns at any range.
const HEIGHT = 430, STAVE_R = 188, FLOOR = -HEIGHT / 2 - 34;
const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]; // hours: 3h … 1 year
const TARGET_TURNS = 8, MAX_TURNS = 14;
const win = { t0: 0, t1: 1, turnHours: 24, auto: true };
let turns = 8, bins = [], binMax = 1;
const hoursOf = t => t * totalHours;
const clamp01 = t => Math.max(0, Math.min(1, t));
const inWindow = t => t >= win.t0 - 1e-9 && t <= win.t1 + 1e-9;
const uOf = t => (t - win.t0) / (win.t1 - win.t0);
function autoTurnHours(spanHours) { return TURN_STEPS.find(h => spanHours / h <= TARGET_TURNS) ?? TURN_STEPS.at(-1); }
// Attention density is re-counted at the current grain: one bin is an eighth of a turn.
const slotTimes = data.density.map((n, i) => ({ t: (i + .5) / data.density.length, n }));
function setWindow(t0, t1, turnHours) {
win.t0 = clamp01(t0); win.t1 = clamp01(t1);
const span = hoursOf(win.t1 - win.t0);
win.turnHours = turnHours ?? autoTurnHours(span);
turns = Math.max(.5, span / win.turnHours);
if (turns > MAX_TURNS) { win.turnHours = TURN_STEPS.find(h => span / h <= MAX_TURNS) ?? TURN_STEPS.at(-1); turns = span / win.turnHours; }
const count = Math.max(4, Math.round(turns * 8));
bins = new Array(count).fill(0);
slotTimes.forEach(s => { if (inWindow(s.t)) bins[Math.min(count - 1, Math.floor(uOf(s.t) * count))] += s.n; });
binMax = Math.max(1, ...bins);
smoothR = Array.from({ length: 401 }, (_, i) => { let s = 0; for (let k = -3; k <= 3; k++) s += density(clamp01((i + k) / 400)); return s / 7; });
}
function density(u) {
const x = u * bins.length - .5, i = Math.floor(x), f = x - i, at = j => bins[Math.max(0, Math.min(bins.length - 1, j))] / binMax;
return at(i) * (1 - f) + at(i + 1) * f;
}
let smoothR = [];
const radius = t => 52 + 100 * smoothR[Math.round(clamp01(uOf(t)) * 400)];
const yOf = t => (uOf(t) - .5) * HEIGHT;
const helixAngle = t => uOf(t) * turns * tau;
const helixPoint = t => { const r = radius(t), a = helixAngle(t); return [r * Math.cos(a), yOf(t), r * Math.sin(a)]; };
const cylinder = (t, a, r = STAVE_R) => [r * Math.cos(a), yOf(t), r * Math.sin(a)];
const centerAngle = node => (node.a0 + node.a1) / 2;
// ---- camera ------------------------------------------------------------------
const VIEWS = { oblique: { yaw: -.4, tilt: .38 }, side: { tilt: 0 }, top: { tilt: Math.PI / 2 } };
const cam = { yaw: -.4, tilt: .38, zoom: 1, pan: 0 };
let mode = 'oblique', tween;
let width = 0, height = 0;
function project(p) {
const y0 = p[1] - cam.pan;
const x = p[0] * Math.cos(cam.yaw) + p[2] * Math.sin(cam.yaw);
const z = -p[0] * Math.sin(cam.yaw) + p[2] * Math.cos(cam.yaw);
const y = y0 * Math.cos(cam.tilt) - z * Math.sin(cam.tilt);
const depth = y0 * Math.sin(cam.tilt) + z * Math.cos(cam.tilt);
const scale = Math.min(width / 500, height / 560) * cam.zoom * 900 / (900 - depth);
return { x: width / 2 + x * scale, y: height / 2 - y * scale, z: depth };
}
const shortest = (from, to) => from + ((((to - from) % tau) + tau * 1.5) % tau - Math.PI);
function animateTo(target, nextMode = mode) {
mode = nextMode; syncViewButtons();
const goal = { ...cam, ...target };
goal.yaw = shortest(cam.yaw, goal.yaw);
if (reducedMotion) { Object.assign(cam, goal); syncZoom(); draw(); return; }
const from = { ...cam }, started = performance.now();
tween = { from, goal, started };
const step = now => {
if (tween?.started !== started) return;
const k = Math.min(1, (now - started) / 480), e = k < .5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
for (const key of ['yaw', 'tilt', 'zoom', 'pan']) cam[key] = from[key] + (goal[key] - from[key]) * e;
syncZoom(); draw();
if (k < 1) requestAnimationFrame(step); else tween = undefined;
};
requestAnimationFrame(step);
}
// Turn the camera so a stave faces the viewer.
function face(node) { if (node && node.depth > 0) animateTo({ yaw: centerAngle(node) - Math.PI / 2, pan: (yOf(Math.max(win.t0, node.start)) + yOf(Math.min(win.t1, node.end))) / 2 * .35 }); }
function freeView() { tween = undefined; if (mode !== 'free') { mode = 'free'; syncViewButtons(); } }
// ---- colour and depth cue -------------------------------------------------------
const hexRgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const GREY = [150, 158, 154];
// k: 0 = farthest, 1 = nearest. Far geometry loses saturation and opacity instead of gaining an outline.
function shade(hex, alpha, k) {
const [r, g, b] = hexRgb(hex), d = (1 - k) * .65;
const mix = (c, i) => Math.round(c * (1 - d) + GREY[i] * d);
return `rgba(${mix(r, 0)},${mix(g, 1)},${mix(b, 2)},${(alpha * (.22 + .78 * k)).toFixed(3)})`;
}
const projectOf = n => { while (n.depth > 1) n = n.parent; return n; };
function colorOf(node) {
if (node.depth <= 1) return node.color ?? '#6f807b';
const p = projectOf(node);
return palette[(node.parent.children.indexOf(node) + root.children.indexOf(p) * 3) % palette.length];
}
function shown(node) { const set = visibleStaves(); for (let n = node; n; n = n.parent) if (set.includes(n)) return n; }
function level() { return focus.children?.length ? focus : focus.parent; }
function visibleStaves() { return level().children; }
function emphasisOf(s) { return focus === level() ? (s === hovered ? 'focus' : 'normal') : s === focus ? 'focus' : 'dim'; }
// Staves whose events are joined to the helix: the focused leaf, or the stave under the pointer.
function connected() { return visibleStaves().filter(s => (focus !== level() && s === focus) || s === hovered); }
// ---- rendering ---------------------------------------------------------------
const tAt = u => win.t0 + u * (win.t1 - win.t0);
function drawFloor() {
// A faint footprint on the floor helps read front and back.
const ring = [];
for (let i = 0; i <= 96; i++) ring.push(project(cylinder(win.t0, i / 96 * tau, STAVE_R).map((v, j) => j === 1 ? FLOOR : v)));
line(ring, 'rgba(120,132,124,.16)', 1);
const shadow = [];
for (let i = 0; i <= 900; i++) { const p = helixPoint(tAt(i / 900)); p[1] = FLOOR; shadow.push(project(p)); }
line(shadow, 'rgba(110,122,114,.10)', 4);
}
function line(points, color, size = 1, dash = []) {
ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
ctx.setLineDash(dash); ctx.lineWidth = size; ctx.strokeStyle = color; ctx.lineCap = 'round'; ctx.stroke(); ctx.setLineDash([]);
}
function polygon(points, color) { ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); }
// Events closer than this on screen are merged into one cluster (≈ 9 px of height).
const CLUSTER_PX = 9;
function scaleNow() { return Math.min(width / 500, height / 560) * cam.zoom; }
function clusterSpan() { return CLUSTER_PX / (HEIGHT * scaleNow()) * (win.t1 - win.t0); }
// Groups a story's in-window events into clusters on the time axis, at the current grain and zoom.
function clusters(events) {
const span = clusterSpan(), out = [];
events.filter(e => inWindow(e.t)).forEach(e => {
const last = out.at(-1);
if (last && e.t - last.t0 <= span) { last.events.push(e); last.t1 = e.t; } else out.push({ t0: e.t, t1: e.t, events: [e] });
});
out.forEach(c => { c.t = (c.t0 + c.t1) / 2; c.n = c.events.length; });
return out;
}
const fmtSpan = c => c.n === 1 ? fmtTime(c.t) : `${fmtTime(c.t0)} – ${fmtTime(c.t1)}`;
function draw() {
if (!width) return;
ctx.clearRect(0, 0, width, height); hits = [];
drawFloor();
const prims = [], staves = visibleStaves().filter(s => s.events.some(e => inWindow(e.t))), links = connected();
const cl = new Map(staves.map(s => [s, clusters(s.events)]));
// Helix: one continuous line split into short segments so it can interleave with staves by depth.
const owner = new Array(600).fill(undefined);
staves.filter(s => emphasisOf(s) !== 'dim').forEach(s => cl.get(s).forEach(c => {
const i0 = Math.round(uOf(c.t0) * 599), i1 = Math.round(uOf(c.t1) * 599);
for (let i = i0 - 1; i <= i1 + 1; i++) if (i >= 0 && i < 600 && owner[i] === undefined) owner[i] = colorOf(s);
}));
const N = 1800;
let previous = project(helixPoint(tAt(0)));
for (let i = 1; i <= N; i++) {
const u = i / N, next = project(helixPoint(tAt(u))), c = owner[Math.round(u * 599)];
const a = previous, b = next;
prims.push({ z: (a.z + b.z) / 2, draw: k => line([a, b], shade(c ?? '#6d7c76', c ? 1 : .8, k), c ? 2.4 : 1.25) });
previous = next;
}
// Staves: vertical strips of a fixed outer cylinder, spanning the story's first to last event in the window.
staves.forEach(s => {
const em = emphasisOf(s), color = colorOf(s), gap = Math.min(.014, (s.a1 - s.a0) * .08);
const inside = s.events.filter(e => inWindow(e.t)), pad = (win.t1 - win.t0) * .006;
const a0 = s.a0 + gap, a1 = s.a1 - gap;
const t0 = Math.max(win.t0, inside[0].t - pad), t1 = Math.min(win.t1, inside.at(-1).t + pad);
const fill = em === 'focus' ? .26 : em === 'dim' ? .05 : .13, edge = em === 'dim' ? .28 : .75;
const strips = Math.max(2, Math.ceil((a1 - a0) / .07));
for (let j = 0; j < strips; j++) {
const b0 = a0 + (a1 - a0) * j / strips, b1 = a0 + (a1 - a0) * (j + 1) / strips;
const quad = [cylinder(t0, b0), cylinder(t0, b1), cylinder(t1, b1), cylinder(t1, b0)].map(project);
const z = quad.reduce((n, p) => n + p.z, 0) / 4;
prims.push({ z, draw: k => polygon(quad, shade(color, fill, k)) });
if (em !== 'dim' && (j === 0 || j === strips - 1)) {
const edgeAngle = j === 0 ? b0 : b1;
prims.push({ z: z + .1, draw: k => line([project(cylinder(t0, edgeAngle)), project(cylinder(t1, edgeAngle))], shade(color, edge, k), 1) });
}
hits.push({ kind: 'stave', node: s, poly: quad, z });
}
// One band per cluster: thickness grows with the number of events; quiet periods stay as gaps.
cl.get(s).forEach(c => {
const band = []; for (let i = 0; i <= 8; i++) band.push(project(cylinder(c.t, a0 + (a1 - a0) * i / 8, STAVE_R + .5)));
const z = band[4].z + .2, active = c.events.includes(selectedEvent);
const size = Math.min(6, 1.3 + Math.log2(c.n) * 1.1) + (active ? 2 : 0);
prims.push({ z, draw: k => line(band, shade(color, em === 'dim' ? .3 : .85, k), size) });
});
s.labelAt = project(cylinder(t1, centerAngle(s), STAVE_R + 2));
s.labelZ = s.labelAt.z;
});
// Connectors: helix -> stave at the same height, one per cluster; clusters show their size and open on click.
links.filter(s => cl.has(s)).forEach(s => {
const color = colorOf(s), a = centerAngle(s);
cl.get(s).forEach(c => {
const p = project(helixPoint(c.t)), q = project(cylinder(c.t, a)), active = c.events.includes(selectedEvent);
const r = c.n === 1 ? (active ? 6 : 3.4) : Math.min(11, 6 + Math.log2(c.n) * 1.4);
prims.push({ z: (p.z + q.z) / 2, draw: k => line([p, q], shade(color, active ? .95 : .45, k), active ? 2 : 1) });
prims.push({ z: p.z + .3, draw: k => {
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, tau); ctx.fillStyle = shade(color, 1, k); ctx.fill();
ctx.strokeStyle = active ? '#273b36' : 'rgba(252,252,248,.9)'; ctx.lineWidth = 1.4; ctx.stroke();
if (c.n > 1) { ctx.fillStyle = '#fcfcf8'; ctx.font = '600 9px system-ui'; const label = String(c.n); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3); }
} });
if (focus.depth >= 2 || s === focus) hits.push({ kind: 'cluster', cluster: c, node: s, x: p.x, y: p.y, r: Math.max(9, r + 2), z: p.z });
});
});
// Turn ticks on the helix: equal time per turn, labelled at the current grain.
const every = Math.max(1, Math.ceil(turns / 10));
for (let d = 0; d <= Math.floor(turns + 1e-6); d++) {
const t = tAt(d / turns), p = project(helixPoint(t));
prims.push({ z: p.z + .4, draw: k => {
ctx.fillStyle = shade('#3f514a', 1, k); ctx.beginPath(); ctx.arc(p.x, p.y, 2.3, 0, tau); ctx.fill();
if (d % every === 0 && d / turns < .999) { ctx.font = '10px system-ui'; ctx.fillText(fmtTick(t), p.x + 6, p.y + 3); }
} });
}
let zmin = Infinity, zmax = -Infinity;
prims.forEach(p => { zmin = Math.min(zmin, p.z); zmax = Math.max(zmax, p.z); });
const norm = z => zmax > zmin ? (z - zmin) / (zmax - zmin) : 1;
prims.sort((a, b) => a.z - b.z).forEach(p => p.draw(norm(p.z)));
drawLabels(staves, norm);
if (showWisdom.checked) data.wisdom.forEach((w, i) => drawWisdom(w, i));
}
// Labels: the focused or hovered stave always; otherwise only staves facing the viewer, without overlaps.
function drawLabels(staves, norm) {
const placed = [];
staves.map(s => ({ s, k: norm(s.labelZ), em: emphasisOf(s) }))
.sort((a, b) => (b.em === 'focus') - (a.em === 'focus') || b.k - a.k)
.forEach(({ s, k, em }) => {
if (em === 'dim' || (em !== 'focus' && k < .45)) return;
ctx.font = (em === 'focus' ? '600 ' : '') + '11px system-ui';
const w = ctx.measureText(s.name).width, x = s.labelAt.x - w / 2, y = s.labelAt.y - 8;
const box = [x - 4, y - 12, w + 8, 16];
if (placed.some(b => box[0] < b[0] + b[2] && b[0] < box[0] + box[2] && box[1] < b[1] + b[3] && b[1] < box[1] + box[3])) return;
placed.push(box);
ctx.fillStyle = 'rgba(252,252,248,.82)'; ctx.fillRect(...box);
ctx.fillStyle = shade(colorOf(s), 1, Math.max(k, em === 'focus' ? 1 : 0)); ctx.fillText(s.name, x, y);
hits.push({ kind: 'label', node: s, rect: box });
});
}
function staveFor(eventId) { const hit = eventIndex.get(eventId); if (!hit) return; const sub = hit.feature.children.find(s => s.events.some(e => e.id === eventId)); return { event: hit.event, node: sub ?? hit.feature }; }
function drawWisdom(w, i) {
const a = staveFor(w.formedIn[0]), b = staveFor(w.appliedIn.at(-1)); if (!a || !b) return;
const sa = shown(a.node), sb = shown(b.node); if (!sa || !sb || sa === sb) return;
if (!inWindow(a.event.t) || !inWindow(b.event.t)) return; // either end lies outside the viewed time window
const pa = cylinder(a.event.t, centerAngle(sa)), pb = cylinder(b.event.t, centerAngle(sb));
const mid = [(pa[0] + pb[0]) * .9, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) * .9 + 120];
const curve = []; for (let k = 0; k <= 50; k++) { const t = k / 50; curve.push(project(pa.map((v, j) => (1 - t) ** 2 * v + 2 * (1 - t) * t * mid[j] + t * t * pb[j]))); }
const on = selectedWisdom === w;
line(curve, '#a47837', on ? 2.6 : 1.3, [5, 5]);
const end = curve[50], prev = curve[47], ang = Math.atan2(end.y - prev.y, end.x - prev.x);
line([{ x: end.x - 8 * Math.cos(ang - .45), y: end.y - 8 * Math.sin(ang - .45) }, end, { x: end.x - 8 * Math.cos(ang + .45), y: end.y - 8 * Math.sin(ang + .45) }], '#a47837', 1.8);
const anchor = curve[25], cw = Math.min(176, width - 24), ch = 44;
const x = Math.max(12, Math.min(width - cw - 12, anchor.x - cw / 2)), y = Math.max(12, Math.min(height - ch - 30, anchor.y - ch / 2 + i * 50));
ctx.fillStyle = on ? '#e9d7b6' : '#f8efdfe8'; ctx.strokeStyle = '#ad854b'; ctx.lineWidth = on ? 2 : 1;
ctx.beginPath(); ctx.roundRect(x, y, cw, ch, 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#936825'; ctx.font = '10px system-ui'; ctx.fillText(`W${i + 1} · 跨故事经验 · 待确认`, x + 10, y + 16);
ctx.fillStyle = '#574522'; ctx.font = '12px system-ui'; ctx.fillText(w.lesson, x + 10, y + 33);
hits.push({ kind: 'wisdom', w, rect: [x, y, cw, ch] });
}
// ---- side panel ------------------------------------------------------------
const dateOf = t => new Date(range[0] + t * (range[1] - range[0]));
const pad2 = n => String(n).padStart(2, '0');
const fmt = t => { const d = dateOf(t); return `${d.getMonth() + 1}.${pad2(d.getDate())}`; };
const fmtTime = t => { const d = dateOf(t); return `${fmt(t)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
// Tick labels follow the grain: hours for sub-day turns, dates for daily turns, months beyond.
const fmtTick = t => win.turnHours < 24 ? fmtTime(t) : win.turnHours >= 720 ? `${dateOf(t).getFullYear()}.${dateOf(t).getMonth() + 1}` : fmt(t);
const turnName = h => ({ 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' })[h] ?? `${h} 小时`;
const typeName = { decision: '决定', change: '变更', milestone: '里程碑', discussion: '讨论' };
function el(tag, props = {}, ...kids) { const n = document.createElement(tag); Object.assign(n, props); n.append(...kids); return n; }
function setFocus(node) {
focus = node; hovered = undefined; layout(level(), 0, tau); selectedEvent = undefined; selectedWisdom = undefined; selectedCluster = undefined; render();
if (focus !== level()) face(focus); else if (mode === 'free') draw(); else animateTo({ ...VIEWS[mode], pan: 0 });
}
function render() {
const crumbs = document.querySelector('#crumbs'); crumbs.replaceChildren();
const trail = []; for (let n = focus; n; n = n.parent) trail.unshift(n);
trail.forEach((n, i) => { if (i) crumbs.append(' › '); crumbs.append(i === trail.length - 1 ? el('strong', { textContent: n.name }) : el('button', { textContent: n.name, onclick: () => setFocus(n), className: 'crumb' })); });
const levelName = ['全部项目', '项目木片', '功能木片', '子线索'][focus.depth];
document.querySelector('#period').textContent = selectedWisdom ? '跨故事经验 · 模型归纳 · 待确认' : `${levelName} · ${fmt(focus.start)} – ${fmt(focus.end)}`;
document.querySelector('#story-title').textContent = selectedWisdom ? selectedWisdom.lesson : focus.name;
const desc = selectedWisdom ? '由真实图谱事件归纳,形成与应用都对应具体事件;需要用户确认后才作为经验使用。'
: focus === root ? `${root.children.length} 个项目,${root.events.length} 个事件。木片越宽,事件越多;选择项目后拆分为功能。`
: `${focus.events.length} 个事件 · ${focus.entities ?? focus.children.reduce((n, c) => n + (c.entities ?? 0), 0)} 个知识实体${focus.scattered ? ` · ${focus.scattered} 个零散实体未归入功能` : ''}${focus.unthreaded ? ` · ${focus.unthreaded} 个实体留在功能层` : ''}`;
document.querySelector('#story-description').textContent = desc;
const list = document.querySelector('#stories'); list.replaceChildren();
if (!selectedWisdom) (focus.children ?? []).forEach(c => {
const b = el('button', { onclick: () => setFocus(c), onmouseenter: () => { hovered = c; draw(); }, onmouseleave: () => { hovered = undefined; draw(); } },
el('i'), el('span', { textContent: c.name }), el('small', { textContent: `${c.events.length} 事件 · ${fmt(c.start)}–${fmt(c.end)}${c.children?.length ? ` · ${c.children.length} 片` : ''}` }));
b.style.setProperty('--story-color', colorOf(c)); list.append(b);
});
const events = document.querySelector('#events'); events.replaceChildren();
const detail = document.querySelector('#detail'); detail.replaceChildren();
if (selectedWisdom) {
[['适用条件', selectedWisdom.conditions], ['边界', selectedWisdom.boundary], ['形成与应用依据', selectedWisdom.why]].forEach(([h, p]) => detail.append(el('section', {}, el('strong', { textContent: h }), el('p', { textContent: p }))));
[['形成', selectedWisdom.formedIn], ['应用', selectedWisdom.appliedIn]].forEach(([label, ids]) => ids.forEach(id => {
const s = staveFor(id); if (!s) return;
detail.append(el('button', { className: 'jump', textContent: `${label} · ${fmt(s.event.t)} · ${s.node.name}`, onclick: () => { setFocus(s.node); selectEvent(s.event); } }));
}));
} else if (focus.depth >= 2) {
const detailed = projectOf(focus).detailed;
// The list follows the viewed window; an opened cluster narrows it to that cluster's events.
const listed = (selectedCluster ? selectedCluster.events : focus.events.filter(e => inWindow(e.t))).slice().reverse();
if (selectedCluster) events.append(el('div', { className: 'cluster-head' }, el('span', { textContent: `${fmtSpan(selectedCluster)} · ${selectedCluster.n} 个事件` }), el('button', { className: 'crumb', textContent: '显示全部', onclick: () => { selectedCluster = undefined; render(); } })));
listed.slice(0, 60).forEach(e => events.append(el('button', { 'aria-pressed': String(e === selectedEvent), onclick: () => selectEvent(e) },
el('span', { textContent: detailed ? e.title : `${typeName[e.type]}事件` }), el('small', { textContent: `${fmtTime(e.t)} · ${typeName[e.type]}` }))));
if (listed.length > 60) events.append(el('small', { textContent: `另有 ${listed.length - 60} 个更早的事件` }));
if (!listed.length) events.append(el('small', { textContent: '这段时间没有该故事的事件' }));
if (selectedEvent) detail.append(el('strong', { textContent: detailed ? selectedEvent.title : '该项目只显示时间与类型' }), el('p', { textContent: detailed ? selectedEvent.text : '' }), el('small', { textContent: new Date(selectedEvent.at).toLocaleString('zh-CN') }));
}
document.querySelector('#wisdom-list').replaceChildren(...data.wisdom.map((w, i) => el('button', { className: 'wisdom-card', 'aria-pressed': String(selectedWisdom === w), onclick: () => { selectedWisdom = w; showWisdom.checked = true; render(); } },
el('span', { textContent: `W${i + 1} · 跨故事经验 · 待确认` }), el('strong', { textContent: w.lesson }))));
draw();
}
function selectEvent(e) {
if (!inWindow(e.t)) { selectedCluster = undefined; applyRange('all'); }
selectedEvent = e; render(); if (mode !== 'free') animateTo({ pan: yOf(e.t) * .5 });
}
// A single event opens directly. A cluster zooms the window into its time span so it splits apart;
// when it cannot split further (same moment) the list narrows to its events instead.
function openCluster(c) {
if (c.n === 1) { selectedCluster = undefined; selectEvent(c.events[0]); return; }
const span = c.t1 - c.t0, minSpan = 6 / totalHours; // never zoom below ~6 hours
if (win.t1 - win.t0 > minSpan * 1.01 && span > 0) {
const pad = Math.max(span * .6, minSpan / 2), mid = (c.t0 + c.t1) / 2, half = Math.max(span / 2 + pad, minSpan / 2);
selectedCluster = undefined; zoomWindow(mid - half, mid + half);
} else { selectedCluster = c; selectedEvent = undefined; render(); }
}
// ---- time window and grain ---------------------------------------------------------
const rangeSelect = document.querySelector('#range'), turnSelect = document.querySelector('#turn'), rangeReset = document.querySelector('#range-reset');
let selectedCluster;
function applyWindow(t0, t1) {
const fixed = turnSelect.value === 'auto' ? undefined : Number(turnSelect.value);
setWindow(t0, t1, fixed);
if (fixed && win.turnHours !== fixed) turnSelect.value = 'auto'; // the chosen turn would make the helix unreadable
document.querySelector('#scale-note').textContent = `高度为时间,一圈 ${turnName(win.turnHours)}(共 ${Math.round(turns * 10) / 10} 圈)· 半径为当时的对话密度`;
document.querySelector('#axis-start').textContent = win.turnHours < 24 ? fmtTime(win.t0) : fmt(win.t0);
document.querySelector('#axis-end').textContent = win.turnHours < 24 ? fmtTime(win.t1) : fmt(win.t1);
rangeReset.hidden = win.t0 <= 0 && win.t1 >= 1;
}
function zoomWindow(t0, t1) {
const len = t1 - t0;
if (t0 < 0) { t0 = 0; t1 = Math.min(1, len); } if (t1 > 1) { t1 = 1; t0 = Math.max(0, 1 - len); }
rangeSelect.value = 'custom';
applyWindow(t0, t1); render(); if (mode !== 'free') animateTo({ pan: 0 });
}
function applyRange(value) {
rangeSelect.value = value;
if (value === 'all') applyWindow(0, 1); else if (value !== 'custom') { const h = Number(value); applyWindow(Math.max(0, 1 - h / totalHours), 1); }
}
rangeSelect.addEventListener('change', () => { selectedCluster = undefined; applyRange(rangeSelect.value); render(); });
turnSelect.addEventListener('change', () => { applyWindow(win.t0, win.t1); render(); });
rangeReset.addEventListener('click', () => { selectedCluster = undefined; applyRange('all'); render(); });
applyRange('all');
// ---- input -----------------------------------------------------------------
const viewButtons = [...document.querySelectorAll('[data-view]')];
function syncViewButtons() { viewButtons.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.view === mode))); }
function syncZoom() { zoom.value = String(Math.round(cam.zoom * 100)); }
viewButtons.forEach(b => b.addEventListener('click', () => {
if (b.dataset.view === 'free') { freeView(); return; }
animateTo({ ...VIEWS[b.dataset.view], zoom: 1, pan: 0 }, b.dataset.view);
}));
zoom.addEventListener('input', () => { tween = undefined; cam.zoom = Number(zoom.value) / 100; draw(); });
showWisdom.addEventListener('input', draw);
document.querySelector('#reset').onclick = () => animateTo({ ...VIEWS.oblique, zoom: 1, pan: 0 }, 'oblique');
const clampTilt = t => Math.max(-Math.PI / 2, Math.min(Math.PI / 2, t));
const clampPan = p => Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, p));
const clampZoom = z => Math.max(.5, Math.min(2.5, z));
function inPoly(x, y, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if ((a.y > y) !== (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside; } return inside; }
const inRect = (x, y, r) => x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3];
function hitAt(x, y) {
return hits.find(h => h.kind === 'wisdom' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'cluster' && Math.hypot(h.x - x, h.y - y) < h.r).sort((a, b) => b.z - a.z)[0]
?? hits.find(h => h.kind === 'label' && inRect(x, y, h.rect))
?? hits.filter(h => h.kind === 'stave' && inPoly(x, y, h.poly)).sort((a, b) => b.z - a.z)[0];
}
let drag;
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pan: e.button === 2 || e.shiftKey };
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
if (!drag) {
const h = hitAt(e.clientX - r.left, e.clientY - r.top), node = h && (h.kind === 'stave' || h.kind === 'label') ? h.node : undefined;
if (node !== hovered) { hovered = node; draw(); }
canvas.style.cursor = h ? 'pointer' : '';
return;
}
if (drag.id !== e.pointerId) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) >= 5) freeView();
if (mode === 'free') {
if (drag.pan) cam.pan = clampPan(cam.pan + dy / (Math.min(width / 500, height / 560) * cam.zoom));
else { cam.yaw += dx * .008; cam.tilt = clampTilt(cam.tilt + dy * .006); }
draw();
}
drag.x = e.clientX; drag.y = e.clientY;
});
canvas.addEventListener('pointerup', e => {
if (drag && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) < 5) {
const r = canvas.getBoundingClientRect(), h = hitAt(e.clientX - r.left, e.clientY - r.top);
if (h?.kind === 'wisdom') { selectedWisdom = h.w; render(); }
else if (h?.kind === 'cluster') openCluster(h.cluster);
else if (h) setFocus(h.node);
}
drag = undefined;
});
canvas.addEventListener('pointercancel', () => { drag = undefined; });
canvas.addEventListener('pointerleave', () => { if (!drag && hovered) { hovered = undefined; draw(); } });
canvas.addEventListener('wheel', e => {
e.preventDefault(); freeView();
if (e.shiftKey) cam.pan = clampPan(cam.pan - e.deltaY * .3);
else cam.zoom = clampZoom(cam.zoom * Math.exp(-e.deltaY * .0012));
syncZoom(); draw();
}, { passive: false });
canvas.addEventListener('keydown', e => {
if (e.key === 'Escape' && focus.parent) { setFocus(focus.parent); return; }
const preset = { 1: 'oblique', 2: 'side', 3: 'top' }[e.key];
if (preset) { animateTo({ ...VIEWS[preset], zoom: 1, pan: 0 }, preset); return; }
const moves = { ArrowLeft: () => { cam.yaw -= .12; }, ArrowRight: () => { cam.yaw += .12; }, ArrowUp: () => { cam.tilt = clampTilt(cam.tilt + .06); }, ArrowDown: () => { cam.tilt = clampTilt(cam.tilt - .06); },
PageUp: () => { cam.pan = clampPan(cam.pan + 20); }, PageDown: () => { cam.pan = clampPan(cam.pan - 20); }, '+': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '=': () => { cam.zoom = clampZoom(cam.zoom * 1.1); }, '-': () => { cam.zoom = clampZoom(cam.zoom / 1.1); } };
if (!moves[e.key]) return;
e.preventDefault(); freeView(); moves[e.key](); syncZoom(); draw();
});
new ResizeObserver(() => { const r = canvas.getBoundingClientRect(); width = r.width; height = r.height; const dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }).observe(canvas);
// Read-only hook for layout checks.
window.storyDemo = { cam, win, get turns() { return turns; }, get mode() { return mode; }, get focus() { return focus.name; },
clustersOf: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); return n ? clusters(n.events).map(c => c.n) : []; },
clusterHits: () => hits.filter(h => h.kind === 'cluster').map(h => ({ n: h.cluster.n, x: Math.round(h.x), y: Math.round(h.y) })),
setWindow: (t0, t1) => zoomWindow(t0, t1), simulateSpan: hours => { const t = totalHours; return { hours, turnHours: autoTurnHours(hours), turns: hours / autoTurnHours(hours), total: t }; }, setFocusByName: name => { const find = n => n.name === name ? n : (n.children ?? []).map(find).find(Boolean); const n = find(root); if (n) setFocus(n); return !!n; } };
syncViewButtons(); syncZoom();
render();
+4
View File
@@ -1,5 +1,9 @@
# 故事线图谱(Story Graph)
下一版视觉方向见 [3D 故事线图谱概念](./3d-concept.md):记录变半径时间螺旋、长短不同的故事木片与跨故事智慧复用,以及概念图生成引用。该方案尚未实现,不替代下述现有演示。
[打开 3D 网页 Demo](./3d-demo.html):基于本机故事线图谱的只读导出(`3d-demo-data.js`),木片按项目 › 功能 › 子线索分层。默认用 WebGL 渲染(`3d-demo-webgl.js`,依赖仓库内的 `vendor/three.min.js`,Three.js r160,MIT 许可见 `vendor/three.LICENSE`),由 GPU 深度缓冲处理遮挡;浏览器不支持 WebGL 时自动改用 Canvas 投影版本(`3d-demo.js`)。直接打开 HTML 即可,不接入生产图谱,不是已上线的产品界面。
这是[监督者应用](../conversation-supervision/supervisor-prd.md)的核心视图。目标对象与交互由[产品设计](./prd.md)定义,用户验收见[User Stories](../conversation-supervision/user-stories.md)。时间事件、知识实体与阶段总结在目标设计中分别建模;下述演示仍使用阶段节点。
[打开交互演示](./demo.html)。这是监督者故事图谱的独立设计原型,使用模拟数据,尚未接入产品或笔记存储。
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.2,以及此后截至 `86347af` 的已提交变化,更新于 2026-09-28。所述功能与修复均已开发完成并提交;0.15.2 之后的提交归属版本待确认。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.7,截至 `b02ce31` 的已提交变化,更新于 2026-10-02;保留 2026-10-01 补入的“下一版故事线”预览及配图。除该未来方向预览外,所述功能与修复均已开发完成并提交。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者按项目回顾进展,并通过图谱保留结论依据;新加入的笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。远程故事图谱读取与 Runtime 配置修复配套 Desktop 0.15.2 和 Agent 0.15.0,请先升级桌面端,再更新 Host 环境。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签,以及按会话、项目和全局分别选择 Runtime 与模型的方式。远程使用建议配套 Desktop 0.15.7 和 Agent 0.15.1,请先升级桌面端,再更新 Host 环境。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.2`,与 `agent-v0.15.0` 同指向 `47ba54f`;故事图谱读取、完整讨论正文编辑及上一轮笔记面板简化已纳入对应版本。本轮检查 `0cdf042..86347af`,手动压缩、长请求授权、终止提示及后续界面调整位于该 tag 之后,发布归属待确认。对外发布前核验最终版本、公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.7`,指向 `b02ce31`;Agent tag `agent-v0.15.1` 指向 `a662b16`。本轮检查 `86347af..b02ce31`,共 36 个提交。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。本稿中的聊天采集功能将数据库格式升至 48,用于保存笔记来源;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。当前数据库格式为 52,包含笔记来源、Runtime 分层选择、心跳建议、笔记标签和统计索引等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
## 新增功能
@@ -30,6 +30,18 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
>
> 建议图注:从回顾图谱查看关联对象,并打开保存的讨论依据。
### 下一版故事线:把经历串成故事,让经验有来路
下一版故事线将用一条连续的时间螺旋呈现交错发生的经历。同一圈里可以发生不同的事,同一件事也可以隔一段时间再继续。时间均匀推进,螺旋半径的起伏表达注意力密度,不代表效率或成果。
外侧长短不一的“木片”把属于同一件事的事件串起来:一次旅行可能很短,一个产品可能陆续做了几年。沿时间线看,可以了解某个阶段经历了什么;沿一片木片看,可以追踪一件事如何发展,中间的停顿也保留下来。
![下一版故事线:连续时间螺旋、跨阶段故事,以及经验的形成依据与后续应用](assets/09-next-storyline-wisdom.png)
*下一版故事线预览:选中 W 经验,查看它从哪次经历中形成、适用于什么条件,以及后来在哪件事中使用。图中为示例数据。*
W 表达的是从经历中归纳出的经验。例如,产品试验形成了“先验证关键假设,再扩大投入”的认识,后来用于研究项目。它会作为可查看的内容呈现,保留形成依据、适用条件和后续应用;新的实践结果还可以支持、修订或否定这条经验。这个方向希望让故事线既能回答“发生了什么”,也能帮助理解“学到了什么,下一次能怎么用”。
### 助手可以查阅故事图谱,接着已有决定继续工作
继续一个项目时,可以请助手“查一下此前选了哪个方案,依据是什么”,或“找出这项决定后来的修订”。助手可搜索监督者已经保存的事件、实体、关系和回顾结果,读取相关上下文,再查看来源片段,作为后续讨论的依据。
@@ -38,6 +50,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
查询默认使用当前项目,也可明确指定全局或项目集合;各范围读取各自保存的结果。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,但完整历史状态重建和跨范围事实自动复用不在本次功能范围内。
### 智能心跳:按新增工作回顾,并提出可处理的建议
为项目设置每日或每周计划后,智能心跳会整理回顾窗口内新增、修改或尚待处理的记录,把变化保存到故事图谱。计划默认生成建议,也可以选择“仅更新记忆”;没有待处理变化时,不调用模型。
建议围绕本次回顾发现的未决事项、分歧、修订和候选约定展开,并保留来源供核对。接受未决事项后,会创建一项暂停任务,方便先检查再开始;候选约定经确认后才进入长期记忆。建议生成失败可以单独重试,已经保存的图谱和回顾进度仍然保留。
计划需要应用保持运行,并按所选全局或项目范围处理。心跳不会在每条聊天消息后实时介入;有候选建议时会额外调用模型整理措辞,回顾本身也可能分批调用模型。
### 在任务中心跟踪当前会话或任务
右侧任务中心的项目概览下方新增监督反馈。选中任务时查看该任务的匹配结果,否则跟随当前会话;也可以固定目标,切换聊天后继续查看同一个事项。没有匹配回顾时会明确显示空状态。
@@ -64,6 +84,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
>
> 建议图注:把有用的回答存入笔记,需要上下文时返回原会话。
### 笔记支持标签筛选,列表与正文并排浏览
给笔记加上“项目资料”“待整理”等标签后,可以按标签查找,也可以同时选择多个标签,筛出同时带有这些标签的笔记。每篇最多添加 10 个标签;标签支持改名、同名合并和删除,删除标签会保留笔记正文。
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。
标签也可由支持魔法笔记工具的助手按你的要求添加或修改。快速采集面板继续用于保存聊天内容,标签管理在完整笔记工作区中进行。
### 在聊天中查找和整理 Obsidian 笔记
已有 Obsidian 笔记可以直接接入 GoodBuddy。在“设置 → 能力与工具 → MCP”的 Obsidian 卡片中,选择本机已注册的仓库,或限定一个文件夹,测试连接并保存,然后启用并分配给所用 Runtime。无需启动 Obsidian,也无需额外安装 Obsidian 插件或配置 REST 服务。
@@ -88,6 +116,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
>
> 建议图注:在工作栏打开原生终端,或在系统浏览器中使用 DS Web 的独立会话。
### Runtime 与模型可以分别跟随项目或临时切换
项目可以保留常用的执行方式与模型,某次会话需要换模型时,在输入区的两栏选择器中临时选择即可。菜单标明当前选择来自会话、项目还是全局,也可以恢复项目默认,减少每次新建会话重复配置。
执行方式与模型分别按“会话 → 项目 → 全局”解析,并检查模型是否适用于当前 Runtime。连接被删除或不兼容时按规则回退;缺少 API Key 时保留选择并提示配置,不会悄悄换用另一个模型。排队消息在实际发送时重新读取有效选择,定时任务则保留创建时确定的配置。
托管 SSH 项目支持 OpenCode、Continue;外部 OpenCode Server 使用服务端自身配置。
### 复制模型连接,保留已有配置
在“设置 → 模型连接”中打开一条 LLM 连接,点击“复制”,即可在副本中修改模型名称、地址或其他选项,再保存为新连接。协议、请求定制和认证配置随副本带入,已保存的 API Key 无需重新输入。
@@ -108,6 +144,12 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
“设置 → 平台功能 → 通用设置”新增“桌面通知”开关,默认开启。关闭后,应用处于后台时的任务完成、失败或取消等系统提醒停止弹出,应用内通知仍然保留。设置保存后立即生效,重启后也会记住选择。
### 设备共享目录:登记设备,发布能力与知识说明
在应用中心启用“设备共享”,连接另行运行的 ShareServer 后,可以登记本机、浏览设备和发布目录,并发布或撤销能力、知识的元数据,供使用同一服务的人查看有哪些资源。
此功能默认关闭,当前提供目录管理。发布“知识”记录不会上传或同步正文,搜索、读取和下载标志仅表示声明;尚不提供远程执行或知识正文访问。设备登记也不表示设备在线,当前服务没有身份认证,应在可信环境中使用。
## 功能改进
### 会话搜索按需展开,快捷键提示集中在输入框
@@ -130,15 +172,15 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
故事线图谱侧栏将原来上下堆叠的列表改为“事件、实体、关系”页签,各自显示数量并独立滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别,查看关联信息时少一些来回滚动。
### 自动监督与手动回顾分开管理
### 手动回顾与智能心跳共用增量进度
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
自动报告与监督回顾分别记录处理进度。对于对应阶段没有新增、修改或待处理内容的情况,会跳过模型调用;手动回顾则重新分析选择的时间范围。删除报告或计划不会重置已经记录的来源处理进度。
手动“回顾”和心跳计划现在共用增量处理方式,默认只分析所选范围内尚待处理的变化,无变化时跳过模型调用。全局与各项目分别记录进度。需要重看完整区间时,可从“更多 → 重新整理”确认发起;重新整理不会推进或重置增量进度。
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录;自动报告与回顾可能分别调用模型,长时间范围也可能分成多批。并发数和超时设置不等于总费用上限。
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录,长时间范围可能分成多批;心跳不再额外生成一份独立报告。并发数和超时设置不等于总费用上限。
### 长回顾可以看进度、保留批次并继续处理
@@ -160,15 +202,15 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
魔法笔记的待办页可以拖动列表与详情之间的分隔条,调整后的宽度会保存。来源笔记标题支持独立折叠,收起一个分组不会清空正在查看的任务详情。分组折叠状态不跨重启保存,窄窗口下不提供横向拖宽。
笔记详情中的记录索引开关移到返回按钮旁,索引和 AI 评论开关都补上了文字标签。任务中心展开列表后,可以直接选择“全部、进行中、暂停、已结束”;默认显示进行中的任务,历史任务仍可通过切换状态查看。
笔记详情的记录索引与 AI 区开关集中在正文顶部的固定工具栏,图标带有悬停提示。任务中心展开列表后,可以直接选择“全部、进行中、暂停、已结束”;默认显示进行中的任务,历史任务仍可通过切换状态查看。
> **【待补图 05|建议|待办布局】** 用虚构笔记创建不同来源的待办,拖动分隔条并折叠一个来源组,保持另一项详情可见。计划文件:`assets/05-note-todos.png`。
>
> 建议图注:调整待办列表宽度,按来源笔记收起暂时不看的任务。
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。这里汇总的是应用已有的用量记录,并非服务商计费账单。
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。“系统任务”页签单列监督回顾、笔记、知识库等后台用量,并补齐回顾、图片重新生成、知识图谱提取和远程嵌入等调用的用量记录;过去未记录的用量无法补算。页面打开时加载,可见期间每 15 秒刷新,也可点击页头手动刷新。这里汇总的是应用记录,并非服务商计费账单。
### 聊天与笔记启动减少重复处理
### 长对话输入、切换与流式回复减少重复处理
任务统计转到后台查询,隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
@@ -176,11 +218,15 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
长回答逐步生成时,已完成的 Markdown 区块可以复用,减少每次新增文字都重新处理整篇内容。切回近期看过的会话时也会复用缓存结果;会话侧栏与运行记录减少无关刷新,降低回复期间继续输入和切换会话的开销。
### 设置页减少来回查找
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
通用设置进一步按用途整理为快捷键、会话与通知、工作区与下载等区域,桌面通知、默认工作目录和模型下载源更容易找到。
设置导航按用途分组,开关统一放在右侧。通用设置集中放置会话与通知、工作目录等选项;本地语音和嵌入模型页面提供“模型下载源”快捷入口,可打开统一弹窗,在 ModelScope 与 Hugging Face 之间切换。选择影响后续托管模型下载,已安装模型、ZIP 导入、Ollama 和应用更新不受影响。
外观设置新增默认关闭的透明磨砂效果。开启后,标题栏和 Runtime 检查清单使用半透明背景,可以看到其后的应用内容;设置会保存,下次启动继续沿用。
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
@@ -204,13 +250,15 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
最后一条失败或取消消息有可恢复的原问题文字时,可以点击“重新编辑”将文字回填输入框,修改后自行发送;不会自动重发,也不承诺恢复附件。
取消或失败的轮次在后续上下文中明确标为中断,并保留已有回答,避免上一条未完成请求被并入新问题继续执行。OpenCode 复用会话在取消后的下一轮也会收到中断说明。
### 远程回答完成后,可以继续对话
远程请求现在等待模型交付确认后再结束。回答已经完成时,随后回收空闲 Runtime 失败不会再把结果改成“未知”。确实遇到结果未知的请求,用户显式发送新消息后会建立新的运行绑定并带入已有历史;自动恢复仍只重新连接原请求,不会擅自重复执行结果未知的任务。
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.0,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.1,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
### 启动、搜索与回顾结果
@@ -224,6 +272,9 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
- 多个会话同时回复时,侧栏列表不再随流式文本或工具进度反复换位。会话按最新消息的创建时间排序,新消息仍会把会话向前移动,置顶会话继续优先显示。
- 输入框的附件、语音与选项按钮统一尺寸、间距和悬停反馈,录音中和选项展开时仍保留对应状态提示。
- 草稿和排队消息中的图片可以直接打开预览,发送前更容易核对附件。文本输入框移除重复问候,只保留快捷键提示。
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
- 笔记保存后退出编辑;从快速笔记跳到指定记录时,等待正文显示后再定位,减少落在错误位置的情况。
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
@@ -234,6 +285,8 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
### 运行配置与界面修复
- OpenCode 原生终端减少重复准备步骤,缩短打开时的等待;临时 MCP 工具名缩短并在同一会话中尽量复用,减少工具名称过长导致的调用失败。
- 本地 OpenCode 初始化工具连接时,遇到明确的暂时性传输故障,清理成功后最多重试一次,不重放问题或工具调用。迟到的旧连接结果不再替换当前连接;自定义 MCP 中一台服务加载工具失败时,其他正常服务仍可使用。
- 修复 Continue 禁用自动更新后仍显示“Checking for updates”的问题,避免空闲终端显示误导性的更新状态。
- 补齐 DS Web、远程 Runtime 和模型桥辅助进程启动时的遥测关闭配置;模型、MCP 和用户主动发起的联网请求保持可用。远程遥测配置与 Continue 更新检查修复由 Agent 0.15.0 配套提供,对更新 Host 后新启动的 Runtime 生效,已有进程不会热更新。
- 修复知识库不同来源的操作相互阻塞,以及多文档来源中任务范围、筛选计数不一致的问题。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,8 +1,46 @@
# 0.13.8 起综合宣传稿核查记录
## 2026-10-02 截至 b02ce31 的增量核查
当前依据为 `86347af..b02ce31`,共 36 个提交;全文范围为 `v0.13.7..b02ce31`。桌面 `v0.15.7` 指向 `b02ce31`,Agent `agent-v0.15.1` 指向 `a662b16`。本轮未在线核验公开安装包。沿用用户确认的标题和综合稿结构,继续更新已授权的草稿。
| 提交 | 入文内容 | 核查依据与边界 |
| --- | --- | --- |
| `6293ef1` | 新增智能心跳建议,替换旧自动报告与手动全量回顾说明 | 心跳服务、建议规则、监督进度文档;手动与计划共用增量机制,全局与项目各自记录进度,无变化不调模型。候选建议另调模型整理措辞,接受未决事项创建暂停任务,候选约定需确认;重新整理不改增量进度 |
| `9c88ca2`、`a662b16` | Runtime 与模型分层选择 | 实际解析器、RuntimeModelPicker 与分层设计;会话、项目、全局分别解析并检查兼容性。排队时重新解析、定时任务冻结选择;缺少 Key 不静默换模型。不采用设计文档中与最终代码不一致的“只继承相同执行方式层”表述 |
| `6edfc00`、`0a02a17`、`bf64181`、`3be5534`、`2d8e58e` | 笔记标签、多标签筛选、双栏及操作修复 | 标签契约与 MagicNotesWorkspace;多标签 AND、每篇最多 10 个、删除标签保留正文,宽屏双栏可调,窄屏切换。快速面板不管理标签;保存退出编辑、等待内容挂载后定位、页头保留新建入口 |
| `a3b41bf`、`c8f327f` | 设备共享目录短节 | 设备共享服务、页面、应用设置;最终默认关闭,需另行运行 ShareServer,仅元数据管理。未认证、无正文上传/读取/下载或远程执行,登记不代表在线,不把声明权限当作已实现能力 |
| `9a91704`、`c8f327f` | 系统任务用量、手动与定时刷新 | ActivityPanel、数据库与用量接线;系统任务单列,补记原有遗漏。最终为可见期间 15 秒刷新,不沿用早期按用量事件即时刷新的描述 |
| `c23dca6`、`c8f327f` | 长对话输入、缓存切换与流式性能 | App、MarkdownRenderer、会话派生值缓存;减少重复解析和无关更新,不宣传固定倍数或全设备收益 |
| `f7f501a`、`e4f7b8d`、`c8f327f` | 可选磨砂、设置分组、下载源快捷入口 | 外观设置、ModelDownloadSourceSettings、语音/嵌入设置及文案;默认关闭,磨砂为应用内内容。下载源入口切换至模型页并打开统一弹窗,影响后续托管下载,既有模型和应用更新不受影响 |
| `608a9a5` | 中断轮次与下一请求区分 | runtime-history、OpenCode 与手动压缩共用历史;保留部分回答及中断标记,避免未完成请求并入下一轮,不扩写为远程取消执行机制修复 |
| `7fb23d9`、`80edd15`、`f7f501a`、`9cc5dc1`、`c8f327f` | 原生终端启动、MCP 工具连接修复 | native terminal、OpenCode 生命周期与工具网关提交 diff;缩短并尽量复用工具名,本地 OpenCode 明确暂时性传输故障清理成功后最多重试一次,隔离迟到连接,故障自定义服务不阻塞正常服务。不宣称远端 Agent 同步获得所有本地恢复修复 |
| `b6d24ad`、`ab85e72`、`d30f794`、`5e77118` | 图片预览、固定工具栏、清单不挤动正文、输入提示精简 | 草稿/队列图片直接预览,工作区操作栏滚动可见,最终文本输入框保留快捷键提示 |
| `3c9229a`、`ed47e2c`、`925d410`、`89fc100`、`cc451f6`、`2c5300d`、`227507d` | 版本依据与发布维护 | 不把说明文件新增当作新功能;数据库按最终代码核对为 52,迁移 49/50/51/52 分别涉及选择层、心跳建议、笔记标签与统计索引 |
| `d1b92de`、`911eed5`、`8201c9c`、`d2ceff8`、`016f987`、`7cb74cc`、`b02ce31` | 文档与测试维护 | 仅留核查记录,不进入宣传正文 |
重点按新的使用方式排序:心跳建议、笔记整理和 Runtime 选择展开介绍;设备共享因范围有限仅作短节。外观、用量和性能归入改进,终止状态与连接故障归入修复。打包、测试与发布维护不作为宣传内容。
保留工作区已有“下一版故事线”预览及素材引用,不将 3D Demo 当作生产能力。本轮未纳入未提交生图等源码变化,也未改动这些文件。已有预览素材的拍摄说明沿用上一节记录,不声称本轮重新拍摄或核验图片。
本轮验证为提交 diff、终点源码与文档的静态核对;没有运行产品测试、构建、模型请求或截图。原生产配图缺口继续保留。新增内容可优先补拍心跳建议、笔记标签与双栏、Runtime 两栏选择器,尚未取得素材,不新增空图片链接。
独立事实复核后修正下载源跳转、MCP 重试范围与工具名复用、旧 Agent 建议、笔记工具栏位置和用量无法补算历史的说明。deai-writing 扫描无阻断项,人工复核保留必要的版本、升级、素材与支持范围限定。未提交、推送或对外发布。
## 2026-10-01 下一版故事线配图
按用户要求在最新未发布稿中增加“下一版故事线”预览,不改变原已实现功能的核查基线。此节为未来版本方向,不能纳入开头“均已开发完成并提交”的范围。
- 图片:`assets/09-next-storyline-wisdom.png`,2804 × 1999 像素。
- 来源:`docs/features/story-graph/3d-demo.html` 的实际 Electron 渲染;选择页面中的 W 卡片,显示归纳经验、依据、适用条件、边界、应用和验证状态。
- 环境:Windows、中文、浅色、页面缩放 80%;实际 CSS 视口 2002 × 1427。独立临时 Electron profile,不读取产品数据库,不调用模型。
- 驱动:临时目录 `opencode/capture-story-next.cjs`;截图没有修改 DOM、拼接或裁切。已打开图片检查,完整内容与操作按钮可见,无私人会话或凭据。
- 真实性:独立网页使用模拟故事与经验,不代表生产图谱已经接入 3D 或自动归纳 W。正文以“下一版”“将”说明时态,图注保留示例数据说明,不使用概念原型作为宣传标题。
- 原图 01、02、04 等生产功能配图缺口仍保留,本图不替代生产结果验收。本轮没有重新核查全部版本变更,未提交或发布。
## 2026-09-28 截至 86347af 的增量核查
本节为最新依据,下方各轮记录保留历史状态。上次内容截止 `66f77b8`,稿件提交为 `0cdf042`;本轮固定检查 `0cdf042..86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`,共 17 个新增提交。全文范围为 `v0.13.7..86347af`,开始检查时工作区干净。沿用用户确认的标题及综合稿结构,打包类维护不进入正文。
本节保留上一轮依据,当前范围以文首最新记录为准。上次内容截止 `66f77b8`,稿件提交为 `0cdf042`;本轮固定检查 `0cdf042..86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`,共 17 个新增提交。全文范围为 `v0.13.7..86347af`,开始检查时工作区干净。沿用用户确认的标题及综合稿结构,打包类维护不进入正文。
桌面 `v0.15.2` 和 `agent-v0.15.0` 同指向 `47ba54f`。已提交双语说明确认远程故事图谱配套 Desktop 0.15.2/Agent 0.15.0,正文据此替换上一轮“Agent 0.15.0 尚未包含”的旧说明;其余本轮产品修复均在该 tag 之后,已完成但发布归属待确认。本轮未在线核验公开安装包或下载链接。
+1 -1
View File
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
| --- | --- | --- | --- | --- | --- |
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
| 2026-09-26(09-28 更新) | v0.15.2(47ba54f)及后续至 86347af;汇总 v0.13.8 起的变化,后续提交发布归属待确认 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补手动压缩、长请求工具授权、取消提示及搜索与笔记操作改进;配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
| 2026-09-26(10-02 更新) | v0.15.7(b02ce31);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补智能心跳、Runtime 分层选择、笔记标签与双栏、设备共享目录及性能改进;保留下一版故事线预览,生产配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |