mocode-ai 1.6.4 → 1.6.5
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 +376 -365
- package/README.zh-CN.md +13 -2
- package/dist/agent/model-turn.js +14 -1
- package/dist/agent/run-coordinator.js +5 -0
- package/dist/agent/spawn.js +38 -5
- package/dist/agent/stages/model-runner.js +1 -1
- package/dist/agent/stages/tool-dispatcher.js +1 -0
- package/dist/agent/work-discipline.js +3 -1
- package/dist/config/index.js +52 -2
- package/dist/config/presets.js +40 -1
- package/dist/config/profiles.js +9 -1
- package/dist/context/clearing.js +53 -0
- package/dist/context/relevance.js +6 -0
- package/dist/context/text-search.js +113 -0
- package/dist/llm/index.js +35 -8
- package/dist/llm/providers/anthropic.js +14 -4
- package/dist/llm/reasoning.js +141 -0
- package/dist/memory/reflect.js +1 -1
- package/dist/memory/store.js +31 -28
- package/dist/models/budgets.js +11 -0
- package/dist/models/catalog.js +130 -0
- package/dist/models/map-preset.js +50 -0
- package/dist/models/model-panel.js +227 -0
- package/dist/models/protocol.js +44 -0
- package/dist/models/reasoning-cap.js +88 -0
- package/dist/models/search.js +58 -0
- package/dist/models/types.js +8 -0
- package/dist/repl/commands/effort.js +81 -0
- package/dist/repl/commands/model-actions.js +138 -0
- package/dist/repl/commands/model-catalog.js +67 -0
- package/dist/repl/commands/model-panel-ui.js +107 -0
- package/dist/repl/commands/model.js +17 -0
- package/dist/repl/commands/registry.js +6 -0
- package/dist/repl/commands/session.js +54 -0
- package/dist/repl/commands/stats.js +28 -0
- package/dist/repl/commands.js +3 -0
- package/dist/repl/runtime.js +5 -1
- package/dist/repl/status-bar.js +4 -2
- package/dist/rollback/store.js +17 -0
- package/dist/runtime/runtime.js +31 -1
- package/dist/session/compact.js +84 -3
- package/dist/session/index.js +2 -0
- package/dist/session/retention.js +297 -0
- package/dist/session/scheduler.js +30 -3
- package/dist/session/store.js +108 -6
- package/dist/session/usage-stats.js +90 -0
- package/dist/skills/activation.js +2 -0
- package/dist/skills/builtin-skills.js +153 -0
- package/dist/skills/discover.js +3 -1
- package/dist/skills/runner.js +4 -1
- package/dist/tools/builtins/index.js +4 -0
- package/dist/tools/builtins/read-file.js +19 -1
- package/dist/tools/builtins/session-search.js +33 -0
- package/dist/tools/builtins/task.js +2 -0
- package/dist/tools/constants.js +8 -0
- package/dist/tools/read-dedup.js +62 -0
- package/dist/tools/tool-runtime.js +1 -0
- package/dist/ui/batch.js +109 -103
- package/dist/ui/fuzzy-picker.js +227 -0
- package/dist/ui/hierarchical-picker.js +302 -0
- package/dist/ui/intervention.js +11 -1
- package/dist/ui/layout-internal/statusbar.js +18 -7
- package/dist/ui/render.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { classifyProvider, resolveBaseURL, toPresetProvider } from './protocol.js';
|
|
2
|
+
/** 上下文窗口缺失/为 0 时的保守默认(宁可小,避免超长发出去被拒)。 */
|
|
3
|
+
export const FALLBACK_CONTEXT_WINDOW = 128000;
|
|
4
|
+
/**
|
|
5
|
+
* 按 provider.env 声明的变量名顺序找一个已设置的 key;都没有返回 null。
|
|
6
|
+
* 读 process.env 是本模块唯一触碰环境的地方,调用方可传 env 覆盖以便测试。
|
|
7
|
+
*/
|
|
8
|
+
export function resolveApiKey(provider, env = process.env) {
|
|
9
|
+
for (const keyName of provider.env ?? []) {
|
|
10
|
+
const v = env[keyName];
|
|
11
|
+
if (typeof v === 'string' && v.trim())
|
|
12
|
+
return v.trim();
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** 把目录来源拼成合法预设名:`<provider>-<model>` 去非法字符。 */
|
|
17
|
+
export function defaultPresetName(providerId, modelId) {
|
|
18
|
+
const raw = `${providerId}-${modelId}`;
|
|
19
|
+
const sanitized = raw
|
|
20
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.slice(0, 32) || 'preset';
|
|
23
|
+
return sanitized;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 产出可直接 savePreset 的对象。provider 不被现有 fetch 支持时返回 null
|
|
27
|
+
* (命令层据此置灰/拒绝,避免存一个必错的预设)。
|
|
28
|
+
*/
|
|
29
|
+
export function buildPreset(input) {
|
|
30
|
+
const { provider, model } = input;
|
|
31
|
+
const proto = classifyProvider(provider);
|
|
32
|
+
const presetProvider = toPresetProvider(proto);
|
|
33
|
+
if (!presetProvider)
|
|
34
|
+
return null;
|
|
35
|
+
const baseURL = resolveBaseURL(provider);
|
|
36
|
+
if (!baseURL)
|
|
37
|
+
return null;
|
|
38
|
+
const contextWindow = typeof model.limit?.context === 'number' && model.limit.context > 0
|
|
39
|
+
? Math.floor(model.limit.context)
|
|
40
|
+
: FALLBACK_CONTEXT_WINDOW;
|
|
41
|
+
return {
|
|
42
|
+
name: input.name ?? defaultPresetName(provider.id, model.id),
|
|
43
|
+
provider: presetProvider,
|
|
44
|
+
baseURL,
|
|
45
|
+
apiKey: input.apiKey,
|
|
46
|
+
model: input.modelOverride ?? model.id,
|
|
47
|
+
contextWindow,
|
|
48
|
+
anthropicPromptCache: presetProvider === 'anthropic',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { classifyProvider, resolveBaseURL } from './protocol.js';
|
|
2
|
+
function fmtK(n) {
|
|
3
|
+
if (!n || n <= 0)
|
|
4
|
+
return '-';
|
|
5
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
6
|
+
}
|
|
7
|
+
/** 该预设是否与当前生效配置一致(用于标 ★)。 */
|
|
8
|
+
export function isPresetCurrent(p, cur) {
|
|
9
|
+
return (p.provider === cur.provider &&
|
|
10
|
+
p.baseURL === cur.baseURL &&
|
|
11
|
+
p.model === cur.model &&
|
|
12
|
+
p.contextWindow === cur.contextWindowTokens);
|
|
13
|
+
}
|
|
14
|
+
/** 组装统一面板的全部行(顺序:当前预设 → 其他预设 → 目录模型 → 操作行)。 */
|
|
15
|
+
export function buildPanelEntries(input) {
|
|
16
|
+
const rows = [];
|
|
17
|
+
// 已存预设:当前的排最前。
|
|
18
|
+
const withCurrent = input.presets.map((p) => ({
|
|
19
|
+
p,
|
|
20
|
+
cur: isPresetCurrent(p, input.current),
|
|
21
|
+
}));
|
|
22
|
+
withCurrent.sort((a, b) => Number(b.cur) - Number(a.cur) || a.p.name.localeCompare(b.p.name));
|
|
23
|
+
for (const { p, cur } of withCurrent) {
|
|
24
|
+
rows.push({
|
|
25
|
+
choice: { kind: 'preset', preset: p },
|
|
26
|
+
searchText: `${p.name} ${p.model} ${p.baseURL} ${p.provider}`,
|
|
27
|
+
label: `${cur ? '★ ' : ''}${p.name}`,
|
|
28
|
+
detail: `${p.model} · ${p.provider}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// 目录模型:跳过不支持的、以及与某预设重复的。
|
|
32
|
+
const presetKeys = new Set(input.presets.map((p) => `${p.baseURL}|${p.model}`));
|
|
33
|
+
for (const e of input.catalog) {
|
|
34
|
+
if (!e.supported)
|
|
35
|
+
continue;
|
|
36
|
+
const provider = input.providers[e.providerId];
|
|
37
|
+
if (!provider)
|
|
38
|
+
continue;
|
|
39
|
+
if (classifyProvider(provider) === 'unsupported')
|
|
40
|
+
continue;
|
|
41
|
+
if (presetKeys.has(`${provider.api ?? ''}|${e.modelId}`))
|
|
42
|
+
continue;
|
|
43
|
+
const c = e.model.cost;
|
|
44
|
+
const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('·');
|
|
47
|
+
rows.push({
|
|
48
|
+
choice: { kind: 'catalog', entry: e },
|
|
49
|
+
searchText: `${e.model.name ?? ''} ${e.modelId}`,
|
|
50
|
+
label: e.model.name || e.modelId,
|
|
51
|
+
detail: [
|
|
52
|
+
e.providerName,
|
|
53
|
+
`${fmtK(e.model.limit?.context)} ctx`,
|
|
54
|
+
c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '',
|
|
55
|
+
caps,
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(' · '),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// 操作行。
|
|
62
|
+
rows.push({
|
|
63
|
+
choice: { kind: 'action', action: 'custom' },
|
|
64
|
+
searchText: '自定义 手动填写 custom baseURL model key',
|
|
65
|
+
label: '+ 自定义模型(手动填写)',
|
|
66
|
+
detail: '',
|
|
67
|
+
});
|
|
68
|
+
rows.push({
|
|
69
|
+
choice: { kind: 'action', action: 'refresh' },
|
|
70
|
+
searchText: '刷新模型目录 refresh catalog 更新',
|
|
71
|
+
label: '⟳ 刷新模型目录',
|
|
72
|
+
detail: '',
|
|
73
|
+
});
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
76
|
+
/** 归一化端点为归并键:小写 host + 去尾斜杠的 path,使预设 baseURL 与目录 api 对齐。 */
|
|
77
|
+
function endpointKey(raw) {
|
|
78
|
+
const v = raw.trim();
|
|
79
|
+
try {
|
|
80
|
+
const u = new URL(v);
|
|
81
|
+
return `${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return v.replace(/\/+$/, '').toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** 从 URL 取 host 作合成组名;失败回退整串。 */
|
|
88
|
+
function hostOf(raw) {
|
|
89
|
+
try {
|
|
90
|
+
return new URL(raw.trim()).host;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return raw.trim();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function presetLeaf(p, cur) {
|
|
97
|
+
return {
|
|
98
|
+
choice: { kind: 'preset', preset: p },
|
|
99
|
+
searchText: `${p.model}`,
|
|
100
|
+
label: `${cur ? '★ ' : ''}${p.name}`,
|
|
101
|
+
detail: `${p.model} · ${p.provider}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function catalogLeaf(e) {
|
|
105
|
+
const c = e.model.cost;
|
|
106
|
+
const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join('·');
|
|
109
|
+
return {
|
|
110
|
+
choice: { kind: 'catalog', entry: e },
|
|
111
|
+
searchText: `${e.model.name ?? ''} ${e.modelId}`,
|
|
112
|
+
label: e.model.name || e.modelId,
|
|
113
|
+
detail: [e.providerName, `${fmtK(e.model.limit?.context)} ctx`, c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '', caps]
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join(' · '),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function actionLeaf(action) {
|
|
119
|
+
if (action === 'custom') {
|
|
120
|
+
return {
|
|
121
|
+
choice: { kind: 'action', action },
|
|
122
|
+
searchText: '自定义 手动填写 custom baseURL model key',
|
|
123
|
+
label: '+ 自定义模型(手动填写)',
|
|
124
|
+
detail: '',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
choice: { kind: 'action', action },
|
|
129
|
+
searchText: '刷新模型目录 refresh catalog 更新',
|
|
130
|
+
label: '⟳ 刷新模型目录',
|
|
131
|
+
detail: '',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 组装两级模型树:root = 厂商组 + 操作行;每个厂商组内 = 当前预设★ → 其他预设 → 目录模型。
|
|
136
|
+
*
|
|
137
|
+
* 归并规则:预设按 baseURL 归并到「端点相同」的目录厂商;目录里没有的端点按 URL host
|
|
138
|
+
* 合成一个组。目录模型与预设同 model 名时不重复列(预设优先)。无 I/O。
|
|
139
|
+
*/
|
|
140
|
+
export function buildModelTree(input) {
|
|
141
|
+
const groups = [];
|
|
142
|
+
const byKey = new Map();
|
|
143
|
+
// 1) 目录厂商:仅可直发且有模型的。
|
|
144
|
+
for (const [providerId, provider] of Object.entries(input.providers)) {
|
|
145
|
+
if (classifyProvider(provider) === 'unsupported')
|
|
146
|
+
continue;
|
|
147
|
+
const endpoint = resolveBaseURL(provider);
|
|
148
|
+
if (!endpoint)
|
|
149
|
+
continue;
|
|
150
|
+
if (Object.keys(provider.models ?? {}).length === 0)
|
|
151
|
+
continue;
|
|
152
|
+
const label = provider.name || providerId;
|
|
153
|
+
const g = {
|
|
154
|
+
label,
|
|
155
|
+
key: endpointKey(endpoint),
|
|
156
|
+
providerId,
|
|
157
|
+
presetLeaves: [],
|
|
158
|
+
catalogLeaves: [],
|
|
159
|
+
seenModels: new Set(),
|
|
160
|
+
searchText: `${label} ${providerId}`,
|
|
161
|
+
};
|
|
162
|
+
groups.push(g);
|
|
163
|
+
if (!byKey.has(g.key))
|
|
164
|
+
byKey.set(g.key, g);
|
|
165
|
+
for (const e of input.catalog) {
|
|
166
|
+
if (!e.supported || e.providerId !== providerId)
|
|
167
|
+
continue;
|
|
168
|
+
if (g.seenModels.has(e.modelId))
|
|
169
|
+
continue;
|
|
170
|
+
g.seenModels.add(e.modelId);
|
|
171
|
+
g.catalogLeaves.push(catalogLeaf(e));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// 2) 预设归并:端点命中目录厂商 → 进该组;否则进/建合成组。
|
|
175
|
+
for (const p of input.presets) {
|
|
176
|
+
const cur = isPresetCurrent(p, input.current);
|
|
177
|
+
const key = endpointKey(p.baseURL);
|
|
178
|
+
let g = byKey.get(key);
|
|
179
|
+
if (!g) {
|
|
180
|
+
g = {
|
|
181
|
+
label: hostOf(p.baseURL) || p.baseURL,
|
|
182
|
+
key,
|
|
183
|
+
providerId: null,
|
|
184
|
+
presetLeaves: [],
|
|
185
|
+
catalogLeaves: [],
|
|
186
|
+
seenModels: new Set(),
|
|
187
|
+
searchText: `${hostOf(p.baseURL)}`,
|
|
188
|
+
};
|
|
189
|
+
groups.push(g);
|
|
190
|
+
byKey.set(key, g);
|
|
191
|
+
}
|
|
192
|
+
g.presetLeaves.push({ node: presetLeaf(p, cur), model: p.model, cur });
|
|
193
|
+
}
|
|
194
|
+
// 3) 组内:当前预设 → 其他预设 → 目录模型(跳过与预设同名的);空组不列。
|
|
195
|
+
const roots = [];
|
|
196
|
+
for (const g of groups) {
|
|
197
|
+
const presetModelNames = new Set(g.presetLeaves.map((x) => x.model));
|
|
198
|
+
g.presetLeaves.sort((a, b) => Number(b.cur) - Number(a.cur));
|
|
199
|
+
const children = [
|
|
200
|
+
...g.presetLeaves.map((x) => x.node),
|
|
201
|
+
...g.catalogLeaves.filter((leaf) => {
|
|
202
|
+
const e = leaf.choice;
|
|
203
|
+
return !(e?.kind === 'catalog' && presetModelNames.has(e.entry.modelId));
|
|
204
|
+
}),
|
|
205
|
+
];
|
|
206
|
+
if (children.length === 0)
|
|
207
|
+
continue;
|
|
208
|
+
roots.push({
|
|
209
|
+
searchText: g.searchText,
|
|
210
|
+
label: g.label,
|
|
211
|
+
detail: `${children.length} 个模型`,
|
|
212
|
+
children,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// 目录厂商在前,合成组在后;各自按名排序。
|
|
216
|
+
roots.sort((a, b) => {
|
|
217
|
+
const ga = groups.find((g) => g.label === a.label && g.searchText === a.searchText);
|
|
218
|
+
const gb = groups.find((g) => g.label === b.label && g.searchText === b.searchText);
|
|
219
|
+
const sa = ga?.providerId ? 0 : 1;
|
|
220
|
+
const sb = gb?.providerId ? 0 : 1;
|
|
221
|
+
return sa - sb || a.label.localeCompare(b.label);
|
|
222
|
+
});
|
|
223
|
+
// 4) 操作行固定在 root 末尾。
|
|
224
|
+
roots.push(actionLeaf('custom'));
|
|
225
|
+
roots.push(actionLeaf('refresh'));
|
|
226
|
+
return roots;
|
|
227
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** OpenAI 官方端点(官方 provider 记录通常不带 api 字段)。 */
|
|
2
|
+
export const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
3
|
+
/**
|
|
4
|
+
* Anthropic 官方端点:provider 记录同样不带 api(端点内置 SDK)。
|
|
5
|
+
* 不带 /v1——anthropic.ts 的 endpoint() 会在后面拼 `/v1/messages`。
|
|
6
|
+
*/
|
|
7
|
+
export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com';
|
|
8
|
+
/**
|
|
9
|
+
* 判定该 provider 能否用 mocode 现有 fetch 直发。
|
|
10
|
+
* - 官方 anthropic 包 → Anthropic Messages 路径
|
|
11
|
+
* - openai-compatible 且带端点 → OpenAI Chat 路径
|
|
12
|
+
* - 官方 openai 包 → OpenAI Chat 路径(端点缺省补官方 /v1)
|
|
13
|
+
* - 其余(google/bedrock/azure/vertex/各 gateway…)→ 二期,UI 置灰
|
|
14
|
+
*/
|
|
15
|
+
export function classifyProvider(provider) {
|
|
16
|
+
switch (provider.npm) {
|
|
17
|
+
case '@ai-sdk/anthropic':
|
|
18
|
+
return 'anthropic-messages';
|
|
19
|
+
case '@ai-sdk/openai-compatible':
|
|
20
|
+
return provider.api && provider.api.trim() ? 'openai-chat' : 'unsupported';
|
|
21
|
+
case '@ai-sdk/openai':
|
|
22
|
+
return 'openai-chat';
|
|
23
|
+
default:
|
|
24
|
+
return 'unsupported';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** provider 实际要用的 baseURL:官方 openai/anthropic 缺 api 时补默认,其余原样返回。 */
|
|
28
|
+
export function resolveBaseURL(provider) {
|
|
29
|
+
if (provider.api && provider.api.trim())
|
|
30
|
+
return provider.api.trim();
|
|
31
|
+
if (provider.npm === '@ai-sdk/openai')
|
|
32
|
+
return OPENAI_DEFAULT_BASE_URL;
|
|
33
|
+
if (provider.npm === '@ai-sdk/anthropic')
|
|
34
|
+
return ANTHROPIC_DEFAULT_BASE_URL;
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
/** 映射到 ModelPreset.provider(presets.ts 的两值)。 */
|
|
38
|
+
export function toPresetProvider(proto) {
|
|
39
|
+
if (proto === 'openai-chat')
|
|
40
|
+
return 'openai';
|
|
41
|
+
if (proto === 'anthropic-messages')
|
|
42
|
+
return 'anthropic';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { THINK_BUDGET_BY_EFFORT } from './budgets.js';
|
|
2
|
+
/**
|
|
3
|
+
* catalogProviderId → 方言。一家厂商一种方言(alibaba 全系 Qwen)。
|
|
4
|
+
* 未列出的 provider 返回 undefined → 保守不下发。
|
|
5
|
+
*/
|
|
6
|
+
const PROVIDER_DIALECT = {
|
|
7
|
+
openai: 'openai-effort',
|
|
8
|
+
openrouter: 'openai-effort',
|
|
9
|
+
xai: 'openai-effort',
|
|
10
|
+
alibaba: 'qwen',
|
|
11
|
+
zhipuai: 'thinking-toggle',
|
|
12
|
+
volcengine: 'thinking-toggle',
|
|
13
|
+
deepseek: 'thinking-toggle',
|
|
14
|
+
moonshotai: 'thinking-toggle',
|
|
15
|
+
};
|
|
16
|
+
export function dialectForProvider(catalogProviderId) {
|
|
17
|
+
return catalogProviderId ? PROVIDER_DIALECT[catalogProviderId] : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** 在 effort values 中按归一化档位挑一个合法值(按优先级,找不到回退到最低/最高档)。 */
|
|
20
|
+
function pickEffortValue(effort, values, off) {
|
|
21
|
+
const v = values.map((s) => s.toLowerCase());
|
|
22
|
+
const priorities = off
|
|
23
|
+
? { any: ['off', 'disabled', 'minimal', 'low'] }
|
|
24
|
+
: effort === 'low'
|
|
25
|
+
? { any: ['low', 'minimal'] }
|
|
26
|
+
: effort === 'medium'
|
|
27
|
+
? { any: ['medium', 'mid', 'low'] }
|
|
28
|
+
: { any: ['high', 'xhigh', 'max', 'medium'] };
|
|
29
|
+
for (const cand of priorities.any) {
|
|
30
|
+
const hit = v.find((x) => x === cand || x.includes(cand));
|
|
31
|
+
if (hit)
|
|
32
|
+
return values[v.indexOf(hit)];
|
|
33
|
+
}
|
|
34
|
+
// 回退:off/低档取最低,高档取最高。
|
|
35
|
+
return off || effort === 'low' ? values[0] : values[values.length - 1];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 目录能力 → 请求字段;无法安全下发时返回 {}。
|
|
39
|
+
* 依据第一个匹配的 reasoning_option 决定形态。
|
|
40
|
+
*/
|
|
41
|
+
export function paramsFromCatalog(input) {
|
|
42
|
+
const dialect = dialectForProvider(input.catalogProviderId);
|
|
43
|
+
if (!dialect)
|
|
44
|
+
return {};
|
|
45
|
+
const opts = input.reasoningOptions ?? [];
|
|
46
|
+
const toggle = opts.find((o) => o.type === 'toggle');
|
|
47
|
+
const effortOpt = opts.find((o) => o.type === 'effort');
|
|
48
|
+
const budgetOpt = opts.find((o) => o.type === 'budget_tokens');
|
|
49
|
+
const isOff = input.effort === 'off';
|
|
50
|
+
// ── qwen 方言:enable_thinking + thinking_budget(budget 选项优先给预算) ──
|
|
51
|
+
if (dialect === 'qwen') {
|
|
52
|
+
if (isOff)
|
|
53
|
+
return { enable_thinking: false };
|
|
54
|
+
const base = { enable_thinking: true };
|
|
55
|
+
if (budgetOpt) {
|
|
56
|
+
let budget = THINK_BUDGET_BY_EFFORT[input.effort];
|
|
57
|
+
if (typeof budgetOpt.min === 'number')
|
|
58
|
+
budget = Math.max(budgetOpt.min, budget);
|
|
59
|
+
base.thinking_budget = budget;
|
|
60
|
+
}
|
|
61
|
+
return base;
|
|
62
|
+
}
|
|
63
|
+
// ── openai effort 方言 ──
|
|
64
|
+
if (dialect === 'openai-effort') {
|
|
65
|
+
if (effortOpt?.values && effortOpt.values.length > 0) {
|
|
66
|
+
// OpenAI 官方无法真关:off 落到最低档。
|
|
67
|
+
return { reasoning_effort: pickEffortValue(input.effort, effortOpt.values, isOff) };
|
|
68
|
+
}
|
|
69
|
+
if (toggle) {
|
|
70
|
+
return isOff ? { reasoning_effort: 'low' } : { reasoning_effort: input.effort };
|
|
71
|
+
}
|
|
72
|
+
// 无 effort/toggle 元数据但已知是 OpenAI 方言:直接下发归一化档位(off→low)。
|
|
73
|
+
return { reasoning_effort: isOff ? 'low' : input.effort };
|
|
74
|
+
}
|
|
75
|
+
// ── thinking-toggle 方言 ──
|
|
76
|
+
if (dialect === 'thinking-toggle') {
|
|
77
|
+
if (effortOpt?.values && effortOpt.values.length > 0 && !isOff) {
|
|
78
|
+
// 个别厂商用 thinking 但分档(如 deepseek low/high/max):仍只开关,标 enabled。
|
|
79
|
+
return { thinking: { type: 'enabled' } };
|
|
80
|
+
}
|
|
81
|
+
return { thinking: { type: isOff ? 'disabled' : 'enabled' } };
|
|
82
|
+
}
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
/** 目录是否声明该模型支持思考(供 recognizesReasoning)。 */
|
|
86
|
+
export function recognizesFromCatalog(reasoning) {
|
|
87
|
+
return reasoning === true;
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { classifyProvider } from './protocol.js';
|
|
2
|
+
/** 把目录拍平成条目列表。 */
|
|
3
|
+
export function flattenCatalog(providers) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
6
|
+
const supported = classifyProvider(provider) !== 'unsupported';
|
|
7
|
+
const providerName = provider.name || providerId;
|
|
8
|
+
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
|
9
|
+
out.push({ providerId, providerName, modelId, model, supported });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
/** 关键字过滤 + 能力过滤。 */
|
|
15
|
+
export function searchModels(providers, filters = {}) {
|
|
16
|
+
const q = filters.query?.trim().toLowerCase();
|
|
17
|
+
return flattenCatalog(providers)
|
|
18
|
+
.filter((e) => {
|
|
19
|
+
if (!filters.includeUnsupported && !e.supported)
|
|
20
|
+
return false;
|
|
21
|
+
if (filters.reasoning && !e.model.reasoning)
|
|
22
|
+
return false;
|
|
23
|
+
if (filters.toolCall && !e.model.tool_call)
|
|
24
|
+
return false;
|
|
25
|
+
if (filters.attachment && !e.model.attachment)
|
|
26
|
+
return false;
|
|
27
|
+
if (q) {
|
|
28
|
+
const hay = [e.providerName, e.providerId, e.modelId, e.model.name ?? '', e.model.family ?? '']
|
|
29
|
+
.join(' ')
|
|
30
|
+
.toLowerCase();
|
|
31
|
+
// 支持多关键字(空格分隔),全部命中才算。
|
|
32
|
+
if (!q.split(/\s+/).every((tok) => hay.includes(tok)))
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => {
|
|
38
|
+
// 可直发优先;其次厂商名;再模型名(按发布时间倒序更适合挑新模型,但纯函数保持确定性)。
|
|
39
|
+
if (a.supported !== b.supported)
|
|
40
|
+
return a.supported ? -1 : 1;
|
|
41
|
+
const p = a.providerName.localeCompare(b.providerName);
|
|
42
|
+
if (p !== 0)
|
|
43
|
+
return p;
|
|
44
|
+
return (b.model.release_date ?? '').localeCompare(a.model.release_date ?? '');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** 按 provider 分组(保持组内顺序),供 UI 分区渲染。 */
|
|
48
|
+
export function groupByProvider(entries) {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const arr = map.get(e.providerId);
|
|
52
|
+
if (arr)
|
|
53
|
+
arr.push(e);
|
|
54
|
+
else
|
|
55
|
+
map.set(e.providerId, [e]);
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 思考强度命令组:/effort [off|low|medium|high|auto](#token-efficiency P3)
|
|
3
|
+
*
|
|
4
|
+
* 无参弹选择菜单(promptIntervention);带参直设;auto = 不下发任何 reasoning 参数。
|
|
5
|
+
* 生效链路:updateModelConfig 改内存/env + 会话 pin 同步 → writeConfigKeys 持久化 →
|
|
6
|
+
* refreshStatusBase 刷底栏。不需要 reconfigureClient(effort 在每次请求体现读)。
|
|
7
|
+
*/
|
|
8
|
+
import * as layout from '../../ui/layout.js';
|
|
9
|
+
import { ui } from '../../ui/theme.js';
|
|
10
|
+
import { config, updateModelConfig } from '../../config/index.js';
|
|
11
|
+
import { writeConfigKeys } from '../../config/file.js';
|
|
12
|
+
import { promptIntervention } from '../../ui/intervention.js';
|
|
13
|
+
import { parseReasoningEffort, recognizesReasoning } from '../../llm/reasoning.js';
|
|
14
|
+
import { unhandled, next } from './types.js';
|
|
15
|
+
const LEVELS = ['off', 'low', 'medium', 'high', 'auto'];
|
|
16
|
+
const LEVEL_LABELS = {
|
|
17
|
+
off: 'off(关闭思考)',
|
|
18
|
+
low: 'low(少思考·快·省)',
|
|
19
|
+
medium: 'medium(均衡)',
|
|
20
|
+
high: 'high(深度思考)',
|
|
21
|
+
auto: 'auto(不下发,后端默认)',
|
|
22
|
+
};
|
|
23
|
+
/** 当前目标模型是否在白名单内(未识别则给提示)。 */
|
|
24
|
+
function currentModelRecognized() {
|
|
25
|
+
return recognizesReasoning({
|
|
26
|
+
provider: config.provider === 'anthropic' ? 'anthropic' : 'openai',
|
|
27
|
+
model: config.model,
|
|
28
|
+
maxTokens: config.maxTokens,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export const effortCommands = [
|
|
32
|
+
async (ctx) => {
|
|
33
|
+
const { line } = ctx;
|
|
34
|
+
if (line !== '/effort' && !line.startsWith('/effort '))
|
|
35
|
+
return unhandled();
|
|
36
|
+
const arg = line.startsWith('/effort ') ? line.slice('/effort '.length).trim() : '';
|
|
37
|
+
let effort;
|
|
38
|
+
if (arg === '') {
|
|
39
|
+
// 无参:弹菜单,预选项为当前档。
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
res = await promptIntervention({
|
|
43
|
+
type: 'choice',
|
|
44
|
+
title: '选择思考强度(reasoning effort)',
|
|
45
|
+
detail: '同档名在不同模型上语义不完全等价;未知模型/第三方网关不下发参数。' + `当前:${getActiveEffortLabel()}`,
|
|
46
|
+
options: LEVELS.map((lv) => LEVEL_LABELS[lv]),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return next(); // Ctrl+C
|
|
51
|
+
}
|
|
52
|
+
if (res.action === 'cancelled')
|
|
53
|
+
return next();
|
|
54
|
+
if (!res.value)
|
|
55
|
+
return next();
|
|
56
|
+
const idx = LEVELS.map((lv) => LEVEL_LABELS[lv]).indexOf(res.value);
|
|
57
|
+
if (idx === -1)
|
|
58
|
+
return next();
|
|
59
|
+
effort = LEVELS[idx];
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
effort = parseReasoningEffort(arg);
|
|
63
|
+
if (!effort) {
|
|
64
|
+
layout.contentWrite(`${ui.yellow}未知思考强度: ${arg};可选 off | low | medium | high | auto${ui.reset}\n`);
|
|
65
|
+
return next();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
updateModelConfig({ reasoningEffort: effort });
|
|
69
|
+
writeConfigKeys({ REASONING_EFFORT: effort });
|
|
70
|
+
ctx.refreshStatusBase(ctx.history);
|
|
71
|
+
layout.contentWrite(`${ui.dim}思考强度已设为 ${LEVEL_LABELS[effort]}${ui.reset}\n`);
|
|
72
|
+
if (effort !== 'auto' && !currentModelRecognized()) {
|
|
73
|
+
layout.contentWrite(`${ui.yellow}(当前模型未识别为支持 reasoning 参数,设置不会下发;切换到 o/gpt-5/Qwen3/GLM-4.5+/Claude 等模型后生效)${ui.reset}\n`);
|
|
74
|
+
}
|
|
75
|
+
return next();
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
/** 读当前会话 effort 的展示标签(模块内辅助)。 */
|
|
79
|
+
function getActiveEffortLabel() {
|
|
80
|
+
return LEVEL_LABELS[config.reasoningEffort];
|
|
81
|
+
}
|