zcode-pro 0.18.0 → 0.19.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/dist/inject.js +573 -111
- package/package.json +1 -1
- package/src/host/helper.mjs +457 -34
- package/src/host/taskIndex.mjs +83 -0
- package/src/inject/core.js +50 -10
- package/src/inject/features/file-menu.js +9 -2
- package/src/inject/features/settings-dialog.js +224 -48
- package/src/inject/features/title-gen.js +169 -0
- package/src/inject/features/title-lock.js +14 -0
- package/src/inject/features/toolbar-icons.js +38 -4
- package/src/inject/index.js +2 -0
- package/src/inject/provider-fields.js +71 -0
- package/src/inject/ui.js +20 -5
package/package.json
CHANGED
package/src/host/helper.mjs
CHANGED
|
@@ -10,13 +10,13 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'no
|
|
|
10
10
|
import { homedir, tmpdir } from 'node:os';
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
12
|
import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
|
|
13
|
-
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap } from './taskIndex.mjs';
|
|
13
|
+
import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, getTaskProviderModel } from './taskIndex.mjs';
|
|
14
14
|
import { pickFolderSystem } from './pickFolder.mjs';
|
|
15
15
|
import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
|
|
16
16
|
import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
|
|
17
17
|
import { resolveZcodeExecutable } from './paths.mjs';
|
|
18
18
|
|
|
19
|
-
const VERSION = '0.
|
|
19
|
+
const VERSION = '0.19.0';
|
|
20
20
|
|
|
21
21
|
// 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
|
|
22
22
|
// 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
|
|
@@ -95,6 +95,22 @@ export function defaultConfig() {
|
|
|
95
95
|
// headroom 启用开关的记忆(供应商 id → { proxy, direct }):关闭时把走 headroom 的
|
|
96
96
|
// 供应商地址切回直连并记录下来,再次开启时按记录把地址切回代理
|
|
97
97
|
headroomRoutes: {},
|
|
98
|
+
// 任务名提示词(自动起名):新会话开始作答后按可配置的供应商与提示词生成
|
|
99
|
+
// 标题,并按“手动重命名”写入任务索引。默认关闭。provider:
|
|
100
|
+
// 'session' = 跟随当前会话(调用时从任务索引解析该会话的供应商,默认)、
|
|
101
|
+
// '' = 不跟随(自填 baseUrl/apiKey/format)、其余 = 供应商 id(保存时同步
|
|
102
|
+
// 解析一份 baseUrl/apiKey/format 副本,供应商日后被删仍可直连);
|
|
103
|
+
// model 默认 glm-5.3-flash(起名用快模型即可),跟随会话时可留空
|
|
104
|
+
//(用会话当前模型);prompt 留空 = 内置默认提示词
|
|
105
|
+
titleGen: {
|
|
106
|
+
enabled: false,
|
|
107
|
+
provider: 'session',
|
|
108
|
+
model: 'glm-5.3-flash',
|
|
109
|
+
prompt: '',
|
|
110
|
+
baseUrl: '',
|
|
111
|
+
apiKey: '',
|
|
112
|
+
format: '',
|
|
113
|
+
},
|
|
98
114
|
// HTTP 代理(http(s)://host:port):helper 发起的网络访问走它——插件市场更新/安装
|
|
99
115
|
// (zcode CLI → git)、headroom/rtk 的安装与升级(pip/GitHub)。空 = 不用代理。
|
|
100
116
|
proxy: '',
|
|
@@ -141,6 +157,7 @@ export function loadConfig(configFile) {
|
|
|
141
157
|
styles: { ...defaultConfig().styles, ...(saved.styles && typeof saved.styles === 'object' ? saved.styles : {}) },
|
|
142
158
|
aliases: { ...((saved.aliases && typeof saved.aliases === 'object') ? saved.aliases : {}) },
|
|
143
159
|
headroomRoutes: { ...((saved.headroomRoutes && typeof saved.headroomRoutes === 'object' && !Array.isArray(saved.headroomRoutes)) ? saved.headroomRoutes : {}) },
|
|
160
|
+
titleGen: mergeTitleGenConfig(saved.titleGen),
|
|
144
161
|
proxyScopes: mergeProxyScopes(saved.proxyScopes),
|
|
145
162
|
devMode: saved.devMode === true,
|
|
146
163
|
// devActive 每次读取时重算(保存时会随配置一起写入文件,但读取方不用保存的值):
|
|
@@ -299,6 +316,9 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
299
316
|
if (body && typeof body === 'object' && body.styles && typeof body.styles === 'object') {
|
|
300
317
|
current.styles = applyStylesPartial(current.styles, body.styles);
|
|
301
318
|
}
|
|
319
|
+
if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
|
|
320
|
+
current.titleGen = mergeTitleGenConfig({ ...current.titleGen, ...body.titleGen });
|
|
321
|
+
}
|
|
302
322
|
saveConfig(configFile, current);
|
|
303
323
|
json(res, 200, { ok: true, config: current });
|
|
304
324
|
return;
|
|
@@ -407,6 +427,89 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
|
|
|
407
427
|
json(res, 200, { ok: true, enabled: config.features.titleLock !== false, titles: r.titles });
|
|
408
428
|
return;
|
|
409
429
|
}
|
|
430
|
+
// —— 智能起名:配置读写 + 供应商列表 + 生成并写库 ——
|
|
431
|
+
if (req.method === 'GET' && url.pathname === '/title-gen') {
|
|
432
|
+
json(res, 200, { ok: true, titleGen: loadConfig(configFile).titleGen, defaultPrompt: titleGenDefaultPrompt() });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (req.method === 'POST' && url.pathname === '/title-gen') {
|
|
436
|
+
const body = await readBody(req);
|
|
437
|
+
if (body && typeof body === 'object' && body.titleGen && typeof body.titleGen === 'object') {
|
|
438
|
+
const current = loadConfig(configFile);
|
|
439
|
+
const next = { ...current.titleGen, ...body.titleGen };
|
|
440
|
+
// 选了列表内供应商:同步解析并保存其地址/密钥/协议副本——供应商日后
|
|
441
|
+
// 被删,这份副本仍可直连(界面标注“已删除”并展开字段);
|
|
442
|
+
// 跟随会话不存副本(每次调用时按该会话实时解析)
|
|
443
|
+
const providerId = (next.provider || '').trim();
|
|
444
|
+
if (providerId && providerId !== 'session') {
|
|
445
|
+
const found = listTitleGenProviders().find((p) => p.id === providerId);
|
|
446
|
+
if (found) {
|
|
447
|
+
next.baseUrl = found.baseUrl;
|
|
448
|
+
next.apiKey = found.apiKey;
|
|
449
|
+
next.format = found.format;
|
|
450
|
+
}
|
|
451
|
+
// 找不到(手输 id 或已删):保留原有副本字段不动
|
|
452
|
+
} else if (providerId === 'session') {
|
|
453
|
+
// 跟随会话不存副本(每次调用时按该会话实时解析)
|
|
454
|
+
next.baseUrl = '';
|
|
455
|
+
next.apiKey = '';
|
|
456
|
+
next.format = '';
|
|
457
|
+
}
|
|
458
|
+
current.titleGen = mergeTitleGenConfig(next);
|
|
459
|
+
saveConfig(configFile, current);
|
|
460
|
+
json(res, 200, { ok: true, titleGen: current.titleGen });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
json(res, 400, { ok: false, code: 'invalid-request', error: 'body 需包含 titleGen 对象' });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// 供应商下拉数据:与视觉代理同源同规则,但只列 helper 能在会话外直接
|
|
467
|
+
// 调用的条目(有地址、密钥且协议可判定),并带可选模型列表;不回密钥
|
|
468
|
+
if (req.method === 'GET' && url.pathname === '/title-gen/providers') {
|
|
469
|
+
json(res, 200, {
|
|
470
|
+
ok: true,
|
|
471
|
+
providers: listTitleGenProviders().map(({ apiKey, ...rest }) => rest),
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// 生成(并可选写库):text = 会话首条消息;sessId 指定且非 test 时按
|
|
476
|
+
// “手动重命名”写入任务索引(已重命名的会话不会被动);test = 设置面板
|
|
477
|
+
// 的测试按钮,只生成不写库
|
|
478
|
+
if (req.method === 'POST' && url.pathname === '/title-gen/run') {
|
|
479
|
+
const body = await readBody(req);
|
|
480
|
+
const text = typeof body?.text === 'string' ? body.text : '';
|
|
481
|
+
if (!text.trim()) {
|
|
482
|
+
json(res, 400, { ok: false, code: 'invalid-request', error: 'text 不能为空' });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const cfg = loadConfig(configFile).titleGen;
|
|
486
|
+
if (cfg.enabled !== true && body.test !== true) {
|
|
487
|
+
json(res, 400, { ok: false, code: 'title-gen-disabled', error: '智能起名未开启' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
|
|
491
|
+
const title = await generateTitleWith(cfg, text, dataRoot, sessId);
|
|
492
|
+
let written = false;
|
|
493
|
+
if (sessId && body.test !== true) {
|
|
494
|
+
// 写库撞上宿主写入时稍候重试(起名不急这几秒)
|
|
495
|
+
for (let attempt = 0; ; attempt++) {
|
|
496
|
+
try {
|
|
497
|
+
const r = await setTaskTitleIfNotOverridden(dataRoot, sessId, title);
|
|
498
|
+
written = r.written;
|
|
499
|
+
break;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
if (err.code === 'index-busy' && attempt < 2) {
|
|
502
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
json(res, 200, { ok: true, title, written });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
410
513
|
// —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
|
|
411
514
|
if (req.method === 'GET' && url.pathname === '/vision') {
|
|
412
515
|
const file = defaultVisionFile();
|
|
@@ -1032,6 +1135,25 @@ function clampInt(v, min, max, dflt) {
|
|
|
1032
1135
|
|
|
1033
1136
|
// —— 设置同步(“同步”标签页)的实现 ——
|
|
1034
1137
|
|
|
1138
|
+
// loadConfig 时把磁盘上的 titleGen 段按默认值归一(智能起名):只收已知字段,
|
|
1139
|
+
// 字符串限长;enabled 默认关闭(改动官方起名行为,需用户显式开启)
|
|
1140
|
+
function mergeTitleGenConfig(saved) {
|
|
1141
|
+
const d = defaultConfig().titleGen;
|
|
1142
|
+
const s = (saved && typeof saved === 'object' && !Array.isArray(saved)) ? saved : {};
|
|
1143
|
+
// 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
|
|
1144
|
+
// 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
|
|
1145
|
+
const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
|
|
1146
|
+
return {
|
|
1147
|
+
enabled: s.enabled === true,
|
|
1148
|
+
provider: provider === 'cli' ? 'session' : provider,
|
|
1149
|
+
model: typeof s.model === 'string' ? s.model.trim().slice(0, 200) : d.model,
|
|
1150
|
+
prompt: typeof s.prompt === 'string' ? s.prompt.slice(0, 20000) : d.prompt,
|
|
1151
|
+
baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
|
|
1152
|
+
apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
|
|
1153
|
+
format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1035
1157
|
// loadConfig 时把磁盘上的 proxyScopes 按默认值归一(只收布尔,缺的补默认:
|
|
1036
1158
|
// 插件市场/依赖软件开、同步关)
|
|
1037
1159
|
function mergeProxyScopes(saved) {
|
|
@@ -1672,33 +1794,290 @@ function sanitizeFontStack(v) {
|
|
|
1672
1794
|
return s;
|
|
1673
1795
|
}
|
|
1674
1796
|
|
|
1675
|
-
//
|
|
1676
|
-
|
|
1797
|
+
// 视觉代理「跟随供应商」下拉取数:与智能起名共用同一套供应商列表和过滤规则
|
|
1798
|
+
//(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
|
|
1677
1799
|
// 导出以便测试脚本直接驱动。
|
|
1678
1800
|
export function listVisionProviders() {
|
|
1679
|
-
|
|
1801
|
+
return listTitleGenProviders().map((p) => ({ id: p.id, name: p.name, aliases: p.aliases || [] }));
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// —— 智能起名:供应商取数、模型调用与标题清洗 ——
|
|
1805
|
+
|
|
1806
|
+
// 内置默认提示词(用户可在设置弹窗覆盖),按界面语言取中文或英文版。
|
|
1807
|
+
// 英文版的词数上限比中文版紧:英文单词更长,3~15 个词在会话列表里放不下
|
|
1808
|
+
const TITLE_GEN_DEFAULT_PROMPTS = {
|
|
1809
|
+
zh: [
|
|
1810
|
+
'为下面的会话生成一个简短的标题。',
|
|
1811
|
+
'要求:',
|
|
1812
|
+
'- 使用用户消息的主要语言(中文消息用中文标题)',
|
|
1813
|
+
'- 概括用户要做的主要事情或主题,不要给出答案或结果',
|
|
1814
|
+
'- 一般3~15个词,在会话列表里能一眼认出',
|
|
1815
|
+
'- 保留重要的专有名词(文件名、产品名、技术名词等,如有)',
|
|
1816
|
+
'- 不要用`用户请求`、`新任务`、`提问`这类泛泛的标题',
|
|
1817
|
+
'- 不要直接复述或截取用户消息原文当标题',
|
|
1818
|
+
'- 只输出标题本身:不要解释、不要引号、不要末尾标点、不要markdown',
|
|
1819
|
+
].join('\n'),
|
|
1820
|
+
en: [
|
|
1821
|
+
'Generate a short title for the session below.',
|
|
1822
|
+
'Requirements:',
|
|
1823
|
+
"- Use the primary language of the user's message (an English message gets an English title)",
|
|
1824
|
+
'- Summarize the user\'s main task or topic; do not give the answer or result',
|
|
1825
|
+
'- Usually 3-8 words, easy to recognize in a session list',
|
|
1826
|
+
'- Keep important proper nouns (file names, product names, technical terms, if any)',
|
|
1827
|
+
'- Avoid generic titles such as `User Request`, `New Task`, or `Question`',
|
|
1828
|
+
'- Do not copy or truncate the user\'s message as the title',
|
|
1829
|
+
'- Output only the title itself: no explanation, no quotes, no trailing punctuation, no markdown',
|
|
1830
|
+
].join('\n'),
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// 界面语言:桌面设置(v2/setting.json 的 localePreference)为 en-US / zh-CN 时
|
|
1834
|
+
// 直接采用;system 或未设置时按系统环境语言判断(中文环境 → 中文,其余 → 英文)
|
|
1835
|
+
function titleGenLocale() {
|
|
1836
|
+
const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
|
|
1837
|
+
const pref = String((s && s.localePreference) || '').toLowerCase();
|
|
1838
|
+
if (pref.startsWith('en')) return 'en';
|
|
1839
|
+
if (pref.startsWith('zh')) return 'zh';
|
|
1840
|
+
const lang = String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').toLowerCase();
|
|
1841
|
+
return lang.startsWith('zh') ? 'zh' : 'en';
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function titleGenDefaultPrompt() {
|
|
1845
|
+
return TITLE_GEN_DEFAULT_PROMPTS[titleGenLocale()] || TITLE_GEN_DEFAULT_PROMPTS.en;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const TITLE_GEN_MAX_TEXT = 1200; // 用户消息送入模型的长度上限(与官方起名一致)
|
|
1849
|
+
const TITLE_GEN_MAX_TITLE = 64; // 生成标题的长度上限(按字符计)
|
|
1850
|
+
const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起名一致;推理模型思考占时较长)
|
|
1851
|
+
|
|
1852
|
+
// 内置套餐显示名:与 ZCode 界面的叫法一致——条目原始名区分度差
|
|
1853
|
+
//(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
|
|
1854
|
+
// coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
|
|
1855
|
+
// providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
|
|
1856
|
+
// individual → 个人、team → 团队),读不到时退回「个人/团队」
|
|
1857
|
+
function codingPlanKindByFamily() {
|
|
1858
|
+
const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
|
|
1859
|
+
const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
|
|
1860
|
+
? s.providerFamilyConnectionSelections : {};
|
|
1861
|
+
const out = {};
|
|
1862
|
+
for (const [family, v] of Object.entries(sel)) {
|
|
1863
|
+
const kind = String(v?.kind || '');
|
|
1864
|
+
if (/individual/i.test(kind)) out[family] = '个人';
|
|
1865
|
+
else if (/team/i.test(kind)) out[family] = '团队';
|
|
1866
|
+
}
|
|
1867
|
+
return out;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function titleGenProviderDisplayName(id, name, planKinds = {}) {
|
|
1871
|
+
const m = /^builtin:(zai|bigmodel)-coding-plan$/.exec(id);
|
|
1872
|
+
if (m) {
|
|
1873
|
+
const fam = m[1] === 'zai' ? 'Z.ai' : 'BigModel';
|
|
1874
|
+
return `${fam}(${planKinds[m[1]] || '个人/团队'})`;
|
|
1875
|
+
}
|
|
1876
|
+
if (/^builtin:(zai|bigmodel)-start-plan$/.test(id)) return 'Start Plan';
|
|
1877
|
+
return (name && String(name).trim()) || id;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// 供应商条目(智能起名与视觉代理共用),列表范围与 ZCode 界面的供应商列表
|
|
1881
|
+
// 保持一致:
|
|
1882
|
+
// - 内置套餐(builtin:*):有地址、密钥才列出(无密钥的 helper 无法在会话外
|
|
1883
|
+
// 调用,如未配 key 的 API Key 型条目);
|
|
1884
|
+
// - 自定义供应商:以 provider_config.json 的 providerOrder 为准(ZCode 界面的
|
|
1885
|
+
// 自定义列表就来自它),provider 表里不在其中的残留条目(已弃用的中转等)
|
|
1886
|
+
// 不列出;
|
|
1887
|
+
// - 协议:api.type / kind 缺失时按地址推断(含 anthropic → anthropic,含
|
|
1888
|
+
// openai 或以 /v\d 结尾 → openai),两个都推不出才放弃该条目。
|
|
1889
|
+
// 两张来源表同 id 合并(模型并集、原始名收进别名)。models 为该供应商的
|
|
1890
|
+
// 可选模型(下拉用,可为空)。导出以便测试
|
|
1891
|
+
export function listTitleGenProviders() {
|
|
1892
|
+
const out = [];
|
|
1680
1893
|
const byId = new Map();
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1894
|
+
const planKinds = codingPlanKindByFamily();
|
|
1895
|
+
const add = (id, { name, aliases = [], kind, baseUrl, apiKey, models = [] }) => {
|
|
1896
|
+
baseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
|
|
1897
|
+
apiKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
|
1898
|
+
if (!id || !baseUrl || !apiKey) return;
|
|
1899
|
+
let format = /anthropic/i.test(String(kind)) ? 'anthropic' : /openai/i.test(String(kind)) ? 'openai' : '';
|
|
1900
|
+
if (!format) format = /anthropic/i.test(baseUrl) ? 'anthropic'
|
|
1901
|
+
: (/openai/i.test(baseUrl) || /\/v\d+$/.test(baseUrl)) ? 'openai' : '';
|
|
1902
|
+
if (!format) return;
|
|
1903
|
+
models = models.filter(Boolean);
|
|
1904
|
+
const known = byId.get(id);
|
|
1905
|
+
if (known) {
|
|
1906
|
+
known.models = [...new Set([...known.models, ...models])];
|
|
1907
|
+
for (const a of aliases) if (a && !known.aliases.includes(a)) known.aliases.push(a);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const shown = titleGenProviderDisplayName(id, name, planKinds);
|
|
1911
|
+
const entry = {
|
|
1912
|
+
id, name: shown, aliases: [...new Set(aliases.filter((a) => a && a !== shown))],
|
|
1913
|
+
format, baseUrl, apiKey, models: [...new Set(models)],
|
|
1914
|
+
};
|
|
1685
1915
|
byId.set(id, entry);
|
|
1686
|
-
|
|
1916
|
+
out.push(entry);
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
const v2 = readJsonFile(join(homedir(), '.zcode', 'v2', 'config.json'));
|
|
1920
|
+
const providers = (v2 && typeof v2.provider === 'object' && v2.provider) || {};
|
|
1687
1921
|
const rulesDoc = readJsonFile(join(homedir(), '.zcode', 'v2', 'provider_config.json'));
|
|
1688
1922
|
const rules = (rulesDoc && rulesDoc.config?.providerConfigRules?.providerRules) || [];
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1923
|
+
const ruleById = new Map(rules.filter((r) => r && typeof r.providerId === 'string').map((r) => [r.providerId, r]));
|
|
1924
|
+
// 同一 id 在两张表的字段并起来(provider 表优先:地址/密钥以桌面实际在用的为准)
|
|
1925
|
+
const addMerged = (id) => {
|
|
1926
|
+
const p = providers[id];
|
|
1927
|
+
if (p !== undefined && (p === null || typeof p !== 'object')) return;
|
|
1928
|
+
const r = ruleById.get(id) || {};
|
|
1929
|
+
const c = r.config || {};
|
|
1930
|
+
add(id, {
|
|
1931
|
+
name: (p && p.name) || r.providerName,
|
|
1932
|
+
aliases: [r.providerName, p && p.name],
|
|
1933
|
+
kind: (p && p.kind) || c.api?.type,
|
|
1934
|
+
baseUrl: (p && p.options?.baseURL) || c.api?.baseUrl,
|
|
1935
|
+
apiKey: (p && p.options?.apiKey) || c.access?.apiKey,
|
|
1936
|
+
models: [...Object.keys((p && p.models) || {}), ...(c.modelOrder || []), ...(c.personalModelIds || [])],
|
|
1937
|
+
});
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
// 内置套餐在前(BigModel(个人/团队)→ Start Plan 的顺序)
|
|
1941
|
+
for (const id of Object.keys(providers)) {
|
|
1942
|
+
if (id.startsWith('builtin:')) addMerged(id);
|
|
1943
|
+
}
|
|
1944
|
+
// 自定义供应商按界面顺序(providerOrder)
|
|
1945
|
+
const order = (rulesDoc && Array.isArray(rulesDoc.config?.providerOrder) ? rulesDoc.config.providerOrder : [])
|
|
1946
|
+
.filter((id) => typeof id === 'string');
|
|
1947
|
+
for (const id of order) addMerged(id);
|
|
1948
|
+
return out;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Anthropic Messages 文本调用(无系统提示词,单条用户消息)。max_tokens 给足
|
|
1952
|
+
// 2048:glm 系列等推理模型先输出 thinking 块再出正文,给小了会全部耗在
|
|
1953
|
+
// 思考上(stop_reason=max_tokens 且没有任何 text 块),起名调用不在意这点额度
|
|
1954
|
+
async function callAnthropicText(baseUrl, apiKey, model, content) {
|
|
1955
|
+
const url = baseUrl.replace(/\/+$/, '') + '/v1/messages';
|
|
1956
|
+
const ctrl = new AbortController();
|
|
1957
|
+
const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
|
|
1958
|
+
try {
|
|
1959
|
+
const resp = await fetch(url, {
|
|
1960
|
+
method: 'POST', signal: ctrl.signal,
|
|
1961
|
+
headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
1962
|
+
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
1963
|
+
});
|
|
1964
|
+
const body = await resp.text();
|
|
1965
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
|
|
1966
|
+
const doc = JSON.parse(body);
|
|
1967
|
+
const text = Array.isArray(doc.content)
|
|
1968
|
+
? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
|
|
1969
|
+
if (!text) throw new Error(doc.stop_reason === 'max_tokens'
|
|
1970
|
+
? '模型的思考用完了输出上限,仍未给出标题' : '响应中没有文本内容');
|
|
1971
|
+
return text;
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
|
|
1974
|
+
throw err;
|
|
1975
|
+
} finally { clearTimeout(timer); }
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// OpenAI Chat Completions 文本调用;baseURL 已含 /v1 时不重复拼
|
|
1979
|
+
async function callOpenAiText(baseUrl, apiKey, model, content) {
|
|
1980
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
1981
|
+
const url = /\/v\d+$/.test(base) ? base + '/chat/completions' : base + '/v1/chat/completions';
|
|
1982
|
+
const ctrl = new AbortController();
|
|
1983
|
+
const timer = setTimeout(() => ctrl.abort(), TITLE_GEN_TIMEOUT_MS);
|
|
1984
|
+
try {
|
|
1985
|
+
const resp = await fetch(url, {
|
|
1986
|
+
method: 'POST', signal: ctrl.signal,
|
|
1987
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
1988
|
+
body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
|
|
1989
|
+
});
|
|
1990
|
+
const body = await resp.text();
|
|
1991
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
|
|
1992
|
+
const doc = JSON.parse(body);
|
|
1993
|
+
const text = doc.choices?.[0]?.message?.content || '';
|
|
1994
|
+
if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
|
|
1995
|
+
return text;
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
if (err && err.name === 'AbortError') throw new Error(`请求超时(${Math.round(TITLE_GEN_TIMEOUT_MS / 1000)} 秒)`);
|
|
1998
|
+
throw err;
|
|
1999
|
+
} finally { clearTimeout(timer); }
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// 生成标题。provider 三种取值:
|
|
2003
|
+
// - 'session':跟随当前会话——从任务索引解析该会话正在用的供应商与模型
|
|
2004
|
+
// (model 已填时用填写的),供应商需在可直连列表内;
|
|
2005
|
+
// - '':不跟随,用配置里的 baseUrl/apiKey/format 直连;
|
|
2006
|
+
// - 供应商 id:按列表解析直连;id 已被删时退回保存时留下的地址/密钥副本。
|
|
2007
|
+
// 输出统一清洗为单行短标题。导出以便测试
|
|
2008
|
+
export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
|
|
2009
|
+
const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
|
|
2010
|
+
const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
|
|
2011
|
+
const userContent = `${prompt}\n\n会话的用户消息:\n${text}`;
|
|
2012
|
+
const providerId = (cfg.provider || '').trim();
|
|
2013
|
+
const model = (cfg.model || '').trim();
|
|
2014
|
+
const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
|
|
2015
|
+
const raw = conn.format === 'anthropic'
|
|
2016
|
+
? await callAnthropicText(conn.baseUrl, conn.apiKey, conn.model, userContent)
|
|
2017
|
+
: await callOpenAiText(conn.baseUrl, conn.apiKey, conn.model, userContent);
|
|
2018
|
+
const title = cleanGeneratedTitle(raw);
|
|
2019
|
+
if (!title) throw new Error('模型未返回可用标题');
|
|
2020
|
+
return title;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// 直连前的连接解析(见 generateTitleWith 的取值说明);解析不出抛明确错误
|
|
2024
|
+
async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId) {
|
|
2025
|
+
if (providerId === 'session') {
|
|
2026
|
+
if (!sessId || !dataRoot) {
|
|
2027
|
+
throw new Error('跟随当前会话需要会话信息(测试按钮请选择具体供应商或“不跟随”)');
|
|
2028
|
+
}
|
|
2029
|
+
const task = await getTaskProviderModel(dataRoot, sessId);
|
|
2030
|
+
if (!task || !task.providerId) throw new Error('索引中查不到该会话的供应商信息');
|
|
2031
|
+
const entry = listTitleGenProviders().find((p) => p.id === task.providerId);
|
|
2032
|
+
if (!entry) throw new Error(`会话供应商 ${task.providerId} 无法在会话外直连(无密钥或协议未知)`);
|
|
2033
|
+
const useModel = model || task.modelId;
|
|
2034
|
+
if (!useModel) throw new Error('未能确定会话当前模型,请手动填写模型');
|
|
2035
|
+
return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model: useModel };
|
|
2036
|
+
}
|
|
2037
|
+
if (providerId) {
|
|
2038
|
+
const entry = listTitleGenProviders().find((p) => p.id === providerId);
|
|
2039
|
+
if (entry) {
|
|
2040
|
+
if (!model) throw new Error('已选择供应商,但未填写模型');
|
|
2041
|
+
return { format: entry.format, baseUrl: entry.baseUrl, apiKey: entry.apiKey, model };
|
|
1696
2042
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2043
|
+
// 供应商已被删:退回保存时的地址/密钥副本
|
|
2044
|
+
if (cfg.baseUrl && cfg.apiKey && cfg.format) {
|
|
2045
|
+
if (!model) throw new Error('已删除的供应商需手动填写模型');
|
|
2046
|
+
return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
|
|
2047
|
+
}
|
|
2048
|
+
throw new Error(`供应商 ${providerId} 已不可用(未保存地址/密钥副本),请重新选择`);
|
|
2049
|
+
}
|
|
2050
|
+
// 不跟随(自填地址)
|
|
2051
|
+
if (!cfg.baseUrl || !cfg.apiKey || !cfg.format) {
|
|
2052
|
+
throw new Error('不跟随(自填地址)需要填写接口地址、API Key 与协议');
|
|
1700
2053
|
}
|
|
1701
|
-
|
|
2054
|
+
if (!model) throw new Error('不跟随(自填地址)需要填写模型');
|
|
2055
|
+
return { format: cfg.format, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, model };
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
|
|
2059
|
+
// 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。导出以便测试
|
|
2060
|
+
export function cleanGeneratedTitle(text) {
|
|
2061
|
+
let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
2062
|
+
// 整段包在代码围栏里时先去掉围栏标记(先取行会把「```json」当成标题内容)
|
|
2063
|
+
t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
|
2064
|
+
let line = t.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
|
|
2065
|
+
line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
|
|
2066
|
+
try {
|
|
2067
|
+
const doc = JSON.parse(line);
|
|
2068
|
+
if (doc && typeof doc.title === 'string') line = doc.title;
|
|
2069
|
+
} catch { /* 不是 JSON,按纯文本处理 */ }
|
|
2070
|
+
line = line.replace(/^["'「『“]+/, '').replace(/["'」』”]+$/, '');
|
|
2071
|
+
line = line.replace(/^\d+[.、)\]]\s*/, '');
|
|
2072
|
+
line = line.replace(/[.。!!??,,;;::]\s*$/, '');
|
|
2073
|
+
line = line.replace(/\s+/g, ' ').trim();
|
|
2074
|
+
const chars = [...line];
|
|
2075
|
+
if (chars.length > TITLE_GEN_MAX_TITLE) {
|
|
2076
|
+
line = chars.slice(0, TITLE_GEN_MAX_TITLE).join('');
|
|
2077
|
+
// 截断落在英文单词中间时退到词边界,避免出现半个单词
|
|
2078
|
+
if (line.includes(' ')) line = line.slice(0, line.lastIndexOf(' '));
|
|
2079
|
+
}
|
|
2080
|
+
return line.trim();
|
|
1702
2081
|
}
|
|
1703
2082
|
|
|
1704
2083
|
// zcode-vision 配置校验:只保留已知字段并规范化;出错时返回中文原因。
|
|
@@ -1743,7 +2122,11 @@ export function validateVisionConfig(raw) {
|
|
|
1743
2122
|
names.add(name);
|
|
1744
2123
|
proxy.name = name;
|
|
1745
2124
|
if (!(proxy.baseUrl || '').trim() && !(proxy.useProvider || '').trim()) return [null, `代理 ${name} 缺少 baseUrl(或设置 useProvider 跟随供应商)`];
|
|
1746
|
-
|
|
2125
|
+
// 跟随会话(useProvider=session)时允许 model 留空:留空 = 用会话当前模型
|
|
2126
|
+
//(zcode-vision ≥1.6.1 支持);其余情况必须填写
|
|
2127
|
+
if (!(proxy.model || '').trim() && proxy.useProvider !== 'session') {
|
|
2128
|
+
return [null, `代理 ${name} 缺少 model`];
|
|
2129
|
+
}
|
|
1747
2130
|
if (proxy.baseUrl) proxy.baseUrl = proxy.baseUrl.trim();
|
|
1748
2131
|
if (proxy.model) proxy.model = proxy.model.trim();
|
|
1749
2132
|
if (proxy.useProvider) proxy.useProvider = proxy.useProvider.trim();
|
|
@@ -2523,13 +2906,14 @@ function zcodeCliBundleCandidates() {
|
|
|
2523
2906
|
].filter(Boolean);
|
|
2524
2907
|
}
|
|
2525
2908
|
|
|
2526
|
-
// 运行 zcode CLI 子命令(plugins
|
|
2527
|
-
|
|
2909
|
+
// 运行 zcode CLI 子命令(plugins … 默认走 plugins 代理范围;智能起名的
|
|
2910
|
+
// 无界面提问传 none——模型 API 直连,不走下载代理)
|
|
2911
|
+
function runZcodeCli(subArgs, timeoutMs = 120000, scope = 'plugins') {
|
|
2528
2912
|
const bundle = zcodeCliBundleCandidates().find((c) => existsSync(c));
|
|
2529
2913
|
if (!bundle) {
|
|
2530
2914
|
return Promise.resolve({ ok: false, code: 'cli-not-found', error: '未找到 ZCode CLI(resources/glm/zcode.cjs)' });
|
|
2531
2915
|
}
|
|
2532
|
-
return runNodeScript([bundle, ...subArgs], timeoutMs,
|
|
2916
|
+
return runNodeScript([bundle, ...subArgs], timeoutMs, scope);
|
|
2533
2917
|
}
|
|
2534
2918
|
|
|
2535
2919
|
// 比对市场清单与已装清单:每个插件的已装版本与最新版本。导出以便测试。
|
|
@@ -2600,12 +2984,30 @@ export function writeAgents(body, agentsFile) {
|
|
|
2600
2984
|
return [200, { ok: true, content: body.content }];
|
|
2601
2985
|
}
|
|
2602
2986
|
|
|
2987
|
+
// 路径不存在时的还原尝试。宿主把消息里的反斜杠路径渲染成文件 chip 时,markdown
|
|
2988
|
+
// 会把 “\.” 当转义序列吃掉反斜杠(Administrator\.zcode 变成 Administrator.zcode),
|
|
2989
|
+
// 其余分隔符再统一显示为 /,chip 上拿到的就是这种坏路径。把每个“段名.点开头后缀”
|
|
2990
|
+
// 形式的段试着拆回两级(Administrator.zcode → Administrator/.zcode),哪个变体
|
|
2991
|
+
// 真实存在就用哪个;都不存在返回 null,按原路径走 not-found。导出以便测试脚本直接驱动。
|
|
2992
|
+
export function recoverSwallowedSeparator(rawPath) {
|
|
2993
|
+
const segs = String(rawPath).split(/[\\/]/);
|
|
2994
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2995
|
+
const dot = segs[i].indexOf('.');
|
|
2996
|
+
if (dot <= 0) continue; // 段里没有点,或本身就是 .zcode 这类点开头目录
|
|
2997
|
+
const candidate = [...segs.slice(0, i), segs[i].slice(0, dot), segs[i].slice(dot), ...segs.slice(i + 1)].join(sep);
|
|
2998
|
+
try {
|
|
2999
|
+
if (existsSync(candidate)) return candidate;
|
|
3000
|
+
} catch { /* ignore */ }
|
|
3001
|
+
}
|
|
3002
|
+
return null;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
2603
3005
|
// 在系统文件管理器中定位文件:
|
|
2604
3006
|
// Linux 走 freedesktop FileManager1.ShowItems(Dolphin/Nautilus 均支持),无会话服务时回退打开所在目录;
|
|
2605
3007
|
// macOS 用 open -R;Windows 用 explorer /select。导出以便测试脚本直接驱动。
|
|
2606
3008
|
export function revealInFileManager(rawPath) {
|
|
2607
3009
|
return new Promise((resolveReveal) => {
|
|
2608
|
-
|
|
3010
|
+
let p = typeof rawPath === 'string' ? rawPath.trim() : '';
|
|
2609
3011
|
if (!p || !isAbsolute(p)) {
|
|
2610
3012
|
resolveReveal([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
|
|
2611
3013
|
return;
|
|
@@ -2660,25 +3062,46 @@ export function openFolder(rawPath) {
|
|
|
2660
3062
|
});
|
|
2661
3063
|
}
|
|
2662
3064
|
|
|
2663
|
-
// 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows
|
|
2664
|
-
// Linux 用
|
|
3065
|
+
// 用系统默认应用打开文件(或文件夹):macOS 用 open;Windows 走 rundll32 的
|
|
3066
|
+
// FileProtocolHandler(等价 ShellExecute,启动失败时退回 cmd start);Linux 用
|
|
3067
|
+
// xdg-open。导出以便测试脚本直接驱动。Windows 上不能用 explorer.exe <文件路径>:
|
|
3068
|
+
// 那只会在资源管理器里定位该文件(打开所在目录并选中),不会启动默认关联应用。
|
|
2665
3069
|
export function openWithDefaultApp(rawPath) {
|
|
2666
3070
|
return new Promise((resolveOpen) => {
|
|
2667
|
-
|
|
3071
|
+
let p = typeof rawPath === 'string' ? rawPath.trim() : '';
|
|
2668
3072
|
if (!p || !isAbsolute(p)) {
|
|
2669
3073
|
resolveOpen([400, { ok: false, code: 'invalid-path', error: '无效的路径' }]);
|
|
2670
3074
|
return;
|
|
2671
3075
|
}
|
|
2672
3076
|
if (!existsSync(p)) {
|
|
2673
|
-
|
|
2674
|
-
|
|
3077
|
+
// chip 带来的路径可能被宿主渲染弄坏(见 recoverSwallowedSeparator),先试还原
|
|
3078
|
+
const recovered = recoverSwallowedSeparator(p);
|
|
3079
|
+
if (!recovered) {
|
|
3080
|
+
resolveOpen([400, { ok: false, code: 'not-found', error: '文件不存在' }]);
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
p = recovered;
|
|
2675
3084
|
}
|
|
2676
3085
|
const done = (err) => resolveOpen(err ? [500, { ok: false, error: err.message }] : [200, { ok: true }]);
|
|
2677
3086
|
if (process.platform === 'darwin') execFile('open', [p], { timeout: 5000 }, done);
|
|
2678
3087
|
else if (process.platform === 'win32') {
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
3088
|
+
// rundll32 的参数不经 shell 解释,路径里的空格、%、& 等字符都安全;
|
|
3089
|
+
// start 的第一个引号参数是窗口标题,兜底时必须先给空标题再给路径。
|
|
3090
|
+
// 进程拉起即算成功:系统里没有该类型的关联应用时 rundll32 会弹
|
|
3091
|
+
// “你要如何打开这个文件”对话框并一直挂着,不能等它退出才回话
|
|
3092
|
+
let settled = false;
|
|
3093
|
+
const finish = (err) => {
|
|
3094
|
+
if (settled) return;
|
|
3095
|
+
settled = true;
|
|
3096
|
+
done(err);
|
|
3097
|
+
};
|
|
3098
|
+
const child = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', p], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
3099
|
+
child.on('spawn', () => finish(null));
|
|
3100
|
+
child.on('error', () => {
|
|
3101
|
+
const viaCmd = spawn('cmd.exe', [`/d /s /c start "" "${p}"`], { windowsVerbatimArguments: true, detached: true, stdio: 'ignore', windowsHide: true });
|
|
3102
|
+
viaCmd.on('spawn', () => finish(null));
|
|
3103
|
+
viaCmd.on('error', finish);
|
|
3104
|
+
});
|
|
2682
3105
|
} else execFile('xdg-open', [p], { timeout: 5000 }, done);
|
|
2683
3106
|
});
|
|
2684
3107
|
}
|