minecodex 1.2.1 → 2.0.0

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 (99) hide show
  1. package/README.md +9 -3
  2. package/features/images/README.md +16 -98
  3. package/features/images/codex-feature.json +6 -14
  4. package/features/images/src/http-server.mjs +15 -5
  5. package/features/images/src/image-library.mjs +37 -9
  6. package/features/images/src/library-prompt-page.mjs +239 -0
  7. package/features/images/src/prompt-index.mjs +27 -24
  8. package/features/images/src/prompt-source.mjs +100 -0
  9. package/features/images/web/app.js +180 -67
  10. package/features/images/web/i18n.mjs +5 -0
  11. package/features/images/web/index.html +29 -24
  12. package/features/images/web/styles.css +169 -139
  13. package/features/model-slider/README.md +13 -1
  14. package/features/model-slider/THIRD_PARTY_NOTICES.md +1 -1
  15. package/features/notes/DESIGN.md +66 -0
  16. package/features/notes/README.md +18 -99
  17. package/features/notes/assets/icons/book-open-nav.svg +15 -0
  18. package/features/notes/assets/icons/book-open.svg +16 -0
  19. package/features/notes/assets/native-emojis.json +1 -0
  20. package/features/notes/assets/native-prompt-icons.json +28 -0
  21. package/features/notes/codex-feature.json +12 -70
  22. package/features/notes/src/main.mjs +2 -2
  23. package/features/notes/src/product.mjs +287 -613
  24. package/features/notes/src/prompt-schema.mjs +117 -0
  25. package/features/notes/src/quick-prompts-page.mjs +1559 -0
  26. package/features/notes/src/quick-prompts-server.mjs +167 -0
  27. package/features/session-status/DESIGN.md +10 -2
  28. package/features/session-status/README.md +13 -3
  29. package/features/session-status/src/session-status.mjs +329 -24
  30. package/features/sidebar-focus/DESIGN.md +16 -0
  31. package/features/sidebar-focus/codex-feature.json +20 -0
  32. package/features/sidebar-focus/src/sidebar-focus.mjs +134 -0
  33. package/features/sidebar-focus/vendor/lucide-LICENSE +43 -0
  34. package/features/unread-inbox/DESIGN.md +28 -0
  35. package/features/unread-inbox/codex-feature.json +20 -0
  36. package/features/unread-inbox/src/unread-inbox.mjs +360 -0
  37. package/features/usage-meter/DESIGN.md +49 -0
  38. package/features/usage-meter/README.md +18 -0
  39. package/features/usage-meter/codex-feature.json +13 -0
  40. package/features/usage-meter/src/usage-meter.mjs +200 -0
  41. package/package.json +21 -6
  42. package/packages/cli/src/config.mjs +4 -1
  43. package/packages/cli/src/control-server.mjs +2 -2
  44. package/packages/runtime-host/src/cloud-images.mjs +123 -0
  45. package/packages/runtime-host/src/codex-cdp.mjs +42 -8
  46. package/packages/runtime-host/src/codex-design-contract.mjs +10 -0
  47. package/packages/runtime-host/src/codex-injection.mjs +364 -116
  48. package/packages/runtime-host/src/codex-runtime.mjs +105 -28
  49. package/packages/runtime-host/src/feature-registry.mjs +3 -0
  50. package/packages/runtime-host/src/session-status-reader.mjs +65 -24
  51. package/features/notes/assets/icons/circle.svg +0 -15
  52. package/features/notes/assets/icons/ellipsis.svg +0 -17
  53. package/features/notes/assets/icons/external-link.svg +0 -17
  54. package/features/notes/assets/icons/list-todo.svg +0 -19
  55. package/features/notes/assets/icons/message-square-quote.svg +0 -17
  56. package/features/notes/assets/icons/paperclip.svg +0 -15
  57. package/features/notes/assets/icons/star-off.svg +0 -17
  58. package/features/notes/assets/icons/star.svg +0 -15
  59. package/features/notes/assets/icons/sticky-note.svg +0 -16
  60. package/features/notes/assets/icons/trash-2.svg +0 -19
  61. package/features/notes/src/http-server.mjs +0 -580
  62. package/features/notes/web/app.js +0 -1202
  63. package/features/notes/web/codex-artifact-document.svg +0 -1
  64. package/features/notes/web/codex-attachment.svg +0 -1
  65. package/features/notes/web/codex-build.svg +0 -1
  66. package/features/notes/web/codex-code.svg +0 -1
  67. package/features/notes/web/codex-cplusplus.svg +0 -1
  68. package/features/notes/web/codex-css.svg +0 -1
  69. package/features/notes/web/codex-document.svg +0 -1
  70. package/features/notes/web/codex-file.svg +0 -1
  71. package/features/notes/web/codex-folder.svg +0 -1
  72. package/features/notes/web/codex-hashes.svg +0 -1
  73. package/features/notes/web/codex-html.svg +0 -1
  74. package/features/notes/web/codex-image.svg +0 -1
  75. package/features/notes/web/codex-java.svg +0 -1
  76. package/features/notes/web/codex-javascript.svg +0 -1
  77. package/features/notes/web/codex-json.svg +0 -1
  78. package/features/notes/web/codex-notebook.svg +0 -1
  79. package/features/notes/web/codex-pdf.svg +0 -1
  80. package/features/notes/web/codex-pencil.svg +0 -3
  81. package/features/notes/web/codex-php.svg +0 -1
  82. package/features/notes/web/codex-plus.svg +0 -3
  83. package/features/notes/web/codex-presentation.svg +0 -1
  84. package/features/notes/web/codex-python.svg +0 -1
  85. package/features/notes/web/codex-react.svg +0 -1
  86. package/features/notes/web/codex-rust.svg +0 -1
  87. package/features/notes/web/codex-shell.svg +0 -1
  88. package/features/notes/web/codex-skill.svg +0 -1
  89. package/features/notes/web/codex-spreadsheet.svg +0 -1
  90. package/features/notes/web/codex-task.svg +0 -3
  91. package/features/notes/web/codex-terminal.svg +0 -1
  92. package/features/notes/web/codex-toml.svg +0 -1
  93. package/features/notes/web/codex-typescript.svg +0 -1
  94. package/features/notes/web/codex-view-all.svg +0 -3
  95. package/features/notes/web/file-icons.mjs +0 -68
  96. package/features/notes/web/i18n.mjs +0 -237
  97. package/features/notes/web/index.html +0 -15
  98. package/features/notes/web/styles.css +0 -1155
  99. package/features/notes/web/todo-drag.mjs +0 -18
