@ailuntz/minilink 0.8.1 → 0.9.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.md CHANGED
@@ -20,11 +20,13 @@ minilink on
20
20
 
21
21
  | 命令 | 用途 |
22
22
  | --- | --- |
23
- | `on` | 开启模型选项,打开模型选择页 |
24
- | `off` | 关闭追加选项和选择页接管 |
23
+ | `on` | 开启并选择模型和推理强度 |
24
+ | `off` | 关闭追加选项和模型接管 |
25
25
  | `doctor` | 检查配置、版本和运行状态 |
26
26
 
27
- 额度提示挡住菜单时,用 `on` 打开选择页切换。选择持续到交回菜单、执行 `off` 或退出 Desktop。
27
+ `on` 直接在终端选择模型和推理强度,用于下一次发送消息的任务;无需网页或选择任务。额度提示挡住菜单时同样可用。选择持续到执行 `off` 或退出 Desktop。
28
+
29
+ 界面跟随终端语言(中文 / English)。支持 SSH 连接到运行 Desktop 的电脑;请使用 `ssh -t`。不带终端时只开启路由,不弹出选择。没有运行 Desktop 的服务器无法控制桌面模型菜单。
28
30
 
29
31
  启动时后台检查更新,每天最多一次。有新版便静默下载安装,断网或失败则继续使用旧版。新版及 profiles、连接、密钥的修改在重启 Desktop 后生效。`MINILINK_NO_UPDATE_CHECK=1` 可关闭自动更新。
30
32
 
package/bin/minilink.mjs CHANGED
@@ -4,32 +4,24 @@ 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
12
  const commands = ['on', '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(command === 'off' ? text('off') : await selectModel(pickerEndpoints(codexHome())));
33
25
  }
34
26
  if (command === 'doctor') {
35
27
  const profiles = discoverProfiles(), env = childEnvironment(profiles);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ailuntz/minilink",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Use your Codex profiles in the Desktop model menu.",
5
5
  "type": "module",
6
6
  "license": "MIT AND Apache-2.0",
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, chats = [] } = {}) {
14
+ Object.assign(this, { bridge, getConfig, serialize, 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,40 @@ 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
+ return { next: this.next, models: models.map(m => ({ model: m.model, name: m.displayName || m.model,
70
56
  effort: m.defaultReasoningEffort, efforts: m.supportedReasoningEfforts || [] })) };
71
57
  }
72
- async choose({ threadId, model, effort }) {
73
- if (!this.getConfig().enabled) throw new Error('路由已关闭,请先运行 minilink on。');
74
- if (typeof threadId !== 'string') throw new Error('请选择任务。');
58
+ async choose({ model, effort }) {
59
+ if (!this.getConfig().enabled) throw new Error(text('disabled'));
75
60
  const entry = (await this.catalog()).find(m => m.model === model);
76
- if (!entry) throw new Error('此模型已不在原生目录中,请刷新。');
61
+ if (!entry) throw new Error(text('missingModel'));
77
62
  const supported = entry.supportedReasoningEfforts || [];
78
63
  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);
64
+ if (supported.length && !supported.some(e => e.reasoningEffort === effort)) throw new Error(text('invalidEffort'));
65
+ this.next = { model, effort };
97
66
  return { ok: true };
98
67
  }
99
68
  async listen() {
100
69
  const token = randomBytes(32).toString('hex');
101
70
  let origin;
102
71
  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);
72
+ const reply = (code, body) => {
73
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff',
74
+ 'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'" });
75
+ res.end(JSON.stringify(body));
107
76
  };
108
77
  if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin)) return reply(403, { error: '访问来源不匹配。' });
109
78
  const chat = this.chats.find(c => req.url?.startsWith(c.path + '/'));
110
79
  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: '入口已失效,请重新打开模型选择页。' });
80
+ if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: text('expired') });
116
81
  try {
117
82
  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)) {
83
+ if (req.method === 'POST' && req.url === '/choose') {
119
84
  if (req.headers['content-type'] !== 'application/json') return reply(415, { error: '请求格式不正确。' });
120
85
  let raw = ''; for await (const data of req) { raw += data; if (raw.length > 8192) return reply(413, { error: '请求过大。' }); }
121
86
  const body = JSON.parse(raw);
122
87
  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)));
