minecodex 1.2.2 → 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.
- package/README.md +9 -3
- package/features/images/README.md +16 -98
- package/features/images/codex-feature.json +6 -23
- package/features/images/src/library-prompt-page.mjs +239 -0
- package/features/model-slider/THIRD_PARTY_NOTICES.md +1 -1
- package/features/notes/DESIGN.md +66 -0
- package/features/notes/README.md +18 -99
- package/features/notes/assets/icons/book-open-nav.svg +15 -0
- package/features/notes/assets/icons/book-open.svg +16 -0
- package/features/notes/assets/native-emojis.json +1 -0
- package/features/notes/assets/native-prompt-icons.json +28 -0
- package/features/notes/codex-feature.json +12 -70
- package/features/notes/src/main.mjs +2 -2
- package/features/notes/src/product.mjs +287 -613
- package/features/notes/src/prompt-schema.mjs +117 -0
- package/features/notes/src/quick-prompts-page.mjs +1559 -0
- package/features/notes/src/quick-prompts-server.mjs +167 -0
- package/features/session-status/DESIGN.md +10 -2
- package/features/session-status/README.md +8 -4
- package/features/session-status/src/session-status.mjs +268 -24
- package/features/sidebar-focus/DESIGN.md +16 -0
- package/features/sidebar-focus/codex-feature.json +20 -0
- package/features/sidebar-focus/src/sidebar-focus.mjs +134 -0
- package/features/sidebar-focus/vendor/lucide-LICENSE +43 -0
- package/features/unread-inbox/DESIGN.md +28 -0
- package/features/unread-inbox/codex-feature.json +20 -0
- package/features/unread-inbox/src/unread-inbox.mjs +360 -0
- package/features/usage-meter/DESIGN.md +49 -0
- package/features/usage-meter/README.md +18 -0
- package/features/usage-meter/codex-feature.json +13 -0
- package/features/usage-meter/src/usage-meter.mjs +200 -0
- package/package.json +17 -5
- package/packages/cli/src/config.mjs +4 -1
- package/packages/cli/src/control-server.mjs +2 -2
- package/packages/runtime-host/src/codex-injection.mjs +38 -3
- package/packages/runtime-host/src/codex-runtime.mjs +41 -20
- package/packages/runtime-host/src/feature-registry.mjs +1 -0
- package/packages/runtime-host/src/session-status-reader.mjs +40 -16
- package/features/notes/assets/icons/circle.svg +0 -15
- package/features/notes/assets/icons/ellipsis.svg +0 -17
- package/features/notes/assets/icons/external-link.svg +0 -17
- package/features/notes/assets/icons/list-todo.svg +0 -19
- package/features/notes/assets/icons/message-square-quote.svg +0 -17
- package/features/notes/assets/icons/paperclip.svg +0 -15
- package/features/notes/assets/icons/star-off.svg +0 -17
- package/features/notes/assets/icons/star.svg +0 -15
- package/features/notes/assets/icons/sticky-note.svg +0 -16
- package/features/notes/assets/icons/trash-2.svg +0 -19
- package/features/notes/src/http-server.mjs +0 -580
- package/features/notes/web/app.js +0 -1202
- package/features/notes/web/codex-artifact-document.svg +0 -1
- package/features/notes/web/codex-attachment.svg +0 -1
- package/features/notes/web/codex-build.svg +0 -1
- package/features/notes/web/codex-code.svg +0 -1
- package/features/notes/web/codex-cplusplus.svg +0 -1
- package/features/notes/web/codex-css.svg +0 -1
- package/features/notes/web/codex-document.svg +0 -1
- package/features/notes/web/codex-file.svg +0 -1
- package/features/notes/web/codex-folder.svg +0 -1
- package/features/notes/web/codex-hashes.svg +0 -1
- package/features/notes/web/codex-html.svg +0 -1
- package/features/notes/web/codex-image.svg +0 -1
- package/features/notes/web/codex-java.svg +0 -1
- package/features/notes/web/codex-javascript.svg +0 -1
- package/features/notes/web/codex-json.svg +0 -1
- package/features/notes/web/codex-notebook.svg +0 -1
- package/features/notes/web/codex-pdf.svg +0 -1
- package/features/notes/web/codex-pencil.svg +0 -3
- package/features/notes/web/codex-php.svg +0 -1
- package/features/notes/web/codex-plus.svg +0 -3
- package/features/notes/web/codex-presentation.svg +0 -1
- package/features/notes/web/codex-python.svg +0 -1
- package/features/notes/web/codex-react.svg +0 -1
- package/features/notes/web/codex-rust.svg +0 -1
- package/features/notes/web/codex-shell.svg +0 -1
- package/features/notes/web/codex-skill.svg +0 -1
- package/features/notes/web/codex-spreadsheet.svg +0 -1
- package/features/notes/web/codex-task.svg +0 -3
- package/features/notes/web/codex-terminal.svg +0 -1
- package/features/notes/web/codex-toml.svg +0 -1
- package/features/notes/web/codex-typescript.svg +0 -1
- package/features/notes/web/codex-view-all.svg +0 -3
- package/features/notes/web/file-icons.mjs +0 -68
- package/features/notes/web/i18n.mjs +0 -237
- package/features/notes/web/index.html +0 -15
- package/features/notes/web/styles.css +0 -1155
- package/features/notes/web/todo-drag.mjs +0 -18
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Usage Meter
|
|
2
|
+
|
|
3
|
+
## 用户任务与范围
|
|
4
|
+
|
|
5
|
+
在原生导航栏 Help 与 Account 之间查看当前账号剩余额度,点击直达 Settings → Usage。
|
|
6
|
+
仅向 `nav[data-app-navigation-rail="true"]` 注入一个控件,不注册通用侧栏
|
|
7
|
+
菜单项,不向 Settings 的分类列表插入内容。
|
|
8
|
+
|
|
9
|
+
## 数据契约
|
|
10
|
+
|
|
11
|
+
已在 2026-09-27 的隔离 Codex 中确认:原生 QueryClient 缓存
|
|
12
|
+
`["rate-limit-status", userId, accountId]`,由 Codex 自行通过流/轮询更新。
|
|
13
|
+
插件只订阅该缓存,按当前原生 auth 的 userId/accountId 精确读取,不自行请求
|
|
14
|
+
接口,不持久化账号或额度数据。切换账号、退出登录、额度数据缺失时隐藏旧值。
|
|
15
|
+
|
|
16
|
+
窗口按 `limit_window_seconds` 识别:604800 为周、18000 为五小时;不依赖
|
|
17
|
+
primary/secondary 顺序或套餐字符串。剩余百分比为 clamp(100-used_percent,0,100),
|
|
18
|
+
四舍五入显示整数。缺失/非法值不当成零。
|
|
19
|
+
|
|
20
|
+
单周窗口显示一个周环和周剩余数字。周与五小时同时存在时,周在外、五小时在内,
|
|
21
|
+
中央显示五小时剩余数字,不带百分号。没有周窗口时不显示本控件。
|
|
22
|
+
|
|
23
|
+
## 视觉 tokens
|
|
24
|
+
|
|
25
|
+
| 角色 | 权威 token / 值 | 用途 |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| 点击区域 | 克隆 Help 的原生按钮 class 和 data 样式属性;当前 36×36px | 与导航项对齐 |
|
|
28
|
+
| 环图 | 24×24px;viewBox 32;圆心 16;周半径 43/3、五小时半径 10.5;描边 10/3 viewBox units(渲染 2.5px) | 自定义数据图,不是替代导航图标 |
|
|
29
|
+
| 周环 | 继承 Help 按钮的 `currentColor`(原生 tertiary 中性灰) | 单圈或外圈 |
|
|
30
|
+
| 五小时环 | `--color-text-secondary`(中性灰) | 双圈内圈 |
|
|
31
|
+
| 轨道 | 对应环色 18% alpha | 剩余额度的底轨 |
|
|
32
|
+
| 中央数字 | 继承 Help 的颜色;`--font-sans-default`;`--text-xs` × 2/3(8px),单双圈与三位数均一致;500、tabular-nums、line-height:1 | 优先五小时 |
|
|
33
|
+
| Tooltip | `--color-background-tooltip` / `--color-text-tooltip` / `--color-border-tooltip` / `--shadow-tooltip` | 与原生 Help Tooltip 一致 |
|
|
34
|
+
| Tooltip 几何 | `--radius-2xl`;5px 12px padding;`--text-sm` / 18px;两列 gap 16px | label 左、数字右对齐 |
|
|
35
|
+
| Tooltip 字体 | `--tooltip-compact-font-weight` / `--tracking-tooltip` | 与原生紧凑提示一致 |
|
|
36
|
+
|
|
37
|
+
环从顶部顺时针填充。Tooltip 在按钮右侧 2px,纵向居中并约束在窗口内;
|
|
38
|
+
鼠标悬停延迟 350ms,键盘聚焦立即显示,Escape 隐藏。单圈一行 Weekly Limit;
|
|
39
|
+
双圈依次为 5h Limit、Weekly Limit,数值带 %。没有额外标题或说明。
|
|
40
|
+
|
|
41
|
+
原生 Help 图标为 20px,点击区域 36px。Account 头像可见直径为 24px;额度外环含描边的可见直径同为 24px,
|
|
42
|
+
数字与环均以按钮几何中心对齐;中性灰取自原生 navbar,不使用彩色强调。
|
|
43
|
+
单双圈使用同一实现,双圈验收图采用模拟值,不更改账号缓存。减少动画偏好不添加进度过渡。
|
|
44
|
+
|
|
45
|
+
## 跳转与生命周期
|
|
46
|
+
|
|
47
|
+
已确认原生入口指向 `/settings/usage?tab=overview`。使用当前 React Router
|
|
48
|
+
的 navigate,并保留当前路由 state;不模拟设置菜单内部点击。
|
|
49
|
+
移除功能时清除缓存订阅、DOM observer、事件、Tooltip 与按钮。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Usage Meter · 额度环
|
|
2
|
+
|
|
3
|
+
在 Codex 原生 navbar 的 Help 与 Account 之间显示当前登录账号的**剩余额度**。
|
|
4
|
+
|
|
5
|
+
- 只有周额度:单圈,中心为周剩余百分比整数。
|
|
6
|
+
- 同时有周与五小时额度:外圈为周、内圈为五小时,中心显示五小时剩余数字。
|
|
7
|
+
- 悬浮或键盘聚焦显示 Tooltip;双圈时先显示 `5h Limit`,再显示 `Weekly Limit`,标签左对齐、百分比右对齐。
|
|
8
|
+
- 点击进入原生 Settings → Usage。
|
|
9
|
+
|
|
10
|
+
数据直接订阅当前账号的原生额度缓存,与 Usage 页同步。窗口类型按实际时长识别,
|
|
11
|
+
不通过套餐名称猜测。缺失数据、退出登录或切换账号时不展示旧额度。
|
|
12
|
+
不新增 API 请求,不持久化账号信息,不修改 Settings 分类导航。
|
|
13
|
+
|
|
14
|
+
MineCodex 管理页面可单独开关此功能。设计与原生接入契约见 [DESIGN.md](DESIGN.md)。
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
node --test features/usage-meter/test/*.test.mjs
|
|
18
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "usage-meter",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"label": { "en": "Usage Meter", "zh-CN": "额度环" },
|
|
6
|
+
"description": {
|
|
7
|
+
"en": "Show remaining weekly and 5-hour limits between Help and Account and open Usage settings.",
|
|
8
|
+
"zh-CN": "在 Help 与 Account 之间显示周与五小时剩余额度,点击进入 Usage 设置。"
|
|
9
|
+
},
|
|
10
|
+
"entry": { "kind": "page-script", "script": "src/usage-meter.mjs" },
|
|
11
|
+
"icon": { "viewBox": "0 0 24 24", "markup": "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle><circle cx=\"12\" cy=\"12\" r=\"5\"></circle>" },
|
|
12
|
+
"placement": { "order": 60 }
|
|
13
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 订阅原生额度缓存;不另行轮询,也不复制账号信息到插件服务。
|
|
2
|
+
const MARKER = "data-minecodex-usage-meter";
|
|
3
|
+
const TIP_ID = "minecodex-usage-tooltip";
|
|
4
|
+
const STYLE_ID = "minecodex-usage-style";
|
|
5
|
+
let record = null;
|
|
6
|
+
let client = null;
|
|
7
|
+
let unsubscribe = null;
|
|
8
|
+
let queued = false;
|
|
9
|
+
let hoverTimer = null;
|
|
10
|
+
|
|
11
|
+
function remainingWindows(data) {
|
|
12
|
+
const windows = [data?.rate_limit?.primary_window, data?.rate_limit?.secondary_window];
|
|
13
|
+
const remaining = seconds => {
|
|
14
|
+
const window = windows.find(window => window?.limit_window_seconds === seconds);
|
|
15
|
+
return typeof window?.used_percent === "number" && Number.isFinite(window.used_percent)
|
|
16
|
+
? Math.round(Math.max(0, Math.min(100, 100 - window.used_percent))) : null;
|
|
17
|
+
};
|
|
18
|
+
const weekly = remaining(604800), fiveHour = remaining(18000);
|
|
19
|
+
return weekly == null ? null : { weekly, fiveHour, primary: fiveHour ?? weekly };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nativeContext(rail) {
|
|
23
|
+
const key = Object.keys(rail).find(key => key.startsWith("__reactFiber$"));
|
|
24
|
+
let fiber = key ? rail[key] : null;
|
|
25
|
+
const result = { auth: null, client: null, router: null };
|
|
26
|
+
for (let depth = 0; fiber && depth < 200; depth++, fiber = fiber.return) {
|
|
27
|
+
const props = fiber.memoizedProps;
|
|
28
|
+
if (!props) continue;
|
|
29
|
+
for (const value of [props.auth, props.value]) {
|
|
30
|
+
if (!result.auth && value && typeof value === "object" && "authMethod" in value && "userId" in value && "accountId" in value) result.auth = value;
|
|
31
|
+
}
|
|
32
|
+
for (const value of [props.queryClient, props.client, props.value]) {
|
|
33
|
+
if (!result.client && typeof value?.getQueryCache === "function" && typeof value?.getQueryData === "function") result.client = value;
|
|
34
|
+
}
|
|
35
|
+
const router = props.router ?? props.value?.router;
|
|
36
|
+
if (!result.router && typeof router?.navigate === "function") result.router = router;
|
|
37
|
+
if (result.auth && result.client && result.router) break;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function currentWindows(context) {
|
|
43
|
+
const { auth, client } = context;
|
|
44
|
+
if (auth?.isLoading || !auth?.userId || !auth?.accountId || !["chatgpt", "chatgptAuthTokens"].includes(auth.authMethod)) return null;
|
|
45
|
+
const data = client?.getQueryData(["rate-limit-status", auth.userId, auth.accountId]);
|
|
46
|
+
// 账号与缓存必须同时一致;旧账号的最后一次响应不能留在导航栏。
|
|
47
|
+
if (!data || data.user_id !== auth.userId || data.account_id !== auth.accountId) return null;
|
|
48
|
+
return remainingWindows(data);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const style = document.createElement("style");
|
|
52
|
+
style.id = STYLE_ID;
|
|
53
|
+
style.textContent = `
|
|
54
|
+
[${MARKER}] { position:relative; display:flex; align-items:center; justify-content:center; flex:none; -webkit-app-region:no-drag; }
|
|
55
|
+
[${MARKER}] .mcx-usage-ring { display:block; width:24px; height:24px; overflow:visible; }
|
|
56
|
+
[${MARKER}] .mcx-usage-number { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:inherit; font-family:var(--font-sans-default); font-size:calc(var(--text-xs) * 2 / 3); line-height:1; font-weight:500; letter-spacing:0; font-variant-numeric:tabular-nums; pointer-events:none; }
|
|
57
|
+
[${MARKER}] [data-window="weekly"] { color:inherit; }
|
|
58
|
+
[${MARKER}] [data-window="fiveHour"] { color:var(--color-text-secondary); }
|
|
59
|
+
#${TIP_ID} { position:fixed; z-index:2147483000; width:max-content; max-width:calc(100vw - 16px); pointer-events:none; user-select:none; padding:5px 12px; border:1px solid var(--color-border-tooltip); border-radius:var(--radius-2xl); background:var(--color-background-tooltip); color:var(--color-text-tooltip); box-shadow:var(--shadow-tooltip); font-family:var(--font-sans-default); font-size:var(--text-sm); line-height:18px; font-weight:var(--tooltip-compact-font-weight); letter-spacing:var(--tracking-tooltip); }
|
|
60
|
+
#${TIP_ID}[hidden] { display:none; }
|
|
61
|
+
#${TIP_ID} dl { display:grid; grid-template-columns:auto auto; gap:2px 16px; margin:0; }
|
|
62
|
+
#${TIP_ID} dt { text-align:left; }
|
|
63
|
+
#${TIP_ID} dd { margin:0; text-align:right; font-variant-numeric:tabular-nums; }
|
|
64
|
+
`;
|
|
65
|
+
document.head.append(style);
|
|
66
|
+
|
|
67
|
+
function svgNode(name, attributes) {
|
|
68
|
+
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
|
|
69
|
+
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function draw(windows) {
|
|
74
|
+
const svg = svgNode("svg", { viewBox: "0 0 32 32", class: "mcx-usage-ring", "aria-hidden": "true" });
|
|
75
|
+
for (const [key, radius] of [["weekly", 43 / 3], ["fiveHour", 10.5]]) {
|
|
76
|
+
if (windows[key] == null) continue;
|
|
77
|
+
const group = svgNode("g", { "data-window": key, fill: "none", stroke: "currentColor", "stroke-width": 10 / 3 });
|
|
78
|
+
group.append(svgNode("circle", { cx: 16, cy: 16, r: radius, opacity: 0.18 }));
|
|
79
|
+
if (windows[key] > 0) group.append(svgNode("circle", {
|
|
80
|
+
cx: 16, cy: 16, r: radius, pathLength: 100,
|
|
81
|
+
"stroke-dasharray": `${windows[key]} 100`, "stroke-linecap": "round", transform: "rotate(-90 16 16)",
|
|
82
|
+
}));
|
|
83
|
+
svg.append(group);
|
|
84
|
+
}
|
|
85
|
+
const number = document.createElement("span");
|
|
86
|
+
number.className = "mcx-usage-number";
|
|
87
|
+
number.textContent = String(windows.primary);
|
|
88
|
+
number.setAttribute("data-dual", String(windows.fiveHour != null));
|
|
89
|
+
number.setAttribute("data-digits", String(number.textContent.length));
|
|
90
|
+
record.button.replaceChildren(svg, number);
|
|
91
|
+
const list = document.createElement("dl");
|
|
92
|
+
const rows = windows.fiveHour == null ? [] : [["5h Limit", windows.fiveHour]];
|
|
93
|
+
rows.push(["Weekly Limit", windows.weekly]);
|
|
94
|
+
for (const [label, percent] of rows) {
|
|
95
|
+
const term = document.createElement("dt"), value = document.createElement("dd");
|
|
96
|
+
term.textContent = label;
|
|
97
|
+
value.textContent = `${percent}%`;
|
|
98
|
+
list.append(term, value);
|
|
99
|
+
}
|
|
100
|
+
record.tooltip.replaceChildren(list);
|
|
101
|
+
record.button.setAttribute("aria-label", rows.map(([label, percent]) => `${label} ${percent}%`).join(", "));
|
|
102
|
+
record.button.setAttribute("aria-description", "Open Usage settings");
|
|
103
|
+
if (!record.tooltip.hidden) positionTooltip();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function positionTooltip() {
|
|
107
|
+
if (!record) return;
|
|
108
|
+
const rect = record.button.getBoundingClientRect();
|
|
109
|
+
const tooltip = record.tooltip;
|
|
110
|
+
// 按物理视口定位,兼容原生 app-shell 的 CSS zoom。
|
|
111
|
+
tooltip.style.left = `${Math.max(8, Math.min(rect.right + 2, window.innerWidth - tooltip.offsetWidth - 8))}px`;
|
|
112
|
+
tooltip.style.top = `${Math.max(8, Math.min(rect.top + (rect.height - tooltip.offsetHeight) / 2, window.innerHeight - tooltip.offsetHeight - 8))}px`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function showTooltip() {
|
|
116
|
+
clearTimeout(hoverTimer);
|
|
117
|
+
if (!record) return;
|
|
118
|
+
record.tooltip.hidden = false;
|
|
119
|
+
record.button.setAttribute("aria-describedby", TIP_ID);
|
|
120
|
+
positionTooltip();
|
|
121
|
+
}
|
|
122
|
+
function hideTooltip() {
|
|
123
|
+
clearTimeout(hoverTimer);
|
|
124
|
+
if (!record) return;
|
|
125
|
+
record.tooltip.hidden = true;
|
|
126
|
+
record.button.removeAttribute("aria-describedby");
|
|
127
|
+
}
|
|
128
|
+
function removeControl() {
|
|
129
|
+
hideTooltip();
|
|
130
|
+
record?.button.remove();
|
|
131
|
+
record?.tooltip.remove();
|
|
132
|
+
record = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mount(help, rail) {
|
|
136
|
+
// 只克隆按钮的外观属性,不携带原生 Help 的菜单行为或 id。
|
|
137
|
+
const button = help.cloneNode(false);
|
|
138
|
+
for (const attribute of [...button.attributes]) {
|
|
139
|
+
if (attribute.name.startsWith("aria-") || attribute.name === "id" || attribute.name === "data-state") button.removeAttribute(attribute.name);
|
|
140
|
+
}
|
|
141
|
+
button.setAttribute(MARKER, "");
|
|
142
|
+
const tooltip = document.createElement("div");
|
|
143
|
+
tooltip.id = TIP_ID;
|
|
144
|
+
tooltip.setAttribute("role", "tooltip");
|
|
145
|
+
tooltip.hidden = true;
|
|
146
|
+
const anchor = help.parentElement.classList.contains("contents") ? help.parentElement : help;
|
|
147
|
+
anchor.after(button);
|
|
148
|
+
document.body.append(tooltip);
|
|
149
|
+
record = { button, tooltip, rail, help, signature: null };
|
|
150
|
+
button.addEventListener("pointerenter", () => { hoverTimer = setTimeout(showTooltip, 350); });
|
|
151
|
+
button.addEventListener("pointerleave", hideTooltip);
|
|
152
|
+
button.addEventListener("focus", showTooltip);
|
|
153
|
+
button.addEventListener("blur", hideTooltip);
|
|
154
|
+
button.addEventListener("keydown", event => { if (event.key === "Escape") { event.stopPropagation(); hideTooltip(); } });
|
|
155
|
+
button.addEventListener("click", () => {
|
|
156
|
+
hideTooltip();
|
|
157
|
+
const { router } = nativeContext(rail);
|
|
158
|
+
router?.navigate("/settings/usage?tab=overview", { state: router.state?.location?.state });
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sync() {
|
|
163
|
+
queued = false;
|
|
164
|
+
if (signal.aborted) return;
|
|
165
|
+
const rail = [...document.querySelectorAll('nav[data-app-navigation-rail="true"]')].find(rail => rail.getBoundingClientRect().width > 0);
|
|
166
|
+
const help = rail?.querySelector('button[aria-label="Help menu"],button[aria-label="帮助菜单"]');
|
|
167
|
+
const context = rail ? nativeContext(rail) : {};
|
|
168
|
+
if (client !== context.client) {
|
|
169
|
+
unsubscribe?.();
|
|
170
|
+
client = context.client;
|
|
171
|
+
unsubscribe = client?.getQueryCache().subscribe(event => {
|
|
172
|
+
if (event.query?.queryKey?.[0] === "rate-limit-status") schedule();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const windows = currentWindows(context);
|
|
176
|
+
if (!help || !context.router || !windows) { removeControl(); return; }
|
|
177
|
+
if (record?.help !== help || !record?.button.isConnected) { removeControl(); mount(help, rail); }
|
|
178
|
+
const signature = JSON.stringify(windows);
|
|
179
|
+
if (record.signature !== signature) { record.signature = signature; draw(windows); }
|
|
180
|
+
}
|
|
181
|
+
function schedule() {
|
|
182
|
+
if (queued || signal.aborted) return;
|
|
183
|
+
queued = true;
|
|
184
|
+
queueMicrotask(sync);
|
|
185
|
+
}
|
|
186
|
+
const observer = new MutationObserver(schedule);
|
|
187
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-label"] });
|
|
188
|
+
window.addEventListener("resize", positionTooltip);
|
|
189
|
+
window.addEventListener("focus", schedule);
|
|
190
|
+
const cleanup = () => {
|
|
191
|
+
observer.disconnect();
|
|
192
|
+
unsubscribe?.();
|
|
193
|
+
window.removeEventListener("resize", positionTooltip);
|
|
194
|
+
window.removeEventListener("focus", schedule);
|
|
195
|
+
removeControl();
|
|
196
|
+
style.remove();
|
|
197
|
+
};
|
|
198
|
+
signal.addEventListener("abort", cleanup, { once: true });
|
|
199
|
+
sync();
|
|
200
|
+
return cleanup;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minecodex",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Lightweight, local-first plugins for the Codex desktop app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
"features/model-slider/THIRD_PARTY_NOTICES.md",
|
|
24
24
|
"features/model-slider/vendor",
|
|
25
25
|
"features/notes/src",
|
|
26
|
-
"features/notes/web",
|
|
27
26
|
"features/notes/assets",
|
|
28
27
|
"features/notes/codex-feature.json",
|
|
28
|
+
"features/notes/DESIGN.md",
|
|
29
|
+
"features/notes/README.md",
|
|
29
30
|
"features/images/src",
|
|
30
31
|
"features/images/web",
|
|
31
32
|
"features/images/codex-feature.json",
|
|
@@ -41,17 +42,28 @@
|
|
|
41
42
|
"apps/dashboard/favicon.svg",
|
|
42
43
|
"apps/dashboard/index.html",
|
|
43
44
|
"apps/dashboard/styles.css",
|
|
44
|
-
"README.md"
|
|
45
|
+
"README.md",
|
|
46
|
+
"features/usage-meter/src",
|
|
47
|
+
"features/usage-meter/codex-feature.json",
|
|
48
|
+
"features/usage-meter/DESIGN.md",
|
|
49
|
+
"features/usage-meter/README.md",
|
|
50
|
+
"features/unread-inbox/src",
|
|
51
|
+
"features/unread-inbox/codex-feature.json",
|
|
52
|
+
"features/unread-inbox/DESIGN.md",
|
|
53
|
+
"features/sidebar-focus/src",
|
|
54
|
+
"features/sidebar-focus/codex-feature.json",
|
|
55
|
+
"features/sidebar-focus/DESIGN.md",
|
|
56
|
+
"features/sidebar-focus/vendor/lucide-LICENSE"
|
|
45
57
|
],
|
|
46
58
|
"engines": {
|
|
47
59
|
"node": ">=22.5.0"
|
|
48
60
|
},
|
|
49
61
|
"scripts": {
|
|
50
62
|
"start": "node packages/cli/bin/mcx.mjs serve",
|
|
51
|
-
"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 apps/dashboard/test/*.test.mjs",
|
|
63
|
+
"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",
|
|
52
64
|
"test:cli": "node --test packages/cli/test/*.test.mjs",
|
|
53
65
|
"test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
|
|
54
|
-
"check": "node
|
|
66
|
+
"check": "node scripts/check-syntax.mjs",
|
|
55
67
|
"build:service": "scripts/build-service-app.zsh",
|
|
56
68
|
"pack:check": "npm pack --dry-run",
|
|
57
69
|
"pack:audit": "node scripts/audit-package.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"]);
|
|
5
|
+
export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner", "session-status", "usage-meter", "unread-inbox", "sidebar-focus"]);
|
|
6
6
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
7
7
|
schemaVersion: 1,
|
|
8
8
|
features: Object.freeze({
|
|
@@ -11,6 +11,9 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
11
11
|
images: true,
|
|
12
12
|
"hide-upsell-banner": true,
|
|
13
13
|
"session-status": true,
|
|
14
|
+
"usage-meter": true,
|
|
15
|
+
"unread-inbox": true,
|
|
16
|
+
"sidebar-focus": true,
|
|
14
17
|
}),
|
|
15
18
|
});
|
|
16
19
|
|
|
@@ -196,8 +196,8 @@ async function iconResponse(id, paths, response) {
|
|
|
196
196
|
if (id === "model-slider") {
|
|
197
197
|
iconPath = path.join(paths.featuresRoot, id, "assets", "ui", "sparkles.svg");
|
|
198
198
|
} else if (id === "notes") {
|
|
199
|
-
iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "
|
|
200
|
-
} else if (["images", "hide-upsell-banner", "session-status"].includes(id)) {
|
|
199
|
+
iconPath = path.join(paths.featuresRoot, id, "assets", "icons", "book-open.svg");
|
|
200
|
+
} else if (["images", "hide-upsell-banner", "session-status", "usage-meter"].includes(id)) {
|
|
201
201
|
const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
|
|
202
202
|
const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
|
|
203
203
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
|
|
@@ -390,12 +390,14 @@ export function createInjectionSource(features, {
|
|
|
390
390
|
"config",
|
|
391
391
|
`"use strict";\n${source}`,
|
|
392
392
|
);
|
|
393
|
+
const pageConfig = feature.pageScript.config ??= {};
|
|
394
|
+
pageConfig.requestHostAction = (action, payload) => requestPageHostAction(feature.id, action, payload);
|
|
393
395
|
const cleanup = installer(
|
|
394
396
|
lifetime.signal,
|
|
395
397
|
window,
|
|
396
398
|
document,
|
|
397
399
|
MutationObserver,
|
|
398
|
-
|
|
400
|
+
pageConfig,
|
|
399
401
|
);
|
|
400
402
|
if (typeof cleanup === "function") pageScriptCleanups.push(cleanup);
|
|
401
403
|
} catch (error) {
|
|
@@ -2779,9 +2781,10 @@ export function createInjectionSource(features, {
|
|
|
2779
2781
|
}
|
|
2780
2782
|
// 原生开合的临时 min-width 钉定与弹层水平居中都基于对 Measurement
|
|
2781
2783
|
// 探针的测量;把探针文本同步为 MCX 显示标签,原生机制即可测得
|
|
2782
|
-
//
|
|
2784
|
+
// 真实显示宽度。保留 React 持有的子节点,避免切模型时卸载失败。
|
|
2783
2785
|
const measurement = trigger.querySelector('[class*="_ModelPickerTriggerMeasurement_"]');
|
|
2784
|
-
|
|
2786
|
+
const measurementText = measurement && firstVisibleTextNode(measurement);
|
|
2787
|
+
if (measurementText && measurementText.nodeValue !== label) measurementText.nodeValue = label;
|
|
2785
2788
|
}
|
|
2786
2789
|
|
|
2787
2790
|
function ensureModelSelector() {
|
|
@@ -4525,10 +4528,36 @@ export function createInjectionSource(features, {
|
|
|
4525
4528
|
}
|
|
4526
4529
|
}
|
|
4527
4530
|
|
|
4531
|
+
function requestPageHostAction(featureId, action, payload) {
|
|
4532
|
+
if (!featureById.get(featureId)?.hostActions.has(action)) return Promise.reject(new Error("Host action is not registered"));
|
|
4533
|
+
if (typeof globalThis[config.bindingName] !== "function") return Promise.reject(new Error("Host bridge is unavailable"));
|
|
4534
|
+
return new Promise((resolve, reject) => {
|
|
4535
|
+
const requestId = crypto.randomUUID();
|
|
4536
|
+
const timer = setTimeout(() => {
|
|
4537
|
+
pendingHostActions.delete(requestId);
|
|
4538
|
+
reject(new Error("Host request timed out"));
|
|
4539
|
+
}, 10_000);
|
|
4540
|
+
pendingHostActions.set(requestId, { kind: "page-script", resolve, reject, timer });
|
|
4541
|
+
try {
|
|
4542
|
+
globalThis[config.bindingName](JSON.stringify({ token: config.bindingToken, featureId, requestId, action, payload }));
|
|
4543
|
+
} catch (error) {
|
|
4544
|
+
clearTimeout(timer);
|
|
4545
|
+
pendingHostActions.delete(requestId);
|
|
4546
|
+
reject(error);
|
|
4547
|
+
}
|
|
4548
|
+
});
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4528
4551
|
function resolveHostAction(requestId, response) {
|
|
4529
4552
|
const pending = pendingHostActions.get(requestId);
|
|
4530
4553
|
if (!pending) return false;
|
|
4531
4554
|
pendingHostActions.delete(requestId);
|
|
4555
|
+
if (pending.kind === "page-script") {
|
|
4556
|
+
clearTimeout(pending.timer);
|
|
4557
|
+
if (response.ok) pending.resolve(response.result);
|
|
4558
|
+
else pending.reject(new Error(response.error?.message ?? "Host request failed"));
|
|
4559
|
+
return true;
|
|
4560
|
+
}
|
|
4532
4561
|
const record = surfaceRecords.get(pending.recordKey);
|
|
4533
4562
|
if (!record) return false;
|
|
4534
4563
|
if (pending.kind === "surface-load") {
|
|
@@ -4711,6 +4740,12 @@ export function createInjectionSource(features, {
|
|
|
4711
4740
|
resolveHostAction,
|
|
4712
4741
|
dispose: () => {
|
|
4713
4742
|
lifetime.abort();
|
|
4743
|
+
for (const [id, pending] of pendingHostActions) {
|
|
4744
|
+
if (pending.kind !== "page-script") continue;
|
|
4745
|
+
clearTimeout(pending.timer);
|
|
4746
|
+
pending.reject(new Error("Runtime disposed"));
|
|
4747
|
+
pendingHostActions.delete(id);
|
|
4748
|
+
}
|
|
4714
4749
|
for (const cleanup of pageScriptCleanups.splice(0)) {
|
|
4715
4750
|
try { cleanup(); } catch (error) {
|
|
4716
4751
|
console.warn("[codex-personal] page-script cleanup failed", error);
|
|
@@ -47,15 +47,11 @@ export {
|
|
|
47
47
|
} from "./codex-injection.mjs";
|
|
48
48
|
|
|
49
49
|
export const SESSION_STATUS_THREAD_ID_EXPRESSION = `(() => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (card?.getAttribute('data-composer-placement') === 'home') return null;
|
|
56
|
-
const selected = document.querySelector('[data-app-action-sidebar-thread-selected="true"]');
|
|
57
|
-
return selected?.getAttribute('data-app-action-sidebar-thread-id')
|
|
58
|
-
?? location.pathname + location.search + location.hash;
|
|
50
|
+
return [...new Set([...document.querySelectorAll('[data-codex-composer="true"][contenteditable="true"]')].map(composer => {
|
|
51
|
+
const card = composer.closest('[data-codex-composer-root]');
|
|
52
|
+
if (card?.getAttribute('data-composer-placement') !== 'thread') return null;
|
|
53
|
+
return card.querySelector('[data-above-composer-portal]')?.getAttribute('data-above-composer-conversation-id');
|
|
54
|
+
}).filter(Boolean))];
|
|
59
55
|
})()`;
|
|
60
56
|
|
|
61
57
|
export class CodexRuntime {
|
|
@@ -550,6 +546,29 @@ export class CodexRuntime {
|
|
|
550
546
|
});
|
|
551
547
|
return;
|
|
552
548
|
}
|
|
549
|
+
if (feature.id === "notes" && request.action === "quick-prompts") {
|
|
550
|
+
try {
|
|
551
|
+
const { route, method = "GET", body } = request.payload ?? {};
|
|
552
|
+
const allowed = (route === "/api/state" && method === "GET")
|
|
553
|
+
|| (["/api/prompts", "/api/categories", "/api/reorder"].includes(route) && method === "POST")
|
|
554
|
+
|| (/^\/api\/categories\/[a-zA-Z0-9-]+$/.test(route) && ["PATCH", "DELETE"].includes(method))
|
|
555
|
+
|| (/^\/api\/prompts\/[a-zA-Z0-9-]+$/.test(route) && ["PATCH", "DELETE"].includes(method))
|
|
556
|
+
|| (/^\/api\/prompts\/[a-zA-Z0-9-]+\/use$/.test(route) && method === "POST");
|
|
557
|
+
const origin = pageScriptServiceOrigin(feature);
|
|
558
|
+
if (!allowed || !origin) throw new Error("Prompt request is not allowed");
|
|
559
|
+
const response = await this.fetchImpl(new URL(route, origin), {
|
|
560
|
+
method, headers: { "Content-Type": "application/json", Origin: CODEX_APP_ORIGIN },
|
|
561
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
562
|
+
signal: AbortSignal.timeout(8_000),
|
|
563
|
+
});
|
|
564
|
+
const result = response.status === 204 ? null : await response.json();
|
|
565
|
+
if (!response.ok) throw new Error(result?.error?.message ?? "Prompt request failed");
|
|
566
|
+
await this.resolveHostAction(client, request.requestId, { ok: true, result });
|
|
567
|
+
} catch (error) {
|
|
568
|
+
await this.resolveHostAction(client, request.requestId, { ok: false, error: { code: "PROMPT_REQUEST_FAILED", message: error.message } });
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
553
572
|
if (request.action === "import-generated-image") {
|
|
554
573
|
await this.importGeneratedImage(feature, request.payload);
|
|
555
574
|
return;
|
|
@@ -741,17 +760,19 @@ export class CodexRuntime {
|
|
|
741
760
|
expression: SESSION_STATUS_THREAD_ID_EXPRESSION,
|
|
742
761
|
returnByValue: true,
|
|
743
762
|
}, { timeoutMs: 3_000 });
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
threadId
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
763
|
+
for (const value of response.result?.value ?? []) {
|
|
764
|
+
const threadId = threadIdFromLocation(value);
|
|
765
|
+
const data = threadId ? await this.sessionStatusReader.read(threadId) : null;
|
|
766
|
+
const snapshot = data ?? (threadId ? {
|
|
767
|
+
threadId,
|
|
768
|
+
retryAfterMs: this.sessionStatusReader.retryAfterMs?.(threadId) ?? 500,
|
|
769
|
+
} : null);
|
|
770
|
+
if (this.stopping || this.clients.get(targetId) !== client) return;
|
|
771
|
+
await client.send("Runtime.evaluate", {
|
|
772
|
+
expression: `window.__minecodexSessionStatus?.update(${JSON.stringify(snapshot)}, ${revision})`,
|
|
773
|
+
returnByValue: true,
|
|
774
|
+
}, { timeoutMs: 3_000 });
|
|
775
|
+
}
|
|
755
776
|
} catch (error) {
|
|
756
777
|
this.logger.warn?.("Session Status update failed", error.message);
|
|
757
778
|
if (this.clients.get(targetId) === client) {
|