minecodex 2.0.11 → 2.0.13

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 (60) hide show
  1. package/README.md +12 -0
  2. package/apps/dashboard/app.js +37 -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 +52 -1
  8. package/features/file-navigation/assets/book-open.svg +16 -0
  9. package/features/file-navigation/codex-feature.json +27 -0
  10. package/features/file-navigation/src/adapters/codex/modules.mjs +67 -0
  11. package/features/file-navigation/src/adapters/codex/navigation.mjs +84 -0
  12. package/features/file-navigation/src/domain/navigation.mjs +12 -0
  13. package/features/file-navigation/src/entry.mjs +34 -0
  14. package/features/file-navigation/src/frontend/navigation.mjs +140 -0
  15. package/features/file-navigation/src/injection-source.mjs +9 -0
  16. package/features/file-navigation/src/navigation.css +16 -0
  17. package/features/file-navigation/vendor/lucide-LICENSE +43 -0
  18. package/features/markdown-reading/assets/book-open.svg +16 -0
  19. package/features/markdown-reading/codex-feature.json +27 -0
  20. package/features/markdown-reading/src/adapters/codex/links.mjs +280 -0
  21. package/features/markdown-reading/src/adapters/codex/lists.mjs +168 -0
  22. package/features/markdown-reading/src/adapters/codex/markdown-native.mjs +28 -0
  23. package/features/markdown-reading/src/adapters/codex/preview.mjs +38 -0
  24. package/features/markdown-reading/src/adapters/codex/providers.mjs +18 -0
  25. package/features/markdown-reading/src/adapters/codex/reading-runtime.mjs +152 -0
  26. package/features/markdown-reading/src/adapters/codex/rich-text.mjs +561 -0
  27. package/features/markdown-reading/src/adapters/codex/table-actions.mjs +270 -0
  28. package/features/markdown-reading/src/adapters/codex/tables.mjs +493 -0
  29. package/features/markdown-reading/src/adapters/codex/toc-native.mjs +131 -0
  30. package/features/markdown-reading/src/adapters/codex/toc.mjs +334 -0
  31. package/features/markdown-reading/src/domain/footnotes.mjs +56 -0
  32. package/features/markdown-reading/src/injection-source.mjs +43 -0
  33. package/features/markdown-reading/src/markdown-reading.mjs +82 -0
  34. package/features/markdown-reading/src/reading.css +346 -0
  35. package/features/markdown-reading/vendor/lucide-LICENSE +43 -0
  36. package/features/model-slider/codex-feature.json +1 -0
  37. package/features/model-slider/src/adapters/codex/controller.mjs +7 -5
  38. package/features/model-slider/src/backend/favorites.mjs +54 -0
  39. package/features/model-slider/src/domain/model-rules.mjs +6 -1
  40. package/features/model-slider/src/frontend/install.mjs +41 -18
  41. package/features/notes/assets/prompt-previews.json +26 -0
  42. package/features/notes/codex-feature.json +2 -1
  43. package/features/notes/src/backend/transfer.mjs +97 -0
  44. package/features/notes/src/frontend/entry.mjs +380 -10
  45. package/features/notes/src/frontend/icons.json +4 -1
  46. package/features/notes/src/frontend/styles.css +63 -0
  47. package/features/session-status/src/domain/session-stats.mjs +22 -8
  48. package/features/session-status/src/session-status.mjs +2 -2
  49. package/features/sites/src/backend/sites-service.mjs +20 -5
  50. package/features/sites/src/backend/transfer.mjs +43 -0
  51. package/package.json +11 -3
  52. package/packages/cli/src/config.mjs +4 -2
  53. package/packages/cli/src/control-server.mjs +23 -4
  54. package/packages/cli/src/data-transfer/service.mjs +42 -0
  55. package/packages/cli/src/runtime-manager.mjs +3 -0
  56. package/packages/runtime-host/src/codex-injection.mjs +1 -0
  57. package/packages/runtime-host/src/codex-runtime.mjs +11 -0
  58. package/packages/runtime-host/src/feature-registry.mjs +1 -0
  59. package/packages/ui/src/native/theme.mjs +22 -1
  60. package/packages/ui/src/native/tokens.mjs +4 -0
@@ -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";
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
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": false,
18
+ "file-navigation": false,
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,6 +9,8 @@ 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;
@@ -16,6 +18,7 @@ const CONTENT_TYPES = new Map([
16
18
  [".css", "text/css; charset=utf-8"],
17
19
  [".html", "text/html; charset=utf-8"],
18
20
  [".js", "text/javascript; charset=utf-8"],
21
+ [".mjs", "text/javascript; charset=utf-8"],
19
22
  [".svg", "image/svg+xml"],
20
23
  [".ttf", "font/ttf"],
21
24
  ]);