88
+ return reply(200, await this.serialize(() => this.choose(body)));
124
89
  }
125
90
  reply(404, { error: '没有这个操作。' });
126
91
  } catch (error) { reply(400, { error: error instanceof SyntaxError ? '请求格式不正确。' : error.message }); }
@@ -134,19 +99,13 @@ export class Picker {
134
99
  close() { for (const chat of this.chats) chat.close(); this.server?.closeAllConnections(); this.server?.close(); }
135
100
  }
136
101
 
137
- export function openPicker(home) {
138
- const active = [];
102
+ export function pickerEndpoints(home) {
103
+ const endpoints = [];
139
104
  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;
105
+ try {
106
+ const item = JSON.parse(readFileSync(join(run.directory, `${run.pid}.picker.json`), 'utf8'));
107
+ if (Number.isInteger(item.port) && item.port > 0 && item.port <= 65535 && /^[a-f0-9]{64}$/.test(item.token)) endpoints.push(item);
108
+ } catch { /* A Desktop connection may close while being read. */ }
150
109
  }
151
- return opened;
110
+ return endpoints;
152
111
  }
package/src/proxy.mjs CHANGED
@@ -102,7 +102,7 @@ export async function runProxy(args, selfPath) {
102
102
  catch { route.error = `Profile ${route.profile} 的模型目录无法加载,请检查 model_catalog_json。`; }
103
103
  }
104
104
  };
105
- Object.assign(picker, { bridge: switches, menu, getConfig, serialize, refreshCatalogs });
105
+ Object.assign(picker, { bridge: switches, getConfig, serialize, refreshCatalogs });
106
106
  for (const chat of chats) Object.assign(chat, { bridge: switches, menu });
107
107
  present = async message => {
108
108
  picker.response(message);
@@ -114,7 +114,7 @@ export async function runProxy(args, selfPath) {
114
114
  });
115
115
  ready.catch(() => {});
116
116
  try { atomicJSON(pickerPath, { pid: process.pid, ...endpoint }); }
117
- catch { process.stderr.write('minilink: 独立模型选择页启动失败,原生菜单仍可使用。\n'); }
117
+ catch { process.stderr.write('minilink: 终端模型选择入口启动失败,原生菜单仍可使用。\n'); }
118
118
  }
119
119
  const visible = switches.response(message); return visible == null ? null : menu.response(visible);
120
120
  };