package/README.md CHANGED
@@ -11,10 +11,12 @@ MineCodex 给 Codex Desktop 增加一组轻量、本地优先的扩展功能。
11
11
  | 插件 | 说明 |
12
12
  | --- | --- |
13
13
  | **Model Slider** | 更清晰、更顺滑的模型切换面板:具备品牌标识、常用收藏与流畅交互 |
14
- | **Notes** | 跨会话管理待办事项、常用提示词与文件,可随时从 Codex 工具栏快速复用 |
15
- | **Images** | 集中浏览生成的图片、Prompt 和来源任务,轻松保存原图或快速返回原会话 |
14
+ | **Prompts** | 专页分类管理提示词与调用统计,输入框 Quick Prompts 快速调用 |
15
+ | **Usage Meter** | 在 Help 与 Account 之间显示剩余额度环,悬浮查看周 / 五小时额度,点击进入 Usage 设置 |
16
+ | **Images** | 在原生 Library 图片详情中查看并复制提示词 |
16
17
  | **Hide Upsell Banner** | 隐藏输入框上方的用量提示横幅,仅优化界面展示,不影响正常的用量统计 |
17
18
  | **Session Status** | 在输入框下方查看当前任务的步骤、观测速度、Token 用量与上下文明细 |
19
+ | **Task Switcher** | 点击聊天标题或标签页前的任务按钮,查看并切换工作中与未读会话 |
18
20
 
19
21
  ## 安装
20
22
 
@@ -54,7 +56,7 @@ mcx open
54
56
 
55
57
  ## 工作原理
56
58
 
57
- MineCodex 统一管理 Codex 内的菜单入口、主题风格与界面适配。Model Slider、Notes、Images 各自管理数据与操作页面;Session Status 只读汇总本机 Codex 会话事件,Hide Upsell Banner 优化界面展示。所有功能按需开启,关闭后即停止运行。
59
+ MineCodex 统一管理 Codex 内的菜单入口、主题风格与界面适配。Model Slider、Prompts 各自管理操作界面,Images 增强原生 Library 的提示词查看;Session Status 只读汇总本机 Codex 会话事件,Hide Upsell Banner 优化界面展示。所有功能按需开启,关闭后即停止运行。
58
60
 
59
61
  ## 开发
60
62
 
@@ -69,3 +71,7 @@ npm run pack:audit
69
71
  ## 许可
70
72
 
71
73
  MineCodex 是非官方独立扩展项目,与 OpenAI 无关联。项目自有代码以 MIT License 授权;第三方图标、品牌和兼容资产遵循各自的权利说明,见 [`features/model-slider/THIRD_PARTY_NOTICES.md`](features/model-slider/THIRD_PARTY_NOTICES.md) 与 [`features/session-status/vendor/lucide-LICENSE`](features/session-status/vendor/lucide-LICENSE)。
74
+
75
+ ### Sidebar Focus / 侧栏聚焦
76
+
77
+ 展开侧栏时,点击铃铛与搜索之间的聚焦按钮,保留当前任务路径并收起其他分支。再次点击恢复原先布局;切换任务时跟随路径,手动调整分支时退出聚焦。
@@ -1,107 +1,25 @@
1
- # CodexImageHost
1
+ # Images · Library 提示词
2
2
 
3
- CodexImageHost 给 Codex 增加一个轻量的 `Images` 入口。它自动汇集不同 Thread 中的 ImageGen PNG,但不复制图片。
3
+ Images 在 Codex 原生 **Library → 图片详情**中补充提示词查看能力。
4
4
 
5
- ## 当前功能
5
+ - 有可追溯的提示词时,在缩放胶囊左侧显示 **Prompt / 提示词**。
6
+ - 点击后,使用原生 Version history 相同布局和 design token 的右侧面板展示内容。
7
+ - 面板只展示提示词正文,支持一键复制;优先使用生成提示词,否则追溯该图片的用户输入。
8
+ - 未找到提示词、普通上传图片或来源对话不可用时,不显示按钮。
9
+ - 切换图片或关闭详情会移除旧内容;Escape 先关闭提示词面板。
6
10
 
