minecodex 2.0.12 → 2.0.14

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 (69) hide show
  1. package/README.md +20 -8
  2. package/apps/dashboard/app.js +4 -4
  3. package/apps/dashboard/assets/data-transfer.js +129 -0
  4. package/apps/dashboard/assets/download.svg +15 -0
  5. package/apps/dashboard/assets/upload.svg +15 -0
  6. package/apps/dashboard/index.html +10 -0
  7. package/apps/dashboard/styles.css +50 -3
  8. package/features/file-navigation/assets/book-open.svg +16 -0
  9. package/features/file-navigation/assets/folder-tree.svg +16 -0
  10. package/features/file-navigation/codex-feature.json +27 -0
  11. package/features/file-navigation/src/adapters/codex/modules.mjs +67 -0
  12. package/features/file-navigation/src/adapters/codex/navigation.mjs +84 -0
  13. package/features/file-navigation/src/domain/navigation.mjs +12 -0
  14. package/features/file-navigation/src/entry.mjs +34 -0
  15. package/features/file-navigation/src/frontend/navigation.mjs +140 -0
  16. package/features/file-navigation/src/injection-source.mjs +9 -0
  17. package/features/file-navigation/src/navigation.css +16 -0
  18. package/features/file-navigation/vendor/lucide-LICENSE +43 -0
  19. package/features/hide-upsell-banner/codex-feature.json +4 -4
  20. package/features/images/codex-feature.json +4 -4
  21. package/features/images/src/adapters/codex/native-image-modules.mjs +13 -2
  22. package/features/images/src/library-prompt-source.mjs +2 -2
  23. package/features/markdown-reading/assets/book-open.svg +16 -0
  24. package/features/markdown-reading/codex-feature.json +27 -0
  25. package/features/markdown-reading/src/adapters/codex/links.mjs +280 -0
  26. package/features/markdown-reading/src/adapters/codex/lists.mjs +168 -0
  27. package/features/markdown-reading/src/adapters/codex/markdown-native.mjs +28 -0
  28. package/features/markdown-reading/src/adapters/codex/preview.mjs +38 -0
  29. package/features/markdown-reading/src/adapters/codex/providers.mjs +18 -0
  30. package/features/markdown-reading/src/adapters/codex/reading-runtime.mjs +152 -0
  31. package/features/markdown-reading/src/adapters/codex/rich-text.mjs +561 -0
  32. package/features/markdown-reading/src/adapters/codex/table-actions.mjs +270 -0
  33. package/features/markdown-reading/src/adapters/codex/tables.mjs +493 -0
  34. package/features/markdown-reading/src/adapters/codex/toc-native.mjs +131 -0
  35. package/features/markdown-reading/src/adapters/codex/toc.mjs +334 -0
  36. package/features/markdown-reading/src/domain/footnotes.mjs +56 -0
  37. package/features/markdown-reading/src/injection-source.mjs +43 -0
  38. package/features/markdown-reading/src/markdown-reading.mjs +82 -0
  39. package/features/markdown-reading/src/reading.css +346 -0
  40. package/features/markdown-reading/vendor/lucide-LICENSE +43 -0
  41. package/features/model-slider/codex-feature.json +5 -4
  42. package/features/model-slider/src/backend/favorites.mjs +54 -0
  43. package/features/model-slider/src/domain/model-rules.mjs +6 -1
  44. package/features/model-slider/src/frontend/install.mjs +24 -3
  45. package/features/notes/assets/prompt-previews.json +26 -0
  46. package/features/notes/codex-feature.json +6 -5
  47. package/features/notes/src/backend/transfer.mjs +97 -0
  48. package/features/notes/src/frontend/entry.mjs +380 -10
  49. package/features/notes/src/frontend/icons.json +4 -1
  50. package/features/notes/src/frontend/styles.css +63 -0
  51. package/features/session-status/codex-feature.json +6 -3
  52. package/features/session-status/src/domain/session-stats.mjs +22 -8
  53. package/features/session-status/src/session-status.mjs +2 -2
  54. package/features/sidebar-focus/codex-feature.json +2 -2
  55. package/features/sidebar-focus/src/sidebar-focus.mjs +13 -5
  56. package/features/sites/codex-feature.json +4 -4
  57. package/features/sites/src/backend/sites-service.mjs +20 -5
  58. package/features/sites/src/backend/transfer.mjs +43 -0
  59. package/features/usage-meter/codex-feature.json +6 -3
  60. package/package.json +11 -3
  61. package/packages/cli/src/config.mjs +4 -2
  62. package/packages/cli/src/control-server.mjs +34 -10
  63. package/packages/cli/src/data-transfer/service.mjs +42 -0
  64. package/packages/cli/src/runtime-manager.mjs +3 -0
  65. package/packages/runtime-host/src/codex-injection.mjs +2 -1
  66. package/packages/runtime-host/src/codex-runtime.mjs +24 -1
  67. package/packages/runtime-host/src/feature-registry.mjs +1 -0
  68. package/packages/ui/src/native/theme.mjs +23 -1
  69. package/packages/ui/src/native/tokens.mjs +4 -4
