cc-viewer 1.6.269 → 1.6.270
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/dist/assets/{App-DzjYA7D5.js → App-BlL1FIJJ.js} +1 -1
- package/dist/assets/{MdxEditorPanel-jhYrPElX.js → MdxEditorPanel-DxMKSSN2.js} +1 -1
- package/dist/assets/{Mobile-CsHrWYjl.js → Mobile-CXv8o6bc.js} +1 -1
- package/dist/assets/{index-CzhQgS4q.js → index-CCAOHnbk.js} +2 -2
- package/dist/assets/index-wU9AHa7Y.css +1 -0
- package/dist/assets/seqResourceLoaders-N07Gfom9.css +41 -0
- package/dist/assets/seqResourceLoaders-ZhPI6y_m.js +2 -0
- package/dist/index.html +2 -2
- package/dist/voice-packs/default/pack.json +0 -10
- package/lib/ask-bridge.js +128 -7
- package/lib/ask-constants.js +13 -0
- package/lib/ask-store.js +319 -0
- package/lib/ensure-hooks.js +54 -9
- package/lib/sdk-manager.js +4 -1
- package/lib/voice-pack-events.js +4 -4
- package/package.json +1 -1
- package/server.js +292 -14
- package/dist/assets/index-_4BCXKKF.css +0 -1
- package/dist/assets/seqResourceLoaders-BZiHSoZE.css +0 -41
- package/dist/assets/seqResourceLoaders-DvOHDZMB.js +0 -2
- package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
package/lib/voice-pack-events.js
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// Adding a 6th event meant editing 5+ files and any miss silently dropped audio
|
|
11
11
|
//(). All consumers now import from here.
|
|
12
12
|
|
|
13
|
+
// 注:timeoutWarning5min / timeoutWarning60s 已删除。AskUserQuestion 实质 24h 无超时后
|
|
14
|
+
// 倒计时不再渲染(AskTimeoutCountdown.jsx isInfiniteTimeout → null),剩余时间预警事件失去意义。
|
|
15
|
+
// 老用户 preferences.json 含这两个 key 由 lib/approval-modal-prefs.js _filterEvents 白名单
|
|
16
|
+
// 自动 strip,零迁移工作量。孤儿 audio 文件留待 cleanup CLI(backlog)。
|
|
13
17
|
export const EVENT_KEYS = [
|
|
14
18
|
'planApproval',
|
|
15
19
|
'askQuestion',
|
|
16
|
-
'timeoutWarning5min',
|
|
17
|
-
'timeoutWarning60s',
|
|
18
20
|
'turnEnd',
|
|
19
21
|
];
|
|
20
22
|
|
|
@@ -26,7 +28,5 @@ export const EVENT_KEYS = [
|
|
|
26
28
|
export const DEFAULT_BINDINGS = Object.freeze({
|
|
27
29
|
planApproval: 'default',
|
|
28
30
|
askQuestion: 'default',
|
|
29
|
-
timeoutWarning5min: 'default',
|
|
30
|
-
timeoutWarning60s: 'default',
|
|
31
31
|
turnEnd: null,
|
|
32
32
|
});
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -10,6 +10,8 @@ import { execFile, exec, spawn } from 'node:child_process';
|
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
import { Worker } from 'node:worker_threads';
|
|
12
12
|
import { isPathContained, ERROR_STATUS_MAP, validateImportDir } from './lib/file-api.js';
|
|
13
|
+
import { loadAskStore, setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, consumeIfFinal as askStoreConsumeIfFinal } from './lib/ask-store.js';
|
|
14
|
+
import { ASK_TIMEOUT_MS } from './lib/ask-constants.js';
|
|
13
15
|
import { isReadAllowed, reasonToStatus, bumpWorkspacesVersion } from './lib/file-access-policy.js';
|
|
14
16
|
|
|
15
17
|
const execFileAsync = promisify(execFile);
|
|
@@ -140,7 +142,66 @@ let _workspaceLaunched = false; // 工作区是否已经启动了会话
|
|
|
140
142
|
// Map supports concurrent ask requests (sub-agents / teammates) so a stale unanswered
|
|
141
143
|
// ask never blocks the next one. Keyed by server-generated id.
|
|
142
144
|
const pendingAskHooks = new Map(); // Map<id, { questions, res, timer, createdAt }>
|
|
143
|
-
|
|
145
|
+
// 1000 远超任何合理并发场景;保留 LRU 仅作为防恶意/bug 撑爆内存的兜底,
|
|
146
|
+
// 不再用于"正常使用时的容量上限"——用户的 ask 不应该因为 50 个 cap 被强行 evict。
|
|
147
|
+
const ASK_HOOK_MAP_MAX = 1000;
|
|
148
|
+
// 单一来源的"无超时"实质上限——延伸至 24h 兼顾防 entry 泄漏;
|
|
149
|
+
// 任何引用此值的地方(HOOK_TIMEOUT / REPLAY_HOOK_TIMEOUT / 广播 timeoutMs)都从这里取。
|
|
150
|
+
// 实际常量定义在 lib/ask-constants.js(hook 路径 + SDK 路径同源)。
|
|
151
|
+
const ASK_HOOK_TIMEOUT_MS = ASK_TIMEOUT_MS;
|
|
152
|
+
|
|
153
|
+
// 内存 Map 是权威源;ask-store 是镜像(best-effort)。崩溃时只丢"未落盘窗口"内的最新一次变更。
|
|
154
|
+
// 任何 pendingAskHooks.set(...) 后必须调 _persistAskEntry;.delete(...) 后必须调 _persistAskDelete。
|
|
155
|
+
function _persistAskEntry(id, entry) {
|
|
156
|
+
if (!entry || !Array.isArray(entry.questions)) return;
|
|
157
|
+
// 异步触发:磁盘 IO 不阻塞 ask 主流程(落盘失败不影响业务)
|
|
158
|
+
setImmediate(() => {
|
|
159
|
+
try { askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }); } catch {}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function _persistAskDelete(id) {
|
|
163
|
+
setImmediate(() => {
|
|
164
|
+
try { askStoreDeleteEntry(id); } catch {}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Phase 3: short-poll listener registry. Hangs GET /api/ask-hook/:id/result responses
|
|
169
|
+
// until either an answer/cancel arrives or wait ms elapses (then 204).
|
|
170
|
+
const shortPollListeners = new Map(); // id -> Set<{ res, tid, finished }>
|
|
171
|
+
|
|
172
|
+
function _notifyShortPollAnswer(id, answers) {
|
|
173
|
+
const set = shortPollListeners.get(id);
|
|
174
|
+
if (!set) return;
|
|
175
|
+
for (const listener of set) {
|
|
176
|
+
if (listener.finished) continue;
|
|
177
|
+
listener.finished = true;
|
|
178
|
+
clearTimeout(listener.tid);
|
|
179
|
+
try {
|
|
180
|
+
if (!listener.res.headersSent) {
|
|
181
|
+
listener.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
182
|
+
listener.res.end(JSON.stringify({ answers }));
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
shortPollListeners.delete(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _notifyShortPollCancel(id, reason) {
|
|
190
|
+
const set = shortPollListeners.get(id);
|
|
191
|
+
if (!set) return;
|
|
192
|
+
for (const listener of set) {
|
|
193
|
+
if (listener.finished) continue;
|
|
194
|
+
listener.finished = true;
|
|
195
|
+
clearTimeout(listener.tid);
|
|
196
|
+
try {
|
|
197
|
+
if (!listener.res.headersSent) {
|
|
198
|
+
listener.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
199
|
+
listener.res.end(JSON.stringify({ cancelled: true, reason: reason || '' }));
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
shortPollListeners.delete(id);
|
|
204
|
+
}
|
|
144
205
|
|
|
145
206
|
// Permission hook bridge state (for PreToolUse permission approval)
|
|
146
207
|
// Map supports concurrent sub-agent/teammate requests (keyed by request id)
|
|
@@ -2655,6 +2716,35 @@ async function handleRequest(req, res) {
|
|
|
2655
2716
|
return;
|
|
2656
2717
|
}
|
|
2657
2718
|
|
|
2719
|
+
// Ask 持久化恢复:浏览器 WS reconnect 后用来"补"那些 ws 断开期间到达的 pending ask
|
|
2720
|
+
// (server.js 已有 ws reconnect replay 路径,但该路径仅 broadcast 内存 Map 中存活的 entry;
|
|
2721
|
+
// server 自身重启过会丢内存态——此端点从磁盘 ask-store 镜像恢复 disk superset 列表)。
|
|
2722
|
+
if (url === '/api/pending-asks' && method === 'GET') {
|
|
2723
|
+
try {
|
|
2724
|
+
// 优先内存 Map(含活跃 res / timer 的真实状态),用 createdAt 排序;
|
|
2725
|
+
// 再 union disk(hydrate 已死 entry 或本进程未持有的孤儿 entry,用于恢复 UI 列表,
|
|
2726
|
+
// 答案投递仍由 WS ask-hook-answer 路径走 server 端 first-write-wins)。
|
|
2727
|
+
const memEntries = [...pendingAskHooks.entries()].map(([id, e]) => ({
|
|
2728
|
+
id, questions: e.questions, createdAt: e.createdAt, source: 'memory',
|
|
2729
|
+
}));
|
|
2730
|
+
const memIds = new Set(memEntries.map(e => e.id));
|
|
2731
|
+
const diskAll = loadAskStore();
|
|
2732
|
+
// 只暴露 status === 'pending' 的 disk entry —— markAnswered 创建的 questions=[] 终态占位
|
|
2733
|
+
// 也会被 loadAskStore 返回,但它不是真"待回答",前端 inject 会渲染空 ghost ask。
|
|
2734
|
+
// 另外过滤 questions 数组非空(防御性)。
|
|
2735
|
+
const diskOnly = Object.values(diskAll)
|
|
2736
|
+
.filter(e => !memIds.has(e.id) && e.status === 'pending' && Array.isArray(e.questions) && e.questions.length > 0)
|
|
2737
|
+
.map(e => ({ id: e.id, questions: e.questions, createdAt: e.createdAt, source: 'disk' }));
|
|
2738
|
+
const all = [...memEntries, ...diskOnly].sort((a, b) => a.createdAt - b.createdAt);
|
|
2739
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2740
|
+
res.end(JSON.stringify({ pendingAsks: all, askProtocolVersion: 1 }));
|
|
2741
|
+
} catch (err) {
|
|
2742
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2743
|
+
res.end(JSON.stringify({ error: 'failed to read pending asks', detail: String(err?.message || err) }));
|
|
2744
|
+
}
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2658
2748
|
// Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
|
|
2659
2749
|
if (url === '/api/ask-hook' && method === 'POST') {
|
|
2660
2750
|
let body = '';
|
|
@@ -2666,6 +2756,10 @@ async function handleRequest(req, res) {
|
|
|
2666
2756
|
req.destroy();
|
|
2667
2757
|
}
|
|
2668
2758
|
});
|
|
2759
|
+
// Phase 3: ask-bridge 用 `X-Ask-Poll-Mode: short` 头声明走短轮询。新 server 看到后
|
|
2760
|
+
// 立即返 { id, capability: 'short-poll' }(不挂 res),client 之后 GET /api/ask-hook/:id/result 取答案。
|
|
2761
|
+
// 老 server 不识别此头 → 按长轮询走(仍挂 res 直到答案/24h 超时)→ 完全向后兼容。
|
|
2762
|
+
const shortPollMode = (req.headers['x-ask-poll-mode'] || '').toLowerCase() === 'short';
|
|
2669
2763
|
req.on('end', async () => {
|
|
2670
2764
|
if (bodyTooLarge) {
|
|
2671
2765
|
try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
|
|
@@ -2699,6 +2793,7 @@ async function handleRequest(req, res) {
|
|
|
2699
2793
|
clearTimeout(oldest.timer);
|
|
2700
2794
|
try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
|
|
2701
2795
|
pendingAskHooks.delete(oldestId);
|
|
2796
|
+
_persistAskDelete(oldestId);
|
|
2702
2797
|
if (terminalWss) {
|
|
2703
2798
|
const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id: oldestId });
|
|
2704
2799
|
terminalWss.clients.forEach((c) => {
|
|
@@ -2709,8 +2804,9 @@ async function handleRequest(req, res) {
|
|
|
2709
2804
|
}
|
|
2710
2805
|
}
|
|
2711
2806
|
|
|
2712
|
-
|
|
2713
|
-
//
|
|
2807
|
+
// 实质等同"无超时",仍保留兜底防 entry 泄漏(plugin throw / OOM 等异常路径)。
|
|
2808
|
+
// ask-bridge 不设客户端 timeout,由 server 单向控制 response 终止。
|
|
2809
|
+
const HOOK_TIMEOUT = ASK_HOOK_TIMEOUT_MS;
|
|
2714
2810
|
// toolUseId 路由策略:
|
|
2715
2811
|
// - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
|
|
2716
2812
|
// - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
|
|
@@ -2728,6 +2824,7 @@ async function handleRequest(req, res) {
|
|
|
2728
2824
|
// 旧 res 已断(包含占位 res:null 残留)— ask-bridge 重试同 toolUseId 合理,复用槽位
|
|
2729
2825
|
if (existing.timer) clearTimeout(existing.timer);
|
|
2730
2826
|
pendingAskHooks.delete(toolUseId);
|
|
2827
|
+
_persistAskDelete(toolUseId);
|
|
2731
2828
|
id = toolUseId;
|
|
2732
2829
|
} else {
|
|
2733
2830
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
@@ -2745,14 +2842,19 @@ async function handleRequest(req, res) {
|
|
|
2745
2842
|
// TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
|
|
2746
2843
|
// abort 的 close 事件落空 → entry 残 5min。占位 set 提前到 await 之前防两条同 ms 并发
|
|
2747
2844
|
// POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
|
|
2748
|
-
|
|
2845
|
+
const _placeholderEntry = { questions, res, timer: null, createdAt: Date.now(), shortPoll: shortPollMode };
|
|
2846
|
+
pendingAskHooks.set(id, _placeholderEntry);
|
|
2847
|
+
_persistAskEntry(id, _placeholderEntry);
|
|
2749
2848
|
|
|
2750
2849
|
// res.on('close') 提前注册:handler 用 entry.timer 守卫(占位期 timer:null → 仅 delete Map)
|
|
2850
|
+
// Phase 3: short-poll 模式下 res 主动 end,close 不视为 client cancel;entry 由 24h timer 兜底清理。
|
|
2751
2851
|
res.on('close', () => {
|
|
2752
2852
|
const entry = pendingAskHooks.get(id);
|
|
2753
2853
|
if (entry) {
|
|
2854
|
+
if (entry.shortPoll) return;
|
|
2754
2855
|
if (entry.timer) clearTimeout(entry.timer);
|
|
2755
2856
|
pendingAskHooks.delete(id);
|
|
2857
|
+
_persistAskDelete(id);
|
|
2756
2858
|
if (terminalWss) {
|
|
2757
2859
|
const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
|
|
2758
2860
|
terminalWss.clients.forEach((c) => {
|
|
@@ -2768,8 +2870,15 @@ async function handleRequest(req, res) {
|
|
|
2768
2870
|
const hookResult = await runWaterfallHook('onAskRequest', { id, questions, mode: 'hook' });
|
|
2769
2871
|
if (hookResult.answers) {
|
|
2770
2872
|
pendingAskHooks.delete(id); // 释放占位 — plugin 直接答了
|
|
2771
|
-
|
|
2772
|
-
res.
|
|
2873
|
+
_persistAskDelete(id);
|
|
2874
|
+
// race: plugin 执行期间 client 主动断开 → res.on('close') 已清 entry,
|
|
2875
|
+
// 但本闭包仍持着 res 引用;guard 防 writeHead 抛 ERR_STREAM_WRITE_AFTER_END
|
|
2876
|
+
if (!res.writableEnded && !res.destroyed && !res.headersSent) {
|
|
2877
|
+
try {
|
|
2878
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2879
|
+
res.end(JSON.stringify({ answers: hookResult.answers }));
|
|
2880
|
+
} catch {}
|
|
2881
|
+
}
|
|
2773
2882
|
return;
|
|
2774
2883
|
}
|
|
2775
2884
|
} catch {}
|
|
@@ -2778,6 +2887,7 @@ async function handleRequest(req, res) {
|
|
|
2778
2887
|
const entry = pendingAskHooks.get(id);
|
|
2779
2888
|
if (entry) {
|
|
2780
2889
|
pendingAskHooks.delete(id);
|
|
2890
|
+
_persistAskDelete(id);
|
|
2781
2891
|
try {
|
|
2782
2892
|
// null guard:plugin throw + outer body-parse race 极端下占位残留时 fire 防 TypeError
|
|
2783
2893
|
if (entry.res && !entry.res.headersSent) {
|
|
@@ -2796,7 +2906,19 @@ async function handleRequest(req, res) {
|
|
|
2796
2906
|
}, HOOK_TIMEOUT);
|
|
2797
2907
|
|
|
2798
2908
|
const askStartedAt = Date.now();
|
|
2799
|
-
|
|
2909
|
+
const _liveEntry = { questions, res, timer, createdAt: askStartedAt, shortPoll: shortPollMode };
|
|
2910
|
+
pendingAskHooks.set(id, _liveEntry);
|
|
2911
|
+
_persistAskEntry(id, _liveEntry);
|
|
2912
|
+
|
|
2913
|
+
// Phase 3: short-poll 模式立即返 ack,让 client 改走 GET 轮询;entry 留在内存等 ws answer。
|
|
2914
|
+
if (shortPollMode) {
|
|
2915
|
+
try {
|
|
2916
|
+
if (!res.headersSent) {
|
|
2917
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2918
|
+
res.end(JSON.stringify({ id, capability: 'short-poll' }));
|
|
2919
|
+
}
|
|
2920
|
+
} catch {}
|
|
2921
|
+
}
|
|
2800
2922
|
|
|
2801
2923
|
// Broadcast to all terminal WS clients — 附 startedAt + timeoutMs 让前端渲染倒计时
|
|
2802
2924
|
if (terminalWss) {
|
|
@@ -2816,6 +2938,75 @@ async function handleRequest(req, res) {
|
|
|
2816
2938
|
return;
|
|
2817
2939
|
}
|
|
2818
2940
|
|
|
2941
|
+
// Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
|
|
2942
|
+
// 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
|
|
2943
|
+
// 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
|
|
2944
|
+
if (url.startsWith('/api/ask-hook/') && url.includes('/result') && method === 'GET') {
|
|
2945
|
+
try {
|
|
2946
|
+
// URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
|
|
2947
|
+
const m = url.match(/^\/api\/ask-hook\/([^/?]+)\/result(?:\?(.*))?$/);
|
|
2948
|
+
if (!m) { res.writeHead(400); res.end(); return; }
|
|
2949
|
+
const id = decodeURIComponent(m[1]);
|
|
2950
|
+
if (!id || id.length > 256 || !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
2951
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2952
|
+
res.end(JSON.stringify({ error: 'invalid id' }));
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
const qs = new URLSearchParams(m[2] || '');
|
|
2956
|
+
const wait = Math.max(1000, Math.min(60000, parseInt(qs.get('wait') || '30000', 10)));
|
|
2957
|
+
|
|
2958
|
+
// 1) disk 命中 answered/cancelled:浏览器答得早过 GET 到达 → 立即返并消费(一次性)
|
|
2959
|
+
// 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
|
|
2960
|
+
// "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
|
|
2961
|
+
// setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
|
|
2962
|
+
const diskEntry = askStoreConsumeIfFinal(id);
|
|
2963
|
+
if (diskEntry && diskEntry.status === 'answered') {
|
|
2964
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2965
|
+
res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (diskEntry && diskEntry.status === 'cancelled') {
|
|
2969
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2970
|
+
res.end(JSON.stringify({ cancelled: true, reason: diskEntry.cancelReason || '' }));
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
// diskEntry.status === 'pending' → consumeIfFinal 已保留它,无须重写
|
|
2974
|
+
|
|
2975
|
+
// 2) entry 仍 pending:注册 listener 等 wait ms。内存或 disk 至少一个有就允许注册——
|
|
2976
|
+
// server 重启后 disk 是 pending,但内存无 entry,仍要让 GET 挂等(浏览器答案到达时
|
|
2977
|
+
// 会走 ws ask-hook-answer handler 路径,handler 内 markAnswered 落 disk + _notifyShortPollAnswer 直推 listener)。
|
|
2978
|
+
const memEntry = pendingAskHooks.get(id);
|
|
2979
|
+
if (!memEntry && !diskEntry) {
|
|
2980
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2981
|
+
res.end(JSON.stringify({ error: 'no such ask' }));
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const listener = { res, finished: false, tid: null };
|
|
2985
|
+
if (!shortPollListeners.has(id)) shortPollListeners.set(id, new Set());
|
|
2986
|
+
shortPollListeners.get(id).add(listener);
|
|
2987
|
+
listener.tid = setTimeout(() => {
|
|
2988
|
+
if (listener.finished) return;
|
|
2989
|
+
listener.finished = true;
|
|
2990
|
+
shortPollListeners.get(id)?.delete(listener);
|
|
2991
|
+
try { if (!res.headersSent) { res.writeHead(204); res.end(); } } catch {}
|
|
2992
|
+
}, wait);
|
|
2993
|
+
res.on('close', () => {
|
|
2994
|
+
if (listener.finished) return;
|
|
2995
|
+
listener.finished = true;
|
|
2996
|
+
clearTimeout(listener.tid);
|
|
2997
|
+
shortPollListeners.get(id)?.delete(listener);
|
|
2998
|
+
});
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
try {
|
|
3001
|
+
if (!res.headersSent) {
|
|
3002
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
3003
|
+
res.end(JSON.stringify({ error: String(err?.message || err) }));
|
|
3004
|
+
}
|
|
3005
|
+
} catch {}
|
|
3006
|
+
}
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2819
3010
|
// Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
|
|
2820
3011
|
if (url === '/api/perm-hook' && method === 'POST') {
|
|
2821
3012
|
let body = '';
|
|
@@ -4132,6 +4323,18 @@ export async function startViewer() {
|
|
|
4132
4323
|
// 清理过期解压缓存(fire-and-forget;任何错误吞掉)
|
|
4133
4324
|
setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
|
|
4134
4325
|
|
|
4326
|
+
// 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
|
|
4327
|
+
// 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
|
|
4328
|
+
// (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
|
|
4329
|
+
// 留下来的 disk 镜像供 /api/pending-asks 端点查询,让浏览器重连后仍能看见 pending 列表。
|
|
4330
|
+
setImmediate(() => { try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {} });
|
|
4331
|
+
// 长跑进程兜底:短轮询路径下 markAnswered 标的终态 entry 若 ask-bridge 已死(GET 不再来 consume),
|
|
4332
|
+
// 仅靠启动 prune 永远清不掉。1h 周期触发一次,.unref() 不阻塞进程退出。
|
|
4333
|
+
const _pruneAskStoreInterval = setInterval(() => {
|
|
4334
|
+
try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {}
|
|
4335
|
+
}, 60 * 60 * 1000);
|
|
4336
|
+
_pruneAskStoreInterval.unref();
|
|
4337
|
+
|
|
4135
4338
|
// 通过插件 hook 获取 HTTPS 证书选项
|
|
4136
4339
|
let httpsOptions = null;
|
|
4137
4340
|
try {
|
|
@@ -4255,6 +4458,7 @@ export async function startViewer() {
|
|
|
4255
4458
|
const { res: hookRes, timer } = entry;
|
|
4256
4459
|
clearTimeout(timer);
|
|
4257
4460
|
pendingAskHooks.delete(id);
|
|
4461
|
+
_persistAskDelete(id);
|
|
4258
4462
|
try {
|
|
4259
4463
|
if (!hookRes.headersSent) {
|
|
4260
4464
|
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -4434,8 +4638,8 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
4434
4638
|
// Replay pending ask-hook 请求:浏览器关 tab 再开(或 ws 重连)时,
|
|
4435
4639
|
// 让新 ws 立即收到当前 server-side 仍 long-poll 的 ask 列表 + startedAt + 剩余 timeoutMs,
|
|
4436
4640
|
// 否则前端 askMetaMap 空 → 倒计时不渲染 + lastPendingAskId 派生错。
|
|
4437
|
-
//
|
|
4438
|
-
const REPLAY_HOOK_TIMEOUT =
|
|
4641
|
+
// 与上方 HOOK_TIMEOUT 同源(实质无超时)。
|
|
4642
|
+
const REPLAY_HOOK_TIMEOUT = ASK_HOOK_TIMEOUT_MS;
|
|
4439
4643
|
const now = Date.now();
|
|
4440
4644
|
for (const [id, entry] of pendingAskHooks) {
|
|
4441
4645
|
const elapsed = now - (entry.createdAt || now);
|
|
@@ -4541,6 +4745,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
4541
4745
|
// 老协议 fallback(取最老)已废弃 — 多 pending 时会"答错对象"造成串答;
|
|
4542
4746
|
// 缺 id 直接 WARN 并丢弃,让前端在 console 里看到为什么答案没生效。
|
|
4543
4747
|
let askAnswered = false;
|
|
4748
|
+
let alreadyAnswered = false; // first-write-wins 抢答失败信号
|
|
4544
4749
|
let askId = msg.id;
|
|
4545
4750
|
let askEntry = null;
|
|
4546
4751
|
if (askId) {
|
|
@@ -4552,12 +4757,58 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
4552
4757
|
const { res: hookRes, timer } = askEntry;
|
|
4553
4758
|
clearTimeout(timer);
|
|
4554
4759
|
pendingAskHooks.delete(askId);
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4760
|
+
// Phase 3: short-poll 模式不立即删 disk —— 落 answered 让 GET listener / disk consume 拿
|
|
4761
|
+
if (askEntry.shortPoll) {
|
|
4762
|
+
let wrote = false;
|
|
4763
|
+
try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
4764
|
+
if (wrote) {
|
|
4765
|
+
_notifyShortPollAnswer(askId, msg.answers);
|
|
4766
|
+
askAnswered = true;
|
|
4767
|
+
} else {
|
|
4768
|
+
// race 边角:进入 handler 时内存 entry 还在,但 disk 已被另一进程 / 之前的 cancel 写过终态。
|
|
4769
|
+
// 不广播 answer,让发起方知道被抢答;hookRes 也按 cancelled 返回让 ask-bridge 走 deny 路径。
|
|
4770
|
+
alreadyAnswered = true;
|
|
4771
|
+
try {
|
|
4772
|
+
if (!hookRes.headersSent) {
|
|
4773
|
+
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4774
|
+
hookRes.end(JSON.stringify({ cancelled: true, reason: 'Already answered by another client' }));
|
|
4775
|
+
}
|
|
4776
|
+
} catch {}
|
|
4560
4777
|
}
|
|
4778
|
+
} else {
|
|
4779
|
+
_persistAskDelete(askId);
|
|
4780
|
+
askAnswered = true;
|
|
4781
|
+
}
|
|
4782
|
+
if (askAnswered) {
|
|
4783
|
+
try {
|
|
4784
|
+
if (!hookRes.headersSent) {
|
|
4785
|
+
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4786
|
+
hookRes.end(JSON.stringify({ answers: msg.answers }));
|
|
4787
|
+
}
|
|
4788
|
+
} catch {}
|
|
4789
|
+
}
|
|
4790
|
+
} else if (askId) {
|
|
4791
|
+
// server 重启 / 内存 entry 已被清,但 ask-bridge 可能仍在短轮询 disk —— 落 answered。
|
|
4792
|
+
// first-write-wins:如果 disk 已被其他 client 抢答(markAnswered 返 false),
|
|
4793
|
+
// 不唤醒 listener(让 listener 等到 GET hit disk 拿到真实抢答者答案)—— 自然 idempotent。
|
|
4794
|
+
// 给当前 ws 发 ack-already-answered 让前端关 modal、不误覆盖灰态。
|
|
4795
|
+
let wrote = false;
|
|
4796
|
+
try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
4797
|
+
if (wrote) {
|
|
4798
|
+
_notifyShortPollAnswer(askId, msg.answers);
|
|
4799
|
+
askAnswered = true;
|
|
4800
|
+
} else {
|
|
4801
|
+
alreadyAnswered = true;
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
// first-write-wins 抢答失败 → 仅 ack 发起方让其关 modal;不广播给其他 client 防覆盖真实 answer。
|
|
4805
|
+
if (alreadyAnswered && askId && ws && ws.readyState === 1) {
|
|
4806
|
+
try {
|
|
4807
|
+
ws.send(JSON.stringify({
|
|
4808
|
+
type: 'ask-hook-already-answered',
|
|
4809
|
+
id: askId,
|
|
4810
|
+
reason: 'Another client answered first (first-write-wins)',
|
|
4811
|
+
}));
|
|
4561
4812
|
} catch {}
|
|
4562
4813
|
}
|
|
4563
4814
|
// Broadcast resolved to other clients so they clear their ask panel
|
|
@@ -4650,6 +4901,13 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
4650
4901
|
const { res: hookRes, timer } = askEntry;
|
|
4651
4902
|
if (timer) clearTimeout(timer);
|
|
4652
4903
|
pendingAskHooks.delete(cancelId);
|
|
4904
|
+
// Phase 3: short-poll 同样要让 disk + listener 知道,否则 ask-bridge 永远收不到 cancelled
|
|
4905
|
+
if (askEntry.shortPoll) {
|
|
4906
|
+
try { askStoreMarkCancelled(cancelId, cancelReason); } catch {}
|
|
4907
|
+
_notifyShortPollCancel(cancelId, cancelReason);
|
|
4908
|
+
} else {
|
|
4909
|
+
_persistAskDelete(cancelId);
|
|
4910
|
+
}
|
|
4653
4911
|
handled = true;
|
|
4654
4912
|
try {
|
|
4655
4913
|
if (hookRes && !hookRes.headersSent) {
|
|
@@ -4657,6 +4915,26 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
4657
4915
|
hookRes.end(JSON.stringify({ cancelled: true, reason: cancelReason }));
|
|
4658
4916
|
}
|
|
4659
4917
|
} catch {}
|
|
4918
|
+
} else {
|
|
4919
|
+
// 内存无 entry,但 disk 可能有 pending(server 重启后 ask-bridge 重 POST 之前,
|
|
4920
|
+
// 浏览器从 /api/pending-asks 看到 disk-only ask 并 Cancel 它)。
|
|
4921
|
+
// first-wins:disk 已是终态(如另一 client 抢先 answer)时 markCancelled 返 false,
|
|
4922
|
+
// 不能唤醒 listener 用 cancel 覆盖真实 answer —— 让 listener 下次 GET consumeIfFinal
|
|
4923
|
+
// 拿到真实 disk 终态自然投递。
|
|
4924
|
+
const wrote = askStoreMarkCancelled(cancelId, cancelReason);
|
|
4925
|
+
if (wrote) {
|
|
4926
|
+
_notifyShortPollCancel(cancelId, cancelReason);
|
|
4927
|
+
handled = true;
|
|
4928
|
+
} else if (ws && ws.readyState === 1) {
|
|
4929
|
+
// 抢答失败 ack:只给发起方关 modal,不广播防覆盖其他 client 真实 answer。
|
|
4930
|
+
try {
|
|
4931
|
+
ws.send(JSON.stringify({
|
|
4932
|
+
type: 'ask-hook-already-answered',
|
|
4933
|
+
id: cancelId,
|
|
4934
|
+
reason: 'Already resolved by another client',
|
|
4935
|
+
}));
|
|
4936
|
+
} catch {}
|
|
4937
|
+
}
|
|
4660
4938
|
}
|
|
4661
4939
|
}
|
|
4662
4940
|
// ack 广播分两档:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root,[data-theme=dark]{--bg-base: #0a0a0a;--bg-base-pure: #000;--bg-base-alt: #0d0d0d;--bg-container: #111;--bg-elevated: #1e1e1e;--bg-surface: #2a2a2a;--bg-code: #14141F;--bg-code-dark: #0d1117;--bg-code-system: #060f2a;--border-primary: #2a2a2a;--border-secondary: #303030;--border-hover: #3a3a3a;--border-light: #444;--border-code: #2a2a3e;--border-code-system: #0b2a5c;--text-primary: #e5e5e5;--text-secondary: #ccc;--text-tertiary: #888;--text-muted: #666;--text-disabled: #555;--text-disabled-faint: rgba(255, 255, 255, .45);--text-white: #fff;--text-gray: #6b7280;--text-light: #aaa;--color-primary: #1668dc;--color-primary-light: #3b82f6;--color-primary-lighter: #60a5fa;--color-primary-pale: #4a9eff;--color-primary-dark: #165bbe;--color-primary-dark-bg: #1e3a5f;--color-primary-bg-light: rgba(22, 104, 220, .15);--color-primary-bg-lighter: rgba(22, 104, 220, .2);--color-primary-bg-medium: rgba(22, 104, 220, .35);--color-primary-bg-faint: rgba(22, 104, 220, .1);--color-primary-shadow: rgba(22, 104, 220, .6);--color-success: #22c55e;--color-success-light: #4ade80;--color-success-dark: #2ea043;--color-success-bg-light: rgba(34, 197, 94, .15);--color-success-bg-medium: rgba(34, 197, 94, .3);--color-error: #ef4444;--color-error-rgb: 239, 68, 68;--color-error-light: #f87171;--color-error-muted: #a33;--color-error-dark-bg: #220000;--color-error-bg-light: rgba(239, 68, 68, .15);--color-error-bg-faint: rgba(239, 68, 68, .1);--color-error-bg-medium: rgba(239, 68, 68, .3);--color-warning: #f59e0b;--color-warning-light: #fbbf24;--color-warning-dark-bg: #2a2000;--color-warning-dark-border: #5c4d00;--color-warning-bg-light: rgba(245, 158, 11, .15);--color-warning-bg-medium: rgba(245, 158, 11, .3);--color-selection-bg: #1a2332;--color-selection-alt: #264f78;--color-green-bg: #1a3a1a;--color-green-dark-bg: #162312;--color-diff-add: #73c991;--color-diff-delete: #ef4444;--color-diff-add-bg: rgba(115, 201, 145, .15);--color-diff-delete-bg: rgba(239, 68, 68, .11);--color-tree-selected: #532f00;--color-accent-yellow: #e2c08d;--color-info-light: #7dd3fc;--color-code-purple: #a78bfa;--color-code-orange: #d97757;--code-keyword: #f87171;--code-string: #7dd3fc;--code-comment: #888;--code-number: #7dd3fc;--code-text: #e5e5e5;--code-inline-color: #aeafff;--code-inline-bg: #14141F;--code-error: #e06c75;--color-purple-bg: rgba(139, 92, 246, .18);--color-purple-border: rgba(139, 92, 246, .3);--color-primary-outline: rgba(22, 104, 220, .75);--color-primary-bg-extra-faint: rgba(74, 159, 255, .05);--color-primary-pale-shadow: rgba(74, 159, 255, .5);--color-error-border: rgba(239, 68, 68, .25);--color-error-border-light: rgba(239, 68, 68, .2);--color-warning-bg-faint: rgba(234, 179, 8, .1);--color-warning-border-light: rgba(234, 179, 8, .2);--color-success-bg-faint: rgba(34, 197, 94, .11);--color-diff-delete-text: #fca5a5;--color-green-border: #2a5a3a;--color-green-dark-border: #274916;--color-red-dark-bg: #8b1a1a;--color-red-dark-border: #2a1a1a;--color-thinking-bg: #111108;--color-thinking-border: #2a2a1e;--color-code-orange-border: rgba(217, 119, 87, .27);--color-code-orange-bg: rgba(217, 119, 87, .07);--bg-container-alt: #161616;--bg-model-avatar: #000;--bg-sub-avatar: #3a3a3a;--avatar-bg-0: #5b6abf;--avatar-bg-1: #2a9d8f;--avatar-bg-2: #6366f1;--avatar-bg-3: #d97706;--avatar-bg-4: #3b82f6;--avatar-bg-5: #8b5cf6;--avatar-bg-6: #0e7490;--avatar-bg-7: #ea580c;--avatar-bg-8: #059669;--avatar-bg-9: #e11d48;--avatar-bg-10: #0284c7;--avatar-bg-11: #dc2626;--avatar-bg-12: #65a30d;--avatar-bg-13: #ca8a04;--avatar-bg-14: #9333ea;--avatar-bg-15: #db2777;--avatar-bg-16: #6b7280;--avatar-bg-17: #1d4ed8;--avatar-bg-18: #b45309;--avatar-bg-19: #047857;--model-logo-mono: #fff;--bg-input-bar: rgba(0, 0, 0, .5);--bg-sticky-btn: rgba(0, 0, 0, .5);--chat-boxer-gradient-dir: to top;--chat-boxer-bg-from: var(--bg-surface);--chat-boxer-bg-to: var(--border-secondary);--chat-boxer-hover-from: var(--border-secondary);--chat-boxer-hover-to: var(--border-hover);--shadow-approval-heavy: rgba(0, 0, 0, .6);--shadow-approval-glow: rgba(245, 158, 11, .15);--color-approval-border: #f59e0b;--color-approval-label: #fbbf24;--color-gray-bg-light: rgba(107, 114, 128, .15);--color-gray-bg-medium: rgba(107, 114, 128, .3);--overlay-light-faint: rgba(255, 255, 255, .04);--overlay-light-medium: rgba(255, 255, 255, .1);--overlay-light-strong: rgba(255, 255, 255, .2);--overlay-light-half: rgba(255, 255, 255, .5);--overlay-light-bright: rgba(255, 255, 255, .7);--overlay-dark: rgba(0, 0, 0, .75);--overlay-dark-medium: rgba(0, 0, 0, .6);--overlay-dark-light: rgba(0, 0, 0, .5);--overlay-dark-strong: rgba(0, 0, 0, .88)}[data-theme=light]{--bg-base: #FAFAFA;--bg-base-pure: #FFFFFF;--bg-base-alt: #F5F5F5;--bg-container: #FFFFFF;--bg-elevated: #F9F9F9;--bg-surface: #F0F0F0;--bg-code: #F5F5F5;--bg-code-dark: #F5F5F5;--bg-code-system: #EFF1F3;--border-primary: #E0E0E0;--border-secondary: #EBEBEB;--border-hover: #C0C0C0;--border-light: #D4D4D4;--border-code: #D4D4D4;--border-code-system: #C0D0E0;--text-primary: #1a1a1a;--text-secondary: #333;--text-tertiary: #666;--text-muted: #888;--text-disabled: #AAA;--text-disabled-faint: rgba(0, 0, 0, .35);--text-white: #1a1a1a;--text-gray: #6b7280;--text-light: #555;--color-primary: #0969DA;--color-primary-light: #0969DA;--color-primary-lighter: #0969DA;--color-primary-pale: #2A7DE1;--color-primary-dark: #0550AE;--color-primary-dark-bg: #DDF4FF;--color-primary-bg-light: rgba(9, 105, 218, .1);--color-primary-bg-lighter: rgba(9, 105, 218, .12);--color-primary-bg-medium: rgba(9, 105, 218, .2);--color-primary-bg-faint: rgba(9, 105, 218, .06);--color-primary-shadow: rgba(9, 105, 218, .3);--color-success: #1A7F37;--color-success-light: #2DA44E;--color-success-dark: #116329;--color-success-bg-light: rgba(26, 127, 55, .08);--color-success-bg-medium: rgba(26, 127, 55, .15);--color-error: #CF222E;--color-error-rgb: 207, 34, 46;--color-error-light: #DA3633;--color-error-muted: #82071E;--color-error-dark-bg: #FFEBE9;--color-error-bg-light: rgba(207, 34, 46, .08);--color-error-bg-faint: rgba(207, 34, 46, .06);--color-error-bg-medium: rgba(207, 34, 46, .15);--color-warning: #9A6700;--color-warning-light: #BF8700;--color-warning-dark-bg: #FFF8E1;--color-warning-dark-border: #D4A72C;--color-warning-bg-light: rgba(154, 103, 0, .08);--color-warning-bg-medium: rgba(154, 103, 0, .15);--color-selection-bg: #B4D7FF;--color-selection-alt: #ADD6FF;--color-green-bg: #DAFBE1;--color-green-dark-bg: #DAFBE1;--color-diff-add: #116329;--color-diff-delete: #82071E;--color-diff-add-bg: #DAFBE1;--color-diff-delete-bg: #FFEBE9;--color-tree-selected: #FFF3D5;--color-accent-yellow: #CB7C5E;--color-info-light: #0969DA;--color-code-purple: #8250DF;--color-code-orange: #953800;--code-keyword: #0000FF;--code-string: #A31515;--code-comment: #6A9955;--code-number: #098658;--code-text: #383838;--code-inline-color: #0969DA;--code-inline-bg: #EFF1F3;--code-error: #CF222E;--color-purple-bg: rgba(130, 80, 223, .1);--color-purple-border: rgba(130, 80, 223, .2);--color-primary-outline: rgba(9, 105, 218, .5);--color-primary-bg-extra-faint: rgba(9, 105, 218, .04);--color-primary-pale-shadow: rgba(9, 105, 218, .3);--color-error-border: rgba(207, 34, 46, .2);--color-error-border-light: rgba(207, 34, 46, .15);--color-warning-bg-faint: rgba(154, 103, 0, .06);--color-warning-border-light: rgba(154, 103, 0, .15);--color-success-bg-faint: rgba(26, 127, 55, .08);--color-diff-delete-text: #DA3633;--color-green-border: #2DA44E;--color-green-dark-border: #1A7F37;--color-red-dark-bg: #FFEBE9;--color-red-dark-border: #FFCECB;--color-thinking-bg: #FAFAF5;--color-thinking-border: #E8E8D0;--color-code-orange-border: rgba(149, 56, 0, .2);--color-code-orange-bg: rgba(149, 56, 0, .06);--bg-container-alt: #F5F5F5;--bg-model-avatar: transparent;--bg-sub-avatar: #E5E5E5;--avatar-bg-0: #ced0db;--avatar-bg-1: #9abcb8;--avatar-bg-2: #edeef5;--avatar-bg-3: #cfb99f;--avatar-bg-4: #d6deea;--avatar-bg-5: #efecf5;--avatar-bg-6: #78a8b5;--avatar-bg-7: #d6bcaf;--avatar-bg-8: #72b8a2;--avatar-bg-9: #d6b7be;--avatar-bg-10: #8fb5c8;--avatar-bg-11: #d6bbbb;--avatar-bg-12: #a4bc83;--avatar-bg-13: #cab893;--avatar-bg-14: #d6cae2;--avatar-bg-15: #d6bbc7;--avatar-bg-16: #bbbcbf;--avatar-bg-17: #b2bad2;--avatar-bg-18: #c2a28a;--avatar-bg-19: #5ead97;--model-logo-mono: #000;--bg-input-bar: #ffffff;--bg-sticky-btn: rgba(255, 255, 255, .85);--chat-boxer-gradient-dir: to bottom;--chat-boxer-bg-from: #F7F7F7;--chat-boxer-bg-to: #F0F0F0;--chat-boxer-hover-from: #EEEEEE;--chat-boxer-hover-to: #E8E8E8;--shadow-approval-heavy: rgba(0, 0, 0, .1);--shadow-approval-glow: rgba(245, 158, 11, .08);--color-approval-border: #1f1f1f;--color-approval-label: #1f1f1f;--color-gray-bg-light: rgba(107, 114, 128, .1);--color-gray-bg-medium: rgba(107, 114, 128, .2);--overlay-light-faint: rgba(0, 0, 0, .03);--overlay-light-medium: rgba(0, 0, 0, .06);--overlay-light-strong: rgba(0, 0, 0, .1);--overlay-light-half: rgba(0, 0, 0, .3);--overlay-light-bright: rgba(0, 0, 0, .5);--overlay-dark: rgba(0, 0, 0, .5);--overlay-dark-medium: rgba(0, 0, 0, .4);--overlay-dark-light: rgba(0, 0, 0, .3);--overlay-dark-strong: rgba(0, 0, 0, .6)}body{margin:0;background-color:var(--bg-base-alt);overflow-x:hidden;-webkit-tap-highlight-color:transparent}html,body{overscroll-behavior:none}::selection{background-color:var(--color-selection-alt);color:inherit}*{scrollbar-width:thin;scrollbar-color:var(--border-hover) var(--bg-base-alt)}@media(pointer:coarse){*{scrollbar-width:auto;scrollbar-color:auto}}@media(pointer:fine){*::-webkit-scrollbar{width:6px;height:6px}*::-webkit-scrollbar-track{background:var(--bg-base-alt)}*::-webkit-scrollbar-thumb{background:var(--border-hover);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:var(--text-disabled)}}.code-highlight{color:var(--code-text)}.hl-keyword{color:var(--code-keyword)}.hl-string{color:var(--code-string)}.hl-comment{color:var(--code-comment);font-style:italic}.hl-number{color:var(--code-number)}.hl-linenum{color:var(--text-disabled);-webkit-user-select:none;user-select:none}.chat-md pre{background:var(--bg-code-dark);border:1px solid var(--border-primary);border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;line-height:1.5}.chat-md code{background:var(--code-inline-bg);padding:2px 6px;border-radius:4px;font-size:13px;color:var(--code-inline-color)}.chat-md pre code{background:none;padding:0}.chat-md p{margin:6px 0}.chat-md ul,.chat-md ol{padding-left:20px;margin:6px 0}.chat-md li{margin:2px 0}.chat-md h1,.chat-md h2,.chat-md h3{margin:12px 0 6px;color:var(--text-white)}.chat-md h1{font-size:1.3em}.chat-md h2{font-size:1.15em}.chat-md h3{font-size:1.05em}.chat-md blockquote{border-left:3px solid var(--color-primary-light);margin:8px 0;padding:4px 12px;color:var(--text-tertiary)}.chat-md table{border-collapse:collapse;margin:8px 0;font-size:13px}.chat-md th,.chat-md td{border:1px solid var(--text-gray);padding:6px 10px}.chat-md th{background:var(--bg-elevated);color:var(--text-white)}.chat-md a{color:var(--color-primary-lighter)}.chat-md img{cursor:pointer;max-width:100%;height:auto;border-radius:6px}.chat-md hr{border:none;border-top:1px solid var(--border-primary);margin:12px 0}.chat-md .chat-boxer hr,.chat-boxer .chat-md hr{border-top-color:var(--border-light);margin:8px 0}.chat-md strong,.chat-md em{color:var(--text-primary)}.mermaid-diagram{display:flex;justify-content:center;margin:12px 0;padding:16px;background:var(--bg-code-dark);border:1px solid var(--border-primary);border-radius:6px;overflow-x:auto}.mermaid-diagram svg{max-width:100%;height:auto}*:focus{outline:none!important;box-shadow:none!important}*:focus-visible{outline:none!important;box-shadow:none!important}.chat-boxer{background:linear-gradient(var(--chat-boxer-gradient-dir),var(--chat-boxer-bg-from),var(--chat-boxer-bg-to));color:var(--text-white);border-radius:6px;padding:2px 10px;margin-bottom:6px;border:1px solid var(--text-muted);box-sizing:border-box}.chat-boxer:hover{background:linear-gradient(var(--chat-boxer-gradient-dir),var(--chat-boxer-hover-from),var(--chat-boxer-hover-to))}.chat-boxer .chat-md tbody{background:var(--bg-base-pure)}.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu{border:1px solid var(--border-hover);border-radius:4px}.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu .ant-dropdown-menu-item,.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu .ant-dropdown-menu-submenu-title{font-size:12px;padding:8px 12px}.ant-dropdown-menu-submenu-popup .ant-dropdown-menu{border:1px solid var(--border-hover);border-radius:4px}.ant-tooltip .ant-tooltip-inner{background-color:var(--bg-base-alt);color:var(--text-primary)}.ant-tooltip .ant-tooltip-arrow:before,.ant-tooltip .ant-tooltip-arrow:after{background:var(--bg-base-alt)}.ant-popover .ant-popover-arrow:before,.ant-popover .ant-popover-arrow:after{background:var(--border-secondary)}[data-theme=light] .ant-popover .ant-popover-arrow:before,[data-theme=light] .ant-popover .ant-popover-arrow:after{background:var(--bg-base-pure)}.ant-typography{font-size:12px}.ant-modal-confirm .ant-modal-content{background-color:var(--bg-elevated);border:1px solid var(--border-primary)}.ant-modal-confirm .ant-modal-confirm-title{color:var(--text-primary)}.ant-modal-confirm .ant-modal-confirm-content{color:var(--text-light)}.ant-modal-confirm .ant-btn-default{background-color:transparent;border-color:var(--border-light);color:var(--text-secondary)}.ant-modal-confirm .ant-btn-default.ant-btn-default:hover,.ant-modal-confirm .ant-btn-default.ant-btn-default:focus{background-color:var(--bg-surface);border-color:var(--text-muted);color:var(--text-white)}.ant-modal-confirm .ant-btn-primary,.ant-modal-confirm .ant-btn-primary:hover{background-color:var(--color-primary);border-color:var(--color-primary)}.ant-tabs .ant-tabs-nav:before{border-bottom-color:var(--border-primary)}[data-theme=light] .hljs{background:var(--bg-code);color:#383838}[data-theme=light] .hljs-keyword{color:#00f}[data-theme=light] .hljs-string{color:#a31515}[data-theme=light] .hljs-comment{color:#6a9955}[data-theme=light] .hljs-number{color:#098658}[data-theme=light] .hljs-title{color:#795e26}[data-theme=light] .hljs-type,[data-theme=light] .hljs-built_in{color:#267f99}[data-theme=light] .hljs-literal{color:#00f}[data-theme=light] .hljs-attr{color:#e50000}[data-theme=light] .hljs-variable{color:#001080}[data-theme=light] .hljs-name,[data-theme=light] .hljs-tag{color:maroon}[data-theme=light] .hljs-attribute{color:red}[data-theme=light] .hljs-symbol{color:#098658}[data-theme=light] .hljs-meta{color:#6a9955}[data-theme=light] .hljs-selector-tag{color:maroon}[data-theme=light] .hljs-selector-class,[data-theme=light] .hljs-selector-id{color:#267f99}
|