@@ -0,0 +1,76 @@
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 开启并选择模型\n off 关闭额外选项和模型接管\n doctor 检查配置和运行状态\n\n读取现有 profiles。其他 Codex 参数原样透传。', 'minilink\n\n on Enable and select a model\n off Disable extra models and overrides\n doctor Check configuration and status\n\nUses existing profiles. Other arguments are forwarded to Codex.'],
6
+ off: ['已关闭。已有任务保留当前提供商。', 'Disabled. Existing tasks keep their current provider.'],
7
+ unavailable: ['已开启。请在运行 Desktop 的电脑上使用;完全退出并重开 Desktop 后,再运行 minilink on。', 'Enabled. Run this on the computer hosting Desktop. Quit and reopen Desktop, then run minilink on again.'],
8
+ interactive: ['已开启。请在交互终端运行 minilink on;SSH 请使用 ssh -t。', 'Enabled. Run minilink on in an interactive terminal; use ssh -t over SSH.'],
9
+ multiple: ['检测到多个 Desktop 连接。请只保留一个连接后重试。', 'Multiple Desktop connections are running. Keep one connection open and try again.'],
10
+ model: ['模型', 'Model'], effort: ['推理强度', 'Reasoning effort'],
11
+ prompt: ['输入编号', 'Enter a number'], invalid: ['请输入列表中的编号。', 'Enter a number from the list.'],
12
+ cancelled: ['已取消,未更改模型。', 'Cancelled. Model unchanged.'],
13
+ done: ['已选择。回到 Desktop 发送下一条消息即可生效。', 'Selected. Return to Desktop and send your next message to apply.'],
14
+ empty: ['暂无可用模型,请检查连接后重试。', 'No models available. Check your connection and try again.'],
15
+ disconnected: ['连接已结束,请重新运行 minilink on。', 'Connection closed. Run minilink on again.'],
16
+ expired: ['连接已失效,请重新运行 minilink on。', 'Connection expired. Run minilink on again.'],
17
+ catalogLarge: ['模型目录过大,请检查配置。', 'Model catalog is too large. Check your configuration.'],
18
+ disabled: ['请先运行 minilink on。', 'Run minilink on first.'],
19
+ missingModel: ['模型已不在目录中,请重新选择。', 'Model is no longer available. Select again.'],
20
+ invalidEffort: ['请选择该模型支持的推理强度。', 'Select a supported reasoning effort.'],
21
+ none: ['关闭', 'None'], minimal: ['轻量', 'Minimal'], low: ['低', 'Low'], medium: ['中', 'Medium'], high: ['高', 'High'], xhigh: ['更高', 'Extra high'], ultra: ['极高', 'Ultra'], persistent: ['持续', 'Persistent'], default: ['默认', 'Default']
22
+ };
23
+ export const text = (key, env) => messages[key]?.[language(env) === 'zh' ? 0 : 1] ?? key;
24
+
25
+ export async function selectModel(endpoints, { input = process.stdin, output = process.stdout, env = process.env } = {}) {
26
+ const t = key => text(key, env);
27
+ if (!input.isTTY || !output.isTTY) return t('interactive');
28
+ if (!endpoints.length) return t('unavailable');
29
+ if (endpoints.length !== 1) throw new Error(t('multiple'));
30
+ const { port, token } = endpoints[0];
31
+ const api = async (path, body) => {
32
+ let response;
33
+ try {
34
+ response = await fetch(`http://127.0.0.1:${port}${path}`, {
35
+ method: body ? 'POST' : 'GET', signal: AbortSignal.timeout(15000),
36
+ headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
37
+ ...(body ? { body: JSON.stringify(body) } : {})
38
+ });
39
+ } catch { throw new Error(t('disconnected')); }
40
+ if (response.status === 401) throw new Error(t('expired'));
41
+ const data = await response.json();
42
+ if (!response.ok) throw new Error(data.error || t('disconnected'));
43
+ return data;
44
+ };
45
+ const state = await api('/state');
46
+ if (!state.models.length) throw new Error(t('empty'));
47
+ const rl = createInterface({ input, output });
48
+ const abort = new AbortController();
49
+ rl.on('SIGINT', () => abort.abort());
50
+ rl.on('close', () => abort.abort());
51
+ // Catalog labels are external data; strip terminal control sequences.
52
+ const safe = value => String(value).replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))|[\x00-\x1f\x7f-\x9f]/g, '');
53
+ const select = async (label, options, preferred) => {
54
+ const index = Math.max(0, options.findIndex(item => item.value === preferred));
55
+ output.write(`\n${label}\n${options.map((item, i) => ` ${i + 1}. ${safe(item.label)}${i === index ? ' *' : ''}`).join('\n')}\n`);
56
+ while (!abort.signal.aborted) {
57
+ const answer = (await rl.question(`${t('prompt')} [${index + 1}]: `, { signal: abort.signal })).trim();
58
+ const choice = answer === '' ? index : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
59
+ if (options[choice]) return options[choice].value;
60
+ output.write(t('invalid') + '\n');
61
+ }
62
+ throw new DOMException('Cancelled', 'AbortError');
63
+ };
64
+ try {
65
+ const model = await select(t('model'), state.models.map(m => ({ value: m.model, label: m.name })), state.next?.model);
66
+ const entry = state.models.find(m => m.model === model);
67
+ const efforts = entry.efforts || [];
68
+ const effort = efforts.length > 1 ? await select(t('effort'), efforts.map(e => ({ value: e.reasoningEffort, label: t(e.reasoningEffort) })), state.next?.model === model ? state.next.effort : entry.effort) : efforts[0]?.reasoningEffort ?? null;
69
+ if (abort.signal.aborted) return t('cancelled');
70
+ await api('/choose', { model, effort });
71
+ return t('done');
72
+ } catch (error) {
73
+ if (error.name === 'AbortError' || error.code === 'ERR_USE_AFTER_CLOSE') return t('cancelled');
74
+ throw error;
75
+ } finally { rl.close(); }
76
+ }
@@ -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>