7
- - 服务先初始化路径索引并开放 HTTP health,再在后台扫描 `~/.codex/generated_images` 中已有的图片;扫描完成后开始文件监听。
8
- - 运行时监听新图片,自动加入 Images。
9
- - 通过 All images、Chats 或有图片的 Project 筛选来源;数量直接显示在筛选菜单中。
10
- - 工具栏内直接提供 2–6 列 Grid 滑条,使用无描边的 4px 轨道;首次打开按可用宽度给一个初始列数,之后只记住用户自己的选择。每张卡片显示来源 Project 或独立 Chat,以及生成时间。
11
- - 可按 Thread 聚合成图板;进入图板后,左侧图片选择带只保留该 Thread 的图片,关闭详情即返回原图板位置。
12
- - 标题栏固定在独立滚动区上方;图片按 36 张一批渐进加载,滚动条保持至少 48px 的可抓取高度。
13
- - 点击图片后,以左侧图片选择带 + 右侧详情区的 20/80 布局查看原图。
14
- - 按需读取该次 ImageGen 实际使用的 Prompt,并显示 Project、Thread、尺寸、时间与文件大小。
15
- - 通过 `Save as` 打开 macOS 系统保存面板并保存原始 PNG。
16
- - 从详情区通过 `Open Thread` 打开真实来源 Thread。
17
- - 已归档 Thread 的图片仍可查看和 Save as,但 `Open Thread` 会禁用并在悬浮时说明原因。
18
- - 通过上层同级项目 `CodexRuntimeHost` 启动一个统一宿主,在 Codex 左侧栏注册 Images。
11
+ 新版不再在 Sites 下方注入独立 Images 入口,也不启动第二套图库服务。
12
+ 图片浏览、编辑与下载使用 Codex 原生能力。读取仅在本机原生 Renderer 内完成,
13
+ 沿用当前登录态,不复制图片或持久化来源对话。
19
14
 
20
- 不包含收藏按钮、图片副本、Asset Store、标签、Collection 或删除功能。Codex 的 PNG 是原图,SQLite 只是路径索引。
15
+ ## 开发
21
16
 
22
- ## 运行
23
-
24
- 需要 Node.js 22.5 或更高版本,不需要安装第三方 npm 依赖。
25
-
26
- 启动统一宿主;它会读取 `codex-feature.json`、启动 Images 服务并注册侧边入口:
27
-
28
- ```bash
29
- npm start
30
- ```
31
-
32
- 只启动独立 Web UI:
33
-
34
- ```bash
35
- npm run service
36
- ```
37
-
38
- 然后打开 <http://127.0.0.1:47831>。
39
-
40
- 运行测试:
17
+ 统一宿主通过 `codex-feature.json` 注册 `src/library-prompt-page.mjs` 页面脚本。
18
+ 原生 DOM 或 API 契约缺失时不注入。视觉契约见 [DESIGN.md](DESIGN.md)。
41
19
 
42
20
  ```bash
43
- npm test
44
- ```
45
-
46
- 服务运行时,路径索引默认保存在 `~/.codex-image-host/library.sqlite`。统一宿主暂时沿用 `~/.codex-image-host/codex-profile`,避免迁移后丢失现有专用 Codex 实例状态。项目不会写入或删除 `~/.codex/generated_images` 中的文件。
47
-
48
- ## 精简结构
49
-
50
- ```text
51
- ~/.codex/generated_images
52
- │ scan + watch
53
- ▼
54
- ImageLibrary ── SQLite path index
55
- │ Codex state + session index
56
- │ ├─ Project / Chat / Thread 名称与 Archive 状态
57
- │ └─ revised_prompt(按需读取 Session JSONL)
58
- ▼
59
- HTTP + Images UI
60
- │
61
- └── codex-feature.json
62
- │ sibling discovery
63
- ▼
64
- ../CodexRuntimeHost
65
- └── Codex sidebar + Theme Bridge + open Thread
66
- ```
67
-
68
- - `src/image-library.mjs`:扫描、监听和路径索引,是产品核心。
69
- - `src/thread-metadata.mjs`:读取 Codex 当前 Project、projectless Chat、Thread 名称与 Archive 状态。
70
- - `src/prompt-index.mjs`:按 Thread 与 ImageGen item 定位真实 `revised_prompt`。
71
- - `src/http-server.mjs`:图片列表、来源 facets、Thread 图板、原图读取、系统 Save as 和静态页面。
72
- - `codex-feature.json`:Images 对统一宿主的最小声明,不包含图片业务逻辑。
73
- - `../CodexRuntimeHost`:可复用的 CDP 集成,统一负责多个个人功能的入口、Surface、主题与导航。
74
- - `web/`:零框架 Images 页面。
75
-
76
- ## Codex 设计语言与主题
77
-
78
- - Images 不维护独立品牌皮肤,而是通过 Theme Bridge 读取当前 Codex Renderer 已解析的 semantic tokens。
79
- - 支持 Codex 的明亮 / 暗黑主题,并监听根节点 theme class 与 style 变化。
80
- - 用户修改 accent、ink 或 surface 后,新的 resolved tokens 会同步到 iframe,不需要重新读取或猜测内部配置文件位置。
81
- - 侧边栏 Images 入口克隆原生 Plugins 控件的布局与状态,再替换为 Codex 图标体系中的独立 Image 图标;iframe 内不导入 Codex 私有 React 模块或 hashed CSS bundle,以免 Codex 更新后直接失效。
82
- - 原生左侧栏隐藏时,Images 标题左侧显示同源图标入口;点击由 RuntimeHost 转发给原生侧栏开关,恢复后入口自动隐藏。
83
-
84
- 取证、token Interface 和代码复用边界见 [`docs/CODEX_DESIGN_LANGUAGE.md`](docs/CODEX_DESIGN_LANGUAGE.md)。
85
-
86
- ## 当前边界
87
-
88
- - Images 服务需要保持运行,才会持续监听新图片;侧边栏注册由 `CodexRuntimeHost` 统一维持。
89
- - 每次服务启动都会先开放 health,再在后台补扫停机期间新增的图片并刷新来源名称;初始补扫完成后,运行期间的新图片由文件监听自动加入。手动 Refresh 会再次补扫来源和 Archive 状态,但不会重置筛选、图板模式、详情、滚动位置或列数。刷新 ring 至少显示 1 秒。
90
- - 侧边栏使用 Codex 当前未公开的 CDP / DOM 能力,Codex UI 更新后只需调整统一宿主;独立 Web UI 和路径索引不受影响。
91
- - 当前 Codex 的 CSP 会屏蔽普通本地 iframe。统一宿主会先启用 CSP bypass 并注册 document-start 脚本;尚未完成该初始化的首次或旧版 document 会受控 reload 一次。完成初始化后会留下 document 标记,后续 RuntimeHost 重启只替换旧的 MineCodex runtime,不再刷新 Codex 页面(Issue #1)。
92
- - 详情打开时会从本机 Session JSONL 按需读取该 ImageGen item 的真实 `revised_prompt` 并缓存到索引;历史记录缺少该事件时明确显示 Prompt 不可用,不从附近文本猜测。
93
- - 当前没有发现 Codex 会因运行时间增长而自动清理 `generated_images` 的证据。如果未来观察到真实清理,再决定是否增加可选备份。
94
-
95
- Phase 0 的原图、来源元数据与 Thread 导航证据见 [`spikes/phase0/README.md`](spikes/phase0/README.md)。
96
-
97
- 需要验证 Codex 内的 iframe 是否真正加载时,可在 `npm start` 运行期间执行:
98
-
99
- ```bash
100
- node spikes/phase0/cdp-probe.mjs frame-check --port 9231 \
101
- --url http://127.0.0.1:47831/
102
-
103
- node spikes/phase0/cdp-probe.mjs overlay-check --port 9231 \
104
- --url http://127.0.0.1:47831/
21
+ node --test features/images/test/library-prompt-page.test.mjs
105
22
  ```
106
23
 
107
- `overlay-check` 会打开 Codex 原生帮助菜单,验证菜单能覆盖 Images,同时确认 iframe 本身仍可交互。
24
+ 旧 `src/image-library.mjs`、索引、独立 `web/` 与服务脚本暂保留为历史工具,
25
+ 不属于当前插件入口;已有本地图片和缓存不会被迁移或删除。
@@ -7,23 +7,15 @@
7
7
  "zh-CN": "图片"
8
8
  },
