@ailuntz/minilink 0.8.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,32 +1,34 @@
1
- # minilink
1
+ # MiniLink
2
2
 
3
- 在 ChatGPT / Codex Desktop 的原生模型菜单中使用已有 profiles。
4
-
5
- 读取 `~/.codex/*.config.toml`:有 `model_catalog_json` 就使用该目录,否则继承原生模型列表。支持 API key 的 Responses 连接还会追加 `-Chat`,用于不带工具和工作区上下文的纯文字聊天。
3
+ 在 ChatGPT / Codex Desktop 的原生模型菜单中使用自定义模型。
6
4
 
7
5
  ## 安装
8
6
 
9
- macOS,Node.js ≥22.16。
7
+ 支持 macOS,需 Node.js ≥22.16。
10
8
 
11
9
  ```sh
12
10
  npm install -g @ailuntz/minilink
11
+ minilink on
13
12
  ```
14
13
 
15
- 完全退出并重开 Desktop,然后开启:
14
+ 完全退出并重开 Desktop。
16
15
 
17
- ```sh
18
- minilink on
19
- ```
16
+ ## 命令
20
17
 
21
18
  | 命令 | 用途 |
22
19
  | --- | --- |
23
- | `on` | 开启模型选项,打开模型选择页 |
24
- | `off` | 关闭追加选项和选择页接管 |
25
- | `doctor` | 检查配置、版本和运行状态 |
20
+ | `minilink on` | 开启 |
21
+ | `minilink link` | 选择模型与推理强度 |
22
+ | `minilink off` | 关闭 |
23
+ | `minilink doctor` | 检查配置与运行状态 |
26
24
 
27
- 额度提示挡住菜单时,用 `on` 打开选择页切换。选择持续到交回菜单、执行 `off` 或退出 Desktop。
25
+ ## 配置
28
26
 
29
- 启动时后台检查更新,每天最多一次。有新版便静默下载安装,断网或失败则继续使用旧版。新版及 profiles、连接、密钥的修改在重启 Desktop 后生效。`MINILINK_NO_UPDATE_CHECK=1` 可关闭自动更新。
27
+ 读取 `~/.codex/*.config.toml`。通过 `model_catalog_json` 指定模型目录;未指定时继承原生目录。密钥使用提供商配置的环境变量,也可保存在 `~/.codex/.env`。
28
+
29
+ 支持 Responses API。符合条件的连接提供 `-Chat` 选项,用于纯文字聊天。
30
+
31
+ 供应商示例:[DeepSeek / 豆包](examples/providers/)。
30
32
 
31
33
  ## 卸载
32
34
 
@@ -34,11 +36,4 @@ minilink on
34
36
  npm uninstall -g @ailuntz/minilink
