dsh-obsidian-memo 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +270 -0
  3. package/README.md +272 -0
  4. package/cordis.patch.yml +10 -0
  5. package/docs/ARCHITECTURE.md +88 -0
  6. package/docs/AUDIT-0.16.md +74 -0
  7. package/docs/CHANGELOG.md +120 -0
  8. package/docs/CONTRACT.md +544 -0
  9. package/docs/GUIDE.md +177 -0
  10. package/docs/KNOWN_ISSUES.md +113 -0
  11. package/icon.svg +7 -0
  12. package/lib/ai.js +161 -0
  13. package/lib/capture.js +175 -0
  14. package/lib/client.js +6994 -0
  15. package/lib/config-picker.js +29 -0
  16. package/lib/filename.js +33 -0
  17. package/lib/files.js +88 -0
  18. package/lib/host/ai-jobs.js +118 -0
  19. package/lib/host/configuration.js +151 -0
  20. package/lib/host/dispatch.js +32 -0
  21. package/lib/host/environment.js +24 -0
  22. package/lib/host/folders.js +77 -0
  23. package/lib/host/images.js +116 -0
  24. package/lib/host/mutations.js +123 -0
  25. package/lib/host/note-content.js +44 -0
  26. package/lib/host/notes.js +148 -0
  27. package/lib/host/rpc.js +82 -0
  28. package/lib/host/scanning.js +67 -0
  29. package/lib/host/session.js +67 -0
  30. package/lib/host/state.js +68 -0
  31. package/lib/host/vault.js +38 -0
  32. package/lib/host/writes.js +21 -0
  33. package/lib/index.js +88 -0
  34. package/lib/notes.js +454 -0
  35. package/lib/obsidian.js +93 -0
  36. package/lib/scan.js +269 -0
  37. package/lib/settings.js +228 -0
  38. package/lib/shared.js +309 -0
  39. package/lib/trash.js +205 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +76 -0
  43. package/scripts/benchmark.mjs +58 -0
  44. package/scripts/build-client.mjs +39 -0
  45. package/src/client/ai/use-comments.js +75 -0
  46. package/src/client/ai/use-job-notifications.js +53 -0
  47. package/src/client/ai/use-project-dialog.js +58 -0
  48. package/src/client/capture/composer.js +266 -0
  49. package/src/client/capture/images.js +64 -0
  50. package/src/client/capture/keyboard.js +34 -0
  51. package/src/client/components/dialogs.js +67 -0
  52. package/src/client/components/feedback.js +50 -0
  53. package/src/client/components/icons.js +160 -0
  54. package/src/client/components/use-toasts.js +58 -0
  55. package/src/client/constants.js +127 -0
  56. package/src/client/data/state.js +116 -0
  57. package/src/client/data/use-notes-data.js +137 -0
  58. package/src/client/data/visibility.js +14 -0
  59. package/src/client/drag/drop-actions.js +47 -0
  60. package/src/client/drag/motion.js +84 -0
  61. package/src/client/drag/overlay.js +33 -0
  62. package/src/client/drag/use-card-drag.js +149 -0
  63. package/src/client/i18n/en.js +219 -0
  64. package/src/client/i18n/index.js +11 -0
  65. package/src/client/i18n/zh.js +219 -0
  66. package/src/client/images/cache.js +55 -0
  67. package/src/client/images/components.js +121 -0
  68. package/src/client/images/queue.js +106 -0
  69. package/src/client/index.js +40 -0
  70. package/src/client/panel/header.js +146 -0
  71. package/src/client/panel/use-shortcuts.js +79 -0
  72. package/src/client/panel.js +424 -0
  73. package/src/client/preferences.js +198 -0
  74. package/src/client/react.js +8 -0
  75. package/src/client/reader/blocks.js +51 -0
  76. package/src/client/reader/note-drawer.js +270 -0
  77. package/src/client/reader/use-dock-offset.js +23 -0
  78. package/src/client/reader/use-drawer-layout.js +96 -0
  79. package/src/client/reader/use-note-editor.js +230 -0
  80. package/src/client/reader/use-pinned-note.js +41 -0
  81. package/src/client/runtime.js +30 -0
  82. package/src/client/settings/ai-budget-row.js +65 -0
  83. package/src/client/settings/ai-budget.js +42 -0
  84. package/src/client/settings/callouts.js +21 -0
  85. package/src/client/settings/config-file.js +74 -0
  86. package/src/client/settings/image-processing.js +31 -0
  87. package/src/client/settings/official-page.js +32 -0
  88. package/src/client/settings/paths.js +131 -0
  89. package/src/client/settings/settings-drawer.js +244 -0
  90. package/src/client/settings/style-select.js +58 -0
  91. package/src/client/settings/trash.js +30 -0
  92. package/src/client/settings/use-settings-draft.js +207 -0
  93. package/src/client/styles/base.js +63 -0
  94. package/src/client/styles/capture.js +94 -0
  95. package/src/client/styles/index.js +26 -0
  96. package/src/client/styles/overrides.js +116 -0
  97. package/src/client/styles/reader.js +66 -0
  98. package/src/client/styles/settings.js +118 -0
  99. package/src/client/styles/tokens.js +29 -0
  100. package/src/client/styles/wall.js +83 -0
  101. package/src/client/testing.js +139 -0
  102. package/src/client/utils.js +66 -0
  103. package/src/client/wall/model.js +158 -0
  104. package/src/client/wall/note-card.js +201 -0
  105. package/src/client/wall/use-note-actions.js +107 -0
  106. package/src/client/wall/use-subfolders.js +42 -0
  107. package/src/client/wall/use-wall-content.js +135 -0
  108. package/src/client/wall/use-wall-filters.js +92 -0
  109. package/src/client/wall/use-wall-paging.js +55 -0