@@ -51,12 +54,12 @@ function json(response, statusCode, value) {
51
54
  response.end(body);
52
55
  }
53
56
 
54
- async function readJson(request) {
57
+ async function readJson(request, maxBytes = MAX_BODY_BYTES) {
55
58
  let size = 0;
56
59
  const chunks = [];
57
60
  for await (const chunk of request) {
58
61
  size += chunk.length;
59
- if (size > MAX_BODY_BYTES) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
62
+ if (size > maxBytes) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
60
63
  chunks.push(chunk);
61
64
  }
62
65
  let value;
@@ -220,8 +223,9 @@ async function iconResponse(id, paths, response) {
220
223
 
221
224
  async function staticResponse(url, paths, response) {
222
225
  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}`)) {
226
+ const sharedTooltip = requested === "assets/ui-tooltip.js";
227
+ const absolute = sharedTooltip ? path.join(packageRoot, "packages/ui/src/components/tooltip.mjs") : path.resolve(paths.dashboardDir, requested);
228
+ if (!sharedTooltip && absolute !== paths.dashboardDir && !absolute.startsWith(`${paths.dashboardDir}${path.sep}`)) {
225
229
  json(response, 404, { error: "NOT_FOUND" });
226
230
  return;
227
231
  }
@@ -251,6 +255,8 @@ export async function createControlServer({
251
255
  logger = console,
252
256
  exchangeStore = new BrowserExchangeStore(),
253
257
  }) {
258
+ let transfer;
259
+ const dataTransfer = () => transfer ??= createDataTransfer({ paths, version: packageMetadata.version });
254
260
  let boundPort = port;
255
261
  const server = createServer(async (request, response) => {
256
262
  try {
@@ -328,6 +334,19 @@ export async function createControlServer({
328
334
  json(response, 200, { exchange: exchangeStore.issue(token), expiresInMs: exchangeStore.ttlMs });
329
335
  return;
330
336
  }
337
+ if (request.method === "GET" && url.pathname === "/api/data") {
338
+ json(response, 200, await dataTransfer().inventory()); return;
339
+ }
340
+ if (request.method === "GET" && url.pathname === "/api/data/export") {
341
+ const bundle = await dataTransfer().export();
342
+ response.setHeader("Content-Disposition", 'attachment; filename="minecodex-data.mcx.json"');
343
+ json(response, 200, bundle); return;
344
+ }
345
+ if (request.method === "POST" && ["/api/data/preview", "/api/data/import"].includes(url.pathname)) {
346
+ requireMutationOrigin(request, host, boundPort);
347
+ const bundle = await readJson(request, MAX_TRANSFER_BYTES);
348
+ json(response, 200, await dataTransfer()[url.pathname.endsWith("/import") ? "import" : "preview"](bundle)); return;
349
+ }
331
350
  const featureMatch = url.pathname.match(/^\/api\/features\/([a-z-]+)$/);
332
351
  if (request.method === "PATCH" && featureMatch) {
333
352
  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());
@@ -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";
@@ -576,6 +577,16 @@ export class CodexRuntime {
576
577
  }
577
578
  return;
578
579
  }
580
+ if (feature.id === "model-slider" && request.action === "model-favorites") {
581
+ try {
582
+ this.modelFavorites ??= new ModelFavorites();
583
+ const result = this.modelFavorites.action(request.payload);
584
+ await this.resolveHostAction(client, request.requestId, { ok: true, result });
585
+ } catch (error) {
586
+ await this.resolveHostAction(client, request.requestId, { ok: false, error: { code: "MODEL_FAVORITES_FAILED", message: error.message } });
587
+ }
588
+ return;
589
+ }
579
590
  if (feature.id === "sites" && request.action === "sites") {
580
591
  try {
581
592
  const result = await this.sitesService.action(request.payload);
@@ -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,26 @@ 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
+ return {read,dispose,checkTokens,bindScope,diagnostics:() => checkTokens(document.documentElement,themeTokenNames)};
50
71
  }
@@ -77,7 +77,11 @@ export const CODEX_THEME_TOKENS = Object.freeze([
77
77
  "--color-token-input-foreground",
78
78
  "--color-token-input-placeholder-foreground",
79
79
  "--font-sans-default",
80
+ "--font-content",
81
+ "--text-document",
82
+ "--leading-relaxed",
80
83
  "--font-weight-normal",
84
+ "--font-weight-medium",
81
85
  "--vscode-font-weight",
82
86
  "--default-mono-font-family",
83
87
  "--text-heading-lg",