@ailuntz/minilink 0.8.0 → 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 +5 -3
- package/bin/minilink.mjs +6 -14
- package/package.json +1 -1
- package/src/picker.mjs +36 -77
- package/src/proxy.mjs +2 -2
- package/src/terminal.mjs +76 -0
- package/src/picker-client.js +0 -49
- package/src/picker.html +0 -8
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
|
-
|
|
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 {
|
|
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(
|
|
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' ? '
|
|
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
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
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
14
|
-
Object.assign(this, { bridge,
|
|
15
|
-
this.pins = new Map(); this.
|
|
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.
|
|
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' &&
|
|
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.
|
|
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
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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({
|
|
73
|
-
if (!this.getConfig().enabled) throw new Error('
|
|
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
|
-
|
|
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
|
|
104
|
-
res.writeHead(code, { 'content-type':
|
|
105
|
-
'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none';
|
|
106
|
-
res.end(
|
|
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.
|
|
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' &&
|
|
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(() =>
|
|
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
|
|
138
|
-
const
|
|
102
|
+
export function pickerEndpoints(home) {
|
|
103
|
+
const endpoints = [];
|
|
139
104
|
for (const run of activeRuns(home)) if (run.version === VERSION) {
|
|
140
|
-
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
};
|
package/src/terminal.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/picker-client.js
DELETED
|
@@ -1,49 +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 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);
|
package/src/picker.html
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
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>
|