package/docs/GUIDE.md ADDED
@@ -0,0 +1,177 @@
1
+ # 使用与开发指南
2
+
3
+ [返回 README](../README.md) · [版本记录](CHANGELOG.md)
4
+
5
+ 本页保留完整的操作说明、文件行为与开发细节。想先了解产品,请看 README。
6
+
7
+ 把 Obsidian 的「进行时」文件夹,变成 DSH 左侧栏入口的一面淡色便签墙(dsh-obsidian-memo):快速新建、阅读/编辑、卡片内图片预览、四种性格的 AI 评论、归档、开档、一键在 Obsidian 打开,全在一个面板里完成。读写 md 文件与新建图片附件,Obsidian 仍是唯一的数据主人。
8
+
9
+ ## 它长什么样
10
+
11
+ ```
12
+ ┌─ DSH 侧栏 ─┐┌─────── Obsidian MEMO v0.16.3 ──────────┐
13
+ │ 会话 ││ 120 张便签 [日期][子文件夹][排序][刷新][⚙] │
14
+ │ 插件 ││ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
15
+ │ 📌 便签 ││ │WORKING_NOW│ │ 无 tag │ │🚧长期 │ │
16
+ │ 自动化任务 ││ │宋体加粗标题│ │标题 │ │标题 │ │
17
+ │ ││ │浅一档正文 │ │正文 │ │正文 │ │
18
+ │ ││ │ 评论… │ │ │ │评论… │ │
19
+ │ ││ │[图][图]+3│ │ │ │[图][图] │ │
20
+ │ ││ │😈✨🧭😇 | 复制 ↗ │ └──────────┘ │ │
21
+ └────────────┘└─────────────────────────────────────────────────┘
22
+ ```
23
+
24
+ - **淡色便签**:按 tag(相对便签目录的第一层子文件夹)稳定分配颜色,无 tag 用纸白;0.13 把日间纸色整体往白里调(`#FDFAF4` 系)。开启暗色主题时自动换成同色相的深色纸与提亮墨色。
25
+ - **标题 = md 文件名**(去掉 `.md`):卡片和阅读视图都用文件名;第一段属于正文。标题用宋体加粗(Songti SC 栈)。
26
+ - **更轻的正文**:正文与卡片摘要统一 80% 不透明度。
27
+ - **版本与署名**:面板标题旁只显示 Host 从 package.json 下发的完整版本号;两处设置共用的「管理与关于」以单行浅色小字显示 GitHub 链接与 by cavenzhong。
28
+ - **卡片图片预览**:正文里的图片(`![](…)` / `![[附件]]`)不需要进阅读模式,直接在卡片**左下角、贴住卡片底部**以 64×64 正方形缩略图预览;最多 3 张,超出的显示淡色 `+N`。缩略图懒加载(并发 ≤3)、失败静默占位、按内存预算缓存,卡片高度仍是 208px(有图时只压缩摘要/评论的行数,标题不压缩)。**只要卡片操作菜单出现(悬停/聚焦整张卡片),图片预览就统一隐藏**,移出后恢复——不再区分鼠标是否停在 emoji 上。
29
+ - **阅读**:单击卡片从右侧推出约 1/3 宽的面板看全文(正文块 + PROJECTS),面板宽度可拖拽调整;评论固定贴在面板下方的独立区域,正文怎么滚都不影响。抽屉标题分割线下方有**操作条**:左边一排 AI emoji 评论(悬停显文字标题),右边第一行依次为归档 / 到项目 / 删除 / 复制全文四个线图标,悬停或聚焦时显示上方文字提示;第二行是紫色的「在 Obsidian 打开」图文按钮——便签卡片在阅读时不显示操作菜单,操作都收在这里(锁定/未锁定都一样)。
30
+ - **编辑**:在抽屉里双击标题或正文进入编辑态;标题用多行输入框完整换行显示(不用横向滚到文件名尾部),正文编辑原始 Markdown;点一次「保存」同时保存标题与正文,重名时拒绝覆盖。
31
+ - **锁定面板**:抽屉右上角有锁定按钮(0.13.9 起图标从锁改成**图钉**)。锁定后便签变成真正的右侧面板,与便签墙左右并排、不再互相遮挡;**锁定态不显示关闭按钮**(只留锁定/解锁,Esc 可关),解锁后关闭按钮恢复。锁定状态记住。**锁定面板再进入时不隐藏**:切换面板/重进会恢复上次打开的那篇便签;若它不在当前日期筛选里(或已被归档/改名),自动打开当前筛选排序后的第一篇。
32
+ - **阅读勾选**:阅读模式下可以直接勾选或取消任务项,写回原 Markdown 的 `[ ]` / `[x]`,保留原换行和 frontmatter。若内容已被外部修改,会提示刷新后再操作。
33
+ - **图片与分界线**:阅读模式显示 Markdown 分界线、`![说明](https://…)`、相对路径图片和 `![[附件]]`。本地图片先从便签相对路径、仓库相对路径查找,Obsidian 裸附件名还会在仓库内查找;重名附件需写完整路径。
34
+ - **子文件夹开关**:顶栏的 folder-tree 图标控制是否显示子文件夹里的便签(默认显示);关闭后墙只保留便签目录顶层的 md。状态写进设置,重启后保持。所有顶栏 icon 的选中态统一为**浅紫底 + 紫色图标**(日期筛选/子文件夹/锁定/排序/设置)。
35
+ - **排序**:默认按文件创建时间(新→旧,菜单第一项);右上角排序按钮可切换创建时间正序、最近修改、文件名。
36
+ - **日期筛选开关(0.14)**:顶栏操作区第一个日历图标,点亮按当前范围筛选,熄灭显示全部;与子文件夹图标等宽,不再显示小三角。鼠标悬停显示今天/3 天/本周/本月菜单,移出关闭;键盘可用 ↓ 打开菜单,Esc 关闭。菜单保留「日期截止按真实时间」选项,范围与开关状态继续记住。
37
+ - **底部快速记录(0.14)**:透明磨砂胶囊浮在便签墙下方,背后以白色到透明的渐变隔开卡片;深色主题自动换成深色渐变。输入想法作为当前便签目录中新 `.md` 的文件名,按 Enter 创建;标题不会重复写入正文。面板空白处(无焦点)按 Tab 先聚焦标题,按钮/卡片/输入框上保持原生 Tab 顺序;标题内再按 Tab 展开带分割线的正文区,文字更小、更淡,并自动聚焦;正文按 Shift+Tab 返回标题修改,保留正文草稿,再按 Tab 可回到正文;正文可正常换行,⌘/Ctrl + Enter 创建。未展开时仅显示淡色 `Tab 补充正文` 提示;正文删空后自动收起为标题态(空正文 Backspace 或失焦也会收起,Esc 退出输入焦点)。正文区右下角显示淡色 `⌘ Enter 提交` 提示,焦点描边更清晰。重名、文件名过长或提交失败时保留草稿与图片,成功才清空。新建时沿用 Obsidian Default Template 为当前文件夹配置的模板 meta(`{{date}}`/`{{time}}`/`{{title}}` 变量按插件语义替换;模板配置损坏或不可读时按无模板新建),带正文或图片也会保留。阅读视图保留有序列表数字与无序列表圆点。
38
+ - **拖拽与删除(0.15)**:拖动卡片时输入岛切换为「归档 / 到项目 / 删除」三列操作栏,输入岛暂时隐藏。拖到归档后可选已配置目录,拖到项目会打开项目名对话框,删除在放手时执行;原位置保留空虚线框,拖动卡片的宽高均缩为原来的 1/3,实色动作提示固定在预览上方且层级更高,底部渐变随目标变色。卡片菜单在 AI 评论下方仅保留「复制 / 在 Obsidian 打开」,归档与到项目从拖拽栏或阅读面板进入;聚焦卡片或阅读面板时按 ⌘/Ctrl + Backspace 可删除。md 与独占图片成组进入库内 `.trash/MEMO-…` 文件夹,5 秒内撤回;共享图片保留,冲突时拒绝覆盖。到期后文件保留,可按组内 `restore.json` 的原路径手动移回,不自动清空;已有回收文件不会被清理。macOS Finder 按 `⌘ + Shift + .` 可显示隐藏文件夹。
39
+ - **归档目录待选择(0.15)**:拖拽缩略卡片带柔和阴影,动作提示在其上方居中。目录菜单固定在底部归档区上方,并带淡阴影。在归档主区放手后,预览停在归档图标与文字的位置,图标文字暂时隐藏;点击目录完成归档。点击外部、焦点移出、窗口失焦或 Esc 回到原位,Enter 回落后聚焦输入岛。
40
+ - **输入岛(0.15)**:Enter 统一聚焦输入岛,输入岛内输入和右侧正文编辑保留原行为,输入法组合态不拦截;卡片悬停或聚焦时也不会再打开详情,Esc 退出焦点。标题自动折行显示全文。向下超过一屏后上方出现圆形 ↑ 返回顶部。浮动或钉住面板都不遮挡输入岛与拖拽栏。
41
+ - **新建图片(0.14)**:在输入栏粘贴或拖入图片,或点左侧加号多选 PNG/JPEG/GIF/WebP;左侧正方形预览默认叠放,悬停或键盘聚焦后展开,停留 600ms 在上方显示最大约 260×220px 的完整图浮窗,可逐张移除。最多 8 张,保存后的单张上限 8 MB、合计 16 MB。在设置的「图片处理 → 图片文件夹」可手填或选择仓库内目录,相对路径按仓库解析,不存在时自动创建;不能选仓库内以 `.` 开头的隐藏目录(Obsidian 不索引);留空仍保存在便签目录的 `_attachments/`。目录调整仅影响之后保存的图片。Markdown 使用仓库相对图片 wikilink,便签归档后仍能在 Obsidian 找到图片。
42
+ - **图片处理(0.14)**:设置里独立的「图片处理」专栏,默认勾选自动压缩为静态 WebP,质量 **70**、最大边长 **2048px**。可调整质量 1–100、最大边长 256–8192px;按比例缩小、不放大小图。取消勾选则保留原始字节、格式及动图。参数在创建时生效,修改设置后待提交的图片也按新参数处理。压缩前单张最多 32 MB、合计 64 MB;压缩完成后按上述保存上限检查。压缩失败保留草稿并提示原因。
43
+
44
+ - **AI 评论(0.13)**:卡片 hover 后,**所有 emoji 预设平铺在操作区第一行**(不折叠);鼠标悬停某一枚时,在它上方浮出文字标题告诉你这个 emoji 是什么(如「毒舌」)。点击后后台用默认模型生成,以 Obsidian callout 追加到 md 末尾,卡片上同步显示。若这张便签已有同款评论,会先问「要再生成一条吗?」。分割线下方是「复制 / 在 Obsidian 打开」。
45
+ - **四个 AI 人格预设**:
46
+ - 😈 **毒舌**:尖锐黑色幽默,要妙、像金句,但不为刻薄而刻薄;专业问题玩笑后会提醒找专业人士;写作类则切换成大哥口吻给一句真正有用的话。
47
+ - ✨ **另一面**:从一个你完全没想过的角度切入,再回到人本身,说中情绪但不空洞共情。
48
+ - 🧭 **落地**:务实判断,惜字如金——一句话结论 → 2–3 条证据链(标明推测)→ 最省力的下一步与验证信号。
49
+ - 😇 **真心话**:贴心伙伴,接住状态、指出你做对的一个小地方,给一句不带压力的鼓励和一个轻起手。
50
+ - 旧版本的 4 个默认预设(洞察/延伸/风险/下一步)如果原样没改过,会自动迁移成这 4 个;你改过的预设不动。
51
+ - **归档**:选一个归档目录,文件移动过去;顶部小浮窗 6 秒内可「撤销」,不挡其他操作。
52
+ - **开档**:输入项目名,在开档目录下建 `<项目名>/<项目名>.md`,用可编辑的建档提示词生成结构化文档(诉求 / 现状与已知 / 调查与线索 / 下一步 / 待确认),并把链接写回源便签的 `PROJECTS`。
53
+ - **在 Obsidian 打开(0.13.8 / 0.13.9 微调)**:卡片操作行「复制」的**右侧**(同一行、右对齐)是「在 Obsidian 打开」:一枚转发 icon + **紫色高亮**加粗文字;右侧面板的操作条右侧第二行也保留同款按钮,第一行固定为归档 / 到项目 / 删除 / 复制全文四个线图标。点击后由 **Host** 调系统打开器打开 `obsidian://open?path=<便签绝对路径>`(macOS `open` / Windows `start` / Linux `xdg-open`);DSH 外壳只放行 http(s) 外链,所以自定义协议必须走 Host。只允许打开仓库内已存在的文件;没装 Obsidian / 没有打开器时用 toast 报原因,不会碰任何文件。
54
+ - **0.13.9 小细节**:卡片没有正文时**不再显示「(暂无正文)」占位**;Obsidian 行尾 / 段中的 block id(`^qa-daily-preview-1e5t8lm` 这种)在正文与摘要里隐藏,且**只去掉锚点本身、不影响锚点后面的文字**;顶栏「Obsidian MEMO」标题区域(含下方的目录小字)在 **DSH 桌面版**里可以直接按住拖动整个窗口(Web GUI 里浏览器会忽略该行为),右侧按钮区仍然正常点击。
55
+ - **动态加载(0.13.8)**:便签墙**首屏只渲染 50 张**,滚到接近底部(提前约 320px)自动补下一批 50 张;补批时墙底部出现**简约三点加载动画 + 「加载中…」淡字**。排序/日期筛选变化会回到首屏 50 张。数据仍是 Host 一次 `state` 下发(几十 KB),这里分批的是**渲染 / DOM**,不是网络请求——对几十张的库感知很小,几百张以上时明显降低首屏渲染与滚动压力(详见「性能与后台开销」)。
56
+
57
+ ## 安装
58
+
59
+ ```bash
60
+ # 在 DSH 里用工具安装(等价于 Plugins 页的“安装”):
61
+ # plugin_manager action=install_bundle target=link:/绝对路径/dsh-obsidian-memo
62
+ ```
63
+
64
+ 安装后左侧栏末尾出现便签图标。首次点击面板会提示去设置里选目录。
65
+
66
+ ### 可选:改代码即时生效(开发用)
67
+
68
+ 在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `hmr` 条目里加上本插件目录:
69
+
70
+ ```yaml
71
+ - id: hmr
72
+ name: "@deepseek-ai/dsh-hmr"
73
+ config:
74
+ root:
75
+ - "."
76
+ - /absolute/path/to/dsh-obsidian-memo
77
+ ```
78
+
79
+ 之后改 `lib/` 下的 Host 模块会自动重载。浏览器开发先运行 `pnpm dev`,修改 `src/client/` 后会自动更新 `lib/client.js`,再由 Harness HMR 重载。
80
+
81
+ ## 首次配置
82
+
83
+ 面板右上角齿轮,或官方「设置 → 插件 → Obsidian MEMO」;两处编辑同一份配置。设置分为文件夹、AI、图片、管理与关于四个标签。
84
+
85
+ | 字段 | 说明 |
86
+ |---|---|
87
+ | 仓库根目录 | Obsidian 仓库绝对路径(如 `…/Documents/2024仓库`) |
88
+ | 便签目录 | 想展示的主目录(如 `⛳️进行时&未整理`),必须是仓库子目录 |
89
+ | 开档目录 | 新的项目文件夹建在这里(如 `✅DONE_PROJECT已完成/projects`) |
90
+ | 归档目录 | 可加多个:名称 + 路径;归档按钮会弹这些目标 |
91
+ | 图片处理 / 图片文件夹 | 自动压缩为 WebP(默认勾选),质量 1–100(默认 70),最大边长 256–8192px(默认 2048);取消勾选保留原图。图片文件夹保存新建图片,留空 = 便签目录的 `_attachments/`,必须是仓库内目录且不能是以 `.` 开头的隐藏目录 |
92
+ | Emoji 与提示词 | 横向 Emoji 标签切换角色,末尾固定加号;所选角色编辑字形、名称、callout、提示词与预算。项目提示词折叠在下方。 |
93
+ | AI 预算 | 每枚 emoji 行内一个下拉:**低 / 中 / 高 / 自定义**(低 = 不思考·2048、中 = 低思考·4096、高 = 高思考·8192 tokens,默认高)。选「自定义」时再露出「思考档位 + 输出上限」两个字段;建档提示词下方同款控件,控制开档。 |
94
+ | 建档提示词 | 「开档」时生成结构化文档的提示词 |
95
+
96
+ 配置默认在用户目录 ~/.config/dsh-obsidian-memo/config.json(Windows 使用 %APPDATA%,尊重 XDG_CONFIG_HOME)。首次读取旧 data/settings.json 并迁移,原文件保留。DSH_STICKY_NOTES_SETTINGS 可固定位置。设置中的「管理与关于」可打开 JSON、显示所在文件夹、重载、直接选择并加载已有 JSON,或另存到自定义位置并记住选择。另存使用已保存配置,不覆盖已有目标;加载/重载会替换当前草稿。文件夹在 JSON 中保存为仓库相对路径,外部修改会自动加载,配置损坏保留文件及上次有效值;并发旧草稿保存会被拒绝。
97
+
98
+ 先选仓库,之后便签、项目、图片和归档共用一个文件夹下拉组件:顶部加号在仓库中新建文件夹,其下为系统选择、搜索和最近使用记录。选择或新建的文件夹必须位于仓库内;隐藏目录和越界符号链接不可选。
99
+
100
+ 日常删除继续使用仓库 .trash 和 5 秒撤回。设置内单独提供「在 Finder / 文件管理器中显示」与「移至系统垃圾桶」:确认后将整个 .trash 交给系统回收 API,包含 Obsidian 放入的内容,之后从系统垃圾桶恢复;不会清空系统垃圾桶。撤回窗口内拒绝清理,失败保留回收文件并提示原因。
101
+
102
+ ## 它怎么碰你的文件
103
+
104
+ - **只写配置目录内的 Markdown 和新建图片附件**:便签目录、归档目录、开档目录、图片文件夹的用户路径与真实位置都必须落在仓库内;图片文件夹不能是隐藏目录;目录符号链接也要校验,新建路径检查最近的已有父目录。
105
+ - **frontmatter 原样保留**:渲染永远丢弃 meta;只有「开档写回 PROJECTS」这一处会改 frontmatter,且只改 `PROJECTS` 一个键,已有非空值时不覆盖。
106
+ - **原子写**:已有文件先写临时文件再 `rename`;新建便签先保存附件,再以独占硬链接发布完整 Markdown,拒绝覆盖同名文件。发布失败会清理本次附件与临时文件。
107
+ - **保存与评论**:保存只替换 frontmatter 之后的正文;评论追加在文末,都是纯文本拼接,不重排 frontmatter、不规范化格式。
108
+ - **与 Obsidian 并发**:编辑保存带有打开时的内容版本,若期间被 Obsidian、AI 评论或其他编辑改变,会拒绝保存并保留草稿。没有跨进程文件锁;版本检查和写盘之间的极短外部写入竞争仍可能发生。
109
+ - **不渲染 meta**:卡片标题取 md 文件名,文件内容作为正文,callout 作为评论;行尾 block id(`^qa-daily-preview-…`)不显示,且不影响锚点后面的文字与行内代码里的 `^`。
110
+
111
+ ## AI 评论与开档
112
+
113
+ - 用的是 DSH 当前默认模型(`agentDefaultModel`),通过 Host 的 `ctx.llm` 调用:**不占用任何会话、不写对话日志**。
114
+ - 生成是异步 job:点击后立即返回,面板显示「正在写评论…」,完成后写入 md 并自动刷新;失败会在 toast 里给出原因。
115
+ - **AI 预算(0.13)**:DeepSeek 的 `max_tokens` 是总输出预算,思考 token 也算。默认「高」档(`high` / 8192)就是给思考留足余量;如果模型把预算全用在思考上、正文没写出来,会弹一条带「去设置 / 知道了」的提示,点「去设置」直接打开设置抽屉调档。同理,撞上上限但写出了半截内容时也会提示(内容照常写入)。
116
+ - 评论 callout 形如:
117
+
118
+ ```markdown
119
+ > [!quote] 😈 毒舌 · 2026-11-04 10:30
120
+ > 这条想法真正的钩子在……
121
+ ```
122
+
123
+ - 开档生成的项目文档带 frontmatter:
124
+
125
+ ```markdown
126
+ ---
127
+ PROJECTS: "[[⛳️进行时&未整理/xxx|xxx]]"
128
+ TODO: []
129
+ ---
130
+ ```
131
+
132
+ ## 技术架构
133
+
134
+ - **Host 半**:`lib/index.js` 组合 `lib/host/` 内的配置、扫描、写入、图片、AI 任务与 RPC 服务;`files.js` 统一真实路径边界、内容版本与原子写入,`capture.js` 负责新建便签与附件;`notes.js` / `scan.js` / `settings.js` / `ai.js` / `obsidian.js` 分别负责解析、扫描缓存、设置、模型和系统打开。
135
+ - **通道**:Host 在 web server 上注册包内已认证 RPC 前缀路由 `/sticky-notes`,复用 client-connection 的 RPC 信封协议(Client 仍用 `ctx.connection.rpc.call('/sticky-notes', endpoint, payload)`),认证复用 `ctx.connection.requestRejection`。Web GUI 与桌面 App 外壳都走同一条已认证通道,不新开端口、不需要额外的 CSRF 防护。
136
+ - **目录选择**:统一使用系统选择器 `pickDirectory`,也可手动输入路径;已移除没有界面调用的目录浏览 RPC。
137
+ - **Client 半**:源码位于 `src/client/`,按功能组织组件、hooks、样式与文案;`pnpm build` 生成单文件 `lib/client.js` 供安装使用。`useNotesData` 管请求与轮询、`useNoteEditor` 管抽屉草稿与保存、`useSettingsDraft` 管设置草稿;主面板组合拖拽、AI、筛选、分页与布局模块。所有 state 请求统一按代次接收,迟到响应不能覆盖新状态,未变化卡片复用对象。
138
+ - **不注册模型工具、不注入提示词**:除用户主动点评论/开档外,不产生任何模型调用与会话成本。
139
+ - **已知问题与暂缓项**:见 [已知问题](KNOWN_ISSUES.md)(v0.16.3 基准,含触发条件与建议修法)。
140
+
141
+ ## 性能与后台开销
142
+
143
+ - Host 端 `state` 轮询在真实库(97 篇)实测热响应 3.5–6.5ms、2.4KB;冷启动约 67KB。扫描按 mtime 缓存,内容没变不重读。
144
+ - **空闲轮询会自适应退避**:4s → 8s → 12s → … 最长 30s;有变化或你主动刷新/评论/保存时立即回到 4s。面板不可见(切到别的面板/窗口隐藏)时暂停请求,约 15s 才探测一次。
145
+ - 便签卡片做了 memo:轮询拿到「没有变化」时面板不会整墙重渲染;有变化时只重渲染变化的卡片。
146
+ - **便签墙分批渲染(0.13.8)**:首屏 50 张,触底补 50 张。Host 下发的 notes 只有几 KB~几十 KB(97 篇热响应 2.4KB),所以这一层不减少网络,只减少首屏 DOM/布局与 React 渲染量;没有引入虚拟列表(不卸载已渲染卡片),复杂度最低。120 张左右收益有限,300+ 张时更明显。
147
+ - 卡片缩略图:进入视口才加载、同时最多 3 个请求、按约 24MB 内存预算做 LRU,避免一次涌入大量 base64 卡住主线程。
148
+ - Host 端对同一设置指纹的并发扫描做了单飞(single-flight),附件名查找有负缓存,避免重复全库遍历。
149
+ - 如果仍然遇到 DSH 整体卡死,请先看:① 是否正在大量新增/同步笔记(iCloud 文件下载会拖慢一切);② 卡死时 CPU 大户是 DSH 的哪个进程;③ 把插件禁用后是否还复现——这能区分「插件开销」与「DSH 自身/HMR」问题。
150
+
151
+ ## 已知限制
152
+
153
+ - 不做全文搜索、拖拽排序、批量选择。
154
+ - **「在 Obsidian 打开」依赖本机 Obsidian**(macOS `open` / Windows `start` / Linux `xdg-open` 调 `obsidian://open?path=…`):未安装或未注册协议时会 toast 失败;只对仓库内已存在的文件生效;这个动作不会写入任何内容,打开后由 Obsidian 自己读取文件。
155
+ - 编辑态保存是显式按钮(或 Cmd/Ctrl+S),退出编辑态会丢弃未保存修改。
156
+ - 归档撤销窗口 6 秒(token 服务端保留 10 分钟);撤销前若原位置已有同名文件则拒绝恢复。
157
+ - 面板以轮询感知外部修改,不是实时监听;空闲时按 4–30 秒间隔检查外部变化,也可以手动刷新。
158
+ - 手动改 `package.json` 或依赖不会被 HMR 检测;改 Host 模块或前端生成文件需要配置 hmr root 才会即时生效,前端源码配合 `pnpm dev`。
159
+ - 本地图片限仓库内的 PNG/JPEG/GIF/WebP/SVG/AVIF,单张不超过 8 MB;卡片缩略图只加载 ≤1.5 MB 的图片(超出的卡片上显示淡占位,进阅读模式仍可看)。卡片预览只覆盖正文图片,不覆盖 callout/列表内的图片。
160
+ - 不读取仓库外的符号链接目标;网络图片由浏览器直接加载,加载失败时显示占位提示。支持正文中的行内和独立图片语法,不支持引用式图片及 callout/列表内图片。
161
+ - 锁定面板状态存在浏览器 localStorage,换浏览器/清缓存后重置。
162
+ - 顶栏标题区拖动整个 DSH 窗口只在**桌面版 App**(Electron 无边框窗口)生效;浏览器 Web GUI 会忽略 `-webkit-app-region`。
163
+
164
+ ## 开发与测试
165
+
166
+ ```bash
167
+ cd dsh-obsidian-memo
168
+ pnpm install --frozen-lockfile
169
+ pnpm build # 修改前端后更新发布文件
170
+ pnpm test # 产物一致性、架构边界与回归测试
171
+ pnpm dev # 可选:持续构建前端
172
+ pnpm bench # 1000 篇合成便签扫描/内存与 5000 条文件名排序基准
173
+ ```
174
+
175
+ 代码入口、职责与测试映射见 [架构导航](ARCHITECTURE.md);接口与 UX 规格见 [实现契约](CONTRACT.md)。运行时升版本只改 `package.json`;[版本记录](CHANGELOG.md)保留对应发布时的信息。
176
+
177
+ 0.16 发版前的修复依据、前后性能数据与剩余限制见 [稳定性排查报告](AUDIT-0.16.md)。基准仅使用自动清理的临时目录,不访问真实 Obsidian 仓库。
@@ -0,0 +1,113 @@
1
+ # 已知问题与暂缓项(0.16.3)
2
+
3
+ > 用途:记录 0.14 审查中确认但**本轮未修**的问题,方便之后快速查阅、按文件定位并批量修复。
4
+ > 状态基准:`v0.16.3`。行号会随代码改动漂移,检索时优先搜符号名/关键词。
5
+ > 原则:每条包含「位置 / 现象与影响 / 建议修法 / 优先级 / 触发条件」,优先级 P1 > P2 > P3。
6
+ > 0.16 发版前排查、性能对比和修复范围见 [排查报告](AUDIT-0.16.md)。
7
+
8
+ ## 已明确暂缓(产品/环境决定,不是遗漏)
9
+
10
+ ### KI-01 overlay 抽屉遮挡浮岛(0.15.0 已修复)
11
+
12
+ 输入岛与拖拽操作栏根据阅读面板的实际宽度调整右侧留白,兼容进场动画、浮动/钉住与宽度拖拽。
13
+
14
+ ### KI-02 触摸/无 hover 设备无法打开日期范围菜单
15
+ - 位置:`src/client/panel/header.js` 日期控件与 `src/client/wall/use-wall-filters.js` `onMouseEnter: openDateMenu`、`toggleDateEnabled` 里 `setDateMenu(false)`。
16
+ - 现象:tap 会先合成 mouseenter 打开菜单,随后 click 切换筛选又把菜单关掉;iOS/Android 长按 contextmenu 对 button 不可靠,软键盘没有 ↓。档位只能停留在持久值/默认 today。
17
+ - 建议:`(hover: none)`/pointerType 分流:无 hover 时点击打开菜单,把筛选开关做成菜单内 menuitemcheckbox;并去掉 `toggleDateEnabled` 的无条件关闭。
18
+ - 优先级:P2(用户已决定「移动端之后再考虑」)。
19
+ - 触发:触摸设备或没有 hover 的输入方式。
20
+
21
+ ### KI-03 日期菜单 hover 桥接区不足
22
+ - 位置:`src/client/styles/base.js` `.SN_dateGroup::after` 只覆盖按钮下方 30px × 8px;菜单宽 214px 右对齐。
23
+ - 现象:从按钮斜向左下移向菜单左半区时,会经过 group 外、菜单上方的 6px 缝隙触发 mouseleave 关闭;CONTRACT 里「菜单与按钮之间有可移入区域」并不完全成立。
24
+ - 建议:把桥挂到菜单上(`.SN_sortMenu::before{top:-8px;left:0;right:0;height:8px}`),或 mouseleave 延迟 120ms 关闭。
25
+ - 优先级:P3(用户已决定「先不改」)。
26
+
27
+ ### KI-04 拖放落在胶囊外可能触发浏览器默认导航
28
+ - 位置:`src/client/capture/composer.js` 仅 `<form class="SN_capture">` 上处理 dragenter/dragover/drop;dock 的 22px 内边距区与墙面没有兜底。
29
+ - 现象:Web GUI 在浏览器里把图片丢到胶囊外,可能按浏览器默认行为打开文件、丢失 SPA 状态(Electron 外壳实测「似乎没事」)。
30
+ - 建议:在 `.SN_wallWrap` 或 document 上对 `dataTransfer.types` 含 `Files` 的 dragover/drop 做 preventDefault。
31
+ - 优先级:P3(用户已实测,暂不改)。
32
+
33
+ ## 待处理
34
+
35
+ ### KI-05 Default Template 正文语义仍与 Obsidian 不完全一致
36
+ - 位置:`lib/capture.js` `captureFrontmatter`/`createCapturedNote`;CONTRACT §11.1。
37
+ - 现状:模板有 frontmatter → 新建只写 meta;模板无 frontmatter + 空正文 → 可能写 0 字节文件,Obsidian 开着才会补整份模板(依赖 Obsidian 是否运行)。
38
+ - 建议(方案 A):空正文且无图时 Host 渲染整份模板(frontmatter + body + 变量);有正文/图片时只写 meta。需要改 CONTRACT 的「不插入模板占位正文」表述并补三种场景测试。
39
+ - 优先级:P1(取决于是否使用带正文的 Default Template)。
40
+ - 触发:使用带正文/占位内容的 Default Template,尤其模板没有 frontmatter 时。
41
+
42
+ ### KI-06 合法正文的传输预算与 URL 解析(0.16 已修)
43
+ - 预算与分发共用归一化 pathname;正文写入预算包含 JSON 最坏六倍转义,新建再叠加图片 base64 预算,补充接近 2 MiB 正文与带点段路径的集成测试。
44
+ - 超出传输预算或连接断流仍返回 HTTP 400;错误信封进一步统一可单独处理,不影响合法大小正文的提交。
45
+
46
+ ### KI-07 硬链接兼容与并发覆盖已修,崩溃后的孤儿附件仍待处理
47
+ - 0.16:新建、改名、归档、撤回共用 `moveFileExclusive`;链接不受支持时回退独占复制,目标存在不覆盖。大小写改名保留同文件特例,补充原语竞争与并发改名测试。网络盘/移动盘仍需真实文件系统兼容验证。
48
+ - 剩余位置:`lib/capture.js` 附件先于 Markdown 发布;崩溃/SIGKILL 可能留下无引用图片,普通异常仍执行清理。
49
+ - 建议:为创建流程增加 journal,启动时识别未发布事务,处理孤儿附件。
50
+ - 优先级:P2,触发条件为写入中途进程崩溃或被强制结束。
51
+
52
+ ### KI-08 Obsidian 自定义配置目录未支持
53
+ - 位置:`lib/capture.js` 硬编码 `.obsidian`;`community-plugins.json` 的启用判断已在本轮改为保守(缺失/损坏/非数组 = 未启用)。
54
+ - 现象:使用 Obsidian「Override config folder」时读不到模板配置,静默不套模板。
55
+ - 建议:优先读 Obsidian 全局配置里的 vault `configDir`,取不到回退 `.obsidian`;或加一个「Obsidian 配置目录」设置项(默认 `.obsidian`,必须在仓库内)。
56
+ - 优先级:P3。
57
+
58
+ ### KI-09 卡片缩略图 1.5MB 上限 vs 保存上限 8MB
59
+ - 位置:`lib/shared.js` `LIMITS.cardImageMaxBytes`;`src/client/images/components.js` 卡片图片 `maxBytes: CARD_IMAGE_MAX_BYTES`。
60
+ - 现象:关闭压缩或高质量/大边长保存的图片可能超过 1.5MB,卡片会静默不显示缩略图(阅读抽屉仍可看原图)。
61
+ - 建议:提高 `cardImageMaxBytes`(注意 LRU 字节预算),或客户端上传前生成缩略图(需要新字段/端点);短期至少在设置提示里说明。
62
+ - 优先级:P2(仅常用「关闭压缩」时明显)。
63
+
64
+ ### KI-10 可访问性细节
65
+ - 位置:`src/client/capture/composer.js`、`src/client/panel/header.js` 与 `src/client/styles/capture.js`:浮岛标题/正文无字段级 `:focus-visible`;缩略图 `div tabIndex=0` 无 `role`/名称;预览 `role="tooltip"` 未 `aria-describedby` 关联;日期按钮 `aria-haspopup="menu"` 但点击只切换筛选;`capture.submitHint` 在 Windows/Linux 也显示 ⌘。
66
+ - 建议:字段级 focus 样式;缩略图加 `role="group"`/`aria-label`;tooltip 关联;日期按钮语义二选一(点击打开菜单或去掉 haspopup);按平台显示 Ctrl/⌘。
67
+ - 优先级:P2(与后续交互修改一起做)。
68
+
69
+ ### KI-11 图片压缩参数类型强制(0.16 已修)
70
+ Host/Client 数值只接受有限 number;布尔、数组和字符串不再被隐式转为质量或分辨率。输入框在提交时显式转换,非法 JSON 类型回默认;补 Host/Client parity 边界测试。
71
+
72
+ ### KI-12 正文总大小的 off-by-one
73
+ - 位置:`lib/capture.js`(`text = content + "\n"` 后与 `LIMITS.maxFileBytes` 比较)。
74
+ - 现象:正文恰好 2MiB 时因多一个换行被判「正文过大」,差 1 字节。
75
+ - 建议:先比较 `content` 的字节数,再决定补换行;或明确 `maxFileBytes` 含尾换行并补 exact-2MiB 测试。
76
+ - 优先级:P3。
77
+
78
+ ### KI-13 附件目录创建的副作用与清理错误
79
+ - 位置:`lib/capture.js` 创建附件目录在模板读取/正文校验之前;清理用 `Promise.all(unlink)` 与 `unlink(temp).catch(()=>{})`。
80
+ - 现象:模板或正文校验失败会留下空附件目录;unlink 失败会覆盖原始错误或静默残留隐藏 `.tmp`。
81
+ - 建议:先完成模板读取与正文大小校验再 mkdir;记录本次创建的目录,失败时尝试删空目录;清理失败 catch + debugLog。
82
+ - 优先级:P3。
83
+
84
+ ### KI-14 `pickDirectory` 的 `initial` 死参数(0.16 已清理)
85
+ Client 已停止发送 Host 不使用的 initial,删除失去调用方的旧归档系统选择函数及其 props;当前目录选择器仍不提供初始定位。
86
+
87
+ ### KI-15 输入岛停用后的对象 URL(0.16 已修)
88
+ 输入岛停用且无运行中的提交时释放附件 URL、预览计时器与图片状态,文字草稿保留;补停用与重新启用的行为测试。
89
+
90
+ ### KI-16 浮岛视觉小问题
91
+ - 位置:`src/client/styles/capture.js` `.SN_captureDock::before` 浅色渐变写死 `#fff`,覆盖墙底滚动条一段;展开正文改变 wall `padding-bottom` 时滚到底的内容会跳动。
92
+ - 建议:渐变改用 `var(--dsw-alias-bg-base)`;高度变化前后保持 scrollTop 锚点(或对小屏降低过渡)。
93
+ - 优先级:P3。
94
+
95
+ ### KI-17 EXIF 旋转照片的压缩方向
96
+ - 位置:`src/client/capture/images.js` `compressCaptureImage`(Image → canvas → WebP)。
97
+ - 现象:未验证 canvas 在 Chromium 中是否按 EXIF orientation 绘制;若方向/比例不对,手机竖拍照片会旋转/变形。
98
+ - 建议:先用 iPhone 竖拍照片实测;若异常改用 `createImageBitmap(file, { imageOrientation: 'from-image' })` 后按 bitmap 尺寸绘制。
99
+ - 优先级:P2(仅照片场景,先测再定)。
100
+
101
+ ## 建议的后续修复顺序
102
+
103
+ 1. KI-05(模板正文方案,先定产品规则)。
104
+ 2. KI-09 / KI-10(压缩关闭时卡片缩略图、与 Tab 同批的 a11y)。
105
+ 3. KI-07(网络盘环境才提前;journal 可拆开做)。
106
+ 4. 剩余 KI-12 / KI-13 / KI-16 按实际影响安排;KI-11 / KI-14 / KI-15 已修。
107
+ 5. KI-17 先实测,确认后再修。
108
+
109
+ ## 已修记录(避免重复排查)
110
+
111
+ - 0.14.1:RPC 失败信封 `details`、Tab 焦点范围、模板变量替换与容错、图片文件夹隐藏目录、限额单源化、正文删空自动收起、文档同步。
112
+ - 0.14.2:文件名规范化统一(`lib/filename.js`)、提交失败焦点恢复、标题 Enter/Tab 阻止冒泡、模板插件启用判断保守化、有序任务显示编号。
113
+ - 0.16:扫描局部失效、摘要与全文缓存分离、Markdown 展示限额生效、轻量设置读取与扫描单飞、图片共用限流队列、设置会话隔离、排序与配置写入收敛、并发文件移动保护、JSON 转义预算及资源过期清理;见排查报告。
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
2
+ <path d="M11 7.5h34a6 6 0 0 1 6 6v27L38 59.5H11a6 6 0 0 1-6-6v-40a6 6 0 0 1 6-6Z" fill="#FCF7EC" stroke="#2E2922" stroke-width="2.8" stroke-linejoin="round"/>
3
+ <path d="M51 40.5 37.5 54V46.5a6 6 0 0 1 6-6H51Z" fill="#E8D9B4" stroke="#2E2922" stroke-width="2.8" stroke-linejoin="round"/>
4
+ <path d="M14 18.5h23M14 27h19M14 35.5h12" stroke="#2E2922" stroke-width="2.6" stroke-linecap="round"/>
5
+ <path d="M14 47.5h10" stroke="#B4472F" stroke-width="2.6" stroke-linecap="round"/>
6
+ <circle cx="43.5" cy="18" r="4.2" fill="#B4472F"/>
7
+ </svg>
package/lib/ai.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * dsh-obsidian-memo —— AI 评论 / 开档 的模型调用。
3
+ *
4
+ * 走 Host 的 `ctx.llm` 抽象:默认模型取自 `ctx.agentDefaultModel`,
5
+ * 不占用任何会话、不写对话记录;失败只影响当前 job。
6
+ * @module dsh-obsidian-memo/ai
7
+ */
8
+
9
+ import { LIMITS, normalizeAi } from './shared.js'
10
+
11
+ /** 带错误码的错误。 */
12
+ function codedError(code, message, cause) {
13
+ const error = new Error(message)
14
+ error.code = code
15
+ if (cause !== undefined) error.cause = cause
16
+ return error
17
+ }
18
+
19
+ /** 把模型输出清理成正文:去围栏、去前后空白、限长。 */
20
+ export function cleanupModelText(input, maxChars = LIMITS.llmCommentChars) {
21
+ let text = String(input ?? '').trim()
22
+ const fenced = text.match(/^```[^\n]*\n([\s\S]*?)\n```$/u)
23
+ if (fenced) text = fenced[1].trim()
24
+ text = text.replace(/\u0000/g, '').trim()
25
+ return Array.from(text).slice(0, maxChars).join('')
26
+ }
27
+
28
+ /**
29
+ * 调一次模型,收集纯文本。
30
+ * @param {*} ctx - Host 插件上下文。
31
+ * @param {{ system: string, prompt: string, maxTokens?: number, maxChars?: number, reasoningEffort?: string, timeoutMs?: number }} request
32
+ * @returns {Promise<{ text: string, truncated: boolean }>} truncated = 撞上 token 上限但至少写出了部分正文
33
+ */
34
+ export async function generateText(ctx, request) {
35
+ const llm = ctx.get('llm')
36
+ const defaultModel = ctx.get('agentDefaultModel')
37
+ if (llm === undefined || typeof llm.stream !== 'function') throw codedError('llm-unavailable', '当前 profile 没有可用的 llm 服务')
38
+ if (defaultModel === undefined || typeof defaultModel.currentSelection !== 'function') {
39
+ throw codedError('llm-unavailable', '当前 profile 没有默认模型配置')
40
+ }
41
+ let selection
42
+ try {
43
+ selection = defaultModel.currentSelection()
44
+ } catch (error) {
45
+ throw codedError('llm-unavailable', `读不到默认模型:${error?.message ?? error}`, error)
46
+ }
47
+ if (!selection?.provider || !selection?.model) throw codedError('llm-unavailable', '还没有可用的默认模型,请先在 DSH 里选模型')
48
+
49
+ // 0.13:单次请求显式带 reasoningEffort / maxTokens(设置里每枚 emoji / 开档各一份);
50
+ // 缺省时才回退默认模型的思考档位。注意 DeepSeek 的 max_tokens 含思考 token。
51
+ const effort = request.reasoningEffort ?? selection.reasoningEffort
52
+
53
+ const controller = new AbortController()
54
+ const timeout = setTimeout(() => controller.abort(new Error('timeout')), request.timeoutMs ?? 180_000)
55
+ let text = ''
56
+ let failure = null
57
+ let truncated = false
58
+ try {
59
+ const stream = llm.stream({
60
+ provider: selection.provider,
61
+ model: selection.model,
62
+ ...(effort === undefined ? {} : { reasoningEffort: effort }),
63
+ system: request.system,
64
+ messages: [{ role: 'user', content: [{ type: 'text', text: request.prompt }] }],
65
+ ...(request.maxTokens === undefined ? {} : { maxTokens: request.maxTokens }),
66
+ signal: controller.signal,
67
+ })
68
+ for await (const chunk of stream) {
69
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
70
+ text += chunk.text
71
+ continue
72
+ }
73
+ if (chunk?.type === 'block-end' && chunk.block?.type === 'text' && text.length === 0 && typeof chunk.block.text === 'string') {
74
+ text += chunk.block.text
75
+ continue
76
+ }
77
+ if (chunk?.type === 'finish') {
78
+ const reason = chunk.reason ?? {}
79
+ if (reason.kind === 'error') failure = reason.failure?.message ?? '模型返回错误'
80
+ else if (reason.kind === 'aborted') failure = '生成被取消或超时'
81
+ else if (reason.kind === 'max-tokens') {
82
+ // 有正文 = 被截断(能救);没正文 = 思考把预算吃光(llm-token-budget,Client 会引导去调设置)。
83
+ if (text.trim() !== '') truncated = true
84
+ else failure = codedError(
85
+ 'llm-token-budget',
86
+ `模型把 token 预算用在思考上了,没写出正文(maxTokens ${request.maxTokens ?? '默认'} / 思考档位 ${effort ?? '默认'})`,
87
+ )
88
+ }
89
+ }
90
+ }
91
+ } catch (error) {
92
+ const message = error?.message ?? String(error)
93
+ throw codedError('llm-failed', `模型调用失败:${message}`, error)
94
+ } finally {
95
+ clearTimeout(timeout)
96
+ }
97
+ if (failure !== null) throw failure instanceof Error ? failure : codedError('llm-failed', failure)
98
+ const cleaned = cleanupModelText(text, Infinity)
99
+ const maxChars = request.maxChars ?? LIMITS.llmCommentChars
100
+ const result = Array.from(cleaned).slice(0, maxChars).join('')
101
+ if (result === '') throw codedError('llm-failed', '模型返回了空内容')
102
+ return { text: result, truncated: truncated || result !== cleaned }
103
+ }
104
+
105
+ /** AI 直接读原始 Markdown(含评论),不复用展示层已裁切、去格式的 blocks。 */
106
+ export function renderNoteForModel(note) {
107
+ const lines = []
108
+ if (note.title) lines.push(`标题:${note.title}`)
109
+ if (note.body) lines.push('', '正文(Markdown,含已有评论):', note.body)
110
+ const joined = lines.join('\n')
111
+ const chars = Array.from(joined)
112
+ if (chars.length <= LIMITS.llmInputChars) return joined
113
+ const marker = '\n\n[源便签内容过长,后续内容未提供;请勿推测缺失部分。]'
114
+ return chars.slice(0, LIMITS.llmInputChars - Array.from(marker).length).join('') + marker
115
+ }
116
+
117
+ /** 评论请求:emoji 的提示词 = system,便签内容 = user;预算取这枚 emoji 的 ai。 */
118
+ export function buildCommentRequest(note, emoji) {
119
+ const ai = normalizeAi(emoji?.ai)
120
+ return {
121
+ system: `${emoji.prompt}\n\n输出要求:只输出评论正文本身;不要复述原文;不要加标题、列表符号或代码围栏;中文。`,
122
+ prompt: `下面是一张 Obsidian 便签的内容:\n\n${renderNoteForModel(note)}\n\n请直接写评论。`,
123
+ maxTokens: ai.maxTokens,
124
+ maxChars: LIMITS.llmCommentChars,
125
+ reasoningEffort: ai.reasoningEffort,
126
+ }
127
+ }
128
+
129
+ /** 开档请求:结构化建档提示词 = system,便签内容 = user;预算取设置里的 projectAi。 */
130
+ export function buildProjectRequest(note, projectPrompt, projectName, ai) {
131
+ const budget = normalizeAi(ai)
132
+ return {
133
+ system: projectPrompt,
134
+ prompt: `项目名:${projectName}\n\n便签内容:\n\n${renderNoteForModel(note)}\n\n请输出结构化项目文档正文。`,
135
+ maxTokens: budget.maxTokens,
136
+ maxChars: LIMITS.llmProjectChars,
137
+ reasoningEffort: budget.reasoningEffort,
138
+ }
139
+ }
140
+
141
+ /** 去掉路径扩展名,给 wikilink 用。 */
142
+ function withoutExt(relPath) {
143
+ return String(relPath ?? '').replace(/\.md$/i, '')
144
+ }
145
+
146
+ /**
147
+ * 拼出项目 md:frontmatter(PROJECTS 指向源便签)+ 模型正文 + 来源。
148
+ * @param {{ name: string, generated: string, sourceRelPath: string, sourceTitle: string }} input
149
+ */
150
+ export function buildProjectDocument(input) {
151
+ const sourceLink = `[[${withoutExt(input.sourceRelPath)}|${input.sourceTitle}]]`
152
+ const frontmatter = ['---', `PROJECTS: "${sourceLink.replace(/"/g, '\\"')}"`, 'TODO: []', '---', ''].join('\n')
153
+ const generated = String(input.generated ?? '').trim()
154
+ const heading = /^#\s/u.test(generated) ? '' : `# ${input.name}\n\n`
155
+ return `${frontmatter}${heading}${generated}\n\n## 来源\n- ${sourceLink}\n`
156
+ }
157
+
158
+ /** 项目 md 里指向源便签的链接文本(给源便签写回用)。 */
159
+ export function sourceWikilink(projectRelPath, projectName) {
160
+ return `[[${withoutExt(projectRelPath)}|${projectName}]]`
161
+ }