9
9
  "description": {
10
- "en": "Browse generated images, inspect prompts and sources, and return to the original task.",
11
- "zh-CN": "集中浏览生成图片,查看提示词和来源任务,并保存原图或返回原任务。"
10
+ "en": "View image prompts inside the native Library image viewer.",
11
+ "zh-CN": "在原生 Library 图片详情中查看并复制提示词。"
12
12
  },
13
- "surfaceUrl": "http://127.0.0.1:47831/",
14
- "healthUrl": "http://127.0.0.1:47831/api/health",
15
- "runtimePortEnvironment": "CODEX_IMAGE_HOST_PORT",
16
- "pageScript": {
17
- "resource": "src/chatgpt-image-source-page.mjs"
18
- },
19
- "hostActions": ["import-generated-image", "create-image-edit-thread", "open-sidebar"],
20
- "placement": {
21
- "after": "sites",
22
- "order": 10
23
- },
24
- "command": ["node", "src/main.mjs"],
25
13
  "icon": {
26
14
  "viewBox": "0 0 24 24",
27
15
  "markup": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\"></rect><circle cx=\"9\" cy=\"9\" r=\"2\"></circle><path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\"></path>"
16
+ },
17
+ "entry": {
18
+ "kind": "page-script",
19
+ "script": "src/library-prompt-page.mjs"
28
20
  }
29
21
  }
@@ -48,7 +48,7 @@ function publicImage(image, { includePrompt = false } = {}) {
48
48
  downloadUrl: `/api/images/${image.id}/download`,
49
49
  sourceUrl: image.threadId.startsWith("chatgpt:") ? null : `codex://threads/${image.threadId}`,
50
50
  };
51
- if (includePrompt) result.prompt = image.prompt;
51
+ if (includePrompt) { result.prompt = image.prompt; result.promptKind = image.promptKind; }
52
52
  return result;
53
53
  }
54
54
 
