opencode-subagent-magazine 1.6.1 → 1.7.1
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 +12 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/core/format.d.ts +7 -0
- package/dist/core/format.js +45 -0
- package/dist/core/kv.d.ts +22 -1
- package/dist/core/kv.js +34 -1
- package/dist/core/types.d.ts +20 -0
- package/dist/core/types.js +7 -0
- package/dist/i18n.d.ts +24 -0
- package/dist/i18n.js +96 -0
- package/dist/index.js +90 -2
- package/dist/panel/SubAgentPanel.d.ts +6 -1
- package/dist/panel/SubAgentPanel.js +645 -266
- package/dist/panel/entry-map.d.ts +17 -0
- package/dist/panel/entry-map.js +75 -1
- package/dist/panel/panel-api.d.ts +24 -0
- package/dist/tui.js +1230 -567
- package/dist/v2/commands.js +202 -44
- package/dist/v2/db.d.ts +60 -0
- package/dist/v2/db.js +341 -0
- package/dist/v2/index.js +28 -6
- package/dist/v2/settings-menu.d.ts +9 -0
- package/dist/v2/settings-menu.js +242 -0
- package/dist/v2/v2-panel-api.js +315 -44
- package/dist/v2.js +2139 -708
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/core/format.ts +44 -0
- package/src/core/kv.ts +43 -2
- package/src/core/types.ts +25 -0
- package/src/i18n.ts +96 -0
- package/src/index.tsx +113 -2
- package/src/panel/SubAgentPanel.tsx +621 -238
- package/src/panel/entry-map.ts +79 -2
- package/src/panel/panel-api.ts +25 -0
- package/src/v2/commands.ts +201 -43
- package/src/v2/db.ts +346 -0
- package/src/v2/index.tsx +54 -7
- package/src/v2/settings-menu.ts +259 -0
- package/src/v2/types.ts +3 -1
- package/src/v2/v2-panel-api.ts +246 -33
package/dist/v2/db.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地 opencode.db 只读索引(V2)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主数据层只为当前 TUI 已加载的会话缓存消息:前台子代理(非 background)
|
|
5
|
+
* 的 success 事件经常拿不到 metadata.sessionID,条目会一直停在 running,
|
|
6
|
+
* model/tokens/cost 也无法回填。这些数据在宿主数据库里是完整的:
|
|
7
|
+
* - 父会话 assistant 消息中,工具 part 的 state.metadata.sessionID 指向子会话(前台/后台都有);
|
|
8
|
+
* - session_v2 保存子会话的 agent/model/tokens/cost 与 time_idle/idle_outcome。
|
|
9
|
+
*
|
|
10
|
+
* 本模块以只读方式查询该库作为补充数据源;bun:sqlite 不可用、库不存在或
|
|
11
|
+
* 设置项 dbSync 关闭时静默禁用(回退宿主 API)。库结构属于宿主内部实现,
|
|
12
|
+
* 任何读取失败都返回 undefined,绝不影响面板其余功能。
|
|
13
|
+
*/
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
// 动态加载:仅 Bun 运行时可用。字符串变量使 tsc/esbuild 不做静态解析,
|
|
16
|
+
// 打包产物保留运行时 import;Node(CI 测试)加载失败会静默禁用。
|
|
17
|
+
const BUN_SQLITE = "bun:sqlite";
|
|
18
|
+
/** 从宿主 model 字段提取模型 id。
|
|
19
|
+
* 兼容三种形态:名字字符串、{ id } 对象,以及序列化后的 JSON 字符串
|
|
20
|
+
* (session_v2.model 列存的就是 `{"id":...}` 文本)。 */
|
|
21
|
+
export function modelIdOf(value) {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
const s = value.trim();
|
|
24
|
+
if (s.length === 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (s.startsWith("{")) {
|
|
27
|
+
try {
|
|
28
|
+
return modelIdOf(JSON.parse(s));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
if (value && typeof value === "object") {
|
|
37
|
+
const id = value.id;
|
|
38
|
+
if (id === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
const direct = modelIdOf(id);
|
|
41
|
+
if (direct !== undefined)
|
|
42
|
+
return direct;
|
|
43
|
+
const s = String(id);
|
|
44
|
+
return s.length > 0 ? s : undefined;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** 宿主 readSessionTokens 同语义:最后一条 assistant 消息的上下文规模。 */
|
|
49
|
+
export function contextTokens(tokens) {
|
|
50
|
+
if (!tokens || typeof tokens !== "object")
|
|
51
|
+
return undefined;
|
|
52
|
+
const t = tokens;
|
|
53
|
+
const cache = (t.cache && typeof t.cache === "object") ? t.cache : {};
|
|
54
|
+
const sum = (Number(t.input) || 0) +
|
|
55
|
+
(Number(t.output) || 0) +
|
|
56
|
+
(Number(t.reasoning) || 0) +
|
|
57
|
+
(Number(cache.read) || 0) +
|
|
58
|
+
(Number(cache.write) || 0);
|
|
59
|
+
return sum > 0 ? sum : undefined;
|
|
60
|
+
}
|
|
61
|
+
/** 子会话是否仍在运行。
|
|
62
|
+
* 恢复过的会话会保留旧的 time_idle(host 不清空),因此以
|
|
63
|
+
* 「最后一条消息是否晚于 time_idle」为准;最后一条是 idle
|
|
64
|
+
* 消息则视为已停止。 */
|
|
65
|
+
export function isSessionActive(input) {
|
|
66
|
+
const { timeIdle, lastMessageType, lastMessageAt } = input;
|
|
67
|
+
if (lastMessageType === "idle")
|
|
68
|
+
return false;
|
|
69
|
+
if (lastMessageAt === undefined)
|
|
70
|
+
return false;
|
|
71
|
+
if (timeIdle === undefined)
|
|
72
|
+
return true;
|
|
73
|
+
return lastMessageAt > timeIdle;
|
|
74
|
+
}
|
|
75
|
+
/** 子会话终态:time_idle 存在且没有恢复活动才算结束;interrupted 视为取消。
|
|
76
|
+
* 没有 time_idle 时不作判断(交给宿主实时状态)——避免时间阈值猜测。 */
|
|
77
|
+
export function statusOfChild(info) {
|
|
78
|
+
if (!info)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (info.active === true)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (info.timeIdle == null)
|
|
83
|
+
return undefined;
|
|
84
|
+
return info.idleOutcome === "interrupted" ? "cancelled" : "done";
|
|
85
|
+
}
|
|
86
|
+
/** 定位宿主数据库(可用 OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */
|
|
87
|
+
export function findDbPath(env = process.env, home = safeHomedir()) {
|
|
88
|
+
if (env.OPENCODE_DB)
|
|
89
|
+
return env.OPENCODE_DB;
|
|
90
|
+
const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0 ? env.XDG_DATA_HOME : `${home}/.local/share`;
|
|
91
|
+
if (!base || base === "/.local/share")
|
|
92
|
+
return undefined;
|
|
93
|
+
return `${base}/opencode/opencode.db`;
|
|
94
|
+
}
|
|
95
|
+
function safeHomedir() {
|
|
96
|
+
try {
|
|
97
|
+
return homedir();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const CALL_MAP_SQL = `
|
|
104
|
+
SELECT json_extract(p.value, '$.id') AS call_id,
|
|
105
|
+
json_extract(p.value, '$.state.metadata.sessionID') AS child_sid
|
|
106
|
+
FROM session_message m, json_each(m.data, '$.content') p
|
|
107
|
+
WHERE m.session_id = ? AND m.type = 'assistant'
|
|
108
|
+
AND json_extract(p.value, '$.id') LIKE 'call_%'
|
|
109
|
+
AND json_extract(p.value, '$.state.metadata.sessionID') IS NOT NULL`;
|
|
110
|
+
const CHILD_INFO_SQL = `
|
|
111
|
+
SELECT parent_id, agent, title, model, cost,
|
|
112
|
+
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
113
|
+
time_created, time_idle, idle_outcome
|
|
114
|
+
FROM session_v2 WHERE id = ?`;
|
|
115
|
+
const LAST_ASSISTANT_SQL = `
|
|
116
|
+
SELECT json_extract(data, '$.tokens.input') AS input,
|
|
117
|
+
json_extract(data, '$.tokens.output') AS output,
|
|
118
|
+
json_extract(data, '$.tokens.reasoning') AS reasoning,
|
|
119
|
+
json_extract(data, '$.tokens.cache.read') AS cache_read,
|
|
120
|
+
json_extract(data, '$.tokens.cache.write') AS cache_write,
|
|
121
|
+
json_extract(data, '$.model.id') AS model_id
|
|
122
|
+
FROM session_message
|
|
123
|
+
WHERE session_id = ? AND type = 'assistant' AND json_extract(data, '$.tokens.output') > 0
|
|
124
|
+
ORDER BY seq DESC LIMIT 1`;
|
|
125
|
+
const LAST_MESSAGE_SQL = `
|
|
126
|
+
SELECT type, COALESCE(time_updated, time_created) AS at
|
|
127
|
+
FROM session_message WHERE session_id = ? ORDER BY seq DESC LIMIT 1`;
|
|
128
|
+
const ASSISTANT_COST_SQL = `
|
|
129
|
+
SELECT COUNT(*) AS n, SUM(COALESCE(json_extract(data, '$.cost'), 0)) AS total
|
|
130
|
+
FROM session_message WHERE session_id = ? AND type = 'assistant'`;
|
|
131
|
+
const CHILDREN_SQL = `
|
|
132
|
+
SELECT id, agent, title, model, cost,
|
|
133
|
+
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
134
|
+
time_created, time_idle, idle_outcome
|
|
135
|
+
FROM session_v2
|
|
136
|
+
WHERE parent_id = ? AND (fork_session_id IS NULL OR fork_session_id = '')
|
|
137
|
+
ORDER BY time_created`;
|
|
138
|
+
const num = (v) => {
|
|
139
|
+
const n = Number(v);
|
|
140
|
+
return Number.isFinite(n) ? n : undefined;
|
|
141
|
+
};
|
|
142
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
143
|
+
export function createSessionDbIndex(enabled) {
|
|
144
|
+
const dbPath = findDbPath();
|
|
145
|
+
if (!dbPath)
|
|
146
|
+
return undefined;
|
|
147
|
+
let db;
|
|
148
|
+
let failed = false;
|
|
149
|
+
void (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const mod = (await import(BUN_SQLITE));
|
|
152
|
+
const Database = mod?.Database;
|
|
153
|
+
if (!Database) {
|
|
154
|
+
failed = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
db = new Database(dbPath, { readonly: true });
|
|
158
|
+
try {
|
|
159
|
+
db.query("pragma query_only = on").all();
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
failed = true;
|
|
165
|
+
db = undefined;
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
const CALL_MAP_TTL_MS = 15000;
|
|
169
|
+
const CALL_MAP_RETRY_MS = 2000;
|
|
170
|
+
const INFO_TTL_MS = 5000;
|
|
171
|
+
const callMaps = new Map();
|
|
172
|
+
const infos = new Map();
|
|
173
|
+
const childrenCache = new Map();
|
|
174
|
+
const ready = () => {
|
|
175
|
+
if (failed || db === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
try {
|
|
178
|
+
return enabled();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const buildCallMap = (parentId) => {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const prev = callMaps.get(parentId);
|
|
187
|
+
if (prev && now - prev.at < CALL_MAP_RETRY_MS)
|
|
188
|
+
return prev.map;
|
|
189
|
+
const map = new Map();
|
|
190
|
+
try {
|
|
191
|
+
for (const row of db.query(CALL_MAP_SQL).all(parentId)) {
|
|
192
|
+
const id = str(row.call_id);
|
|
193
|
+
const sid = str(row.child_sid);
|
|
194
|
+
if (id && sid)
|
|
195
|
+
map.set(id, sid);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
callMaps.set(parentId, { map, at: now });
|
|
200
|
+
return map;
|
|
201
|
+
};
|
|
202
|
+
const resolveCall = (parentId, callId) => {
|
|
203
|
+
if (!ready() || !parentId || !callId)
|
|
204
|
+
return undefined;
|
|
205
|
+
const prev = callMaps.get(parentId);
|
|
206
|
+
const map = prev && Date.now() - prev.at < CALL_MAP_TTL_MS ? prev.map : buildCallMap(parentId);
|
|
207
|
+
return map.get(callId) ?? buildCallMap(parentId).get(callId);
|
|
208
|
+
};
|
|
209
|
+
const matchChild = (parentId, agent, startedAt) => {
|
|
210
|
+
if (!ready() || !parentId || startedAt === undefined)
|
|
211
|
+
return undefined;
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
let cached = childrenCache.get(parentId);
|
|
214
|
+
if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
|
|
215
|
+
let list = [];
|
|
216
|
+
try {
|
|
217
|
+
list = db.query(CHILDREN_SQL).all(parentId);
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
cached = { list, at: now };
|
|
221
|
+
childrenCache.set(parentId, cached);
|
|
222
|
+
}
|
|
223
|
+
let best;
|
|
224
|
+
let bestAt = Number.POSITIVE_INFINITY;
|
|
225
|
+
for (const row of cached.list) {
|
|
226
|
+
const sid = str(row.id);
|
|
227
|
+
const t = num(row.time_created);
|
|
228
|
+
if (!sid || t === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
if (t < startedAt - 2000)
|
|
231
|
+
continue;
|
|
232
|
+
if (agent !== undefined && str(row.agent) !== agent)
|
|
233
|
+
continue;
|
|
234
|
+
if (t < bestAt) {
|
|
235
|
+
bestAt = t;
|
|
236
|
+
best = sid;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return best;
|
|
240
|
+
};
|
|
241
|
+
const info = (sid) => {
|
|
242
|
+
if (!sid)
|
|
243
|
+
return undefined;
|
|
244
|
+
const hit = infos.get(sid);
|
|
245
|
+
if (hit && Date.now() - hit.at < INFO_TTL_MS)
|
|
246
|
+
return hit.info;
|
|
247
|
+
if (!ready())
|
|
248
|
+
return undefined;
|
|
249
|
+
let result;
|
|
250
|
+
try {
|
|
251
|
+
const row = db.query(CHILD_INFO_SQL).get(sid);
|
|
252
|
+
if (row) {
|
|
253
|
+
const last = db.query(LAST_ASSISTANT_SQL).get(sid);
|
|
254
|
+
const lastMsg = db.query(LAST_MESSAGE_SQL).get(sid);
|
|
255
|
+
let tokens = contextTokens(last ? {
|
|
256
|
+
input: last.input, output: last.output, reasoning: last.reasoning,
|
|
257
|
+
cache: { read: last.cache_read, write: last.cache_write },
|
|
258
|
+
} : undefined);
|
|
259
|
+
if (tokens === undefined) {
|
|
260
|
+
const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
|
|
261
|
+
(num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0);
|
|
262
|
+
if (agg > 0)
|
|
263
|
+
tokens = agg;
|
|
264
|
+
}
|
|
265
|
+
// cost 语义对齐宿主 readSessionCost:优先会话聚合值,
|
|
266
|
+
// 0 时回退 assistant 消息求和(有消息即权威,0 表示确实免费)。
|
|
267
|
+
let cost = num(row.cost);
|
|
268
|
+
if (!(typeof cost === "number" && cost > 0)) {
|
|
269
|
+
const aggCost = db.query(ASSISTANT_COST_SQL).get(sid);
|
|
270
|
+
if (aggCost && Number(aggCost.n) > 0)
|
|
271
|
+
cost = Number(aggCost.total) || 0;
|
|
272
|
+
}
|
|
273
|
+
result = {
|
|
274
|
+
id: sid,
|
|
275
|
+
parentId: str(row.parent_id),
|
|
276
|
+
agent: str(row.agent),
|
|
277
|
+
title: str(row.title),
|
|
278
|
+
model: modelIdOf(last?.model_id) ?? modelIdOf(row.model),
|
|
279
|
+
cost,
|
|
280
|
+
tokens,
|
|
281
|
+
timeCreated: num(row.time_created),
|
|
282
|
+
timeIdle: num(row.time_idle),
|
|
283
|
+
idleOutcome: str(row.idle_outcome),
|
|
284
|
+
active: isSessionActive({
|
|
285
|
+
timeIdle: num(row.time_idle),
|
|
286
|
+
lastMessageType: str(lastMsg?.type),
|
|
287
|
+
lastMessageAt: num(lastMsg?.at),
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
result = undefined;
|
|
294
|
+
}
|
|
295
|
+
infos.set(sid, { info: result, at: Date.now() });
|
|
296
|
+
return result;
|
|
297
|
+
};
|
|
298
|
+
const children = (parentId) => {
|
|
299
|
+
if (!ready() || !parentId)
|
|
300
|
+
return undefined;
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
let cached = childrenCache.get(parentId);
|
|
303
|
+
if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
|
|
304
|
+
let list = [];
|
|
305
|
+
try {
|
|
306
|
+
list = db.query(CHILDREN_SQL).all(parentId);
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
cached = { list, at: now };
|
|
310
|
+
childrenCache.set(parentId, cached);
|
|
311
|
+
}
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const row of cached.list) {
|
|
314
|
+
const sid = str(row.id);
|
|
315
|
+
if (!sid)
|
|
316
|
+
continue;
|
|
317
|
+
const full = info(sid);
|
|
318
|
+
if (full) {
|
|
319
|
+
out.push(full);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// info 缓存未命中时不丢字段:用列表行自身的汇总兜底。
|
|
323
|
+
const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
|
|
324
|
+
(num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0);
|
|
325
|
+
out.push({
|
|
326
|
+
id: sid,
|
|
327
|
+
parentId,
|
|
328
|
+
agent: str(row.agent),
|
|
329
|
+
title: str(row.title),
|
|
330
|
+
model: modelIdOf(row.model),
|
|
331
|
+
cost: num(row.cost),
|
|
332
|
+
tokens: agg > 0 ? agg : undefined,
|
|
333
|
+
timeCreated: num(row.time_created),
|
|
334
|
+
timeIdle: num(row.time_idle),
|
|
335
|
+
idleOutcome: str(row.idle_outcome),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
};
|
|
340
|
+
return { enabled: ready, resolveCall, matchChild, info, children };
|
|
341
|
+
}
|
package/dist/v2/index.js
CHANGED
|
@@ -5,16 +5,20 @@ import { createPanelApi } from "./v2-panel-api";
|
|
|
5
5
|
import { makeCommands } from "./commands";
|
|
6
6
|
import { mapTheme } from "./theme";
|
|
7
7
|
import { SubAgentPanel } from "../panel/SubAgentPanel";
|
|
8
|
+
import { TIME_FORMATS } from "../core/format";
|
|
8
9
|
import { SETTING_KEYS } from "../core/kv";
|
|
9
10
|
import { LANG_META, detectLang } from "../i18n";
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
function PluginRoot(props) {
|
|
11
|
+
/** 命令层必须在 app 槽注册:侧栏隐藏(sidebar: auto)时命令仍需可用。 */
|
|
12
|
+
function CommandRoot(props) {
|
|
13
13
|
props.context.keymap.layer(() => ({
|
|
14
14
|
mode: "global",
|
|
15
15
|
commands: makeCommands(props.context, props.api, props.signals),
|
|
16
16
|
}));
|
|
17
|
-
return
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/** 面板根组件:渲染共享 SubAgentPanel(命令层见 CommandRoot)。 */
|
|
20
|
+
function PluginRoot(props) {
|
|
21
|
+
return (_jsx(SubAgentPanel, { api: props.api, theme: mapTheme(props.context.theme), lang: props.signals.lang, maxEntries: props.signals.maxEntries, sortOrder: props.signals.sortOrder, scrollMode: props.signals.scrollMode, borderVisible: props.signals.borderVisible, showEntryCost: props.signals.showEntryCost, showEntryTime: props.signals.showEntryTime, showEntryTokens: props.signals.showEntryTokens, timeFormat: props.signals.timeFormat, showOrigin: props.signals.showOrigin, sessionId: props.sessionID }));
|
|
18
22
|
}
|
|
19
23
|
/** V2 入口:setup 创建共享信号 + PanelApi + 命令 layer + 侧边栏槽位。
|
|
20
24
|
* 行为对齐 V1 tui()(信号初始值从 KV 恢复;侧边栏渲染共享 SubAgentPanel)。 */
|
|
@@ -40,14 +44,32 @@ const mod = {
|
|
|
40
44
|
const [orderSignal, setSortOrder] = createSignal(String(api.kv.get(SETTING_KEYS.order, "desc")) === "asc" ? "asc" : "desc");
|
|
41
45
|
const [scrollSignal, setScrollMode] = createSignal(String(api.kv.get(SETTING_KEYS.scrollMode, "wheel")) === "click" ? "click" : "wheel");
|
|
42
46
|
const [borderSignal, setBorderVisible] = createSignal(api.kv.get(SETTING_KEYS.border, false) === true);
|
|
47
|
+
const [showEntryCostSignal, setShowEntryCost] = createSignal(api.kv.get(SETTING_KEYS.showEntryCost, false) === true);
|
|
48
|
+
const [showEntryTimeSignal, setShowEntryTime] = createSignal(api.kv.get(SETTING_KEYS.showEntryTime, true) !== false);
|
|
49
|
+
const [showEntryTokensSignal, setShowEntryTokens] = createSignal(api.kv.get(SETTING_KEYS.showEntryTokens, true) !== false);
|
|
50
|
+
const storedTimeFormat = String(api.kv.get(SETTING_KEYS.timeFormat, "short"));
|
|
51
|
+
const [timeFormatSignal, setTimeFormat] = createSignal(TIME_FORMATS.includes(storedTimeFormat) ? storedTimeFormat : "short");
|
|
52
|
+
const [showOriginSignal, setShowOrigin] = createSignal(api.kv.get(SETTING_KEYS.showOrigin, false) === true);
|
|
53
|
+
const [dbSyncSignal, setDbSync] = createSignal(api.kv.get(SETTING_KEYS.dbSync, true) !== false);
|
|
43
54
|
lang = langSignal;
|
|
44
55
|
maxEntries = maxSignal;
|
|
45
56
|
sortOrder = orderSignal;
|
|
46
57
|
scrollMode = scrollSignal;
|
|
47
58
|
const signals = {
|
|
48
|
-
lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible,
|
|
59
|
+
lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible,
|
|
60
|
+
showEntryCost: showEntryCostSignal, setShowEntryCost,
|
|
61
|
+
showEntryTime: showEntryTimeSignal, setShowEntryTime,
|
|
62
|
+
showEntryTokens: showEntryTokensSignal, setShowEntryTokens,
|
|
63
|
+
timeFormat: timeFormatSignal, setTimeFormat,
|
|
64
|
+
showOrigin: showOriginSignal, setShowOrigin,
|
|
65
|
+
dbSync: dbSyncSignal, setDbSync,
|
|
66
|
+
sessionId: "",
|
|
49
67
|
};
|
|
50
|
-
//
|
|
68
|
+
// 命令层挂 app 槽:侧栏隐藏时斜杠命令仍需可用(见 CommandRoot)
|
|
69
|
+
context.ui.slot({
|
|
70
|
+
append: "app",
|
|
71
|
+
render: () => (_jsx(CommandRoot, { context: context, api: api, signals: signals })),
|
|
72
|
+
});
|
|
51
73
|
// 侧边栏面板(共享 SubAgentPanel——V1/V2 同一组件)
|
|
52
74
|
context.ui.slot({
|
|
53
75
|
prepend: "sidebar.content",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Context } from "./types";
|
|
2
|
+
import type { PanelApi } from "../panel/panel-api";
|
|
3
|
+
import type { SharedSignals } from "../core/types";
|
|
4
|
+
/**
|
|
5
|
+
* 原生设置菜单(visual-cache 风格):用单个选择列表展示每项设置
|
|
6
|
+
* 及其当前值;选中某项会打开对应的编辑器,
|
|
7
|
+
* 随后菜单重新打开,直到用户关闭(Esc / Close)。
|
|
8
|
+
*/
|
|
9
|
+
export declare function openSettingsMenu(context: Context, api: PanelApi, signals: SharedSignals): void;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { SETTING_KEYS, readTTLDays } from "../core/kv";
|
|
2
|
+
import { TIME_FORMATS, TIME_FORMAT_SAMPLES } from "../core/format";
|
|
3
|
+
import { LANG_META, createT } from "../i18n";
|
|
4
|
+
/** 与侧栏设置共用的 TTL 周期:3/7/14/30 天或无限期。 */
|
|
5
|
+
const TTL_VALUES = [3, 7, 14, 30, 0];
|
|
6
|
+
/** 链式原生对话框之间的延迟,让宿主能关闭上一个对话框。 */
|
|
7
|
+
const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
8
|
+
/**
|
|
9
|
+
* 原生设置菜单(visual-cache 风格):用单个选择列表展示每项设置
|
|
10
|
+
* 及其当前值;选中某项会打开对应的编辑器,
|
|
11
|
+
* 随后菜单重新打开,直到用户关闭(Esc / Close)。
|
|
12
|
+
*/
|
|
13
|
+
export function openSettingsMenu(context, api, signals) {
|
|
14
|
+
runSettingsMenu(context, api, signals).catch((err) => {
|
|
15
|
+
try {
|
|
16
|
+
context.ui.toast.show({ message: String(err), variant: "error" });
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function runSettingsMenu(context, api, signals) {
|
|
22
|
+
const t = createT(() => signals.lang());
|
|
23
|
+
const kv = api.kv;
|
|
24
|
+
const onOff = (v) => (v ? t("settings.on") : t("settings.off"));
|
|
25
|
+
const langLabel = () => LANG_META.find((m) => m.code === signals.lang())?.label ?? signals.lang();
|
|
26
|
+
const ttlTitle = (d) => (d === 0 ? t("ttl.unlimited") : t(d === 3 ? "ttl.3d" : d === 7 ? "ttl.7d" : d === 14 ? "ttl.14d" : "ttl.30d"));
|
|
27
|
+
const ttlLabel = () => ttlTitle(readTTLDays(kv));
|
|
28
|
+
const orderLabel = () => (signals.sortOrder() === "desc" ? t("order.desc") : t("order.asc"));
|
|
29
|
+
const scrollLabel = () => (signals.scrollMode() === "wheel" ? t("scroll.wheel") : t("scroll.click"));
|
|
30
|
+
const toast = (item, value) => context.ui.toast.show({ message: t("settings.saved", { item, value }) });
|
|
31
|
+
while (true) {
|
|
32
|
+
const category = t("settings.section.general");
|
|
33
|
+
const choice = await context.ui.dialog.select({
|
|
34
|
+
title: t("settings.title"),
|
|
35
|
+
options: [
|
|
36
|
+
{ title: t("settings.lang"), value: "lang", description: langLabel(), category },
|
|
37
|
+
{ title: t("settings.max"), value: "max", description: String(signals.maxEntries()), category },
|
|
38
|
+
{ title: t("settings.order"), value: "order", description: orderLabel(), category },
|
|
39
|
+
{ title: t("settings.scroll"), value: "scroll", description: scrollLabel(), category },
|
|
40
|
+
{ title: t("settings.ttl"), value: "ttl", description: ttlLabel(), category },
|
|
41
|
+
{ title: t("settings.border"), value: "border", description: onOff(signals.borderVisible()), category },
|
|
42
|
+
{ title: t("settings.showEntryCost"), value: "cost", description: onOff(signals.showEntryCost()), category },
|
|
43
|
+
{ title: t("settings.showEntryTime"), value: "time", description: onOff(signals.showEntryTime()), category },
|
|
44
|
+
{ title: t("settings.timeFormat"), value: "timefmt", description: TIME_FORMAT_SAMPLES[signals.timeFormat()], category },
|
|
45
|
+
{ title: t("settings.showEntryTokens"), value: "tokens", description: onOff(signals.showEntryTokens()), category },
|
|
46
|
+
{ title: t("settings.showOrigin"), value: "origin", description: onOff(signals.showOrigin()), category },
|
|
47
|
+
{ title: t("settings.dbSync"), value: "dbsync", description: onOff(signals.dbSync()), category },
|
|
48
|
+
{ title: t("settings.close"), value: "close" },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
if (choice === undefined || choice === "close")
|
|
52
|
+
return;
|
|
53
|
+
// ── 语言 ──
|
|
54
|
+
if (choice === "lang") {
|
|
55
|
+
const picked = await context.ui.dialog.select({
|
|
56
|
+
title: t("settings.lang"),
|
|
57
|
+
options: LANG_META.map((m) => ({ title: m.label, value: m.code })),
|
|
58
|
+
current: signals.lang(),
|
|
59
|
+
});
|
|
60
|
+
if (picked) {
|
|
61
|
+
signals.setLang(picked);
|
|
62
|
+
kv.set(SETTING_KEYS.lang, picked);
|
|
63
|
+
toast(t("settings.lang"), LANG_META.find((m) => m.code === picked)?.label ?? picked);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── 最大可见条目数 ──
|
|
67
|
+
if (choice === "max") {
|
|
68
|
+
const val = await context.ui.dialog.prompt({
|
|
69
|
+
title: t("settings.max"),
|
|
70
|
+
message: "1–50",
|
|
71
|
+
placeholder: String(signals.maxEntries()),
|
|
72
|
+
});
|
|
73
|
+
if (val !== undefined) {
|
|
74
|
+
const n = parseInt(val.trim(), 10);
|
|
75
|
+
if (Number.isNaN(n)) {
|
|
76
|
+
context.ui.toast.show({ message: t("settings.invalid"), variant: "warning" });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const clamped = Math.max(1, Math.min(50, n));
|
|
80
|
+
signals.setMaxEntries(clamped);
|
|
81
|
+
kv.set(SETTING_KEYS.maxEntries, clamped);
|
|
82
|
+
toast(t("settings.max"), String(clamped));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── 排序方式 ──
|
|
87
|
+
if (choice === "order") {
|
|
88
|
+
const picked = await context.ui.dialog.select({
|
|
89
|
+
title: t("settings.order"),
|
|
90
|
+
options: [
|
|
91
|
+
{ title: t("order.desc"), value: "desc" },
|
|
92
|
+
{ title: t("order.asc"), value: "asc" },
|
|
93
|
+
],
|
|
94
|
+
current: signals.sortOrder(),
|
|
95
|
+
});
|
|
96
|
+
if (picked) {
|
|
97
|
+
signals.setSortOrder(picked);
|
|
98
|
+
kv.set(SETTING_KEYS.order, picked);
|
|
99
|
+
toast(t("settings.order"), picked === "desc" ? t("order.desc") : t("order.asc"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── 滚动模式 ──
|
|
103
|
+
if (choice === "scroll") {
|
|
104
|
+
const picked = await context.ui.dialog.select({
|
|
105
|
+
title: t("settings.scroll"),
|
|
106
|
+
options: [
|
|
107
|
+
{ title: t("scroll.wheel"), value: "wheel" },
|
|
108
|
+
{ title: t("scroll.click"), value: "click" },
|
|
109
|
+
],
|
|
110
|
+
current: signals.scrollMode(),
|
|
111
|
+
});
|
|
112
|
+
if (picked) {
|
|
113
|
+
signals.setScrollMode(picked);
|
|
114
|
+
kv.set(SETTING_KEYS.scrollMode, picked);
|
|
115
|
+
toast(t("settings.scroll"), picked === "wheel" ? t("scroll.wheel") : t("scroll.click"));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ── TTL / 保留期 ──
|
|
119
|
+
if (choice === "ttl") {
|
|
120
|
+
const picked = await context.ui.dialog.select({
|
|
121
|
+
title: t("settings.ttl"),
|
|
122
|
+
options: TTL_VALUES.map((d) => ({ title: ttlTitle(d), value: d })),
|
|
123
|
+
current: readTTLDays(kv),
|
|
124
|
+
});
|
|
125
|
+
if (picked !== undefined) {
|
|
126
|
+
kv.set(SETTING_KEYS.ttlDays, String(picked));
|
|
127
|
+
toast(t("settings.ttl"), ttlTitle(picked));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ── 边框 ──
|
|
131
|
+
if (choice === "border") {
|
|
132
|
+
const picked = await context.ui.dialog.select({
|
|
133
|
+
title: t("settings.border"),
|
|
134
|
+
options: [
|
|
135
|
+
{ title: t("settings.on"), value: true },
|
|
136
|
+
{ title: t("settings.off"), value: false },
|
|
137
|
+
],
|
|
138
|
+
current: signals.borderVisible(),
|
|
139
|
+
});
|
|
140
|
+
if (picked !== undefined) {
|
|
141
|
+
signals.setBorderVisible(picked);
|
|
142
|
+
kv.set(SETTING_KEYS.border, picked);
|
|
143
|
+
toast(t("settings.border"), onOff(picked));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ── 单条目费用 ──
|
|
147
|
+
if (choice === "cost") {
|
|
148
|
+
const picked = await context.ui.dialog.select({
|
|
149
|
+
title: t("settings.showEntryCost"),
|
|
150
|
+
options: [
|
|
151
|
+
{ title: t("settings.on"), value: true },
|
|
152
|
+
{ title: t("settings.off"), value: false },
|
|
153
|
+
],
|
|
154
|
+
current: signals.showEntryCost(),
|
|
155
|
+
});
|
|
156
|
+
if (picked !== undefined) {
|
|
157
|
+
signals.setShowEntryCost(picked);
|
|
158
|
+
kv.set(SETTING_KEYS.showEntryCost, picked);
|
|
159
|
+
toast(t("settings.showEntryCost"), onOff(picked));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ── 单条目时间 ──
|
|
163
|
+
if (choice === "time") {
|
|
164
|
+
const picked = await context.ui.dialog.select({
|
|
165
|
+
title: t("settings.showEntryTime"),
|
|
166
|
+
options: [
|
|
167
|
+
{ title: t("settings.on"), value: true },
|
|
168
|
+
{ title: t("settings.off"), value: false },
|
|
169
|
+
],
|
|
170
|
+
current: signals.showEntryTime(),
|
|
171
|
+
});
|
|
172
|
+
if (picked !== undefined) {
|
|
173
|
+
signals.setShowEntryTime(picked);
|
|
174
|
+
kv.set(SETTING_KEYS.showEntryTime, picked);
|
|
175
|
+
toast(t("settings.showEntryTime"), onOff(picked));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── 时间格式 ──
|
|
179
|
+
if (choice === "timefmt") {
|
|
180
|
+
const picked = await context.ui.dialog.select({
|
|
181
|
+
title: t("settings.timeFormat"),
|
|
182
|
+
options: TIME_FORMATS.map((f) => ({ title: `${f} — ${TIME_FORMAT_SAMPLES[f]}`, value: f })),
|
|
183
|
+
current: signals.timeFormat(),
|
|
184
|
+
});
|
|
185
|
+
if (picked) {
|
|
186
|
+
signals.setTimeFormat(picked);
|
|
187
|
+
kv.set(SETTING_KEYS.timeFormat, picked);
|
|
188
|
+
toast(t("settings.timeFormat"), TIME_FORMAT_SAMPLES[picked]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── 单条目 token 数 ──
|
|
192
|
+
if (choice === "tokens") {
|
|
193
|
+
const picked = await context.ui.dialog.select({
|
|
194
|
+
title: t("settings.showEntryTokens"),
|
|
195
|
+
options: [
|
|
196
|
+
{ title: t("settings.on"), value: true },
|
|
197
|
+
{ title: t("settings.off"), value: false },
|
|
198
|
+
],
|
|
199
|
+
current: signals.showEntryTokens(),
|
|
200
|
+
});
|
|
201
|
+
if (picked !== undefined) {
|
|
202
|
+
signals.setShowEntryTokens(picked);
|
|
203
|
+
kv.set(SETTING_KEYS.showEntryTokens, picked);
|
|
204
|
+
toast(t("settings.showEntryTokens"), onOff(picked));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// ── Origin indicators ──
|
|
208
|
+
if (choice === "origin") {
|
|
209
|
+
const picked = await context.ui.dialog.select({
|
|
210
|
+
title: t("settings.showOrigin"),
|
|
211
|
+
options: [
|
|
212
|
+
{ title: t("settings.on"), value: true },
|
|
213
|
+
{ title: t("settings.off"), value: false },
|
|
214
|
+
],
|
|
215
|
+
current: signals.showOrigin(),
|
|
216
|
+
});
|
|
217
|
+
if (picked !== undefined) {
|
|
218
|
+
signals.setShowOrigin(picked);
|
|
219
|
+
kv.set(SETTING_KEYS.showOrigin, picked);
|
|
220
|
+
toast(t("settings.showOrigin"), onOff(picked));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ── 本地数据库补全 ──
|
|
224
|
+
if (choice === "dbsync") {
|
|
225
|
+
const picked = await context.ui.dialog.select({
|
|
226
|
+
title: t("settings.dbSync"),
|
|
227
|
+
options: [
|
|
228
|
+
{ title: t("settings.on"), value: true },
|
|
229
|
+
{ title: t("settings.off"), value: false },
|
|
230
|
+
],
|
|
231
|
+
current: signals.dbSync(),
|
|
232
|
+
});
|
|
233
|
+
if (picked !== undefined) {
|
|
234
|
+
signals.setDbSync(picked);
|
|
235
|
+
kv.set(SETTING_KEYS.dbSync, picked);
|
|
236
|
+
toast(t("settings.dbSync"), onOff(picked));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 让宿主先关闭编辑器,再重新打开菜单。
|
|
240
|
+
await nextTick();
|
|
241
|
+
}
|
|
242
|
+
}
|