@@ -7,6 +7,7 @@ function addSections(target, sections) {
7
7
  export function createSessionState() {
8
8
  return {
9
9
  turns: new Set(),
10
+ currentTurnId: null,
10
11
  runningTurns: new Set(),
11
12
  responses: new Set(),
12
13
  hasResponseUsage: false,
@@ -58,13 +59,15 @@ export function applySessionStatusEvent(state, event) {
58
59
  state.hasVisibleHistory=event.sections !== null;
59
60
  break;
60
61
  case "history-added": addSections(state.visibleSections,event.sections); state.hasVisibleHistory=true; break;
61
- case "turn-observed": state.turns.add(event.id); break;
62
+ case "turn-observed":
63
+ if (state.currentTurnId !== event.id) reset();
64
+ state.currentTurnId=event.id; state.turns.add(event.id); break;
62
65
  case "session-configured":
63
66
  if (Number.isFinite(event.contextWindow)) state.contextWindow=event.contextWindow;
64
67
  state.baseInstructionTokens=event.instructionTokens; break;
65
68
  case "context-reset": state.compactionEvents++; state.last=null; reset(); break;
66
- case "turn-started": reset(); state.turns.add(event.id); state.runningTurns.add(event.id); break;
67
- case "turn-ended": reset(); if (event.id) state.runningTurns.delete(event.id); else state.runningTurns.clear(); break;
69
+ case "turn-started": reset(); state.currentTurnId=event.id; state.turns.add(event.id); state.runningTurns.add(event.id); break;
70
+ case "turn-ended": reset(); state.currentTurnId=null; if (event.id) state.runningTurns.delete(event.id); else state.runningTurns.clear(); break;
68
71
  case "first-token-measured": state.ttftTotalMs+=event.elapsed; state.ttftSamples++; break;
69
72
  case "compaction-finished": state.compactionCompleted++; break;
70
73
  case "output-observed":
@@ -88,7 +91,7 @@ export function applySessionStatusEvent(state, event) {
88
91
  state.responses.add(event.id);
89
92
  const output=event.last?.output;
90
93
  if (Number.isFinite(output) && output > 0 && state.firstOutputAt != null && Number.isFinite(event.end) && event.end > state.firstOutputAt)
91
- state.speedMeasurements.push({ start:state.firstOutputAt,end:event.end,output,
94
+ state.speedMeasurements.push({ turnId:state.currentTurnId,start:state.firstOutputAt,end:event.end,output,
92
95
  outputs:state.pendingOutputIntervals.slice(),tools:state.pendingToolIntervals.slice() });
93
96
  }
94
97
  reset();
@@ -103,12 +106,23 @@ export function sessionStatusSnapshot(state, threadId) {
103
106
  let decodeTokens = 0;
104
107
  let decodeMs = 0;
105
108
  let speedSamples = 0;
109
+ const turnSpeeds = new Map();
106
110
  for (const sample of state.speedMeasurements) {
107
111
  const elapsed = sample.end - sample.start - boundedToolWaitMs(sample.tools, sample.start, sample.end, sample.outputs);
108
- if (elapsed <= 0 || elapsed >= 600_000) continue;
109
- decodeTokens += sample.output;
110
- decodeMs += elapsed;
111
- speedSamples += 1;
112
+ if (!sample.turnId || elapsed <= 0 || elapsed >= 600_000) continue;
113
+ const turn = turnSpeeds.get(sample.turnId) ?? { tokens:0, ms:0, samples:0 };
114
+ turn.tokens += sample.output;
115
+ turn.ms += elapsed;
116
+ turn.samples += 1;
117
+ turnSpeeds.set(sample.turnId, turn);
118
+ }
119
+ // 按回合顺序取最近三个有效回合;同轮多步合并,无数据的回合不占名额。
120
+ const recentTurns = [...state.turns].filter(id => turnSpeeds.has(id)).slice(-3);
121
+ for (const id of recentTurns) {
122
+ const turn = turnSpeeds.get(id);
123
+ decodeTokens += turn.tokens;
124
+ decodeMs += turn.ms;
125
+ speedSamples += turn.samples;
112
126
  }
113
127
  const total = state.total;
114
128
  const input = total?.input ?? 0;
@@ -7,14 +7,14 @@ const copyByLocale = { "zh-CN": {
7
7
  firstToken: "平均首字延迟", input: "输入", cached: "缓存输入", output: "输出",
8
8
  sum: "合计", capacity: "窗口容量", contextShare: "上下文占用", compactions: "压缩次数",
9
9
  instructions: "指令", messages: "消息", toolHistory: "工具交互", other: "未分类",
10
- speedHelp: "关于 TPS", speedInfo: "GPT 等模型不公开完整思考过程及逐 Token 生成时间。这里的 TPS 是基于可观测事件估算的体感响应速度,仅供参考,不代表模型真实的 Token 生成速度。",
10
+ speedHelp: "关于 TPS", speedInfo: "GPT 等模型不公开完整思考过程及逐 Token 生成时间。TPS 取最近 3 个有有效数据的回合,不足 3 轮时按已有数据计算。它是基于可观测事件估算的体感响应速度,仅供参考,不代表模型真实的 Token 生成速度。",
11
11
  }, en: {
12
12
  turns: "turns", steps: "steps", speed: "TPS", cache: "Cache", cacheDetail: "Cache hit",
13
13
  statistics: "Session stats", usage: "Token usage", context: "Context", total: "Total",
14
14
  firstToken: "Avg first token", input: "Input", cached: "Cached input", output: "Output",
15
15
  sum: "Sum", capacity: "Window size", contextShare: "Context used", compactions: "Compactions",
16
16
  instructions: "Instructions", messages: "Messages", toolHistory: "Tool activity", other: "Unclassified",
17
- speedHelp: "About TPS", speedInfo: "Models such as GPT do not expose their full reasoning process or per-token generation timing. This TPS estimate reflects perceived response speed based on observable events, rather than the model’s actual token generation speed.",
17
+ speedHelp: "About TPS", speedInfo: "Models such as GPT do not expose their full reasoning process or per-token generation timing. TPS uses the latest 3 turns with valid timing data, or fewer if unavailable. It estimates perceived response speed from observable events, rather than the model’s actual token generation speed.",
18
18
  } };
19
19
  function selectedCopy() {
20
20
  const language = document.documentElement.lang || navigator.languages?.[0] || navigator.language || "en";
@@ -7,8 +7,8 @@
7
7
  "zh-CN": "侧栏聚焦"
8
8
  },
9
9
  "description": {
10
- "en": "Fold unrelated branches to keep the current task in view.",
11
- "zh-CN": "收起无关分支,专注当前任务路径。"
10
+ "en": "Collapse other sidebar groups with one click, then click again to restore them.",
11
+ "zh-CN": "一键收起侧栏中的其他分组,再次点击恢复原来的展开状态。"
12
12
  },
13
13
  "entry": {
14
14
  "kind": "page-script",
@@ -4,7 +4,7 @@ const EXPAND_GLYPH = "<svg aria-hidden=\"true\"\n xmlns=\"http://www.w3.org/200
4
4
 
5
5
 
6
6
  const states = new Map();
7
- let active = false, restoring = false, button, timer, disposed = false;
7
+ let active = false, restoring = false, button, timer = null, disposed = false;
8
8
  const text = (en, cn) => (document.documentElement.lang || navigator.language).startsWith("zh") ? cn : en;
9
9
 
10
10
 
@@ -74,8 +74,8 @@ function reconcile() {
74
74
  if (change) { change.toggle(); schedule(); }
75
75
  }
76
76
  function schedule() {
77
- clearTimeout(timer);
78
- timer = setTimeout(reconcile, 40);
77
+ if (disposed || timer !== null) return;
78
+ timer = requestAnimationFrame(() => { timer = null; reconcile(); });
79
79
  }
80
80
  document.addEventListener("click", event => {
81
81
  if (!active || !event.isTrusted) return;
@@ -83,7 +83,15 @@ document.addEventListener("click", event => {
83
83
  active = false; states.clear(); schedule();
84
84
  }
85
85
  }, { capture:true, signal });
86
- const observer = new MutationObserver(schedule);
86
+ const observer = new MutationObserver(records => {
87
+ // 流式回复等侧栏外更新不应推迟或重复触发布局整理。
88
+ if (records.some(record =>
89
+ record.target.closest?.("#app-shell-sidebar")
90
+ || record.type === "attributes" && ["data-above-composer-conversation-id", "lang"].includes(record.attributeName)
91
+ || [...record.addedNodes ?? [], ...record.removedNodes ?? []].some(node =>
92
+ node.matches?.("#app-shell-sidebar") || node.querySelector?.("#app-shell-sidebar"))
93
+ )) schedule();
94
+ });
87
95
  observer.observe(document.documentElement, { subtree:true, childList:true, attributes:true, attributeFilter:[
88
96
  "aria-expanded", "aria-pressed", "data-app-action-sidebar-thread-selected",
89
97
  "data-app-action-sidebar-section-collapsed", "data-above-composer-conversation-id", "lang"
@@ -97,4 +105,4 @@ config.registerSelfCheck?.(() => {
97
105
  return {state:available ? "available" : "unavailable",reason:available ? null : "sidebar-focus-anchor-unavailable",
98
106
  checks:[{name:"sidebar-focus-anchor",available}],scopes:[{name:"sidebar",element:sidebar}]};
99
107
  });
100
- return () => { disposed = true; clearTimeout(timer); observer.disconnect(); button?.remove(); };
108
+ return () => { disposed = true; cancelAnimationFrame(timer); observer.disconnect(); button?.remove(); };
@@ -3,12 +3,12 @@
3
3
  "id": "sites",
4
4
  "version": "0.1.0",
5
5
  "label": {
6
- "en": "Local & Self-hosted Sites",
7
- "zh-CN": "本地与自托管站点"
6
+ "en": "Saved Sites",
7
+ "zh-CN": "站点收藏"
8
8
  },
9
9
  "description": {
10
- "en": "Find project web services and open your saved sites.",
11
- "zh-CN": "发现项目网页服务,收藏并打开常用站点。"
10
+ "en": "Find running project sites and save useful links for quick access.",
11
+ "zh-CN": "发现项目正在运行的网页,收藏常用网址,随时打开。"
12
12
  },
13
13
  "entry": {
14
14
  "kind": "page-script",
@@ -3,6 +3,8 @@ import { localListeners, probePage } from "./discovery.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { mkdir, readFile, rename, writeFile, realpath } from "node:fs/promises";
5
5
  import os from "node:os";
6
+ import { readFileSync, writeFileSync, renameSync, rmSync } from "node:fs";
7
+ import { DatabaseSync } from "node:sqlite";
6
8
  import path from "node:path";
7
9
  import { CodexCatalog } from "../adapters/codex/catalog.mjs";
8
10
 
@@ -21,17 +23,30 @@ export class SitesService {
21
23
  this.catalogReader = catalog; this.listeners = listeners; this.probe = probe; this.queue = Promise.resolve();
22
24
  }
23
25
  catalog() { return this.catalogReader.snapshot(); }
24
- async read() {
25
- try { const data = JSON.parse(await readFile(this.file, "utf8")); if (data.schemaVersion !== 1 || !Array.isArray(data.sites)) throw new Error("Unsupported sites data."); return data.sites.map(site => site.url ? { ...site, hosting: isLocalAddress(new URL(site.url).hostname) ? "local" : "self-hosted" } : site); }
26
+ readStoredSites() {
27
+ try { const data = JSON.parse(readFileSync(this.file, "utf8")); if (data.schemaVersion !== 1 || !Array.isArray(data.sites)) throw new Error("Unsupported sites data."); return data.sites.map(site => site.url ? { ...site, hosting: isLocalAddress(new URL(site.url).hostname) ? "local" : "self-hosted" } : site); }
26
28
  catch (error) { if (error.code === "ENOENT") return []; throw error; }
27
29
  }
30
+ async read() { return this.readStoredSites(); }
28
31
  async mutate(operation) {
29
32
  const task = this.queue.catch(() => {}).then(async () => {
30
- const sites = await this.read(); const result = operation(sites);
31
33
  await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
34
+ // SQLite 仅协调跨进程写锁,进程退出自动释放;业务数据仍在 sites.json。
35
+ const lock = new DatabaseSync(path.join(path.dirname(this.file), "sites-write-lock.sqlite"));
32
36
  const temporary = `${this.file}.${randomUUID()}.tmp`;
33
- await writeFile(temporary, JSON.stringify({ schemaVersion: 1, sites }, null, 2) + "\n", { mode: 0o600 });
34
- await rename(temporary, this.file); return result;
37
+ try {
38
+ lock.exec("PRAGMA busy_timeout=3000; BEGIN IMMEDIATE");
39
+ // 锁内不让出事件循环,避免同进程另一个写入等待自己。
40
+ const sites = this.readStoredSites(); const result = operation(sites);
41
+ writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, sites }, null, 2) + "\n", { mode: 0o600 });
42
+ renameSync(temporary, this.file);
43
+ lock.exec("COMMIT");
44
+ return result;
45
+ } finally {
46
+ if (lock.isTransaction) lock.exec("ROLLBACK");
47
+ lock.close();
48
+ rmSync(temporary, { force: true });
49
+ }
35
50
  });
36
51
  this.queue = task; return task;
37
52
  }
@@ -0,0 +1,43 @@
1
+ import { SitesService } from './sites-service.mjs';
2
+ import { normalizeSite, isLocalAddress } from '../domain/site.mjs';
3
+
4
+ function portable(row) {
5
+ if (!row.url) return null;
6
+ const site = normalizeSite({ title: row.title, url: row.url, origin: { kind: 'external' } });
7
+ if (isLocalAddress(new URL(site.url).hostname) || new URL(site.url).hostname === '0.0.0.0' || new URL(site.url).hostname === '[::]') return null;
8
+ return { id: row.id, title: site.title, url: site.url };
9
+ }
10
+ export function validateSites(data) {
11
+ if (!data || data.version !== 1 || !Array.isArray(data.sites) || data.sites.length > 50000) throw new Error('Unsupported Sites data');
12
+ const ids = new Set();
13
+ return { version: 1, sites: data.sites.map(row => {
14
+ if (!row || typeof row.id !== 'string' || !row.id || row.id.length > 256 || ids.has(row.id) || typeof row.title !== 'string' || row.title.length > 160 || typeof row.url !== 'string' || row.url.length > 8192) throw new Error('Invalid Site record');
15
+ const clean = portable(row);
16
+ if (!clean) throw new Error('Local URLs cannot be transferred');
17
+ ids.add(row.id); return clean;
18
+ }) };
19
+ }
20
+ function merge(sites, incoming) {
21
+ const result = { added: 0, duplicate: 0, conflict: 0 };
22
+ const ids = new Map(sites.map(row => [row.id,row])), urls = new Set(sites.map(row => row.url));
23
+ for (const row of incoming.sites) {
24
+ const match = ids.get(row.id);
25
+ if (match) result[match.url === row.url && match.title === row.title ? 'duplicate' : 'conflict']++;
26
+ else if (urls.has(row.url)) result.duplicate++;
27
+ else {
28
+ result.added++;
29
+ ids.set(row.id,row); urls.add(row.url);
30
+ sites.push({ ...row, origin: { kind: 'external' }, hosting: 'self-hosted', createdAt: Date.now(), updatedAt: Date.now() });
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+ export function sitesTransfer(dataDir) {
36
+ const service = new SitesService({ dataDir });
37
+ return {
38
+ id: 'sites', validate: validateSites,
39
+ async snapshot() { const all = await service.read(), sites = all.map(portable).filter(Boolean); return { data: { version: 1, sites }, count: sites.length, excluded: all.length - sites.length }; },
40
+ async preview(data) { return merge(await service.read(), data); },
41
+ async apply(data) { return service.mutate(sites => merge(sites, data)); },
42
+ };
43
+ }
@@ -2,10 +2,13 @@
2
2
  "schemaVersion": 1,
3
3
  "id": "usage-meter",
4
4
  "version": "0.1.0",
5
- "label": { "en": "Usage Meter", "zh-CN": "额度环" },
5
+ "label": {
6
+ "en": "Usage Ring",
7
+ "zh-CN": "额度环"
8
+ },
6
9
  "description": {
7
- "en": "Check your remaining weekly and five-hour allowance.",
8
- "zh-CN": "随时查看每周与五小时剩余额度。"
10
+ "en": "Check your five-hour and weekly allowance in the sidebar; click for details.",
11
+ "zh-CN": "在侧栏查看五小时和每周剩余额度,点击打开用量详情。"
9
12
  },
10
13
  "entry": { "kind": "page-script", "script": "src/usage-meter.mjs", "sourceFactory": "src/injection-source.mjs" },
11
14
  "icon": { "viewBox": "0 0 24 24", "markup": "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle><circle cx=\"12\" cy=\"12\" r=\"5\"></circle>" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -59,14 +59,22 @@
59
59
  "assets/brand/minecodex-icon-dark.svg",
60
60
  "assets/brand/references/lucide-LICENSE.txt",
61
61
  "assets/readme/hero.svg",
62
- "packages/ui/assets"
62
+ "packages/ui/assets",
63
+ "features/markdown-reading/src",
64
+ "features/markdown-reading/codex-feature.json",
65
+ "features/markdown-reading/vendor/lucide-LICENSE",
66
+ "features/markdown-reading/assets",
67
+ "features/file-navigation/src",
68
+ "features/file-navigation/codex-feature.json",
69
+ "features/file-navigation/assets",
70
+ "features/file-navigation/vendor/lucide-LICENSE"
63
71
  ],
64
72
  "engines": {
65
73
  "node": ">=22.5.0"
66
74
  },
67
75
  "scripts": {
68
76
  "start": "node packages/cli/bin/mcx.mjs serve",
69
- "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs features/session-status/test/*.test.mjs features/usage-meter/test/*.test.mjs apps/dashboard/test/*.test.mjs features/unread-inbox/test/*.test.mjs features/sidebar-focus/test/*.test.mjs features/sites/test/*.test.mjs packages/ui/test/*.test.mjs packages/codex-adapter/test/*.test.mjs",
77
+ "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs features/session-status/test/*.test.mjs features/usage-meter/test/*.test.mjs apps/dashboard/test/*.test.mjs features/unread-inbox/test/*.test.mjs features/sidebar-focus/test/*.test.mjs features/sites/test/*.test.mjs packages/ui/test/*.test.mjs packages/codex-adapter/test/*.test.mjs features/markdown-reading/test/*.test.mjs features/file-navigation/test/*.test.mjs",
70
78
  "test:cli": "node --test packages/cli/test/*.test.mjs",
71
79
  "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
72
80
  "check": "node scripts/check-syntax.mjs",
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner", "session-status", "usage-meter", "sidebar-focus", "sites"]);
5
+ export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner", "session-status", "usage-meter", "sidebar-focus", "sites", "markdown-reading", "file-navigation"]);
6
6
  export const DEFAULT_CONFIG = Object.freeze({
7
7
  schemaVersion: 1,
8
8
  features: Object.freeze({
@@ -14,6 +14,8 @@ export const DEFAULT_CONFIG = Object.freeze({
14
14
  "usage-meter": true,
15
15
  "sidebar-focus": true,
16
16
  sites: true,
17
+ "markdown-reading": true,
18
+ "file-navigation": true,
17
19
  }),
18
20
  });
19
21
 
@@ -22,7 +24,7 @@ const configQueues = new Map();
22
24
  function normalizeFeatures(features) {
23
25
  return Object.fromEntries(FEATURE_IDS.map((id) => [
24
26
  id,
25
- typeof features?.[id] === "boolean" ? features[id] : true,
27
+ typeof features?.[id] === "boolean" ? features[id] : DEFAULT_CONFIG.features[id],
26
28
  ]));
27
29
  }
28
30
 
@@ -9,13 +9,17 @@ import { BrowserExchangeStore } from "./browser-exchange.mjs";
9
9
  import { FEATURE_IDS, readConfig, updateFeature } from "./config.mjs";
10
10
  import { normalizeRuntimeReady } from "./runtime-manager.mjs";
11
11
 
12
+ import { createDataTransfer, MAX_TRANSFER_BYTES } from "./data-transfer/service.mjs";
13
+
12
14
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
13
15
  const packageMetadata = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
14
16
  const MAX_BODY_BYTES = 64 * 1024;
17
+ const DASHBOARD_FEATURE_ORDER = ["model-slider", "notes", "usage-meter", "session-status", "markdown-reading", "file-navigation", "images", "sites", "sidebar-focus", "hide-upsell-banner"];
15
18
  const CONTENT_TYPES = new Map([
16
19
  [".css", "text/css; charset=utf-8"],
17
20
  [".html", "text/html; charset=utf-8"],
18
21
  [".js", "text/javascript; charset=utf-8"],
22
+ [".mjs", "text/javascript; charset=utf-8"],
19
23
  [".svg", "image/svg+xml"],
20
24
  [".ttf", "font/ttf"],
21
25
  ]);
@@ -51,12 +55,12 @@ function json(response, statusCode, value) {
51
55
  response.end(body);
52
56
  }
53
57
 
54
- async function readJson(request) {
58
+ async function readJson(request, maxBytes = MAX_BODY_BYTES) {
55
59
  let size = 0;
56
60
  const chunks = [];
57
61
  for await (const chunk of request) {
58
62
  size += chunk.length;
59
- if (size > MAX_BODY_BYTES) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
63
+ if (size > maxBytes) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
60
64
  chunks.push(chunk);
61
65
  }
62
66
  let value;
@@ -90,7 +94,7 @@ function acceptedHealth(featureId, health) {
90
94
  }
91
95
 
92
96
  async function loadFeatureDefinitions(paths, locale = "en") {
93
- return Promise.all(FEATURE_IDS.map(async (id) => {
97
+ return Promise.all([...FEATURE_IDS].sort((a, b) => DASHBOARD_FEATURE_ORDER.indexOf(a) - DASHBOARD_FEATURE_ORDER.indexOf(b)).map(async (id) => {
94
98
  const manifestPath = path.join(paths.featuresRoot, id, "codex-feature.json");
95
99
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
96
100
  return {
@@ -203,11 +207,15 @@ async function iconResponse(id, paths, response) {
203
207
  iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "book-open.svg");
204
208
  } else if (FEATURE_IDS.includes(id)) {
205
209
  const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
206
- const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
207
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
208
- response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
209
- response.end(svg);
210
- return;
210
+ if (manifest.icon?.resource) {
211
+ iconPath = path.resolve(paths.featuresRoot, id, manifest.icon.resource);
212
+ } else {
213
+ const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
214
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
215
+ response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
216
+ response.end(svg);
217
+ return;
218
+ }
211
219
  } else {
212
220
  json(response, 404, { error: "NOT_FOUND" });
213
221
  return;
@@ -220,8 +228,9 @@ async function iconResponse(id, paths, response) {
220
228
 
221
229
  async function staticResponse(url, paths, response) {
222
230
  const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
223
- const absolute = path.resolve(paths.dashboardDir, requested);
224
- if (absolute !== paths.dashboardDir && !absolute.startsWith(`${paths.dashboardDir}${path.sep}`)) {
231
+ const sharedTooltip = requested === "assets/ui-tooltip.js";
232
+ const absolute = sharedTooltip ? path.join(packageRoot, "packages/ui/src/components/tooltip.mjs") : path.resolve(paths.dashboardDir, requested);
233
+ if (!sharedTooltip && absolute !== paths.dashboardDir && !absolute.startsWith(`${paths.dashboardDir}${path.sep}`)) {
225
234
  json(response, 404, { error: "NOT_FOUND" });
226
235
  return;
227
236
  }
@@ -251,6 +260,8 @@ export async function createControlServer({
251
260
  logger = console,
252
261
  exchangeStore = new BrowserExchangeStore(),
253
262
  }) {
263
+ let transfer;
264
+ const dataTransfer = () => transfer ??= createDataTransfer({ paths, version: packageMetadata.version });
254
265
  let boundPort = port;
255
266
  const server = createServer(async (request, response) => {
256
267
  try {
@@ -328,6 +339,19 @@ export async function createControlServer({
328
339
  json(response, 200, { exchange: exchangeStore.issue(token), expiresInMs: exchangeStore.ttlMs });
329
340
  return;
330
341
  }
342
+ if (request.method === "GET" && url.pathname === "/api/data") {
343
+ json(response, 200, await dataTransfer().inventory()); return;
344
+ }
345
+ if (request.method === "GET" && url.pathname === "/api/data/export") {
346
+ const bundle = await dataTransfer().export();
347
+ response.setHeader("Content-Disposition", 'attachment; filename="minecodex-data.mcx.json"');
348
+ json(response, 200, bundle); return;
349
+ }
350
+ if (request.method === "POST" && ["/api/data/preview", "/api/data/import"].includes(url.pathname)) {
351
+ requireMutationOrigin(request, host, boundPort);
352
+ const bundle = await readJson(request, MAX_TRANSFER_BYTES);
353
+ json(response, 200, await dataTransfer()[url.pathname.endsWith("/import") ? "import" : "preview"](bundle)); return;
354
+ }
331
355
  const featureMatch = url.pathname.match(/^\/api\/features\/([a-z-]+)$/);
332
356
  if (request.method === "PATCH" && featureMatch) {
333
357
  requireMutationOrigin(request, host, boundPort);
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { promptsTransfer } from '../../../../features/notes/src/backend/transfer.mjs';
3
+ import { sitesTransfer } from '../../../../features/sites/src/backend/transfer.mjs';
4
+
5
+ import { favoritesTransfer } from '../../../../features/model-slider/src/backend/favorites.mjs';
6
+ import { FEATURE_IDS } from '../config.mjs';
7
+
8
+ export const MAX_TRANSFER_BYTES = 16 * 1024 * 1024;
9
+ export function createDataTransfer({ paths, version, adapters = [
10
+ promptsTransfer(process.env.CODEX_NOTES_DATABASE || path.join(paths.notesDataDir, 'codex-notes.sqlite')),
11
+ sitesTransfer(process.env.MINECODEX_SITES_DATA_DIR || path.join(paths.supportDir, 'sites')),
12
+ favoritesTransfer(process.env.MINECODEX_MODEL_DATA_FILE || path.join(paths.supportDir, 'model-slider', 'favorites.sqlite')),
13
+ ] }) {
14
+ const registry = new Map(adapters.map(adapter => [adapter.id, adapter]));
15
+ function validate(bundle) {
16
+ if (!bundle || bundle.format !== 'minecodex-data' || bundle.version !== 1 || !bundle.features || typeof bundle.features !== 'object' || Array.isArray(bundle.features)) throw Object.assign(new Error('Unsupported MineCodex data file'), { statusCode: 400 });
17
+ try {
18
+ return Object.entries(bundle.features).map(([id, data]) => {
19
+ if (!registry.has(id)) throw new Error(`Unsupported feature: ${id}`);
20
+ return [id, registry.get(id).validate(data)];
21
+ });
22
+ } catch (error) { throw Object.assign(error, { statusCode: 400 }); }
23
+ }
24
+ return {
25
+ async inventory() { return { features: await Promise.all(FEATURE_IDS.map(async id => { const adapter = registry.get(id); if (!adapter) return { id, portable: false }; const { data, ...counts } = await adapter.snapshot(); return { id, portable: true, ...counts }; })) }; },
26
+ async export() {
27
+ const bundle = { format: 'minecodex-data', version: 1, appVersion: version, exportedAt: new Date().toISOString(), features: Object.fromEntries(await Promise.all(adapters.map(async adapter => [adapter.id, (await adapter.snapshot()).data]))) };
28
+ if (Buffer.byteLength(JSON.stringify(bundle, null, 2)) > MAX_TRANSFER_BYTES) throw Object.assign(new Error('Data file exceeds 16 MB'), { statusCode: 413 });
29
+ return bundle;
30
+ },
31
+ async preview(bundle) { return { features: await Promise.all(validate(bundle).map(async ([id, data]) => ({ id, ...await registry.get(id).preview(data) }))) }; },
32
+ async import(bundle) {
33
+ const entries = validate(bundle), results = [];
34
+ // 各领域独立提交;失败时保留已完成结果,重试依靠幂等合并。
35
+ for (const [id, data] of entries) {
36
+ try { results.push({ id, status: 'imported', ...await registry.get(id).apply(data) }); }
37
+ catch { results.push({ id, status: 'failed' }); }
38
+ }
39
+ return { complete: results.every(result => result.status === 'imported'), features: results };
40
+ },
41
+ };
42
+ }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { spawn } from "node:child_process";
2
3
  import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
4
  import { ChatGPTLaunchCoordinator } from "./chatgpt-launch-coordinator.mjs";
@@ -189,6 +190,8 @@ export class RuntimeManager {
189
190
  MINECODEX_CODEX_BUILD: codexBuild ?? "",
190
191
  CODEX_IMAGE_HOST_DATA_DIR: this.paths.imagesDataDir,
191
192
  CODEX_NOTES_DATA_DIR: this.paths.notesDataDir,
193
+ MINECODEX_MODEL_DATA_FILE: process.env.MINECODEX_MODEL_DATA_FILE || path.join(this.paths.supportDir, "model-slider", "favorites.sqlite"),
194
+ MINECODEX_SITES_DATA_DIR: process.env.MINECODEX_SITES_DATA_DIR || path.join(this.paths.supportDir, "sites"),
192
195
  MINECODEX_ENABLED_FEATURES: features.join(","),
193
196
  MINECODEX_CODEX_PID_FILE: this.paths.codexPidPath,
194
197
  MINECODEX_RUNTIME_READY_FILE: this.paths.runtimeReadyPath,
@@ -254,6 +254,7 @@ export function createInjectionSource(features, {
254
254
  try {
255
255
  modelSlider = installModelSliderFeature(lifetime.signal, {
256
256
  feature: modelSelectorFeature, codex, ui: featureUI, createIcon: createMarkupIcon, requestRefresh: queueEnsure, locale,
257
+ requestAction: payload => requestPageHostAction(modelSelectorFeature.id, "model-favorites", payload),
257
258
  });
258
259
  if (modelSelectorFeature) {
259
260
  selfCheck.register(modelSelectorFeature.id, () => modelSlider.selfCheck());
@@ -736,7 +737,7 @@ export function createInjectionSource(features, {
736
737
  "--codex-summary-row-line-height": "24px",
737
738
  "--codex-summary-label-font-size": tokens["--text-base"] ?? "14px",
738
739
  "--codex-summary-label-line-height": tokens["--text-base--line-height"] ?? "1.5",
739
- "--codex-summary-font-weight": tokens["--vscode-font-weight"] ?? (style.fontWeight || "445"),
740
+ "--codex-summary-font-weight": style.fontWeight || "445",
740
741
  "--codex-summary-icon-size": "18px",
741
742
  "--codex-summary-icon-stroke-width": "1.5px",
742
743
  "--codex-summary-panel-radius": panelRadius,
@@ -36,6 +36,7 @@ import {
36
36
  attachFilesToComposer,
37
37
  createImageEditThread,
38
38
  } from "./host-actions.mjs";
39
+ import { ModelFavorites } from "../../../features/model-slider/src/backend/favorites.mjs";
39
40
  import { SitesService } from "../../../features/sites/src/backend/sites-service.mjs";
40
41
  import { SessionStatusReader, threadIdFromLocation } from "../../../features/session-status/src/adapters/codex/session-reader.mjs";
41
42
  import { createCodexEnvironment } from "../../codex-adapter/src/renderer/environment.mjs";
@@ -108,6 +109,7 @@ export class CodexRuntime {
108
109
  this.rendererTargetIds = new Set();
109
110
  this.targetFailures = new Map();
110
111
  this.targetChecks = new Map();
112
+ this.targetCheckDeadlines = new Map();
111
113
  this.clients = new Map();
112
114
  this.scriptIds = new Map();
113
115
  this.bindingTokens = new Map();
@@ -244,6 +246,7 @@ export class CodexRuntime {
244
246
  this.rendererTargetIds.clear();
245
247
  this.targetFailures.clear();
246
248
  this.targetChecks.clear();
249
+ this.targetCheckDeadlines.clear();
247
250
  this.rendererDiscovered = false;
248
251
  this.browserClient?.socket.close();
249
252
  this.browserClient = null;
@@ -576,6 +579,16 @@ export class CodexRuntime {
576
579
  }
577
580
  return;
578
581
  }
582
+ if (feature.id === "model-slider" && request.action === "model-favorites") {
583
+ try {
584
+ this.modelFavorites ??= new ModelFavorites();
585
+ const result = this.modelFavorites.action(request.payload);
586
+ await this.resolveHostAction(client, request.requestId, { ok: true, result });
587
+ } catch (error) {
588
+ await this.resolveHostAction(client, request.requestId, { ok: false, error: { code: "MODEL_FAVORITES_FAILED", message: error.message } });
589
+ }
590
+ return;
591
+ }
579
592
  if (feature.id === "sites" && request.action === "sites") {
580
593
  try {
581
594
  const result = await this.sitesService.action(request.payload);
@@ -703,6 +716,7 @@ export class CodexRuntime {
703
716
  this.scriptIds.delete(id);
704
717
  this.bindingTokens.delete(id);
705
718
  this.targetChecks.delete(id);
719
+ this.targetCheckDeadlines.delete(id);
706
720
  this.hostActionChains.delete(id);
707
721
  this.injectedTargets.delete(id);
708
722
  this.targetFailures.delete(id);
@@ -724,6 +738,7 @@ export class CodexRuntime {
724
738
  this.bindingTokens.delete(target.id);
725
739
  this.injectedTargets.delete(target.id);
726
740
  this.targetChecks.delete(target.id);
741
+ this.targetCheckDeadlines.delete(target.id);
727
742
  }
728
743
  if (target.type !== "page" || target.url !== "app://-/index.html" || this.injectedTargets.has(target.id)) {
729
744
  continue;
@@ -748,6 +763,7 @@ export class CodexRuntime {
748
763
  if (this.clients.get(target.id) !== client) return;
749
764
  // 新文档撤销启动检查,下一次接入周期重新检查一次。
750
765
  this.targetChecks.delete(target.id);
766
+ this.targetCheckDeadlines.delete(target.id);
751
767
  this.notifyStatusChange();
752
768
  });
753
769
  client.on?.("Runtime.bindingCalled", (params) => {
@@ -814,7 +830,12 @@ export class CodexRuntime {
814
830
  async checkTarget(targetId) {
815
831
  const client=this.clients.get(targetId);
816
832
  if (!client || this.stopping) return false;
817
- if (this.targetChecks.has(targetId)) return true;
833
+ const previous = this.targetChecks.get(targetId);
834
+ const needsRetry = !previous?.checkedAt || previous.plugins.some(plugin =>
835
+ ["unknown", "unavailable", "degraded"].includes(plugin.state));
836
+ if (previous && (!needsRetry || Date.now() >= this.targetCheckDeadlines.get(targetId))) return true;
837
+ // 页面启动时锚点尚未就绪:借用发现周期有限重试,稳定后停止检查。
838
+ if (!this.targetCheckDeadlines.has(targetId)) this.targetCheckDeadlines.set(targetId, Date.now() + 30_000);
818
839
  try {
819
840
  const response=await client.send("Runtime.evaluate", {
820
841
  expression:`(() => { const runtime=window.__codexPersonalRuntime; return runtime?.version === ${RUNTIME_VERSION} && runtime.sessionId === ${JSON.stringify(this.runtimeSessionId)} ? runtime.selfCheck() : null; })()`,
@@ -865,6 +886,7 @@ export class CodexRuntime {
865
886
  client.socket.close();
866
887
  this.injectedTargets.delete(targetId);
867
888
  this.targetChecks.delete(targetId);
889
+ this.targetCheckDeadlines.delete(targetId);
868
890
  this.notifyStatusChange();
869
891
  }
870
892
  }
@@ -909,6 +931,7 @@ export class CodexRuntime {
909
931
  this.rendererTargetIds.clear();
910
932
  this.targetFailures.clear();
911
933
  this.targetChecks.clear();
934
+ this.targetCheckDeadlines.clear();
912
935
  this.rendererDiscovered = false;
913
936
  this.notifyStatusChange();
914
937
  this.monitorPromise = null;
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
  const MANIFEST_NAME = "codex-feature.json";
9
9
  const HOST_ACTIONS = new Set([
10
10
  "sites",
11
+ "model-favorites",
11
12
  "quick-prompts",
12
13
  "insert-text",
13
14
  "attach-file",
@@ -46,5 +46,27 @@ export function createNativeThemeAdapter(themeTokenNames,themeTokenAliases,theme
46
46
  : projectName && projectValue !== value ? "scope-mapping-mismatch" : null};
47
47
  });
48
48
  }
49
- return {read,dispose,checkTokens,diagnostics:() => checkTokens(document.documentElement,themeTokenNames)};
49
+ // 局部组件的映射保留原生变量引用,随作用域和主题变化,不复制根节点的快照值。
50
+ function bindScope(element, names) {
51
+ const bindings = names.map(name => {
52
+ const projectName = themeBindings[name];
53
+ if (!projectName) throw new Error(`Unknown theme binding: ${name}`);
54
+ const candidates = [name, ...(themeTokenAliases[name] ?? [])].flatMap(candidate => candidate.startsWith("--color-")
55
+ ? [candidate, candidate.replace("--color-", "--app-color-")] : [candidate]);
56
+ const reference = candidates.reduceRight((value, candidate) => value ? `var(${candidate}, ${value})` : `var(${candidate})`, "");
57
+ const previous = element.style.getPropertyValue(projectName);
58
+ const priority = element.style.getPropertyPriority(projectName);
59
+ element.style.setProperty(projectName, reference);
60
+ return {projectName, reference, previous, priority};
61
+ });
62
+ return () => {
63
+ for (const {projectName, reference, previous, priority} of bindings) {
64
+ if (element.style.getPropertyValue(projectName) !== reference) continue;
65
+ if (previous) element.style.setProperty(projectName, previous, priority);
66
+ else element.style.removeProperty(projectName);
67
+ }
68
+ };
69
+ }
70
+ // 根清单仅展示已定义变量;必要值缺失仍由控件作用域检查报告。
71
+ return {read,dispose,checkTokens,bindScope,diagnostics:() => checkTokens(document.documentElement,themeTokenNames).filter(token => token.available)};
50
72
  }