zcode-pro 0.18.0 → 0.20.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.en.md +1 -0
- package/README.md +1 -0
- package/bin/zcode-pro.mjs +32 -9
- package/dist/inject.js +684 -130
- package/package.json +1 -1
- package/src/host/helper.mjs +613 -50
- package/src/host/main.mjs +8 -4
- package/src/host/setup.mjs +45 -42
- package/src/host/taskIndex.mjs +258 -0
- package/src/inject/core.js +67 -19
- package/src/inject/features/file-menu.js +9 -2
- package/src/inject/features/settings-dialog.js +266 -63
- package/src/inject/features/styles.js +1 -1
- package/src/inject/features/task-order.js +1 -1
- package/src/inject/features/title-gen.js +178 -0
- package/src/inject/features/title-lock.js +73 -1
- package/src/inject/features/toolbar-icons.js +38 -4
- package/src/inject/features/ws-running.js +1 -1
- package/src/inject/index.js +2 -0
- package/src/inject/provider-fields.js +71 -0
- package/src/inject/ui.js +20 -5
package/src/host/main.mjs
CHANGED
|
@@ -17,11 +17,15 @@ import { runSetup } from './setup.mjs';
|
|
|
17
17
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
const INJECT_BUNDLE = resolve(here, '..', '..', 'dist', 'inject.js');
|
|
19
19
|
|
|
20
|
-
// Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/
|
|
21
|
-
|
|
22
|
-
//
|
|
20
|
+
// Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/cmd 的默认代码页
|
|
21
|
+
//(936/GBK)按 GBK 解码会显示乱码。检测到非 UTF-8 代码页时切到 65001——只影响
|
|
22
|
+
// 当前控制台会话(重开终端即恢复)。不能用 stdout.isTTY 单独作前提:npm 全局
|
|
23
|
+
// 入口(bin/zcode-pro.mjs)spawn ZCode 运行时跑 cli.mjs,子进程 stdout 经
|
|
24
|
+
// stdio:'inherit' 是管道(isTTY=false),但控制台乱码照样发生(0.18/0.19 用户
|
|
25
|
+
// 实拍);此时 stdin 仍连着终端,故用 stdout 或 stdin 任一 isTTY 判定。真正的
|
|
26
|
+
// 重定向场景(stdout 与 stdin 都不是 TTY)不干预。
|
|
23
27
|
// 放在模块顶层:cli.mjs 经 import main.mjs 进入,setup 等子命令的输出同样受益。
|
|
24
|
-
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
28
|
+
if (process.platform === 'win32' && (process.stdout.isTTY || process.stdin.isTTY)) {
|
|
25
29
|
try {
|
|
26
30
|
const cp = execSync('chcp.com', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).match(/(\d+)/)?.[1];
|
|
27
31
|
if (cp && cp !== '65001') execSync('chcp.com 65001', { stdio: 'ignore', windowsHide: true });
|
package/src/host/setup.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// zcode-pro setup:本机集成设置入口(npm / pip 等包管理器安装不写系统目录,
|
|
2
|
-
//
|
|
2
|
+
// 需要菜单入口等集成时显式运行本子命令)。按“设置项”组织(id / 标题 /
|
|
3
3
|
// install / uninstall),以后新增设置项往 items 里加即可。
|
|
4
4
|
// setup 不依赖 ZCode 运行,在 main.run 里先于 ZCode 定位处理。
|
|
5
5
|
//
|
|
@@ -291,17 +291,17 @@ function plistContent(hasIcon) {
|
|
|
291
291
|
|
|
292
292
|
const shortcutItem = {
|
|
293
293
|
id: 'desktop-shortcut',
|
|
294
|
-
title: '
|
|
294
|
+
title: 'Shortcut "ZCode Pro" (app/start menu, icon reuses ZCode)',
|
|
295
295
|
supported: (plat) => SUPPORTED.includes(plat),
|
|
296
296
|
|
|
297
297
|
install(plat, opts = {}) {
|
|
298
298
|
const cmd = commandInPath(plat);
|
|
299
299
|
if (!cmd) {
|
|
300
|
-
throw new Error('
|
|
300
|
+
throw new Error('zcode-pro command not found in PATH, cannot create shortcut; check your install and retry');
|
|
301
301
|
}
|
|
302
302
|
if (!sameInstall(cmd, plat)) {
|
|
303
|
-
throw new Error(`
|
|
304
|
-
'
|
|
303
|
+
throw new Error(`the zcode-pro in PATH (${cmd}) is not part of this installation, ` +
|
|
304
|
+
'skipped so the shortcut will not point at an old version; manage shortcuts with that installation instead');
|
|
305
305
|
}
|
|
306
306
|
if (plat === 'linux') return this.installLinux(cmd, opts);
|
|
307
307
|
if (plat === 'win32') return this.installWindows(cmd, opts);
|
|
@@ -312,7 +312,7 @@ const shortcutItem = {
|
|
|
312
312
|
// 系统级(AUR 等)已有同名条目时不再建用户级:desktop 文件 id 相同时用户级
|
|
313
313
|
// 会遮蔽系统级(项目既有约定),残留后表现为菜单一直运行另一份
|
|
314
314
|
if (existsSync('/usr/share/applications/zcode-pro.desktop')) {
|
|
315
|
-
return { skipped: '
|
|
315
|
+
return { skipped: 'system-level shortcut detected (e.g. AUR); a user entry would shadow it, skipped' };
|
|
316
316
|
}
|
|
317
317
|
const appsDir = applicationsDir();
|
|
318
318
|
const target = join(appsDir, DESKTOP_FILE);
|
|
@@ -320,21 +320,21 @@ const shortcutItem = {
|
|
|
320
320
|
if (existsSync(target)) {
|
|
321
321
|
const cur = readFileSync(target, 'utf-8');
|
|
322
322
|
if (!cur.includes(SETUP_MARK)) {
|
|
323
|
-
return { skipped: `${target}
|
|
323
|
+
return { skipped: `${target} exists and was not created by setup (e.g. install.sh), not overwritten` };
|
|
324
324
|
}
|
|
325
325
|
// 早期版本写过 0644 的条目:即使内容无需重写也补上执行位(原因见 writeDesktopEntry)
|
|
326
326
|
chmodSync(target, 0o755);
|
|
327
327
|
const exec = (cur.match(/^Exec=(.+)$/m) || [])[1] || '';
|
|
328
328
|
if (exec.includes(cmd)) {
|
|
329
|
-
parts.push('
|
|
329
|
+
parts.push('app-menu entry already exists and points to the current command, nothing to do');
|
|
330
330
|
} else {
|
|
331
331
|
writeDesktopEntry(target, cmd);
|
|
332
|
-
parts.push(
|
|
332
|
+
parts.push(`updated ${target} (Exec=${cmd})`);
|
|
333
333
|
}
|
|
334
334
|
} else {
|
|
335
335
|
mkdirSync(appsDir, { recursive: true });
|
|
336
336
|
writeDesktopEntry(target, cmd);
|
|
337
|
-
parts.push(
|
|
337
|
+
parts.push(`written ${target} (Exec=${cmd})`);
|
|
338
338
|
}
|
|
339
339
|
try { execFileSync('update-desktop-database', [appsDir], { stdio: 'ignore' }); } catch { /* 无该命令时忽略 */ }
|
|
340
340
|
// 桌面条目独立判断(不因菜单条目幂等而短路)
|
|
@@ -347,13 +347,13 @@ const shortcutItem = {
|
|
|
347
347
|
// GNOME 的桌面(Nautilus)额外要求 trusted 元数据才显示“允许启动”;
|
|
348
348
|
// 其他环境无 gio / 写不进元数据时忽略
|
|
349
349
|
try { execFileSync('gio', ['set', dTarget, 'metadata::trusted', 'true'], { stdio: 'ignore' }); } catch { /* 非 GNOME 忽略 */ }
|
|
350
|
-
parts.push(
|
|
350
|
+
parts.push(`desktop ${dTarget}`);
|
|
351
351
|
} else {
|
|
352
|
-
parts.push('
|
|
352
|
+
parts.push('desktop entry exists and was not created by setup, not overwritten');
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
-
if (parts.length === 1 && parts[0].startsWith('
|
|
356
|
-
return { detail: parts.join('
|
|
355
|
+
if (parts.length === 1 && parts[0].startsWith('app-menu entry already exists')) return { skipped: parts[0] };
|
|
356
|
+
return { detail: parts.join('; ') };
|
|
357
357
|
},
|
|
358
358
|
|
|
359
359
|
installWindows(cmd, opts = {}) {
|
|
@@ -367,9 +367,9 @@ const shortcutItem = {
|
|
|
367
367
|
const plan = windowsShortcutPlan(cmd, !!opts.desktop, wshOk, !menuForeign);
|
|
368
368
|
if (menuForeign && (!opts.desktop || deskForeign)) {
|
|
369
369
|
return { skipped: [
|
|
370
|
-
menuForeign ?
|
|
371
|
-
deskForeign ?
|
|
372
|
-
].filter(Boolean).join('
|
|
370
|
+
menuForeign ? `${plan.lnkPath} exists (created elsewhere), not overwritten` : '',
|
|
371
|
+
deskForeign ? `desktop ${deskPath} exists (created elsewhere), not overwritten` : '',
|
|
372
|
+
].filter(Boolean).join('; ') };
|
|
373
373
|
}
|
|
374
374
|
if (wshOk) {
|
|
375
375
|
mkdirSync(plan.vbsDir, { recursive: true });
|
|
@@ -377,14 +377,14 @@ const shortcutItem = {
|
|
|
377
377
|
}
|
|
378
378
|
runPowerShell(plan.psScript);
|
|
379
379
|
const how = wshOk
|
|
380
|
-
?
|
|
381
|
-
: '
|
|
380
|
+
? `silent launch via ${plan.vbsPath}`
|
|
381
|
+
: 'WSH unavailable on this machine; minimized cmd launch (console flashes briefly on start)';
|
|
382
382
|
const parts = [];
|
|
383
383
|
parts.push(menuForeign
|
|
384
|
-
?
|
|
385
|
-
:
|
|
386
|
-
if (opts.desktop) parts.push(deskForeign ?
|
|
387
|
-
return { detail: `${parts.join('
|
|
384
|
+
? `start menu ${plan.lnkPath} exists (created elsewhere), not overwritten`
|
|
385
|
+
: `created ${plan.lnkPath}`);
|
|
386
|
+
if (opts.desktop) parts.push(deskForeign ? `desktop ${deskPath} exists (created elsewhere), not overwritten` : 'desktop shortcut created');
|
|
387
|
+
return { detail: `${parts.join('; ')} (${how})` };
|
|
388
388
|
},
|
|
389
389
|
|
|
390
390
|
installMac(cmd, opts = {}) {
|
|
@@ -392,7 +392,7 @@ const shortcutItem = {
|
|
|
392
392
|
const contents = join(app, 'Contents');
|
|
393
393
|
const marker = join(contents, 'zcodepro-setup-marker');
|
|
394
394
|
if (existsSync(app) && !existsSync(marker)) {
|
|
395
|
-
return { skipped: `${app}
|
|
395
|
+
return { skipped: `${app} exists and was not created by setup, not overwritten` };
|
|
396
396
|
}
|
|
397
397
|
let iconCopied = false;
|
|
398
398
|
for (const src of [join('/', 'Applications', 'ZCode.app', 'Contents', 'Resources', 'app.icns'),
|
|
@@ -411,15 +411,15 @@ const shortcutItem = {
|
|
|
411
411
|
chmodSync(launcher, 0o755);
|
|
412
412
|
writeFileSync(join(contents, 'Info.plist'), plistContent(iconCopied));
|
|
413
413
|
writeFileSync(marker, '');
|
|
414
|
-
let detail =
|
|
414
|
+
let detail = `created ${app} (launcher -> ${cmd}${iconCopied ? ', icon copied' : ''})`;
|
|
415
415
|
if (opts.desktop) {
|
|
416
416
|
const link = macDesktopLink();
|
|
417
417
|
if (existsSync(link)) {
|
|
418
|
-
detail += '
|
|
418
|
+
detail += '; desktop entry with the same name exists, not overwritten';
|
|
419
419
|
} else {
|
|
420
420
|
try {
|
|
421
421
|
symlinkSync(app, link);
|
|
422
|
-
detail +=
|
|
422
|
+
detail += `; desktop ${link}`;
|
|
423
423
|
} catch { /* 建链接失败仅省略桌面入口 */ }
|
|
424
424
|
}
|
|
425
425
|
}
|
|
@@ -445,16 +445,16 @@ const shortcutItem = {
|
|
|
445
445
|
rmSync(dTarget);
|
|
446
446
|
removed.push(dTarget);
|
|
447
447
|
}
|
|
448
|
-
if (!removed.length) return { skipped: '
|
|
448
|
+
if (!removed.length) return { skipped: 'no setup-created shortcuts found' };
|
|
449
449
|
try { execFileSync('update-desktop-database', [applicationsDir()], { stdio: 'ignore' }); } catch { /* 无该命令时忽略 */ }
|
|
450
|
-
return { detail:
|
|
450
|
+
return { detail: `removed ${removed.join(', ')}` };
|
|
451
451
|
},
|
|
452
452
|
|
|
453
453
|
uninstallWindows() {
|
|
454
454
|
const paths = windowsPaths();
|
|
455
455
|
const hadVbs = existsSync(paths.vbsPath);
|
|
456
456
|
const hadLnk = existsSync(paths.lnkPath);
|
|
457
|
-
if (!hadVbs && !hadLnk) return { skipped: '
|
|
457
|
+
if (!hadVbs && !hadLnk) return { skipped: 'no setup-created shortcuts found' };
|
|
458
458
|
// .lnk 可能已被 install.ps1 等重建并指向它自己:只有 Arguments 带本功能归属
|
|
459
459
|
// 标记才删;校验失败也保留 .lnk(宁可漏删不误删)
|
|
460
460
|
let lnkRemoved = false;
|
|
@@ -488,29 +488,29 @@ const shortcutItem = {
|
|
|
488
488
|
vbsCleaned = true;
|
|
489
489
|
} catch {
|
|
490
490
|
vbsCleaned = !existsSync(paths.vbsPath);
|
|
491
|
-
if (!vbsCleaned) console.error(`
|
|
491
|
+
if (!vbsCleaned) console.error(` (${paths.vbsDir} could not be deleted (may be in use); remove it manually later)`);
|
|
492
492
|
}
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
const parts = [];
|
|
496
496
|
if (hadLnk) {
|
|
497
|
-
parts.push(lnkRemoved ? '
|
|
497
|
+
parts.push(lnkRemoved ? 'start-menu/desktop shortcut' : verified ? 'shortcut not created by setup, kept' : 'shortcut ownership check failed, .lnk kept');
|
|
498
498
|
}
|
|
499
|
-
if (hadVbs) parts.push(vbsCleaned ? '
|
|
500
|
-
return { detail:
|
|
499
|
+
if (hadVbs) parts.push(vbsCleaned ? 'generated launcher script' : 'launcher script (delete failed)');
|
|
500
|
+
return { detail: `removed: ${parts.join(', ')}` };
|
|
501
501
|
},
|
|
502
502
|
|
|
503
503
|
uninstallMac() {
|
|
504
504
|
const app = macAppBundle();
|
|
505
505
|
const marker = join(app, 'Contents', 'zcodepro-setup-marker');
|
|
506
|
-
if (!existsSync(marker)) return { skipped: '
|
|
506
|
+
if (!existsSync(marker)) return { skipped: 'no setup-created app bundle found' };
|
|
507
507
|
// setup --desktop 建的桌面入口一并清理(只删指向本应用包的链接)
|
|
508
508
|
const link = macDesktopLink();
|
|
509
509
|
try {
|
|
510
510
|
if (existsSync(link) && realpathSync(link) === realpathSync(app)) rmSync(link);
|
|
511
511
|
} catch { /* 链接失效则忽略 */ }
|
|
512
512
|
rmSync(app, { recursive: true, force: true });
|
|
513
|
-
return { detail:
|
|
513
|
+
return { detail: `removed ${app}` };
|
|
514
514
|
},
|
|
515
515
|
};
|
|
516
516
|
|
|
@@ -532,22 +532,25 @@ function rmSyncWithRetry(target, tries = 4) {
|
|
|
532
532
|
const items = [shortcutItem];
|
|
533
533
|
|
|
534
534
|
export async function runSetup({ uninstall = false, platform: plat = platform(), desktop = false } = {}) {
|
|
535
|
-
|
|
535
|
+
// 输出全用 ASCII 英文:setup 常在 GBK 代码页的 cmd/PowerShell 里跑,中文经
|
|
536
|
+
// 管道输出时极易乱码(chcp 适配也不是所有入口都来得及生效);ASCII 文案
|
|
537
|
+
// 在任何代码页下都稳定显示
|
|
538
|
+
console.log(`[zcodepro] setup ${uninstall ? 'remove' : 'install'} local integration${desktop && !uninstall ? ' (with desktop)' : ''}:`);
|
|
536
539
|
let failed = 0;
|
|
537
540
|
for (const item of items) {
|
|
538
541
|
if (!item.supported(plat)) {
|
|
539
|
-
console.log(`
|
|
542
|
+
console.log(` - ${item.title}: not supported on ${plat}, skipped`);
|
|
540
543
|
continue;
|
|
541
544
|
}
|
|
542
545
|
try {
|
|
543
546
|
const res = uninstall ? item.uninstall(plat) : item.install(plat, { desktop });
|
|
544
|
-
if (res && res.skipped) console.log(`
|
|
545
|
-
else console.log(`
|
|
547
|
+
if (res && res.skipped) console.log(` - ${item.title}: ${res.skipped}`);
|
|
548
|
+
else console.log(` [OK] ${item.title}: ${res && res.detail ? res.detail : 'done'}`);
|
|
546
549
|
} catch (err) {
|
|
547
550
|
failed++;
|
|
548
|
-
console.error(`
|
|
551
|
+
console.error(` [FAIL] ${item.title}: ${err?.message || err}`);
|
|
549
552
|
}
|
|
550
553
|
}
|
|
551
|
-
console.log(uninstall ? '' : '
|
|
554
|
+
console.log(uninstall ? '' : ' undo: zcode-pro setup --uninstall');
|
|
552
555
|
if (failed) process.exitCode = 1;
|
|
553
556
|
}
|
package/src/host/taskIndex.mjs
CHANGED
|
@@ -206,11 +206,140 @@ export async function listOverriddenTitles(dataRoot) {
|
|
|
206
206
|
return { titles };
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// 智能起名的前置判定:会话是否已被手动重命名(或已被起名写过)。双写前先查它,
|
|
210
|
+
// 已重命名就整体跳过——避免起名晚到的写入盖掉用户的 custom 标题。读取失败按
|
|
211
|
+
// 未重命名处理(后续写入自带原子守卫兜底)
|
|
212
|
+
export async function isTaskTitleOverridden(dataRoot, sessId) {
|
|
213
|
+
const dbFile = taskIndexPath(dataRoot);
|
|
214
|
+
if (!existsSync(dbFile)) return false;
|
|
215
|
+
const driver = await loadDriver();
|
|
216
|
+
if (!driver) return false;
|
|
217
|
+
try {
|
|
218
|
+
if (driver.kind === 'node') {
|
|
219
|
+
const db = new driver.DatabaseSync(dbFile, { readOnly: true });
|
|
220
|
+
try {
|
|
221
|
+
const r = db.prepare('SELECT title_overridden FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId);
|
|
222
|
+
return r?.title_overridden === 1;
|
|
223
|
+
} finally { db.close(); }
|
|
224
|
+
}
|
|
225
|
+
const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
|
|
226
|
+
const r = runCli(dbFile, `SELECT title_overridden FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
|
|
227
|
+
return /^1/m.test(r.stdout);
|
|
228
|
+
} catch { return false; }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 智能起名写入:把生成标题按“手动重命名”存入索引(title_overridden = 1),
|
|
232
|
+
// 之后官方自动标题不会再覆盖这条记录。仅在会话未删除、且从未被手动重命名过
|
|
233
|
+
// (title_overridden = 0)时写入——手动重命名永远优先。返回 { written, title };
|
|
234
|
+
// written = false 表示没写(已重命名或会话不在索引中)。库被占用时抛 index-busy。
|
|
235
|
+
export async function setTaskTitleIfNotOverridden(dataRoot, sessId, title) {
|
|
236
|
+
const dbFile = taskIndexPath(dataRoot);
|
|
237
|
+
if (!existsSync(dbFile)) return { written: false, title };
|
|
238
|
+
const driver = await loadDriver();
|
|
239
|
+
if (!driver) {
|
|
240
|
+
const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
|
|
241
|
+
err.code = 'index-error';
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
if (driver.kind === 'node') {
|
|
246
|
+
const db = new driver.DatabaseSync(dbFile);
|
|
247
|
+
try {
|
|
248
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
249
|
+
db.exec('BEGIN IMMEDIATE');
|
|
250
|
+
try {
|
|
251
|
+
const r = db.prepare(
|
|
252
|
+
'UPDATE tasks SET title = ?, title_overridden = 1 WHERE task_id = ? AND deleted = 0 AND title_overridden = 0'
|
|
253
|
+
).run(title, sessId);
|
|
254
|
+
db.exec('COMMIT');
|
|
255
|
+
return { written: r.changes > 0, title };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
} finally { db.close(); }
|
|
261
|
+
}
|
|
262
|
+
const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
|
|
263
|
+
const r = runCli(dbFile,
|
|
264
|
+
'BEGIN IMMEDIATE;\n'
|
|
265
|
+
+ `UPDATE tasks SET title = ${esc(title)}, title_overridden = 1`
|
|
266
|
+
+ ` WHERE task_id = ${esc(sessId)} AND deleted = 0 AND title_overridden = 0;\n`
|
|
267
|
+
+ 'SELECT changes();\nCOMMIT;');
|
|
268
|
+
return { written: Number(r.stdout.trim()) > 0, title };
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (isBusyError(err)) {
|
|
271
|
+
const e = new Error('任务索引正被占用(ZCode 可能正在写入)');
|
|
272
|
+
e.code = 'index-busy';
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
const e = new Error('写入任务标题失败: ' + (err?.message || err));
|
|
276
|
+
e.code = 'index-error';
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
209
281
|
function isBusyError(err) {
|
|
210
282
|
const s = String(err?.code || '') + String(err?.message || '');
|
|
211
283
|
return /SQLITE_BUSY|database is locked/i.test(s);
|
|
212
284
|
}
|
|
213
285
|
|
|
286
|
+
// 读某会话记录的模型信息(智能起名“跟随当前会话”用)。model 列存
|
|
287
|
+
// “<供应商id>/<模型id>”(如 be0a90cb…/glm-5.3),拆开后返回
|
|
288
|
+
// { providerId, modelId };没有斜线时 providerId 退回 provider 列的值。
|
|
289
|
+
// 会话不存在返回 null;读取失败按 null 处理(调用方换明确报错)
|
|
290
|
+
export async function getTaskProviderModel(dataRoot, sessId) {
|
|
291
|
+
const driver = await loadDriver();
|
|
292
|
+
if (!driver) return null;
|
|
293
|
+
const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
|
|
294
|
+
|
|
295
|
+
// 优先读 CLI 会话库的 model_usage:首个 turn 开始时即写入,比任务索引的
|
|
296
|
+
// model 列早十几秒(任务索引由桌面同步器延迟写;新会话头十几秒里它还是
|
|
297
|
+
// 空值,起名第一次调用会因此解析到不存在的模型,实测 400 模型不存在)
|
|
298
|
+
const usageFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
|
|
299
|
+
if (existsSync(usageFile)) {
|
|
300
|
+
try {
|
|
301
|
+
if (driver.kind === 'node') {
|
|
302
|
+
const db = new driver.DatabaseSync(usageFile, { readOnly: true });
|
|
303
|
+
try {
|
|
304
|
+
const r = db.prepare(
|
|
305
|
+
"SELECT provider_id, model_id FROM model_usage WHERE session_id = ? AND provider_id IS NOT NULL AND provider_id != '' ORDER BY started_at DESC LIMIT 1"
|
|
306
|
+
).get(sessId);
|
|
307
|
+
if (r && r.model_id) return { providerId: r.provider_id, modelId: r.model_id };
|
|
308
|
+
} finally { db.close(); }
|
|
309
|
+
} else {
|
|
310
|
+
const r = runCli(usageFile,
|
|
311
|
+
`SELECT coalesce(json_object('provider_id', provider_id, 'model_id', model_id), 'null') FROM (SELECT provider_id, model_id FROM model_usage WHERE session_id = ${esc(sessId)} AND provider_id IS NOT NULL AND provider_id != '' ORDER BY started_at DESC LIMIT 1);`);
|
|
312
|
+
const parsed = JSON.parse(r.stdout.trim() || 'null');
|
|
313
|
+
if (parsed && parsed.model_id) return { providerId: parsed.provider_id, modelId: parsed.model_id };
|
|
314
|
+
}
|
|
315
|
+
} catch { /* 表不存在(旧版 CLI)等:走任务索引兜底 */ }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const dbFile = taskIndexPath(dataRoot);
|
|
319
|
+
if (!existsSync(dbFile)) return null;
|
|
320
|
+
let row = null;
|
|
321
|
+
try {
|
|
322
|
+
if (driver.kind === 'node') {
|
|
323
|
+
const db = new driver.DatabaseSync(dbFile);
|
|
324
|
+
try {
|
|
325
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
326
|
+
row = db.prepare('SELECT provider, model FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId) || null;
|
|
327
|
+
} finally { db.close(); }
|
|
328
|
+
} else {
|
|
329
|
+
const r = runCli(dbFile,
|
|
330
|
+
`SELECT coalesce(json_object('provider', provider, 'model', model), 'null') FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
|
|
331
|
+
const parsed = JSON.parse((r.stdout.trim() || 'null'));
|
|
332
|
+
row = parsed && typeof parsed === 'object' ? parsed : null;
|
|
333
|
+
}
|
|
334
|
+
} catch { /* 读不了就当没有 */ }
|
|
335
|
+
if (!row) return null;
|
|
336
|
+
const model = String(row.model || '');
|
|
337
|
+
const slash = model.indexOf('/');
|
|
338
|
+
return slash > 0
|
|
339
|
+
? { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) }
|
|
340
|
+
: { providerId: String(row.provider || ''), modelId: model };
|
|
341
|
+
}
|
|
342
|
+
|
|
214
343
|
// 任务(会话)ID → 工作区路径(无尾分隔符):空闲回收在 Windows 上的后台命令
|
|
215
344
|
// 归属判定用(命令行里只带会话 ID,需经索引反查它属于哪个项目)。
|
|
216
345
|
// 读取失败返回空表,调用方按“无法归属”保守处理
|
|
@@ -253,3 +382,132 @@ function runCli(dbFile, script) {
|
|
|
253
382
|
if (r.status !== 0) throw new Error('sqlite3 CLI 执行失败: ' + (r.stderr || r.stdout || ('exit ' + r.status)));
|
|
254
383
|
return r;
|
|
255
384
|
}
|
|
385
|
+
|
|
386
|
+
// CLI 会话库(~/.zcode/cli/db/db.sqlite)的会话标题权威写入:title + title_source=
|
|
387
|
+
// 'custom'(官方 setCustomSessionTitle 同款语义)。custom 后官方运行时彻底知情:
|
|
388
|
+
// 自动起名写回后的自动校正:桌面端打开会话时会把运行时内存缓存的旧标题写回
|
|
389
|
+
// 任务索引(实测点开会话的瞬间发生,运行时不知道我们直写过 CLI 库),索引里的
|
|
390
|
+
// 标题因此被改回旧值。CLI 库的 custom 标题才是权威——对“我们写过、且 CLI 标题
|
|
391
|
+
// 仍等于我们所写”的会话,索引标题若不同就改回 CLI 值。只认自己写过的记录:
|
|
392
|
+
// 官方手动改名(renameTask)两库同步更新不会分歧;其 v4 同步失败时索引比 CLI 新
|
|
393
|
+
// (历史会话场景),不认记录就绝不会误回滚用户的手动改名。
|
|
394
|
+
// writtenMap: { sessId → { title, at } };返回本次校正的会话数(失败静默,下次再试)
|
|
395
|
+
export async function reconcileCustomTitles(dataRoot, writtenMap) {
|
|
396
|
+
const repaired = [];
|
|
397
|
+
if (!writtenMap || typeof writtenMap !== 'object') return repaired;
|
|
398
|
+
const driver = await loadDriver();
|
|
399
|
+
if (!driver) return repaired;
|
|
400
|
+
const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
|
|
401
|
+
const cliFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
|
|
402
|
+
const idxFile = taskIndexPath(dataRoot);
|
|
403
|
+
if (!existsSync(cliFile) || !existsSync(idxFile)) return repaired;
|
|
404
|
+
|
|
405
|
+
const readCli = async (sessId) => {
|
|
406
|
+
if (driver.kind === 'node') {
|
|
407
|
+
const db = new driver.DatabaseSync(cliFile, { readOnly: true });
|
|
408
|
+
try {
|
|
409
|
+
return db.prepare("SELECT title, title_source FROM session WHERE id = ?").get(sessId) || null;
|
|
410
|
+
} finally { db.close(); }
|
|
411
|
+
}
|
|
412
|
+
const r = runCli(cliFile, `SELECT coalesce(json_object('title', title, 'title_source', title_source), 'null') FROM session WHERE id = ${esc(sessId)};`);
|
|
413
|
+
const parsed = JSON.parse(r.stdout.trim() || 'null');
|
|
414
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
for (const [sessId, rec] of Object.entries(writtenMap)) {
|
|
418
|
+
if (!/^sess_[A-Za-z0-9._-]+$/.test(sessId) || !rec || typeof rec.title !== 'string') continue;
|
|
419
|
+
let cli;
|
|
420
|
+
try { cli = await readCli(sessId); } catch { continue; }
|
|
421
|
+
// CLI 已不是我们所写的 custom 标题:别人改过(手动改名/官方),不碰
|
|
422
|
+
if (!cli || cli.title_source !== 'custom' || cli.title !== rec.title) continue;
|
|
423
|
+
try {
|
|
424
|
+
if (driver.kind === 'node') {
|
|
425
|
+
const db = new driver.DatabaseSync(idxFile);
|
|
426
|
+
try {
|
|
427
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
428
|
+
db.exec('BEGIN IMMEDIATE');
|
|
429
|
+
try {
|
|
430
|
+
const r = db.prepare(
|
|
431
|
+
'UPDATE tasks SET title = ? WHERE task_id = ? AND deleted = 0 AND title_overridden = 1 AND title != ?'
|
|
432
|
+
).run(rec.title, sessId, rec.title);
|
|
433
|
+
db.exec('COMMIT');
|
|
434
|
+
if (r.changes > 0) repaired.push(sessId);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
try { db.exec('ROLLBACK'); } catch { /* 已回滚 */ }
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
} finally { db.close(); }
|
|
440
|
+
} else {
|
|
441
|
+
const r = runCli(idxFile,
|
|
442
|
+
'BEGIN IMMEDIATE;\n'
|
|
443
|
+
+ `UPDATE tasks SET title = ${esc(rec.title)} WHERE task_id = ${esc(sessId)} AND deleted = 0 AND title_overridden = 1 AND title != ${esc(rec.title)};\n`
|
|
444
|
+
+ 'SELECT changes();\nCOMMIT;');
|
|
445
|
+
if (Number(r.stdout.trim()) > 0) repaired.push(sessId);
|
|
446
|
+
}
|
|
447
|
+
} catch { /* 占用等:下次再校正 */ }
|
|
448
|
+
}
|
|
449
|
+
return repaired;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 自动起名在写回前重读时看到 custom 会放弃(session-title.ts 的 custom_title 短路),
|
|
453
|
+
// 桌面同步器(zcodeTaskIndexSyncer)会把 custom 标题带着 titleOverridden=1 写回
|
|
454
|
+
// 任务索引——单写任务索引被官方排队的自动标题覆盖的竞态(实测 14 秒后覆盖)由此
|
|
455
|
+
// 消除。time_title_updated/time_updated 与官方 updateSession 一致(title 变更即刷新,
|
|
456
|
+
// time_updated 只增不减);title_message_id 保留原值。旧版 CLI 库没有 title_source
|
|
457
|
+
// 列时返回 skipped(缺 custom 语义,写了也守不住,退回单写任务索引的旧行为)。
|
|
458
|
+
// 库被占用时抛 index-busy(与任务索引写入同款重试语义)。
|
|
459
|
+
export async function setCustomSessionTitle(dataRoot, sessId, title) {
|
|
460
|
+
const dbFile = join(dataRoot, 'cli', 'db', 'db.sqlite');
|
|
461
|
+
if (!existsSync(dbFile)) return { written: false, skipped: 'no-cli-db' };
|
|
462
|
+
const driver = await loadDriver();
|
|
463
|
+
if (!driver) {
|
|
464
|
+
const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
|
|
465
|
+
err.code = 'index-error';
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
if (driver.kind === 'node') {
|
|
470
|
+
const db = new driver.DatabaseSync(dbFile);
|
|
471
|
+
try {
|
|
472
|
+
const cols = db.prepare("PRAGMA table_info('session')").all();
|
|
473
|
+
if (!cols.some((c) => c.name === 'title_source')) return { written: false, skipped: 'no-title-source' };
|
|
474
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
475
|
+
db.exec('BEGIN IMMEDIATE');
|
|
476
|
+
try {
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
const r = db.prepare(
|
|
479
|
+
"UPDATE session SET title = ?, title_source = 'custom', time_title_updated = ?, time_updated = max(time_updated, ?) WHERE id = ?"
|
|
480
|
+
).run(title, now, now, sessId);
|
|
481
|
+
db.exec('COMMIT');
|
|
482
|
+
return { written: r.changes > 0, skipped: r.changes > 0 ? '' : 'no-session-row' };
|
|
483
|
+
} catch (err) {
|
|
484
|
+
try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
} finally {
|
|
488
|
+
db.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
|
|
492
|
+
const cols = runCli(dbFile, "PRAGMA table_info('session');").stdout;
|
|
493
|
+
if (!/title_source/.test(cols)) return { written: false, skipped: 'no-title-source' };
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const r = runCli(dbFile,
|
|
496
|
+
'BEGIN IMMEDIATE;\n'
|
|
497
|
+
+ `UPDATE session SET title = ${esc(title)}, title_source = 'custom',`
|
|
498
|
+
+ ` time_title_updated = ${now}, time_updated = max(time_updated, ${now})`
|
|
499
|
+
+ ` WHERE id = ${esc(sessId)};\n`
|
|
500
|
+
+ 'SELECT changes();\nCOMMIT;');
|
|
501
|
+
const changed = Number(r.stdout.trim()) > 0;
|
|
502
|
+
return { written: changed, skipped: changed ? '' : 'no-session-row' };
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (isBusyError(err)) {
|
|
505
|
+
const e = new Error('CLI 会话库正被占用(ZCode 可能正在写入)');
|
|
506
|
+
e.code = 'index-busy';
|
|
507
|
+
throw e;
|
|
508
|
+
}
|
|
509
|
+
const e = new Error('写入 CLI 会话标题失败: ' + (err?.message || err));
|
|
510
|
+
e.code = 'index-error';
|
|
511
|
+
throw e;
|
|
512
|
+
}
|
|
513
|
+
}
|