35
37
  ```
36
38
 
37
- 约两分钟后自动移除接入,重启 Desktop 生效。保留用户配置和运行中任务所需的文件。
38
-
39
- ## 注意
40
-
41
- - 跨连接切换需要任务已保存且空闲;临时任务、尚无历史或含子代理的任务请新建。
42
- - Chat 仅支持纯文字历史,不支持附件、中途追加和上下文压缩。
43
- - 切换模型保留当前任务权限。
44
- - 密钥使用提供商声明的环境变量,也可放在 `~/.codex/.env`。支持 `CODEX_HOME`。
39
+ 卸载后完全退出并重开 Desktop。
package/bin/minilink.mjs CHANGED
@@ -4,32 +4,28 @@ import { fileURLToPath } from 'node:url';
4
4
  import { VERSION, readConfig, configPath, codexHome, atomicJSON, discoverProfiles, resolveRealCodex, childEnvironment } from '../src/config.mjs';
5
5
  import { startUpdate, updateStatus } from '../src/update.mjs';
6
6
  import { runProxy } from '../src/proxy.mjs';
7
- import { openPicker } from '../src/picker.mjs';
7
+ import { pickerEndpoints } from '../src/picker.mjs';
8
+ import { selectModel, text, language } from '../src/terminal.mjs';
8
9
  import { installation, environmentStatus } from '../src/install.mjs';
9
10
 
10
11
  const self = fileURLToPath(import.meta.url), args = process.argv.slice(2);
11
- const commands = ['on', 'off', 'doctor'];
12
+ const commands = ['on', 'link', 'off', 'doctor'];
12
13
  const output = value => console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
13
- const help = `minilink
14
-
15
- on 开启 profile 模型选项,并打开模型选择页
16
- off 隐藏额外选项,停止选择页接管
17
- doctor 检查 profiles、连接和是否需要重启
18
-
19
- 自动读取 CODEX_HOME 下的 *.config.toml,无需重复配置。
20
- 有 model_catalog_json 时使用生效目录,否则继承客户端模型目录。
21
- 权限仍由当前任务管理。其他 Codex 参数原样透传。`;
22
14
 
23
15
  async function main() {
24
16
  startUpdate();
25
17
  const command = args[0];
26
- if (['install', 'uninstall'].includes(command)) throw new Error(`请使用 npm ${command} -g @ailuntz/minilink。`);
27
- if (!command || ['--help', '-h'].includes(command)) return output(help);
28
- if (commands.includes(command) && args.length !== 1) throw new Error(`${command} 不接受额外参数。`);
18
+ if (['install', 'uninstall'].includes(command)) throw new Error(`${language() === 'zh' ? '请使用' : 'Use'} npm ${command} -g @ailuntz/minilink.`);
19
+ if (!command || ['--help', '-h'].includes(command)) return output(text('help'));
20
+ if (commands.includes(command) && args.length !== 1) throw new Error(language() === 'zh' ? `${command} 不接受额外参数。` : `${command} does not accept extra arguments.`);
29
21
  if (command === 'on' || command === 'off') {
30
22
  if (command === 'on') discoverProfiles();
31
23
  atomicJSON(configPath(), { version: 1, enabled: command === 'on' });
32
- return output(command === 'off' ? '已关闭额外选项和选择页接管。已有任务保留当前提供商。' : openPicker(codexHome()) ? '已开启,并打开模型选择页。' : '已开启。请完全退出并重开 Desktop;需要选择页时再次运行 on。');
24
+ return output(text(command));
25
+ }
26
+ if (command === 'link') {
27
+ if (!readConfig().enabled) throw new Error(text('disabled'));
28
+ return output(await selectModel(pickerEndpoints(codexHome())));
33
29
  }
34
30
  if (command === 'doctor') {
35
31
  const profiles = discoverProfiles(), env = childEnvironment(profiles);
@@ -0,0 +1,2 @@
1
+ DEEPSEEK_API_KEY=
2
+ ARK_API_KEY=
@@ -0,0 +1,12 @@
1
+ # 供应商配置
2
+
3
+ 将所需的 `.config.toml` 和 `-models.json` 文件放入 `~/.codex/`,按 `.env.example` 将密钥补充到 `~/.codex/.env`,然后重启 Desktop。
4
+
5
+ | 供应商 | 模型 |
6
+ | --- | --- |
7
+ | DeepSeek | V4.1 Flash、V4 Pro |
8
+ | 豆包 | Seed 2.1 Pro、Seed 2.1 Lite(260915) |
9
+
10
+ MiniLink 的 Chat 模式仅支持文字。
11
+
12
+ 模型核对:2026-09-24。[DeepSeek](https://api-docs.deepseek.com/quick_start/pricing/) · [豆包](https://docs.volcengine.com/docs/ark/model-list?lang=zh)
@@ -0,0 +1,83 @@
1
+ {
2
+ "models": [
3
+ {
4
+ "slug": "deepseek-flash",
5
+ "display_name": "DeepSeek V4.1 Flash",
6
+ "description": "DeepSeek V4.1 Flash",
7
+ "default_reasoning_level": "low",
8
+ "supported_reasoning_levels": [
9
+ {
10
+ "effort": "low",
11
+ "description": "low"
12
+ },
13
+ {
14
+ "effort": "high",
15
+ "description": "high"
16
+ },
17
+ {
18
+ "effort": "max",
19
+ "description": "max"
20
+ }
21
+ ],
22
+ "shell_type": "unified_exec",
23
+ "visibility": "list",
24
+ "supported_in_api": true,
25
+ "priority": 0,
26
+ "upgrade": null,
27
+ "base_instructions": "You are a helpful coding assistant. Follow the user instructions, inspect the workspace when needed, and explain results clearly.",
28
+ "support_verbosity": false,
29
+ "supports_reasoning_summaries": false,
30
+ "default_reasoning_summary": "none",
31
+ "supports_parallel_tool_calls": false,
32
+ "truncation_policy": {
33
+ "mode": "bytes",
34
+ "limit": 10000
35
+ },
36
+ "experimental_supported_tools": [],
37
+ "context_window": 1000000,
38
+ "input_modalities": [
39
+ "text",
40
+ "image"
41
+ ]
42
+ },
43
+ {
44
+ "slug": "deepseek-v4-pro",
45
+ "display_name": "DeepSeek V4 Pro",
46
+ "description": "DeepSeek V4 Pro",
47
+ "default_reasoning_level": "low",
48
+ "supported_reasoning_levels": [
49
+ {
50
+ "effort": "low",
51
+ "description": "low"
52
+ },
53
+ {
54
+ "effort": "high",
55
+ "description": "high"
56
+ },
57
+ {
58
+ "effort": "max",
59
+ "description": "max"
60
+ }
61
+ ],
62
+ "shell_type": "unified_exec",
63
+ "visibility": "list",
64
+ "supported_in_api": true,
65
+ "priority": 0,
66
+ "upgrade": null,
67
+ "base_instructions": "You are a helpful coding assistant. Follow the user instructions, inspect the workspace when needed, and explain results clearly.",
68
+ "support_verbosity": false,
69
+ "supports_reasoning_summaries": false,
70
+ "default_reasoning_summary": "none",
71
+ "supports_parallel_tool_calls": false,
72
+ "truncation_policy": {
73
+ "mode": "bytes",
74
+ "limit": 10000
75
+ },
76
+ "experimental_supported_tools": [],
77
+ "context_window": 1000000,
78
+ "input_modalities": [
79
+ "text"
80
+ ]
81
+ }
82
+ ]
83
+ }
@@ -0,0 +1,9 @@
1
+ model_provider = "deepseek"
2
+ model_catalog_json = "deepseek-models.json"
3
+
4
+ [model_providers.deepseek]
5
+ name = "DeepSeek"
6
+ base_url = "https://api.deepseek.com"
7
+ env_key = "DEEPSEEK_API_KEY"
8
+ wire_api = "responses"
9
+ requires_openai_auth = false
@@ -0,0 +1,68 @@
1
+ {
2
+ "models": [
3
+ {
4
+ "slug": "doubao-seed-2-1-pro-260915",
5
+ "display_name": "Doubao Seed 2.1 Pro",
6
+ "description": "Doubao Seed 2.1 Pro",
7
+ "default_reasoning_level": "low",
8
+ "supported_reasoning_levels": [
9
+ {
10
+ "effort": "low",
11
+ "description": "low"
12
+ }
13
+ ],
14
+ "shell_type": "unified_exec",
15
+ "visibility": "list",
16
+ "supported_in_api": true,
17
+ "priority": 0,
18
+ "upgrade": null,
19
+ "base_instructions": "You are a helpful coding assistant. Follow the user instructions, inspect the workspace when needed, and explain results clearly.",
20
+ "support_verbosity": false,
21
+ "supports_reasoning_summaries": false,
22
+ "default_reasoning_summary": "none",
23
+ "supports_parallel_tool_calls": false,
24
+ "truncation_policy": {
25
+ "mode": "bytes",
26
+ "limit": 10000
27
+ },
28
+ "experimental_supported_tools": [],
29
+ "context_window": 1048576,
30
+ "input_modalities": [
31
+ "text",
32
+ "image"
33
+ ]
34
+ },
35
+ {
36
+ "slug": "doubao-seed-2-1-lite-260915",
37
+ "display_name": "Doubao Seed 2.1 Lite",
38
+ "description": "Doubao Seed 2.1 Lite",
39
+ "default_reasoning_level": "low",
40
+ "supported_reasoning_levels": [
41
+ {
42
+ "effort": "low",
43
+ "description": "low"
44
+ }
45
+ ],
46
+ "shell_type": "unified_exec",
47
+ "visibility": "list",
48
+ "supported_in_api": true,
49
+ "priority": 0,
50
+ "upgrade": null,
51
+ "base_instructions": "You are a helpful coding assistant. Follow the user instructions, inspect the workspace when needed, and explain results clearly.",
52
+ "support_verbosity": false,
53
+ "supports_reasoning_summaries": false,
54
+ "default_reasoning_summary": "none",
55
+ "supports_parallel_tool_calls": false,
56
+ "truncation_policy": {
57
+ "mode": "bytes",
58
+ "limit": 10000
59
+ },
60
+ "experimental_supported_tools": [],
61
+ "context_window": 1048576,
62
+ "input_modalities": [
63
+ "text",
64
+ "image"
65
+ ]
66
+ }
67
+ ]
68
+ }
@@ -0,0 +1,9 @@
1
+ model_provider = "doubao"
2
+ model_catalog_json = "doubao-models.json"
3
+
4
+ [model_providers.doubao]
5
+ name = "Doubao"
6
+ base_url = "https://ark.cn-beijing.volces.com/api/v3"
7
+ env_key = "ARK_API_KEY"
8
+ wire_api = "responses"
9
+ requires_openai_auth = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ailuntz/minilink",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "description": "Use your Codex profiles in the Desktop model menu.",
5
5
  "type": "module",
6
6
  "license": "MIT AND Apache-2.0",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "bin/",
15
15
  "src/",
16
+ "examples/",
16
17
  "README.md",
17
18
  "LICENSE"
18
19
  ],
package/src/picker.mjs CHANGED
@@ -2,27 +2,27 @@ import { createServer } from 'node:http';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { execFileSync } from 'node:child_process';
6
5
  import { activeRuns } from './runtime.mjs';
7
6
  import { VERSION } from './config.mjs';
7
+ import { text } from './terminal.mjs';
8
8
  import { additionalModels } from './model-menu.mjs';
9
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.
10
+ // Keep the selected model when Desktop substitutes its reserve model at quota.
11
+ // A pending choice belongs to the next user task that starts or sends a message.
12
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;
13
+ constructor({ bridge, getConfig, serialize, configRpc, chats = [] } = {}) {
14
+ Object.assign(this, { bridge, getConfig, serialize, configRpc, chats });
15
+ this.pins = new Map(); this.pending = new Map(); this.next = null;
16
16
  }
17
17
  request(message) {
18
- if (!this.getConfig().enabled) { this.pins.clear(); this.starts.clear(); this.next = null; }
18
+ if (!this.getConfig().enabled) { this.pins.clear(); this.pending.clear(); this.next = null; }
19
19
  const params = message?.params;
20
20
  if (!params || !Object.hasOwn(message, 'id')) return message;
21
21
  let pin = this.pins.get(params.threadId);
22
- if (message.method === 'thread/start' && params.threadSource === 'user' && this.next) {
22
+ if (this.next && ((message.method === 'thread/start' && params.threadSource === 'user') || (message.method === 'turn/start' && params.threadId))) {
23
23
  pin = this.next;
24
24
  this.next = null;
25
- this.starts.set(message.id, pin);
25
+ this.pending.set(message.id, { pin, threadId: params.threadId });
26
26
  }
27
27
  if (!pin || !['thread/start', 'thread/resume', 'thread/settings/update', 'turn/settings/update', 'turn/start'].includes(message.method)) return message;
28
28
  const updated = { ...params, model: pin.model, effort: pin.effort };
@@ -31,11 +31,12 @@ export class Picker {
31
31
  return { ...message, params: updated };
32
32
  }
33
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;
34
+ const pending = this.pending.get(message?.id);
35
+ if (!pending || message.method) return;
36
+ const { pin, threadId } = pending;
37
+ this.pending.delete(message.id);
38
+ if (message.error) { if (!this.next) this.next = pin; }
39
+ else if (message.result && (threadId || message.result.thread?.id)) this.pins.set(threadId || message.result.thread.id, pin);
39
40
  }
40
41
  async catalog() {
41
42
  await this.refreshCatalogs?.();
@@ -43,7 +44,7 @@ export class Picker {
43
44
  do {
44
45
  const page = await this.bridge.rpc('model/list', { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) });
45
46
  models.push(...page.data); cursor = page.nextCursor;
46
- if (models.length > 10000) throw new Error('模型目录过大,请重新打开选择页。');
47
+ if (models.length > 10000) throw new Error(text('catalogLarge'));
47
48
  } while (cursor);
48
49
  const config = this.getConfig();
49
50
  return [...models, ...(config.enabled ? additionalModels(models, config) : [])].filter(m => !m.hidden);
@@ -51,76 +52,48 @@ export class Picker {
51
52
  async state() {
52
53
  this.request(null);
53
54
  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,
55
+ const { config } = await this.configRpc('config/read', { includeLayers: false });
56
+ return { next: this.next, selected: { model: config.model, effort: config.model_reasoning_effort }, models: models.map(m => ({ model: m.model, name: m.displayName || m.model,
70
57
  effort: m.defaultReasoningEffort, efforts: m.supportedReasoningEfforts || [] })) };
71
58
  }
72
- async choose({ threadId, model, effort }) {
73
- if (!this.getConfig().enabled) throw new Error('路由已关闭,请先运行 minilink on。');
74
- if (typeof threadId !== 'string') throw new Error('请选择任务。');
59
+ async choose({ model, effort }) {
60
+ if (!this.getConfig().enabled) throw new Error(text('disabled'));
75
61
  const entry = (await this.catalog()).find(m => m.model === model);
76
- if (!entry) throw new Error('此模型已不在原生目录中,请刷新。');
62
+ if (!entry) throw new Error(text('missingModel'));
77
63
  const supported = entry.supportedReasoningEfforts || [];
78
64
  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);
65
+ if (supported.length && !supported.some(e => e.reasoningEffort === effort)) throw new Error(text('invalidEffort'));
66
+ await this.configRpc('config/batchWrite', {
67
+ edits: [
68
+ { keyPath: 'model', value: model, mergeStrategy: 'upsert' },
69
+ { keyPath: 'model_reasoning_effort', value: effort, mergeStrategy: 'upsert' }
70
+ ],
71
+ reloadUserConfig: true
72
+ });
73
+ this.next = { model, effort };
97
74
  return { ok: true };
98
75
  }
99
76
  async listen() {
100
77
  const token = randomBytes(32).toString('hex');
101
78
  let origin;
102
79
  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'; img-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" });
106
- res.end(type === 'application/json' ? JSON.stringify(body) : body);
80
+ const reply = (code, body) => {
81
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
82
+ 'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'" });
83
+ res.end(JSON.stringify(body));
107
84
  };
108
85
  if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) return reply(403, { error: '访问来源不匹配。' });
109
86
  const chat = this.chats.find(c => req.url?.startsWith(c.path + '/'));
110
87
  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: '入口已失效,请重新打开模型选择页。' });
88
+ if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: text('expired') });
116
89
  try {
117
90
  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)) {
91
+ if (req.method === 'POST' && req.url === '/choose') {
119
92
  if (req.headers['content-type'] !== 'application/json') return reply(415, { error: '请求格式不正确。' });
120
93
  let raw = ''; for await (const data of req) { raw += data; if (raw.length > 8192) return reply(413, { error: '请求过大。' }); }
121
94
  const body = JSON.parse(raw);
122
95
  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)));
96
+ return reply(200, await this.serialize(() => this.choose(body)));
124
97
  }
125
98
  reply(404, { error: '没有这个操作。' });
126
99
  } catch (error) { reply(400, { error: error instanceof SyntaxError ? '请求格式不正确。' : error.message }); }
@@ -134,19 +107,13 @@ export class Picker {
134
107
  close() { for (const chat of this.chats) chat.close(); this.server?.closeAllConnections(); this.server?.close(); }
135
108
  }
136
109
 
137
- export function openPicker(home) {
138
- const active = [];
110
+ export function pickerEndpoints(home) {
111
+ const endpoints = [];
139
112
  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;
113
+ try {
114
+ const item = JSON.parse(readFileSync(join(run.directory, `${run.pid}.picker.json`), 'utf8'));
115
+ if (Number.isInteger(item.port) && item.port > 0 && item.port <= 65535 && /^[a-f0-9]{64}$/.test(item.token)) endpoints.push(item);
116
+ } catch { /* A Desktop connection may close while being read. */ }
150
117
  }
151
- return opened;
118
+ return endpoints;
152
119
  }
package/src/proxy.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { setting } from './legacy.mjs';
2
2
  import { runtimeDirectory } from './runtime.mjs';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { join } from 'node:path';
5
6
  import { rmSync, existsSync, readFileSync } from 'node:fs';
@@ -102,7 +103,19 @@ export async function runProxy(args, selfPath) {
102
103
  catch { route.error = `Profile ${route.profile} 的模型目录无法加载,请检查 model_catalog_json。`; }
103
104
  }
104
105
  };
105
- Object.assign(picker, { bridge: switches, menu, getConfig, serialize, refreshCatalogs });
106
+ const configRpc = async (method, params) => {
107
+ // Use the same alias translation and saved provider identity as Desktop.
108
+ const id = `minilink-picker-${randomUUID()}`;
109
+ const request = menu.request({ id, method, params });
110
+ try {
111
+ const result = await switches.rpc(method, request.params);
112
+ return menu.response({ id, result }).result;
113
+ } catch (error) {
114
+ menu.response({ id, error: { message: error.message } });
115
+ throw error;
116
+ }
117
+ };
118
+ Object.assign(picker, { bridge: switches, getConfig, serialize, refreshCatalogs, configRpc });
106
119
  for (const chat of chats) Object.assign(chat, { bridge: switches, menu });
107
120
  present = async message => {
108
121
  picker.response(message);
@@ -114,7 +127,7 @@ export async function runProxy(args, selfPath) {
114
127
  });
115
128
  ready.catch(() => {});
116
129
  try { atomicJSON(pickerPath, { pid: process.pid, ...endpoint }); }
117
- catch { process.stderr.write('minilink: 独立模型选择页启动失败,原生菜单仍可使用。\n'); }
130
+ catch { process.stderr.write('minilink: 终端模型选择入口启动失败,原生菜单仍可使用。\n'); }
118
131
  }
119
132
  const visible = switches.response(message); return visible == null ? null : menu.response(visible);
120
133
  };
@@ -0,0 +1,78 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+
3
+ export const language = (env = process.env) => /^zh(?:[_-]|$)/i.test(env.LC_ALL || env.LC_MESSAGES || env.LANG || '') ? 'zh' : 'en';
4
+ const messages = {
5
+ help: ['minilink\n\n on 开启 MiniLink\n link 选择模型和推理强度\n off 关闭额外选项和模型接管\n doctor 检查配置和运行状态\n\n读取现有 profiles。其他 Codex 参数原样透传。', 'minilink\n\n on Enable MiniLink\n link Select a model and reasoning effort\n off Disable extra models and overrides\n doctor Check configuration and status\n\nUses existing profiles. Other arguments are forwarded to Codex.'],
6
+ on: ['minilink on 已开启,请完全退出并重开 Desktop。', 'MiniLink is enabled. Fully quit and reopen Desktop.'],
7
+ off: ['已关闭。已有任务保留当前提供商。', 'Disabled. Existing tasks keep their current provider.'],
8
+ unavailable: ['未连接到 Desktop。请在运行 Desktop 的电脑上完全退出并重开 Desktop,然后运行 minilink link。', 'No Desktop connection. On the computer hosting Desktop, fully quit and reopen Desktop, then run minilink link.'],
9
+ interactive: ['请在交互终端运行 minilink link;SSH 请使用 ssh -t。', 'Run minilink link in an interactive terminal; use ssh -t over SSH.'],
10
+ multiple: ['检测到多个 Desktop 连接。请只保留一个连接后重试。', 'Multiple Desktop connections are running. Keep one connection open and try again.'],
11
+ model: ['模型', 'Model'], effort: ['推理强度', 'Reasoning effort'],
12
+ prompt: ['输入编号', 'Enter a number'], invalid: ['请输入列表中的编号。', 'Enter a number from the list.'],
13
+ cancelled: ['已取消,未更改模型。', 'Cancelled. Model unchanged.'],
14
+ done: ['已保存到 config.toml。请完全退出并重开 Desktop,让模型菜单加载已保存的选择。', 'Saved to config.toml. Fully quit and reopen Desktop to load the saved selection in the model menu.'],
15
+ empty: ['暂无可用模型,请检查连接后重试。', 'No models available. Check your connection and try again.'],
16
+ disconnected: ['连接已结束,请重新运行 minilink link。', 'Connection closed. Run minilink link again.'],
17
+ expired: ['连接已失效,请重新运行 minilink link。', 'Connection expired. Run minilink link again.'],
18
+ catalogLarge: ['模型目录过大,请检查配置。', 'Model catalog is too large. Check your configuration.'],
19
+ disabled: ['请先运行 minilink on。', 'Run minilink on first.'],
20
+ missingModel: ['模型已不在目录中,请重新选择。', 'Model is no longer available. Select again.'],
21
+ invalidEffort: ['请选择该模型支持的推理强度。', 'Select a supported reasoning effort.'],
22
+ none: ['关闭', 'None'], minimal: ['轻量', 'Minimal'], low: ['低', 'Low'], medium: ['中', 'Medium'], high: ['高', 'High'], xhigh: ['更高', 'Extra high'], ultra: ['极高', 'Ultra'], persistent: ['持续', 'Persistent'], default: ['默认', 'Default']
23
+ };
24
+ export const text = (key, env) => messages[key]?.[language(env) === 'zh' ? 0 : 1] ?? key;
25
+
26
+ export async function selectModel(endpoints, { input = process.stdin, output = process.stdout, env = process.env } = {}) {
27
+ const t = key => text(key, env);
28
+ if (!input.isTTY || !output.isTTY) return t('interactive');
29
+ if (!endpoints.length) return t('unavailable');
30
+ if (endpoints.length !== 1) throw new Error(t('multiple'));
31
+ const { port, token } = endpoints[0];
32
+ const api = async (path, body) => {
33
+ let response;
34
+ try {
35
+ response = await fetch(`http://127.0.0.1:${port}${path}`, {
36
+ method: body ? 'POST' : 'GET', signal: AbortSignal.timeout(15000),
37
+ headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
38
+ ...(body ? { body: JSON.stringify(body) } : {})
39
+ });
40
+ } catch { throw new Error(t('disconnected')); }
41
+ if (response.status === 401) throw new Error(t('expired'));
42
+ const data = await response.json();
43
+ if (!response.ok) throw new Error(data.error || t('disconnected'));
44
+ return data;
45
+ };
46
+ const state = await api('/state');
47
+ if (!state.models.length) throw new Error(t('empty'));
48
+ const preferred = state.next ?? state.selected;
49
+ const rl = createInterface({ input, output });
50
+ const abort = new AbortController();
51
+ rl.on('SIGINT', () => abort.abort());
52
+ rl.on('close', () => abort.abort());
53
+ // Catalog labels are external data; strip terminal control sequences.
54
+ const safe = value => String(value).replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))|[\x00-\x1f\x7f-\x9f]/g, '');
55
+ const select = async (label, options, preferred) => {
56
+ const index = Math.max(0, options.findIndex(item => item.value === preferred));
57
+ output.write(`\n${label}\n${options.map((item, i) => ` ${i + 1}. ${safe(item.label)}${i === index ? ' *' : ''}`).join('\n')}\n`);
58
+ while (!abort.signal.aborted) {
59
+ const answer = (await rl.question(`${t('prompt')} [${index + 1}]: `, { signal: abort.signal })).trim();
60
+ const choice = answer === '' ? index : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
61
+ if (options[choice]) return options[choice].value;
62
+ output.write(t('invalid') + '\n');
63
+ }
64
+ throw new DOMException('Cancelled', 'AbortError');
65
+ };
66
+ try {
67
+ const model = await select(t('model'), state.models.map(m => ({ value: m.model, label: m.name })), preferred?.model);
68
+ const entry = state.models.find(m => m.model === model);
69
+ const efforts = entry.efforts || [];
70
+ const effort = efforts.length > 1 ? await select(t('effort'), efforts.map(e => ({ value: e.reasoningEffort, label: t(e.reasoningEffort) })), preferred?.model === model ? preferred.effort : entry.effort) : efforts[0]?.reasoningEffort ?? null;
71
+ if (abort.signal.aborted) return t('cancelled');
72
+ await api('/choose', { model, effort });
73
+ return t('done');
74
+ } catch (error) {
75
+ if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') return t('cancelled');
76
+ throw error;
77
+ } finally { rl.close(); }
78
+ }
@@ -1,74 +0,0 @@
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 effortNames = { none: '关闭', minimal: '轻量', low: '低', medium: '中', high: '高', xhigh: '更高', ultra: '极高', persistent: '持续', default: '默认' };
7
- const selectedEffort = () => document.querySelector('input[name="effort"]:checked')?.value;
8
- const currentThread = () => state?.threads.find(t => t.id === $('thread').value);
9
- const currentPin = () => currentThread()?.pin || ($('thread').value === 'next' ? state?.next : null);
10
- const say = (text, error = false) => { $('message').textContent = text; $('message').className = `message${error ? ' error' : ''}`; };
11
- async function api(path, body) {
12
- let response;
13
- try { response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); }
14
- catch { throw new Error('连接已结束,请重新运行 minilink on。'); }
15
- if (response.status === 401) throw new Error('入口已失效,请重新运行 minilink on。');
16
- const data = await response.json(); if (!response.ok) throw new Error(data.error || '操作未完成,请重试。'); return data;
17
- }
18
- function fill(select, options, selected) {
19
- select.replaceChildren(...options.map(([value, text]) => { const option = document.createElement('option'); option.value = value; option.textContent = text; return option; }));
20
- if (options.some(([value]) => value === selected)) select.value = selected;
21
- }
22
- function controls() {
23
- const thread = currentThread(), available = Boolean(state?.models.length);
24
- $('form').setAttribute('aria-busy', String(busy));
25
- $('refresh').disabled = busy; $('refresh').dataset.loading = String(busy);
26
- $('thread').disabled = busy || !state;
27
- $('model').disabled = busy || !available;
28
- for (const input of $('effort').querySelectorAll('input')) input.disabled = busy || !available;
29
- $('apply').disabled = busy || !available || Boolean(thread && thread.status !== 'idle');
30
- $('release').disabled = busy || !currentPin();
31
- }
32
- function changeModel(preferred) {
33
- const model = state.models.find(m => m.model === $('model').value);
34
- const values = model?.efforts?.length ? model.efforts.map(e => e.reasoningEffort) : ['default'];
35
- const candidate = preferred ?? model?.effort, selected = values.includes(candidate) ? candidate : values[0];
36
- if (values.length > 5) $('effort').dataset.columns = values.length > 6 ? '4' : '3';
37
- else delete $('effort').dataset.columns;
38
- $('effort').replaceChildren(...values.map(value => {
39
- const label = document.createElement('label'), input = document.createElement('input');
40
- label.className = 'segment'; input.type = 'radio'; input.name = 'effort'; input.value = value; input.checked = value === selected;
41
- label.append(input, document.createTextNode(effortNames[value] || value)); return label;
42
- }));
43
- controls();
44
- }
45
- function changeThread(draft) {
46
- const thread = currentThread(), pin = currentPin();
47
- $('details').textContent = thread ? thread.status !== 'idle' ? '任务正在运行,完成后可切换。' : `${thread.cwd?.split('/').filter(Boolean).pop() || '当前任务'} · ${pin ? '由此页管理' : '由 Desktop 管理'}` : pin ? '已为下一个新任务设置模型。' : '';
48
- $('details').title = thread?.cwd || '';
49
- fill($('model'), state.models.length ? state.models.map(m => [m.model, m.name]) : [['', '暂无可用模型']], draft?.model || pin?.model || thread?.model);
50
- changeModel(draft?.effort ?? pin?.effort);
51
- }
52
- async function refresh(preserve = false) {
53
- const selected = $('thread').value, draft = preserve && { model: $('model').value, effort: selectedEffort() };
54
- state = await api('/state');
55
- fill($('thread'), [['next', '下一个新任务'], ...state.threads.map(t => [t.id, t.name.slice(0, 90)])], selected);
56
- changeThread($('thread').value === selected ? draft : null);
57
- if (!state.models.length) say('暂无可用模型,请检查连接后刷新。', true);
58
- }
59
- async function action(operation) {
60
- if (busy) return;
61
- busy = true; controls(); say('');
62
- try { await operation(); } catch (error) { say(error.message || '操作未完成,请重试。', true); }
63
- finally { busy = false; controls(); }
64
- }
65
- $('thread').onchange = () => { changeThread(); say(''); };
66
- $('model').onchange = () => { changeModel(); say(''); };
67
- $('refresh').onclick = () => action(() => refresh(true));
68
- $('form').onsubmit = event => { event.preventDefault(); action(async () => {
69
- const effort = selectedEffort();
70
- await api('/choose', { threadId: $('thread').value, model: $('model').value, effort: effort === 'default' ? null : effort });
71
- await refresh(); say('已设置,回到 Desktop 继续。');
72
- }); };
73
- $('release').onclick = () => action(async () => { await api('/release', { threadId: $('thread').value }); await refresh(); say('已交回 Desktop。'); });
74
- action(refresh);
package/src/picker.html DELETED
@@ -1,39 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <meta name="color-scheme" content="light dark">
7
- <title>选择模型 · minilink</title>
8
- <link rel="icon" href="data:,">
9
- <style>
10
- :root{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#1d1d1f;background:#f5f5f7;color-scheme:light dark;--surface:#fff;--control:#f5f5f7;--line:#e8e8ed;--muted:#6e6e73;--accent:#007aff;--focus:#007aff55;--selected:#fff;--error:#c9342e;--success:#248a3d}
11
- *{box-sizing:border-box}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:40px 20px;-webkit-font-smoothing:antialiased}main{width:min(100%,520px)}.panel{padding:26px 28px 22px;background:var(--surface);border:1px solid var(--line);border-radius:16px;box-shadow:0 4px 24px #00000004}.toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.brand{font-size:19px;font-weight:650;letter-spacing:-.6px}button,select,input{font:inherit}button{cursor:pointer}button:disabled{cursor:default}button,select{transition:background .15s,box-shadow .15s,opacity .15s}button:focus-visible,select:focus-visible,.segment:has(input:focus-visible){outline:3px solid var(--focus);outline-offset:3px}.refresh{width:30px;height:30px;display:grid;place-items:center;padding:0;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:var(--surface)}.refresh:hover:not(:disabled){background:var(--control);color:inherit}.refresh svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.refresh[data-loading=true] svg{animation:rotate 1s linear infinite}h1{margin:0;font-size:22px;line-height:1.3;font-weight:650;letter-spacing:-.6px}.subtitle{margin:7px 0 22px;color:var(--muted);font-size:13px}.fields{border-top:1px solid var(--line)}.field{padding:15px 0;border-bottom:1px solid var(--line)}.field>label,.effort-title{display:block;margin-bottom:8px;font-size:13px;font-weight:600}.select-wrap{position:relative}.select-wrap svg{position:absolute;right:12px;top:11px;width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}select{appearance:none;-webkit-appearance:none;display:block;width:100%;min-width:0;height:38px;padding:0 34px 0 12px;border:1px solid var(--line);border-radius:8px;background:var(--control);color:inherit;text-overflow:ellipsis}select:disabled{opacity:.5}.details{margin:9px 1px 0;font-size:12px;color:var(--muted);overflow-wrap:anywhere}.details:empty{display:none}.effort-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:62px}.effort-title{margin:0;flex-shrink:0}.segments{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:2px;padding:3px;border-radius:9px;background:var(--control);max-width:100%;min-width:156px}.segments[data-columns]{display:grid;min-width:198px;grid-template-columns:repeat(4,minmax(0,1fr))}.segments[data-columns="3"]{grid-template-columns:repeat(3,minmax(0,1fr))}.segments[data-columns] .segment{min-width:0;padding-inline:7px}.segment{position:relative;display:flex;align-items:center;justify-content:center;min-height:28px;min-width:46px;flex:1;padding:3px 10px;border-radius:6px;color:var(--muted);font-size:12px;white-space:nowrap;cursor:pointer}.segment input{position:absolute;opacity:0;width:1px;height:1px}.segment:has(input:checked){background:var(--selected);color:#1d1d1f;box-shadow:0 1px 4px #00000012}.segment:has(input:disabled){cursor:default;opacity:.5}.actions{padding-top:22px}.apply{width:100%;min-height:38px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:13px;font-weight:600}.apply:hover:not(:disabled){background:#0071e3}.apply:active:not(:disabled){background:#0065cc}.apply:disabled{opacity:.4}.release{display:block;margin:12px auto 0;padding:5px 12px;border:0;border-radius:6px;background:transparent;color:var(--muted);font-size:12px}.release:hover:not(:disabled){color:var(--accent);background:var(--control)}.release:disabled{opacity:.45}.message{margin:14px 0 0;font-size:12px;color:var(--success);text-align:center;overflow-wrap:anywhere}.message:empty{display:none}.message.error{color:var(--error)}footer{margin-top:16px;text-align:center;color:var(--muted);font-size:12px}footer p{margin:0}
12
- @keyframes rotate{to{transform:rotate(360deg)}}
13
- @media(max-width:480px){body{padding:24px 16px}.panel{padding:22px 20px 18px}.toolbar{margin-bottom:22px}.effort-row{gap:10px;flex-wrap:wrap}.segments{min-width:144px}.segment{min-width:38px;padding-inline:8px}}
14
- @media(prefers-color-scheme:dark){:root{background:#161618;color:#f5f5f7;--surface:#242426;--control:#303033;--line:#3a3a3d;--muted:#a1a1a6;--accent:#0a84ff;--focus:#0a84ff77;--selected:#56565b;--error:#ff6961;--success:#6bd68b}.panel{box-shadow:0 8px 32px #00000016}.segment:has(input:checked){color:#fff}.apply:hover:not(:disabled){background:#2994ff}}
15
- @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
16
- </style>
17
- </head>
18
- <body>
19
- <main>
20
- <section class="panel" aria-labelledby="title">
21
- <header>
22
- <div class="toolbar"><span class="brand">minilink</span><button id="refresh" class="refresh" type="button" aria-label="刷新" title="刷新" disabled><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 7A6.3 6.3 0 1 0 16 13M16 3v4h-4"/></svg></button></div>
23
- <h1 id="title">选择模型</h1><p class="subtitle">为下一条回复选择模型。</p>
24
- </header>
25
- <form id="form" aria-busy="true">
26
- <div class="fields">
27
- <div class="field"><label for="thread">任务</label><div class="select-wrap"><select id="thread" aria-describedby="details" required disabled><option>正在连接…</option></select><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></div><p id="details" class="details"></p></div>
28
- <div class="field"><label for="model">模型</label><div class="select-wrap"><select id="model" required disabled><option>正在读取模型…</option></select><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></div></div>
29
- <div class="field effort-row"><span id="effort-label" class="effort-title">推理强度</span><div id="effort" class="segments" role="radiogroup" aria-labelledby="effort-label"></div></div>
30
- </div>
31
- <div class="actions"><button id="apply" class="apply" type="submit" disabled>使用此模型</button><button id="release" class="release" type="button" disabled>交回 Desktop</button></div>
32
- <p id="message" class="message" role="status" aria-live="polite"></p>
33
- </form>
34
- </section>
35
- <footer><p>设置后,回到 Desktop 继续。</p></footer>
36
- </main>
37
- <script src="/picker.js"></script>
38
- </body>
39
- </html>