@ailuntz/minilink 0.8.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.
@@ -0,0 +1,135 @@
1
+ import { existsSync, statSync, readdirSync, readFileSync, mkdirSync, writeFileSync, cpSync, rmSync, mkdtempSync, renameSync } from 'node:fs';
2
+ import { join, dirname, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { activeRuns } from './runtime.mjs';
7
+ import { legacyInstallation } from './legacy.mjs';
8
+ import { atomicJSON, configPath, codexHome, resolveRealCodex } from './config.mjs';
9
+
10
+ const label = 'com.ailuntz.minilink';
11
+ export const installRoot = () => join(homedir(), '.local/share/minilink');
12
+ export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
13
+ export const xml = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
14
+ const launchctl = (args, required = true) => { try { return execFileSync('/bin/launchctl', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch { if (required) throw new Error(`launchctl ${args[0]} 失败。`); return null; } };
15
+ const defaultPlist = () => join(homedir(), 'Library/LaunchAgents', `${label}.plist`);
16
+ const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ export function installation(root = installRoot()) {
18
+ try { return JSON.parse(readFileSync(join(root, 'installation.json'), 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error('安装状态损坏,请检查 installation.json。'); }
19
+ }
20
+
21
+ function atomicFile(path, text, mode) {
22
+ const temporary = `${path}.${process.pid}.tmp`;
23
+ try { writeFileSync(temporary, text, { mode }); renameSync(temporary, path); }
24
+ finally { rmSync(temporary, { force: true }); }
25
+ }
26
+
27
+ // Each installation has its own files: active Desktop processes keep their
28
+ // original modules and picker assets while the next launch uses the new copy.
29
+ export function install({ root = installRoot(), plist = defaultPlist(), source = sourceRoot, home = codexHome(), config = configPath(), node = process.execPath, npm = process.env.npm_execpath, prefix = process.env.npm_config_prefix, run = launchctl, legacy = root === installRoot() ? legacyInstallation() : null, realCodex = resolveRealCodex(join(source, 'bin/minilink.mjs')) } = {}) {
30
+ if (process.platform !== 'darwin') throw new Error('Desktop 接入目前仅支持 macOS。');
31
+ const previous = installation(root), wrapper = join(root, 'minilink');
32
+ if (existsSync(plist) && !previous) throw new Error('同名 LaunchAgent 已存在且不属于本次安装。');
33
+ const env = run(['getenv', 'CODEX_CLI_PATH'], false) || null;
34
+ if (env && env !== wrapper && env !== legacy?.state.wrapper) throw new Error('CODEX_CLI_PATH 已指向其他程序,请先移除该覆盖。');
35
+ mkdirSync(join(root, 'releases'), { recursive: true, mode: 0o700 });
36
+ const runtime = mkdtempSync(join(root, 'releases', 'runtime-'));
37
+ const activate = join(root, 'activate.sh'), stateFile = join(root, 'installation.json');
38
+ const migrationFiles = legacy ? [legacy.state.wrapper, legacy.stateFile, legacy.plist, legacy.activate, config, `${config}.selection.json`] : [];
39
+ const snapshots = [...new Set([wrapper, activate, plist, stateFile, ...migrationFiles])].map(path => ({ path, data: existsSync(path) ? readFileSync(path) : null }));
40
+ let migrationLocked = false, retired = false, touched = false;
41
+ try {
42
+ if (legacy) {
43
+ try { writeFileSync(join(legacy.root, 'update.lock'), JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 }); migrationLocked = true; }
44
+ catch { throw new Error('旧版正在更新,请稍后重新安装 minilink。'); }
45
+ if (JSON.parse(readFileSync(legacy.stateFile, 'utf8')).installedAt !== legacy.state.installedAt) throw new Error('旧版安装已改变,请重新安装 minilink。');
46
+ touched = true;
47
+ for (const suffix of ['', '.selection.json']) if (!existsSync(config + suffix) && existsSync(legacy.state.configPath + suffix)) {
48
+ mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
49
+ cpSync(legacy.state.configPath + suffix, config + suffix);
50
+ }
51
+ }
52
+ touched = true;
53
+ for (const entry of ['bin', 'src', 'package.json', 'LICENSE']) cpSync(join(source, entry), join(runtime, entry), { recursive: true });
54
+ let dependency = dirname(fileURLToPath(import.meta.resolve('smol-toml')));
55
+ while (!existsSync(join(dependency, 'package.json'))) {
56
+ const parent = dirname(dependency);
57
+ if (parent === dependency) throw new Error('找不到 smol-toml 依赖包。');
58
+ dependency = parent;
59
+ }
60
+ if (JSON.parse(readFileSync(join(dependency, 'package.json'), 'utf8')).name !== 'smol-toml') throw new Error('smol-toml 依赖路径无效。');
61
+ cpSync(dependency, join(runtime, 'node_modules/smol-toml'), { recursive: true });
62
+ const state = { version: 2, source, runtime, wrapper, node, npm: npm || previous?.npm, prefix: prefix || previous?.prefix, codexHome: home, configPath: config, previousEnv: previous?.previousEnv || legacy?.state.previousEnv || null, installedAt: new Date().toISOString() };
63
+ // The small fallback also serves Desktop instances that inherited the old
64
+ // launch environment before npm uninstall removed the integration.
65
+ atomicFile(wrapper, `#!/bin/sh\nif [ ! -f ${shellQuote(stateFile)} ]; then exec ${shellQuote(realCodex)} "$@"; fi\nexport CODEX_HOME=${shellQuote(home)}\nexport MINILINK_CONFIG=${shellQuote(config)}\nexec ${shellQuote(node)} ${shellQuote(join(runtime, 'bin/minilink.mjs'))} "$@"\n`, 0o755);
66
+ atomicFile(activate, `#!/bin/sh\nexec ${shellQuote(node)} ${shellQuote(join(runtime, 'src/lifecycle.mjs'))}\n`, 0o700);
67
+ mkdirSync(dirname(plist), { recursive: true });
68
+ atomicFile(plist, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>/bin/sh</string><string>${xml(activate)}</string></array><key>RunAtLoad</key><true/><key>StartInterval</key><integer>30</integer><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`, 0o600);
69
+ atomicJSON(stateFile, state);
70
+ run(['bootout', `gui/${process.getuid()}/${label}`], false);
71
+ run(['bootstrap', `gui/${process.getuid()}`, plist]);
72
+ run(['setenv', 'CODEX_CLI_PATH', wrapper]);
73
+ if (run(['getenv', 'CODEX_CLI_PATH'], false) !== wrapper) throw new Error('启动环境验证失败。');
74
+ if (legacy) {
75
+ run(['bootout', `gui/${process.getuid()}/${legacy.label}`], false); retired = true;
76
+ atomicFile(legacy.state.wrapper, `#!/bin/sh\nexec ${shellQuote(wrapper)} "$@"\n`, 0o755);
77
+ for (const path of [legacy.stateFile, legacy.plist, legacy.activate]) rmSync(path, { force: true });
78
+ }
79
+ return state;
80
+ } catch (error) {
81
+ if (!touched) { rmSync(runtime, { recursive: true, force: true }); throw error; }
82
+ for (const { path, data } of snapshots) { if (data === null) rmSync(path, { force: true }); else atomicFile(path, data, [wrapper, legacy?.state.wrapper].includes(path) ? 0o755 : [activate, legacy?.activate].includes(path) ? 0o700 : 0o600); }
83
+ run(['bootout', `gui/${process.getuid()}/${label}`], false);
84
+ if (previous) run(['bootstrap', `gui/${process.getuid()}`, plist], false);
85
+ if (retired) run(['bootstrap', `gui/${process.getuid()}`, legacy.plist], false);
86
+ if (env) run(['setenv', 'CODEX_CLI_PATH', env], false); else run(['unsetenv', 'CODEX_CLI_PATH'], false);
87
+ // A briefly started process may already hold this release; retain it.
88
+ throw error;
89
+ } finally { if (migrationLocked) rmSync(join(legacy.root, 'update.lock'), { force: true }); }
90
+ }
91
+
92
+ function pruneReleases(root, state, now) {
93
+ try {
94
+ const active = new Set(activeRuns(state.codexHome).map(run => run.version));
95
+ for (const name of readdirSync(join(root, 'releases'))) {
96
+ if (!name.startsWith('runtime-')) continue;
97
+ const path = join(root, 'releases', name);
98
+ if (path === state.runtime || now - statSync(path).mtimeMs < 600000) continue;
99
+ const version = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version;
100
+ if (!active.has(version)) rmSync(path, { recursive: true, force: true });
101
+ }
102
+ } catch { /* Keep files if activity cannot be established. */ }
103
+ }
104
+
105
+ export function updateInProgress(root = installRoot()) {
106
+ try { const { pid } = JSON.parse(readFileSync(join(root, 'update.lock'), 'utf8')); if (!Number.isInteger(pid) || pid <= 0) return false; process.kill(pid, 0); return true; } catch { return false; }
107
+ }
108
+
109
+ // npm >=7 has no uninstall hooks. The login agent reconciles package presence,
110
+ // with a grace period for npm's temporary moves during upgrades.
111
+ export function reconcile({ root = installRoot(), plist = defaultPlist(), run = launchctl, now = Date.now(), busy = updateInProgress } = {}) {
112
+ const state = installation(root);
113
+ if (!state?.source) return;
114
+ const lock = join(root, 'update.lock');
115
+ try { if (now - statSync(lock).mtimeMs > 300000 && !busy(root)) rmSync(lock); } catch {}
116
+ const marker = join(root, 'missing.json');
117
+ if (existsSync(join(state.source, 'package.json')) || busy(root)) {
118
+ if (!busy(root)) pruneReleases(root, state, now);
119
+ rmSync(marker, { force: true });
120
+ const env = run(['getenv', 'CODEX_CLI_PATH'], false);
121
+ if (!env) run(['setenv', 'CODEX_CLI_PATH', state.wrapper]);
122
+ return;
123
+ }
124
+ let since;
125
+ try { since = JSON.parse(readFileSync(marker, 'utf8')).since; } catch {}
126
+ if (!Number.isFinite(since) || now < since) { atomicJSON(marker, { since: now }); return; }
127
+ if (now - since < 120000) return;
128
+ if (run(['getenv', 'CODEX_CLI_PATH'], false) === state.wrapper) {
129
+ if (state.previousEnv) run(['setenv', 'CODEX_CLI_PATH', state.previousEnv]); else run(['unsetenv', 'CODEX_CLI_PATH']);
130
+ }
131
+ for (const path of [plist, join(root, 'installation.json'), marker, join(root, 'activate.sh')]) rmSync(path, { force: true });
132
+ run(['bootout', `gui/${process.getuid()}/${label}`], false);
133
+ }
134
+
135
+ export function environmentStatus() { return process.platform === 'darwin' ? launchctl(['getenv', 'CODEX_CLI_PATH'], false) || null : null; }
package/src/legacy.mjs ADDED
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // Existing settings and launchers migrate once; persisted cdr: model aliases
6
+ // and cdr_profile_/cdr_chat_ provider IDs remain stable so saved tasks resume.
7
+ export const legacyName = 'codex-desktop-router';
8
+ export const legacyRuns = home => join(home, 'desktop-router-runs');
9
+ export const setting = key => process.env[`MINILINK_${key}`] ?? process.env[`CODEX_DESKTOP_ROUTER_${key}`];
10
+ export function legacyInstallation(userHome = homedir()) {
11
+ const root = join(userHome, '.local/share', legacyName), stateFile = join(root, 'installation.json');
12
+ if (!existsSync(stateFile)) return null;
13
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'));
14
+ if (state.wrapper !== join(root, legacyName) || typeof state.configPath !== 'string') throw new Error('旧安装记录无效,无法迁移。');
15
+ return { root, stateFile, state, label: `com.ailuntz.${legacyName}`, plist: join(userHome, 'Library/LaunchAgents', `com.ailuntz.${legacyName}.plist`), activate: join(root, 'activate.sh') };
16
+ }
@@ -0,0 +1,10 @@
1
+ import { install, reconcile } from './install.mjs';
2
+
3
+ if (process.argv[2] === 'postinstall') {
4
+ if (process.env.npm_config_global === 'true') {
5
+ try { install(); }
6
+ catch (error) { process.stderr.write(`minilink: ${error.message}\n`); process.exitCode = 1; }
7
+ }
8
+ } else {
9
+ try { reconcile(); } catch { /* Login and uninstall checks stay silent. */ }
10
+ }
@@ -0,0 +1,170 @@
1
+ import { object, chatProviderId, isRoutedProvider, findRoute, isChatProvider } from './config.mjs';
2
+
3
+ export const aliasFor = (provider, model) => `cdr:${provider}:${encodeURIComponent(model)}`;
4
+ const isAlias = value => typeof value === 'string' && value.startsWith('cdr:');
5
+ const threadMethods = new Set(['thread/start', 'thread/resume', 'thread/fork']);
6
+ const settingMethods = new Set(['turn/start', 'turn/settings/update', 'thread/settings/update']);
7
+ const trackedMethods = new Set([...threadMethods, ...settingMethods, 'thread/read', 'model/list', 'config/read', 'config/value/write', 'config/batchWrite']);
8
+
9
+ export function decodeAlias(value, config) {
10
+ if (!isAlias(value)) return null;
11
+ const parts = value.split(':');
12
+ let model;
13
+ try { model = decodeURIComponent(parts[2]); } catch { throw new Error('Profile 模型选项格式无效;请刷新模型列表。'); }
14
+ if (parts.length !== 3 || !isRoutedProvider(parts[1], config) || !model || model.length > 200 || /[\s\x00-\x1f]/u.test(model) || aliasFor(parts[1], model) !== value) throw new Error('Profile 模型选项不属于当前提供商;请刷新模型列表。');
15
+ if (isChatProvider(parts[1], config) && !findRoute(parts[1], config).chat) throw new Error('此 profile 尚未提供可用于 Chat 的连接。');
16
+ return { provider: parts[1], model };
17
+ }
18
+
19
+ export function additionalModels(catalog, config, { firstPage = true, includeHidden = false } = {}) {
20
+ return config.routes.flatMap(route => {
21
+ if (!route.providerDefinition || route.error || (!firstPage && route.catalogPath)) return [];
22
+ const seen = new Set();
23
+ const models = (route.catalogPath ? route.catalog || [] : catalog).filter(entry => {
24
+ if (typeof entry?.model !== 'string' || !entry.model || isAlias(entry.model) || seen.has(entry.model) || (!includeHidden && entry.hidden)) return false;
25
+ seen.add(entry.model); return true;
26
+ });
27
+ return [route.provider, ...(route.chat ? [chatProviderId(route)] : [])].flatMap(provider => models.map(entry => ({
28
+ ...entry, id: aliasFor(provider, entry.model), model: aliasFor(provider, entry.model),
29
+ displayName: `${route.profile} · ${entry.displayName || entry.model}${provider === route.provider ? '' : '-Chat'}`,
30
+ isDefault: false,
31
+ ...(provider === route.provider ? {} : { inputModalities: ['text'], description: '纯文字聊天,不向模型提供工作区上下文或工具。' })
32
+ })));
33
+ });
34
+ }
35
+
36
+ function modelSlots(params) {
37
+ return [params?.model, params?.config?.model, params?.collaborationMode?.settings?.model].filter(v => v != null);
38
+ }
39
+
40
+ function normalizedParams(params, config) {
41
+ const result = { ...params };
42
+ if (isAlias(result.model)) result.model = decodeAlias(result.model, config).model;
43
+ if (isAlias(result.config?.model)) result.config = { ...result.config, model: decodeAlias(result.config.model, config).model };
44
+ if (isAlias(result.collaborationMode?.settings?.model)) result.collaborationMode = { ...result.collaborationMode, settings: { ...result.collaborationMode.settings, model: decodeAlias(result.collaborationMode.settings.model, config).model } };
45
+ return result;
46
+ }
47
+
48
+ // Only model routes and catalog settings are translated here. Native task
49
+ // permissions, prompts and persisted provider metadata remain untouched.
50
+ export class ModelMenu {
51
+ constructor({ getConfig, requireProvider, readSelection = () => null, saveSelection = () => {}, onCatalog = () => {}, onSelection = () => {}, onPrewarmSkipped = () => {} }) {
52
+ Object.assign(this, { getConfig, requireProvider, readSelection, saveSelection, onCatalog, onSelection, onPrewarmSkipped });
53
+ this.pending = new Map(); this.threads = new Map();
54
+ this.desktopClient = false;
55
+ }
56
+ rememberThread(thread) {
57
+ if (!object(thread) || typeof thread.id !== 'string' || typeof thread.modelProvider !== 'string') return;
58
+ this.threads.set(thread.id, thread.modelProvider);
59
+ if (this.threads.size > 10000) this.threads.delete(this.threads.keys().next().value);
60
+ }
61
+ request(message) {
62
+ if (!object(message) || !Object.hasOwn(message, 'id') || typeof message.method !== 'string') return message;
63
+ const method = message.method;
64
+ if (method === 'initialize') {
65
+ this.desktopClient = message.params?.clientInfo?.title === 'Codex Desktop';
66
+ return message;
67
+ }
68
+ if (!trackedMethods.has(method)) return message;
69
+ const params = message.params ?? {};
70
+ if (!object(params)) return message;
71
+ const config = this.getConfig();
72
+ // Verified in Desktop 26.908: preparePrewarmRequest omits threadSource;
73
+ // actual prepareConversation defaults it to "user". Desktop catches a
74
+ // failed prewarm and starts a fresh thread on send. Otherwise its cache
75
+ // (keyed by cwd, not provider/model) can pin a new task to the wrong provider.
76
+ if (this.desktopClient && config.enabled && method === 'thread/start' && params.threadSource == null) {
77
+ this.onPrewarmSkipped();
78
+ throw new Error('菜单路由已跳过 Desktop 预热;正常发送时会按所选模型创建新任务。');
79
+ }
80
+ let result = message;
81
+ const record = { method, time: Date.now(), threadId: params.threadId, firstPage: !params.cursor, includeHidden: params.includeHidden === true };
82
+ if (threadMethods.has(method) || settingMethods.has(method)) {
83
+ const values = modelSlots(params);
84
+ const selected = values.map(v => decodeAlias(v, config)).filter(Boolean);
85
+ if (selected.length && (selected.some(s => s.model !== selected[0].model || s.provider !== selected[0].provider) || values.some(v => !isAlias(v) && v !== selected[0].model))) throw new Error('请求中包含冲突的模型选择,请重新选择模型。');
86
+ if (selected.length) {
87
+ const selection = selected[0];
88
+ this.requireProvider(findRoute(selection.provider, config));
89
+ if (settingMethods.has(method)) {
90
+ const provider = this.threads.get(params.threadId);
91
+ if (provider !== selection.provider && !(findRoute(provider, config) === findRoute(selection.provider, config) && isChatProvider(provider, config) === isChatProvider(selection.provider, config))) throw new Error('任务路由尚未就绪,请重新打开任务后再选择 profile 模型。');
92
+ } else if (!config.enabled) throw new Error('Profile 新任务路由已关闭;请先执行 minilink on。');
93
+ const rewritten = normalizedParams(params, config);
94
+ if (threadMethods.has(method)) {
95
+ if (rewritten.modelProvider && rewritten.modelProvider !== 'openai' && !isRoutedProvider(rewritten.modelProvider, config)) throw new Error('模型选项与显式提供商不一致。');
96
+ rewritten.modelProvider = selection.provider;
97
+ rewritten.model = selection.model;
98
+ const route = findRoute(selection.provider, config);
99
+ rewritten.config = { ...(route.catalogPath ? { model_catalog_json: route.catalogPath } : {}), ...rewritten.config };
100
+ }
101
+ result = { ...message, params: rewritten };
102
+ this.onSelection(method);
103
+ } else if (settingMethods.has(method) && values.length && isRoutedProvider(this.threads.get(params.threadId), config)) {
104
+ // An official picker row must not silently use the existing custom key.
105
+ throw new Error('任务路由尚未切换,请重新打开任务后再选择模型。');
106
+ }
107
+ } else if (method === 'config/value/write' || method === 'config/batchWrite') {
108
+ const edits = method === 'config/value/write' ? [params] : params.edits;
109
+ if (Array.isArray(edits)) {
110
+ let changed = false;
111
+ const normalized = edits.map(edit => {
112
+ if (object(edit) && edit.keyPath === 'model' && params.filePath == null && !isAlias(edit.value)) record.savedSelection = null;
113
+ if (!object(edit) || !isAlias(edit.value)) return edit;
114
+ if (edit.keyPath !== 'model' || params.filePath != null) throw new Error('Profile 菜单默认值目前只支持主配置;不要将虚拟模型保存进独立 profile。');
115
+ const selection = decodeAlias(edit.value, config);
116
+ this.requireProvider(findRoute(selection.provider, config));
117
+ record.savedSelection = selection;
118
+ changed = true;
119
+ return { ...edit, value: selection.model };
120
+ });
121
+ if (changed) result = { ...message, params: method === 'config/value/write' ? normalized[0] : { ...params, edits: normalized } };
122
+ }
123
+ }
124
+ for (const [id, request] of this.pending) if (Date.now() - request.time > 300000) this.pending.delete(id);
125
+ if (this.pending.size >= 4096) throw new Error('路由器等待中的请求过多,请稍后重试。');
126
+ this.pending.set(message.id, record);
127
+ return result;
128
+ }
129
+ response(message) {
130
+ if (!object(message)) return message;
131
+ const record = Object.hasOwn(message, 'id') && !message.method ? this.pending.get(message.id) : undefined;
132
+ if (record) this.pending.delete(message.id);
133
+ this.rememberThread(message.result?.thread);
134
+ this.rememberThread(message.params?.thread);
135
+ if (message.method === 'thread/settings/updated') this.rememberThread({ id: message.params?.threadId, modelProvider: message.params?.threadSettings?.modelProvider });
136
+ let config;
137
+ try { config = this.getConfig(); } catch { return message; }
138
+ if (message.error) return message;
139
+ if (record?.method === 'model/list' && Array.isArray(message.result?.data)) {
140
+ if (!config.enabled) return message;
141
+ // Inherited profiles mirror each native page; explicit catalogs appear once.
142
+ const additions = additionalModels(message.result.data, config, record).filter(entry => !message.result.data.some(m => m.id === entry.id || m.model === entry.model));
143
+ this.onCatalog(additions.length);
144
+ return { ...message, result: { ...message.result, data: [...message.result.data, ...additions] } };
145
+ }
146
+ if (record && Object.hasOwn(record, 'savedSelection')) this.saveSelection(record.savedSelection);
147
+ if (record?.method === 'config/read' && object(message.result?.config) && config.enabled) {
148
+ const saved = this.readSelection();
149
+ if (saved && isRoutedProvider(saved.provider, config) && saved.model === message.result.config.model) return { ...message, result: { ...message.result, config: { ...message.result.config, model: aliasFor(isChatProvider(saved.provider, config) ? chatProviderId(findRoute(saved.provider, config)) : findRoute(saved.provider, config).provider, saved.model) } } };
150
+ }
151
+ const threadId = message.params?.threadId || message.result?.thread?.id || record?.threadId;
152
+ const actualProvider = message.result?.modelProvider || this.threads.get(threadId);
153
+ const route = findRoute(actualProvider, config);
154
+ const provider = route ? (isChatProvider(actualProvider, config) ? chatProviderId(route) : route.provider) : actualProvider;
155
+ if (!isRoutedProvider(provider, config)) return message;
156
+ const decorate = value => {
157
+ if (!object(value)) return value;
158
+ let updated = value;
159
+ if (typeof value.model === 'string' && !isAlias(value.model)) updated = { ...updated, model: aliasFor(provider, value.model) };
160
+ if (typeof value.collaborationMode?.settings?.model === 'string' && !isAlias(value.collaborationMode.settings.model)) updated = { ...updated, collaborationMode: { ...value.collaborationMode, settings: { ...value.collaborationMode.settings, model: aliasFor(provider, value.collaborationMode.settings.model) } } };
161
+ return updated;
162
+ };
163
+ if (record && (threadMethods.has(record.method) || record.method === 'thread/settings/update')) {
164
+ const result = decorate(message.result);
165
+ if (result !== message.result) return { ...message, result };
166
+ }
167
+ if (message.method === 'thread/settings/updated') return { ...message, params: { ...message.params, threadSettings: decorate(message.params.threadSettings) } };
168
+ return message;
169
+ }
170
+ }
@@ -0,0 +1,49 @@
1
+ const $ = id => document.getElementById(id);
2
+ const token = location.hash.slice(1) || sessionStorage.getItem('picker-token');
3
+ if (token) sessionStorage.setItem('picker-token', token);
4
+ history.replaceState(null, '', '/');
5
+ let state, busy = false;
6
+ const say = (text, error = false) => { $('message').textContent = text; $('message').className = error ? 'error' : ''; };
7
+ async function api(path, body) {
8
+ const response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
9
+ const data = await response.json(); if (!response.ok) throw new Error(data.error); return data;
10
+ }
11
+ function fill(select, options, selected) {
12
+ select.replaceChildren(...options.map(([value, text]) => { const option = document.createElement('option'); option.value = value; option.textContent = text; return option; }));
13
+ if (options.some(([value]) => value === selected)) select.value = selected;
14
+ }
15
+ function changeModel(effort) {
16
+ const model = state.models.find(m => m.model === $('model').value);
17
+ fill($('effort'), model?.efforts?.length ? model.efforts.map(e => [e.reasoningEffort, e.reasoningEffort]) : [['default', '默认']], effort || model?.effort);
18
+ }
19
+ function changeThread() {
20
+ const thread = state.threads.find(t => t.id === $('thread').value);
21
+ const pin = thread?.pin || ($('thread').value === 'next' ? state.next : null);
22
+ const current = state.models.find(m => m.model === pin?.model);
23
+ $('details').textContent = thread ? `${thread.cwd || ''}\n${thread.status === 'active' ? '正在回复,请等待完成。' : '任务已载入。'}${pin ? ` 由此页管理:${current?.name || pin.model}` : ' 由 Desktop 菜单管理。'}` : pin ? `下一个新任务将使用 ${current?.name || pin.model}。` : '先在这里选好模型,再回 Desktop 新建任务并发送。';
24
+ fill($('model'), state.models.map(m => [m.model, m.name]), pin?.model || thread?.model);
25
+ changeModel(pin?.effort);
26
+ $('apply').disabled = busy || (thread && thread.status !== 'idle');
27
+ $('release').disabled = busy || !pin;
28
+ }
29
+ async function refresh() {
30
+ const selected = $('thread').value;
31
+ state = await api('/state');
32
+ fill($('thread'), [['next', '下一个新任务'], ...state.threads.map(t => [t.id, t.name.slice(0, 90)])], selected);
33
+ changeThread();
34
+ }
35
+ async function action(operation) {
36
+ if (busy) return;
37
+ busy = true; $('apply').disabled = true; $('release').disabled = true;
38
+ try { await operation(); } catch (error) { say(error.message || '连接已结束,请重新打开选择页。', true); }
39
+ finally { busy = false; if (state) changeThread(); }
40
+ }
41
+ $('thread').onchange = () => { changeThread(); say(''); };
42
+ $('model').onchange = () => changeModel();
43
+ $('refresh').onclick = () => action(refresh);
44
+ $('form').onsubmit = event => { event.preventDefault(); action(async () => {
45
+ await api('/choose', { threadId: $('thread').value, model: $('model').value, effort: $('effort').value === 'default' ? null : $('effort').value });
46
+ await refresh(); say('已设置。回到 Desktop 继续发送消息即可。');
47
+ }); };
48
+ $('release').onclick = () => action(async () => { await api('/release', { threadId: $('thread').value }); await refresh(); say('已交回 Desktop 菜单。'); });
49
+ action(refresh);
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模型选择 · minilink</title>
3
+ <style>
4
+ :root{color-scheme:light dark;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f5f5f3;color:#242623}body{margin:0;padding:56px 20px}main{max-width:640px;margin:auto}header{margin-bottom:32px}.eyebrow{font-size:12px;letter-spacing:.1em;color:#687368}h1{font-size:32px;line-height:1.2;margin:12px 0}p{color:#646d65;margin:12px 0}form{background:#fff;border:1px solid #e2e6df;border-radius:18px;padding:28px;box-shadow:0 8px 28px #18371a08}label{display:block;font-weight:600;font-size:14px;margin:16px 0 7px}label:first-child{margin-top:0}select,button{font:inherit;border-radius:9px}select{width:100%;padding:11px;border:1px solid #cad2c8;background:#fafbf9;color:inherit}button{cursor:pointer;border:0;padding:11px 17px;background:#255b3d;color:white;font-weight:600}button:disabled{opacity:.45;cursor:default}.secondary{background:#eef1ec;color:#425044}nav{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.detail{font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere}.note{font-size:13px;margin:20px 0 0}#message{min-height:26px;margin-top:18px;color:#255b3d}#message.error{color:#ad3434}footer{font-size:12px;color:#778076;margin-top:24px}a{color:inherit}#refresh{float:right;padding:5px 10px;font-size:13px}@media(prefers-color-scheme:dark){:root{background:#171c18;color:#e0e8e1}form{background:#202721;border-color:#394238}p,footer{color:#abb7ad}select{background:#252e27;border-color:#526152}.secondary{background:#364337;color:#d4e0d6}#message{color:#87d6a4}#message.error{color:#ffa0a0}}
5
+ </style>
6
+ <main><header><button id="refresh" class="secondary" type="button">刷新</button><div class="eyebrow">MINILINK</div><h1>选择下一条回复的模型</h1><p>Desktop 的额度提示挡住菜单时,在这里切换。</p></header>
7
+ <form id="form"><label for="thread">任务</label><select id="thread" required></select><p id="details" class="detail"></p><label for="model">模型</label><select id="model" required></select><label for="effort">推理强度</label><select id="effort" required></select><nav><button id="apply" type="submit">使用此模型</button><button id="release" class="secondary" type="button">交回 Desktop 菜单</button></nav><p class="note">启用后,该任务的模型与推理强度由此页管理,直到交回菜单或退出 Desktop。原生菜单可能仍显示额度提示,以此页的选择为准。</p><div id="message" role="status" aria-live="polite"></div></form>
8
+ <footer>模型和推理选项来自客户端及 profile 指定的目录。此页不改变账户额度。新任务选项只作用于下一次正式创建的本地任务。</footer></main><script src="/picker.js"></script></html>
package/src/picker.mjs ADDED
@@ -0,0 +1,152 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { activeRuns } from './runtime.mjs';
7
+ import { VERSION } from './config.mjs';
8
+ import { additionalModels } from './model-menu.mjs';
9
+
10
+ // A choice in this page explicitly owns model selection until released. Desktop
11
+ // can substitute its reserve model at submission time, not just hide its menu.
12
+ export class Picker {
13
+ constructor({ bridge, menu, getConfig, serialize, chats = [] } = {}) {
14
+ Object.assign(this, { bridge, menu, getConfig, serialize, chats });
15
+ this.pins = new Map(); this.starts = new Map(); this.next = null; this.sequence = 0;
16
+ }
17
+ request(message) {
18
+ if (!this.getConfig().enabled) { this.pins.clear(); this.starts.clear(); this.next = null; }
19
+ const params = message?.params;
20
+ if (!params || !Object.hasOwn(message, 'id')) return message;
21
+ let pin = this.pins.get(params.threadId);
22
+ if (message.method === 'thread/start' && params.threadSource === 'user' && this.next) {
23
+ pin = this.next;
24
+ this.next = null;
25
+ this.starts.set(message.id, pin);
26
+ }
27
+ if (!pin || !['thread/start', 'thread/resume', 'thread/settings/update', 'turn/settings/update', 'turn/start'].includes(message.method)) return message;
28
+ const updated = { ...params, model: pin.model, effort: pin.effort };
29
+ if (params.config?.model != null) updated.config = { ...params.config, model: pin.model };
30
+ if (params.collaborationMode) updated.collaborationMode = { ...params.collaborationMode, settings: { ...params.collaborationMode.settings, model: pin.model, reasoning_effort: pin.effort } };
31
+ return { ...message, params: updated };
32
+ }
33
+ response(message) {
34
+ const pin = this.starts.get(message?.id);
35
+ if (!pin || message.method) return;
36
+ this.starts.delete(message.id);
37
+ if (message.result?.thread?.id) this.pins.set(message.result.thread.id, pin);
38
+ else if (message.error && !this.next) this.next = pin;
39
+ }
40
+ async catalog() {
41
+ await this.refreshCatalogs?.();
42
+ const models = []; let cursor;
43
+ do {
44
+ const page = await this.bridge.rpc('model/list', { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) });
45
+ models.push(...page.data); cursor = page.nextCursor;
46
+ if (models.length > 10000) throw new Error('模型目录过大,请重新打开选择页。');
47
+ } while (cursor);
48
+ const config = this.getConfig();
49
+ return [...models, ...(config.enabled ? additionalModels(models, config) : [])].filter(m => !m.hidden);
50
+ }
51
+ async state() {
52
+ this.request(null);
53
+ const models = await this.catalog();
54
+ const threads = [];
55
+ let cursor;
56
+ do {
57
+ const page = await this.bridge.rpc('thread/loaded/list', { limit: 100, ...(cursor ? { cursor } : {}) });
58
+ for (const id of page.data) {
59
+ try {
60
+ const { thread } = await this.bridge.rpc('thread/read', { threadId: id, includeTurns: false });
61
+ if (thread.parentThreadId || thread.source?.subAgent) continue;
62
+ this.menu.rememberThread(thread);
63
+ threads.push({ id, name: thread.name || thread.preview || '未命名任务', cwd: thread.cwd, status: thread.status?.type,
64
+ model: thread.model, provider: thread.modelProvider, pin: this.pins.get(id) || null });
65
+ } catch { /* a task may close while the list is being read */ }
66
+ }
67
+ cursor = page.nextCursor;
68
+ } while (cursor);
69
+ return { threads, next: this.next, models: models.map(m => ({ model: m.model, name: m.displayName || m.model,
70
+ effort: m.defaultReasoningEffort, efforts: m.supportedReasoningEfforts || [] })) };
71
+ }
72
+ async choose({ threadId, model, effort }) {
73
+ if (!this.getConfig().enabled) throw new Error('路由已关闭,请先运行 minilink on。');
74
+ if (typeof threadId !== 'string') throw new Error('请选择任务。');
75
+ const entry = (await this.catalog()).find(m => m.model === model);
76
+ if (!entry) throw new Error('此模型已不在原生目录中,请刷新。');
77
+ const supported = entry.supportedReasoningEfforts || [];
78
+ effort = supported.length ? (effort ?? entry.defaultReasoningEffort) : null;
79
+ if (supported.length && !supported.some(e => e.reasoningEffort === effort)) throw new Error('请选择该模型支持的推理强度。');
80
+ const pin = { model, effort };
81
+ if (threadId === 'next') { this.next = pin; return { ok: true }; }
82
+ const { thread } = await this.bridge.rpc('thread/read', { threadId, includeTurns: false });
83
+ if (thread.status?.type !== 'idle') throw new Error('请等待任务回复和工具运行结束。');
84
+ this.menu.rememberThread(thread);
85
+ const message = { id: `picker-${++this.sequence}`, method: 'thread/settings/update', params: { threadId, model, effort } };
86
+ await this.bridge.prepare(message);
87
+ const routed = this.menu.request(message);
88
+ this.menu.pending.delete(message.id);
89
+ await this.bridge.rpc(message.method, routed.params);
90
+ this.pins.set(threadId, pin);
91
+ return { ok: true };
92
+ }
93
+ release({ threadId }) {
94
+ if (typeof threadId !== 'string') throw new Error('请选择任务。');
95
+ if (threadId === 'next') this.next = null;
96
+ else this.pins.delete(threadId);
97
+ return { ok: true };
98
+ }
99
+ async listen() {
100
+ const token = randomBytes(32).toString('hex');
101
+ let origin;
102
+ const server = createServer(async (req, res) => {
103
+ const reply = (code, body, type = 'application/json') => {
104
+ res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
105
+ 'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
106
+ res.end(type === 'application/json' ? JSON.stringify(body) : body);
107
+ };
108
+ if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) return reply(403, { error: '访问来源不匹配。' });
109
+ const chat = this.chats.find(c => req.url?.startsWith(c.path + '/'));
110
+ if (chat) return chat.handle(req, res);
111
+ if (req.method === 'GET' && ['/', '/picker.js'].includes(req.url)) {
112
+ const filename = req.url === '/' ? 'picker.html' : 'picker-client.js';
113
+ return reply(200, readFileSync(new URL(filename, import.meta.url), 'utf8'), req.url === '/' ? 'text/html' : 'text/javascript');
114
+ }
115
+ if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: '入口已失效,请重新打开模型选择页。' });
116
+ try {
117
+ if (req.method === 'GET' && req.url === '/state') return reply(200, await this.serialize(() => this.state()));
118
+ if (req.method === 'POST' && ['/choose', '/release'].includes(req.url)) {
119
+ if (req.headers['content-type'] !== 'application/json') return reply(415, { error: '请求格式不正确。' });
120
+ let raw = ''; for await (const data of req) { raw += data; if (raw.length > 8192) return reply(413, { error: '请求过大。' }); }
121
+ const body = JSON.parse(raw);
122
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('请求格式不正确。');
123
+ return reply(200, await this.serialize(() => req.url === '/choose' ? this.choose(body) : this.release(body)));
124
+ }
125
+ reply(404, { error: '没有这个操作。' });
126
+ } catch (error) { reply(400, { error: error instanceof SyntaxError ? '请求格式不正确。' : error.message }); }
127
+ });
128
+ server.requestTimeout = 10000; server.headersTimeout = 10000;
129
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
130
+ origin = `http://127.0.0.1:${server.address().port}`;
131
+ server.unref(); this.server = server;
132
+ return { port: server.address().port, token };
133
+ }
134
+ close() { for (const chat of this.chats) chat.close(); this.server?.closeAllConnections(); this.server?.close(); }
135
+ }
136
+
137
+ export function openPicker(home) {
138
+ const active = [];
139
+ for (const run of activeRuns(home)) if (run.version === VERSION) {
140
+ try { active.push(JSON.parse(readFileSync(join(run.directory, `${run.pid}.picker.json`), 'utf8'))); } catch {}
141
+ }
142
+ if (!active.length) return false;
143
+ // Each native connection has its own set of loaded tasks. Open all active pages
144
+ // when Desktop has more than one connection, instead of silently picking one.
145
+ let opened = false;
146
+ for (const item of active) {
147
+ if (!Number.isInteger(item.port) || item.port < 1 || item.port > 65535 || !/^[a-f0-9]{64}$/.test(item.token)) continue;
148
+ execFileSync('/usr/bin/open', [`http://127.0.0.1:${item.port}/#${item.token}`], { stdio: 'ignore' });
149
+ opened = true;
150
+ }
151
+ return opened;
152
+ }