@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 +17 -22
- package/bin/minilink.mjs +11 -15
- package/examples/providers/.env.example +2 -0
- package/examples/providers/README.md +12 -0
- package/examples/providers/deepseek-models.json +83 -0
- package/examples/providers/deepseek.config.toml +9 -0
- package/examples/providers/doubao-models.json +68 -0
- package/examples/providers/doubao.config.toml +9 -0
- package/package.json +2 -1
- package/src/picker.mjs +44 -77
- package/src/proxy.mjs +15 -2
- package/src/terminal.mjs +78 -0
- package/src/picker-client.js +0 -74
- package/src/picker.html +0 -39
package/README.md
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MiniLink
|
|
2
2
|
|
|
3
|
-
在 ChatGPT / Codex Desktop
|
|
4
|
-
|
|
5
|
-
读取 `~/.codex/*.config.toml`:有 `model_catalog_json` 就使用该目录,否则继承原生模型列表。支持 API key 的 Responses 连接还会追加 `-Chat`,用于不带工具和工作区上下文的纯文字聊天。
|
|
3
|
+
在 ChatGPT / Codex Desktop 的原生模型菜单中使用自定义模型。
|
|
6
4
|
|
|
7
5
|
## 安装
|
|
8
6
|
|
|
9
|
-
macOS
|
|
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
|
-
|
|
18
|
-
minilink on
|
|
19
|
-
```
|
|
16
|
+
## 命令
|
|
20
17
|
|
|
21
18
|
| 命令 | 用途 |
|
|
22
19
|
| --- | --- |
|
|
23
|
-
| `on` |
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
20
|
+
| `minilink on` | 开启 |
|
|
21
|
+
| `minilink link` | 选择模型与推理强度 |
|
|
22
|
+
| `minilink off` | 关闭 |
|
|
23
|
+
| `minilink doctor` | 检查配置与运行状态 |
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
## 配置
|
|
28
26
|
|
|
29
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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,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,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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ailuntz/minilink",
|
|
3
|
-
"version": "0.
|
|
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
|
-
//
|
|
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, 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.
|
|
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,48 @@ export class Picker {
|
|
|
51
52
|
async state() {
|
|
52
53
|
this.request(null);
|
|
53
54
|
const models = await this.catalog();
|
|
54
|
-
const
|
|
55
|
-
|
|
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({
|
|
73
|
-
if (!this.getConfig().enabled) throw new Error('
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
104
|
-
res.writeHead(code, { 'content-type':
|
|
105
|
-
'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none';
|
|
106
|
-
res.end(
|
|
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.
|
|
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' &&
|
|
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(() =>
|
|
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
|
|
138
|
-
const
|
|
110
|
+
export function pickerEndpoints(home) {
|
|
111
|
+
const endpoints = [];
|
|
139
112
|
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;
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
};
|
package/src/terminal.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/picker-client.js
DELETED
|
@@ -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>
|