@@ -295,11 +295,13 @@ export async function createHttpServer({
295
295
 
296
296
  if (request.method === "GET" && url.pathname === "/api/images") {
297
297
  const { limit, offset } = paginationFor(url);
298
+ const snapshot = parseIntegerQuery(url.searchParams, "snapshot", { defaultValue: library.snapshot(), min: 0, max: Number.MAX_SAFE_INTEGER });
298
299
  const source = url.searchParams.get("source") ?? "all";
299
300
  const threadId = url.searchParams.get("threadId") || null;
300
301
  const sort = url.searchParams.get("sort") === "asc" ? "asc" : "desc";
301
302
  const query = url.searchParams.get("q") ?? "";
302
303
  const images = library.list({
304
+ snapshot,
303
305
  query,
304
306
  source,
305
307
  threadId,
@@ -309,7 +311,8 @@ export async function createHttpServer({
309
311
  }).map(publicImage);
310
312
  json(response, 200, {
311
313
  images,
312
- count: library.count({ query, source, threadId }),
314
+ count: library.count({ query, source, threadId, snapshot }),
315
+ snapshot,
313
316
  limit,
314
317
  offset,
315
318
  });
@@ -323,18 +326,25 @@ export async function createHttpServer({
323
326
 
324
327
  if (request.method === "GET" && url.pathname === "/api/image-groups") {
325
328
  const { limit, offset } = paginationFor(url);
329
+ const snapshot = parseIntegerQuery(url.searchParams, "snapshot", { defaultValue: library.snapshot(), min: 0, max: Number.MAX_SAFE_INTEGER });
326
330
  const source = url.searchParams.get("source") ?? "all";
327
- const groups = library.groups({ source, limit, offset }).map(publicGroup);
331
+ const groups = library.groups({ source, limit, offset, snapshot }).map(publicGroup);
328
332
  json(response, 200, {
329
333
  groups,
330
- count: library.countGroups({ source }),
331
- imageCount: library.count({ source }),
334
+ count: library.countGroups({ source, snapshot }),
335
+ imageCount: library.count({ source, snapshot }),
336
+ snapshot,
332
337
  limit,
333
338
  offset,
334
339
  });
335
340
  return;
336
341
  }
337
342
 
343
+ if (request.method === "GET" && url.pathname === "/api/cloud-image-cache") {
344
+ json(response, 200, { items: library.cloudCache() });
345
+ return;
346
+ }
347
+
338
348
  if (request.method === "POST" && url.pathname === "/api/rescan") {
339
349
  if (library.rescanStatus().inProgress) {
340
350
  json(response, 202, { imported: 0, total: library.count(), inProgress: true });
@@ -28,10 +28,15 @@ function imageIdFor(sourcePath) {
28
28
  return createHash("sha256").update(sourcePath).digest("hex").slice(0, 24);
29
29
  }
30
30
 
31
- function buildWhere({ query = "", source = "all", threadId = null } = {}) {
31
+ function buildWhere({ query = "", source = "all", threadId = null, snapshot = null } = {}) {
32
32
  const clauses = [];
33
33
  const parameters = [];
34
34
 
35
+ if (snapshot !== null) {
36
+ clauses.push("images.rowid <= ?");
37
+ parameters.push(snapshot);
38
+ }
39
+
35
40
  if (threadId) {
36
41
  clauses.push("images.thread_id = ?");
37
42
  parameters.push(threadId);
@@ -153,6 +158,7 @@ function rowToImage(row) {
153
158
  imageGenerationItemId: row.image_generation_item_id,
154
159
  sourcePath: row.source_path,
155
160
  prompt: row.prompt,
161
+ promptKind: row.prompt_kind ?? "generation",
156
162
  width: row.width,
157
163
  height: row.height,
158
164
  byteLength: row.byte_length,
@@ -235,6 +241,10 @@ export class ImageLibrary {
235
241
  updated_at TEXT NOT NULL
236
242
  );
237
243
  `);
244
+ const imageColumns = new Set(this.database.prepare("PRAGMA table_info(images)").all().map(column => column.name));
245
+ for (const name of ["prompt_kind", "cloud_library_id", "cloud_version"]) {
246
+ if (!imageColumns.has(name)) this.database.exec(`ALTER TABLE images ADD COLUMN ${name} TEXT`);
247
+ }
238
248
  const threadSourceColumns = new Set(
239
249
  this.database.prepare("PRAGMA table_info(thread_sources)").all().map((column) => column.name),
240
250
  );
@@ -299,6 +309,10 @@ export class ImageLibrary {
299
309
  });
300
310
  }
301
311
 
312
+ snapshot() {
313
+ return this.database.prepare("SELECT COALESCE(MAX(rowid), 0) AS value FROM images").get().value;
314
+ }
315
+
302
316
  count(options = {}) {
303
317
  const { sql, parameters } = buildWhere(options);
304
318
  return this.database.prepare(`
@@ -309,8 +323,8 @@ export class ImageLibrary {
309
323
  `).get(...parameters).count;
310
324
  }
311
325
 
312
- list({ query = "", source = "all", threadId = null, sort = "desc", limit = null, offset = 0 } = {}) {
313
- const { sql, parameters } = buildWhere({ query, source, threadId });
326
+ list({ query = "", source = "all", threadId = null, sort = "desc", limit = null, offset = 0, snapshot = null } = {}) {
327
+ const { sql, parameters } = buildWhere({ query, source, threadId, snapshot });
314
328
  const pagination = Number.isInteger(limit) ? "LIMIT ? OFFSET ?" : "";
315
329
  if (pagination) parameters.push(limit, offset);
316
330
  const direction = sort === "asc" ? "ASC" : "DESC";
@@ -358,8 +372,8 @@ export class ImageLibrary {
358
372
  return { total: this.count(), chats, works, projects };
359
373
  }
360
374
 
361
- countGroups({ source = "all" } = {}) {
362
- const { sql, parameters } = buildWhere({ source });
375
+ countGroups({ source = "all", snapshot = null } = {}) {
376
+ const { sql, parameters } = buildWhere({ source, snapshot });
363
377
  return this.database.prepare(`
364
378
  SELECT COUNT(DISTINCT images.thread_id) AS count
365
379
  FROM images
@@ -368,8 +382,8 @@ export class ImageLibrary {
368
382
  `).get(...parameters).count;
369
383
  }
370
384
 
371
- groups({ source = "all", limit = null, offset = 0, previewLimit = 4 } = {}) {
372
- const { sql, parameters } = buildWhere({ source });
385
+ groups({ source = "all", limit = null, offset = 0, previewLimit = 4, snapshot = null } = {}) {
386
+ const { sql, parameters } = buildWhere({ source, snapshot });
373
387
  const pagination = Number.isInteger(limit) ? "LIMIT ? OFFSET ?" : "";
374
388
  if (pagination) parameters.push(limit, offset);
375
389
  const rows = this.database.prepare(`
@@ -400,10 +414,11 @@ export class ImageLibrary {
400
414
  FROM images
401
415
  LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
402
416
  WHERE images.thread_id IN (${placeholders})
417
+ ${snapshot === null ? "" : "AND images.rowid <= ?"}
403
418
  )
404
419
  WHERE preview_rank <= ?
405
420
  ORDER BY thread_id ASC, created_at ASC, id ASC
406
- `).all(...threadIds, previewLimit);
421
+ `).all(...threadIds, ...(snapshot === null ? [] : [snapshot]), previewLimit);
407
422
  for (const previewRow of previewRows) {
408
423
  const previews = previewsByThread.get(previewRow.thread_id) ?? [];
409
424
  previews.push(rowToImage(previewRow));
@@ -621,6 +636,10 @@ export class ImageLibrary {
621
636
  return true;
622
637
  }
623
638
 
639
+ cloudCache() {
640
+ return this.database.prepare("SELECT cloud_library_id AS libraryId, cloud_version AS version FROM images WHERE cloud_library_id IS NOT NULL").all();
641
+ }
642
+
624
643
  async importExternalImage({
625
644
  bytes,
626
645
  mimeType,
@@ -630,6 +649,9 @@ export class ImageLibrary {
630
649
  sourceKind,
631
650
  prompt = null,
632
651
  createdAt = null,
652
+ promptKind = "generation",
653
+ cloudLibraryId = null,
654
+ cloudVersion = null,
633
655
  } = {}) {
634
656
  if (!this.isLifecycleActive() || !Buffer.isBuffer(bytes)) return { imported: false, reason: "INVALID_BYTES" };
635
657
  const normalizedMime = normalizedImportMime(mimeType);
@@ -660,10 +682,15 @@ export class ImageLibrary {
660
682
  const assetDir = path.join(this.dataDir, "external-images");
661
683
  const sourcePath = path.join(assetDir, `${id}.${extension}`);
662
684
  const existing = this.database.prepare("SELECT source_path, byte_length FROM images WHERE id = ?").get(id);
685
+ const updateCloudMetadata = () => {
686
+ if (typeof cloudLibraryId !== "string" || !/^libfile_[\w-]+$/.test(cloudLibraryId)) return;
687
+ this.database.prepare("UPDATE images SET cloud_library_id = ?, cloud_version = ?, prompt = ?, prompt_kind = ? WHERE id = ?")
688
+ .run(cloudLibraryId, String(cloudVersion ?? "").slice(0, 256), safePrompt, promptKind === "request" ? "request" : "generation", id);
689
+ };
663
690
  if (existing && existing.byte_length === bytes.length) {
664
691
  try {
665
692
  const existingBytes = await readFile(existing.source_path);
666
- if (existingBytes.equals(bytes)) return { imported: false, id, reason: "DUPLICATE" };
693
+ if (existingBytes.equals(bytes)) { updateCloudMetadata(); return { imported: false, id, reason: "DUPLICATE" }; }
667
694
  } catch (error) {
668
695
  if (error.code !== "ENOENT") throw error;
669
696
  }
@@ -712,6 +739,7 @@ export class ImageLibrary {
712
739
  normalizedCreatedAt,
713
740
  importedAt,
714
741
  );
742
+ updateCloudMetadata();
715
743
  this.database
716
744
  .prepare(`
717
745
  INSERT INTO thread_sources (
@@ -0,0 +1,239 @@
1
+ // 仅增强原生 Library 图片详情;不创建第二个图库或侧栏入口。
2
+ const PANEL_ID = "minecodex-library-prompt";
3
+ const zh = () => (document.documentElement.lang || navigator.language || "en").toLowerCase().startsWith("zh");
4
+ const label = (en, cn) => zh() ? cn : en;
5
+ let mounted = null;
6
+ let apiPromise = null;
7
+ let scheduled = false;
8
+ let candidate = null;
9
+
10
+ function libraryImage(header) {
11
+ const key = Object.keys(header).find(key => key.startsWith("__reactFiber$"));
12
+ let fiber = key ? header[key] : null;
13
+ let selected = null;
14
+ for (let depth = 0; fiber && depth < 80; depth++, fiber = fiber.return) {
15
+ const file = fiber.memoizedProps?.file;
16
+ if (!file || !/^libfile_[\w-]+$/.test(file.id ?? "") || !file.mime_type?.startsWith("image/")) continue;
17
+ if (selected && selected.id !== file.id) break;
18
+ selected = file;
19
+ if (file.origination_thread_id && file.origination_message_id) break;
20
+ }
21
+ if (!selected) return null;
22
+ // 只保留关联字段,签名下载地址和图片数据不进入插件。
23
+ return Object.fromEntries(["id", "file_id", "current_version_number", "origination_thread_id", "origination_message_id"].map(key => [key, selected[key]]));
24
+ }
25
+
26
+ async function nativeApi() {
27
+ if (!apiPromise) apiPromise = (async () => {
28
+ const initialUrl = [...document.querySelectorAll('link[rel="modulepreload"],script[src]')]
29
+ .map(element => element.href || element.src).find(url => /\/app-initial-[^/]+\.js/.test(url));
30
+ if (!initialUrl) throw new Error("Library API unavailable");
31
+ const initialSource = await (await fetch(initialUrl, { signal })).text();
32
+ const metadataAsset = initialSource.match(/library-file-metadata-[\w-]+\.js/)?.[0];
33
+ if (!metadataAsset) throw new Error("Library metadata unavailable");
34
+ const metadataUrl = new URL(metadataAsset, initialUrl).href;
35
+ const source = await (await fetch(metadataUrl, { signal })).text();
36
+ const apiLocal = source.match(/([\w$]+)\.safePost\(`\/files\/library`/)?.[1];
37
+ const imports = [...source.matchAll(/import\{([^}]+)\}from["']([^"']+)["']/g)]
38
+ .find(match => match[1].split(",").some(binding => binding.trim().split(/\s+as\s+/).at(-1) === apiLocal));
39
+ const apiExport = imports?.[1].split(",").map(s => s.trim().split(/\s+as\s+/)).find(parts => parts.at(-1) === apiLocal)?.[0];
40
+ const initLocal = source.match(/function ([\w$]+)\(\)\{return\(\1=/)?.[1];
41
+ const exports = source.match(/export\{([^}]+)\}/)?.[1].split(",").map(s => s.trim().split(/\s+as\s+/)) ?? [];
42
+ const initExport = exports.find(parts => parts[0] === initLocal)?.at(-1);
43
+ const metadata = await import(metadataUrl);
44
+ if (typeof metadata[initExport] !== "function" || !apiExport) throw new Error("Library API contract changed");
45
+ metadata[initExport]();
46
+ const api = (await import(new URL(imports[2], metadataUrl).href))[apiExport];
47
+ if (typeof api?.safeGet !== "function") throw new Error("Library API unavailable");
48
+ return api;
49
+ })().catch(error => { apiPromise = null; throw error; });
50
+ return apiPromise;
51
+ }
52
+
53
+ function promptFromConversation(file, conversation) {
54
+ const node = conversation.mapping?.[file.origination_message_id];
55
+ const part = node?.message?.content?.parts?.find(part => part && typeof part === "object"
56
+ && part.content_type === "image_asset_pointer"
57
+ && [file.file_id, `sediment://${file.file_id}`].includes(part.asset_pointer));
58
+ if (!part || !["assistant", "tool"].includes(node.message.author?.role)
59
+ || !(part.metadata?.generation?.gen_id || part.metadata?.dalle?.gen_id)) return null;
60
+ const prompt = part.metadata?.dalle?.prompt;
61
+ if (typeof prompt === "string" && prompt.trim()) return { text: prompt, kind: "generation" };
62
+ let ancestor = conversation.mapping[node.parent];
63
+ const visited = new Set();
64
+ while (ancestor && !visited.has(ancestor.id)) {
65
+ visited.add(ancestor.id);
66
+ if (ancestor.message?.author?.role === "user") {
67
+ const text = ancestor.message.content?.parts?.filter(part => typeof part === "string").join("\n");
68
+ return text?.trim() ? { text, kind: "request" } : null;
69
+ }
70
+ ancestor = conversation.mapping[ancestor.parent];
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function element(tag, className, text) {
76
+ const node = document.createElement(tag);
77
+ node.className = className;
78
+ if (text != null) node.textContent = text;
79
+ return node;
80
+ }
81
+
82
+ // 复用旧 Images 的 Lucide copy/check 图形,统一为 16px 图标按钮。
83
+ function copyGlyph(copied = false) {
84
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
85
+ for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", width: "16", height: "16", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true" })) svg.setAttribute(key, value);
86
+ svg.innerHTML = copied
87
+ ? '<path d="m20 6-11 11-5-5"></path>'
88
+ : '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>';
89
+ return svg;
90
+ }
91
+
92
+ function closePanel(record, restoreFocus = false) {
93
+ record.request?.abort();
94
+ record.request = null;
95
+ record.panel.hidden = true;
96
+ record.button.setAttribute("aria-expanded", "false");
97
+ if (restoreFocus && record.button.isConnected) record.button.focus();
98
+ }
99
+
100
+ function showPrompt(record) {
101
+ record.panel.hidden = false;
102
+ record.button.setAttribute("aria-expanded", "true");
103
+ record.close.focus();
104
+ }
105
+
106
+ async function resolvePrompt(file, request) {
107
+ if (!file.origination_thread_id || !file.origination_message_id) return null;
108
+ const api = await nativeApi();
109
+ request.signal.throwIfAborted();
110
+ const conversation = await api.safeGet("/conversation/{conversation_id}", {
111
+ parameters: { path: { conversation_id: file.origination_thread_id } },
112
+ signal: AbortSignal.any([signal, request.signal, AbortSignal.timeout(25_000)]),
113
+ });
114
+ return promptFromConversation(file, conversation);
115
+ }
116
+
117
+ function unmount() {
118
+ if (!mounted) return;
119
+ closePanel(mounted);
120
+ clearTimeout(mounted.copyTimer);
121
+ mounted.button.remove();
122
+ mounted.panel.remove();
123
+ mounted = null;
124
+ }
125
+
126
+ async function scan() {
127
+ scheduled = false;
128
+ if (signal.aborted) return;
129
+ const header = [...document.querySelectorAll('[data-testid="viewer-header"]')].find(header => header.getBoundingClientRect().width > 0 && libraryImage(header));
130
+ const file = header && libraryImage(header);
131
+ const identity = file && JSON.stringify(file);
132
+ if (candidate?.header === header && candidate?.identity === identity
133
+ && (!mounted || (mounted.button.isConnected && mounted.panel.isConnected))) return;
134
+ candidate?.request.abort();
135
+ unmount();
136
+ candidate = null;
137
+ if (!file) return;
138
+ const next = { header, identity, request: new AbortController() };
139
+ candidate = next;
140
+ let prompt;
141
+ try { prompt = await resolvePrompt(file, next.request); }
142
+ catch { return; }
143
+ // 异步结果只能写回当前图片;没有提示词时不展示入口。
144
+ if (!prompt || signal.aborted || candidate !== next || !header.isConnected) return;
145
+ // 缩放项在图片详情中是百分比菜单;沿用它的胶囊类,不固定构建 hash。
146
+ const zoom = [...header.querySelectorAll('button[aria-haspopup="menu"]')].find(button => /^\d+(?:\.\d+)?%$/.test(button.textContent.trim()));
147
+ const group = zoom?.parentElement?.parentElement;
148
+ const host = header.closest('[role="presentation"]');
149
+ if (!zoom || !group || !host) return;
150
+ const capsuleClass = zoom.className.split(/\s+/).filter(name => !/^_(?:dropdown|zoom)_/.test(name)).join(" ");
151
+ const button = element("button", capsuleClass, label("Prompt", "提示词"));
152
+ button.type = "button";
153
+ button.dataset.minecodexLibraryPrompt = "trigger";
154
+ button.setAttribute("aria-expanded", "false");
155
+ button.setAttribute("aria-controls", PANEL_ID);
156
+ button.style.width = "max-content";
157
+ button.style.minWidth = "0";
158
+ button.style.flex = "0 0 auto";
159
+ button.style.paddingInline = "calc(var(--spacing) * 3)";
160
+ group.prepend(button);
161
+ // 对齐原生 Library Version history 的面板、标题和内容区 token。
162
+ const panel = element("aside", "absolute inset-x-2 top-toolbar bottom-2 z-30 ms-auto mt-2 flex max-w-75 flex-col overflow-hidden rounded-2xl border border-default bg-surface shadow-lg select-none");
163
+ panel.id = PANEL_ID;
164
+ panel.setAttribute("aria-label", label("Image prompt", "图片提示词"));
165
+ panel.hidden = true;
166
+ panel.style.setProperty("-webkit-app-region", "no-drag");
167
+ panel.style.paddingBottom = "calc(var(--spacing) * 3)";
168
+ const heading = element("div", "flex items-center justify-between py-2 ps-4 pe-2");
169
+ const close = element("button", "flex size-8 shrink-0 items-center justify-center rounded-full text-default hover:bg-primary-ghost-hover focus-visible:outline-ring");
170
+ close.type = "button";
171
+ close.setAttribute("aria-label", label("Close prompt", "关闭提示词"));
172
+ // 直接复用原生关闭图标,避免手绘近似图形。
173
+ const nativeClose = [...header.querySelectorAll("button")].at(-1)?.querySelector("svg");
174
+ if (nativeClose) close.append(nativeClose.cloneNode(true));
175
+ else close.textContent = "×";
176
+ const actions = element("div", "flex items-center gap-1");
177
+ const copy = element("button", close.className);
178
+ copy.type = "button";
179
+ function copyFeedback(copied = false, failed = false) {
180
+ const text = failed ? label("Try copying again", "重试复制") : copied ? label("Copied", "已复制") : label("Copy prompt", "复制提示词");
181
+ copy.title = text;
182
+ copy.setAttribute("aria-label", text);
183
+ copy.replaceChildren(copyGlyph(copied));
184
+ }
185
+ copyFeedback();
186
+ actions.append(copy, close);
187
+ heading.append(element("span", "text-base font-semibold text-default", label("Prompt", "提示词")), actions);
188
+ const body = element("div", "min-h-0 flex-1 overflow-y-auto px-3");
189
+ body.style.paddingTop = "var(--spacing)";
190
+ body.append(
191
+ element("p", "px-1 text-sm leading-5 text-default whitespace-pre-wrap break-words select-text", prompt.text),
192
+ );
193
+ panel.append(heading, body);
194
+ host.append(panel);
195
+ const record = { header, file, identity, button, panel, body, close, request: null,
196
+ refreshLocale() {
197
+ button.textContent = label("Prompt", "提示词");
198
+ panel.setAttribute("aria-label", label("Image prompt", "图片提示词"));
199
+ close.setAttribute("aria-label", label("Close prompt", "关闭提示词"));
200
+ heading.firstElementChild.textContent = label("Prompt", "提示词");
201
+ copyFeedback();
202
+ },
203
+ };
204
+ mounted = record;
205
+ copy.addEventListener("click", async () => {
206
+ try {
207
+ await navigator.clipboard.writeText(prompt.text);
208
+ if (mounted !== record) return;
209
+ copyFeedback(true);
210
+ } catch {
211
+ if (mounted !== record) return;
212
+ copyFeedback(false, true);
213
+ }
214
+ clearTimeout(record.copyTimer);
215
+ record.copyTimer = setTimeout(() => copyFeedback(), 2000);
216
+ });
217
+ button.addEventListener("click", () => panel.hidden ? showPrompt(record) : closePanel(record));
218
+ close.addEventListener("click", () => closePanel(record, true));
219
+ }
220
+
221
+ const localeObserver = new MutationObserver(() => mounted?.refreshLocale());
222
+ localeObserver.observe(document.documentElement, { attributes:true, attributeFilter:["lang"] });
223
+ const observer = new MutationObserver(() => {
224
+ if (scheduled) return;
225
+ scheduled = true;
226
+ queueMicrotask(scan);
227
+ });
228
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "data-state", "inert"] });
229
+ function escape(event) {
230
+ if (event.key !== "Escape" || !mounted || mounted.panel.hidden) return;
231
+ event.preventDefault();
232
+ event.stopImmediatePropagation();
233
+ closePanel(mounted, true);
234
+ }
235
+ document.addEventListener("keydown", escape, true);
236
+ const cleanup = () => { candidate?.request.abort(); candidate = null; observer.disconnect(); localeObserver.disconnect(); document.removeEventListener("keydown", escape, true); unmount(); };
237
+ signal.addEventListener("abort", cleanup, { once: true });
238
+ scan();
239
+ return cleanup;