dsh-prompt-predict 0.1.0 → 0.2.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/CHANGELOG.md +55 -0
- package/CHANGELOG.zh.md +54 -0
- package/README.md +140 -94
- package/README.zh.md +216 -0
- package/client/client.js +364 -51
- package/lib/host-route.mjs +113 -33
- package/lib/messages.mjs +96 -0
- package/lib/predict.mjs +28 -8
- package/package.json +7 -4
package/lib/host-route.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 同源 HTTP 接口:提示词预测 + 设置页(启用开关、模型选择、高级参数)。
|
|
2
|
-
import { predictNextPrompt } from './predict.mjs';
|
|
2
|
+
import { predictNextPrompt, resolveRoute } from './predict.mjs';
|
|
3
3
|
import { readSettings, writeSettings, clearSettings } from './config.mjs';
|
|
4
|
+
import { makeT, normalizeLang, listSeparator } from './messages.mjs';
|
|
4
5
|
|
|
5
6
|
const MAX_BODY = 16 * 1024;
|
|
6
7
|
|
|
@@ -63,6 +64,16 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
63
64
|
/** 有效设置 = 部署默认 + 用户保存的覆盖(每次请求时读取,改设置立即生效)。 */
|
|
64
65
|
const effective = () => ({ ...deployDefaults, ...readSettings() });
|
|
65
66
|
|
|
67
|
+
// 每个会话上一条已展示的建议:用于「避免复读」——模型很容易连续两轮给出几乎相同的建议。
|
|
68
|
+
// 只存内存,进程重启即清空(不需要持久化,也不涉及隐私)。
|
|
69
|
+
const lastSuggestion = new Map();
|
|
70
|
+
|
|
71
|
+
/** 请求体里的路由(显式指定优先,其次读设置;都是 auto 时返回 undefined = 跟随会话)。 */
|
|
72
|
+
const routeFrom = (body, eff) =>
|
|
73
|
+
(typeof body?.provider === 'string' && typeof body?.model === 'string' && body.provider && body.model)
|
|
74
|
+
? routeOf(body.provider, body.model)
|
|
75
|
+
: routeOf(eff.provider, eff.model);
|
|
76
|
+
|
|
66
77
|
|
|
67
78
|
async function catalogGroups() {
|
|
68
79
|
const providers = ctx.llm.listProviders();
|
|
@@ -76,33 +87,33 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
76
87
|
}));
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
async function validateRoute(provider, model) {
|
|
90
|
+
async function validateRoute(t, provider, model) {
|
|
80
91
|
try {
|
|
81
92
|
const models = await ctx.llm.listModels(provider);
|
|
82
|
-
if (!models.some((m) => m.id === model)) return
|
|
93
|
+
if (!models.some((m) => m.id === model)) return t('err.modelUnknown', { provider, model });
|
|
83
94
|
return null;
|
|
84
95
|
} catch (error) {
|
|
85
|
-
return
|
|
96
|
+
return t('err.providerUnavailable', { provider, detail: String(error?.message ?? error) });
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
89
|
-
async function validateEffort(provider, model, effort) {
|
|
90
|
-
if (!['off', 'low', 'high', 'max'].includes(effort)) return '
|
|
100
|
+
async function validateEffort(t, lang, provider, model, effort) {
|
|
101
|
+
if (!['off', 'low', 'high', 'max'].includes(effort)) return t('err.effortUnsupported');
|
|
91
102
|
const controller = new AbortController();
|
|
92
103
|
let timer;
|
|
93
104
|
try {
|
|
94
105
|
const info = await Promise.race([
|
|
95
106
|
ctx.llm.resolveModelInfo(provider, model, controller.signal),
|
|
96
107
|
new Promise((_, reject) => {
|
|
97
|
-
timer = setTimeout(() => { controller.abort(); reject(new Error('
|
|
108
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error(t('err.effortQueryTimeout'))); }, 3500);
|
|
98
109
|
}),
|
|
99
110
|
]);
|
|
100
111
|
const efforts = info?.reasoning?.efforts?.map((e) => e.id) ?? [];
|
|
101
|
-
if (efforts.length === 0) return
|
|
102
|
-
if (!efforts.includes(effort)) return
|
|
112
|
+
if (efforts.length === 0) return t('err.effortNoLevels', { model });
|
|
113
|
+
if (!efforts.includes(effort)) return t('err.effortLevelUnsupported', { model, effort, options: efforts.join(listSeparator(lang)) });
|
|
103
114
|
return null;
|
|
104
115
|
} catch {
|
|
105
|
-
return '
|
|
116
|
+
return t('err.effortUnknown');
|
|
106
117
|
} finally {
|
|
107
118
|
clearTimeout(timer);
|
|
108
119
|
}
|
|
@@ -124,12 +135,29 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
const path = new URL(req.url ?? '/', 'http://x').pathname;
|
|
138
|
+
// 语言由客户端在请求头带来(默认情况下 profile 的 cordis.patch.yml 里没有偏好,读不到)
|
|
139
|
+
const lang = normalizeLang(req.headers['x-dsh-locale']);
|
|
140
|
+
const t = makeT(lang);
|
|
127
141
|
|
|
128
142
|
if (path === '/dsh-prompt-predict/status' && req.method === 'GET') {
|
|
129
143
|
const current = currentSelection();
|
|
144
|
+
// 可选 sessionId:带上时回传「该会话实际使用的模型」,设置页据此生成思考档位。
|
|
145
|
+
// 不带 sessionId 时只能给全局默认——两者可能不同,混用会让档位选项对不上真实调用。
|
|
146
|
+
const sid = new URL(req.url ?? '/', 'http://x').searchParams.get('sessionId') ?? '';
|
|
147
|
+
let sessionModel = null;
|
|
148
|
+
if (sid) {
|
|
149
|
+
try {
|
|
150
|
+
const session = ctx.sessions?.get?.(sid);
|
|
151
|
+
if (session) {
|
|
152
|
+
const resolved = resolveRoute(ctx, session) ?? {};
|
|
153
|
+
sessionModel = { provider: resolved.provider ?? null, model: resolved.model ?? null };
|
|
154
|
+
}
|
|
155
|
+
} catch { /* 拿不到就算了,客户端回退到 current */ }
|
|
156
|
+
}
|
|
130
157
|
sendJson(res, 200, {
|
|
131
158
|
ok: true,
|
|
132
159
|
current: { provider: current.provider ?? null, model: current.model ?? null },
|
|
160
|
+
sessionModel,
|
|
133
161
|
effective: effective(),
|
|
134
162
|
saved: readSettings(),
|
|
135
163
|
defaults: deployDefaults,
|
|
@@ -147,7 +175,7 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
147
175
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
148
176
|
const provider = url.searchParams.get('provider') ?? '';
|
|
149
177
|
const model = url.searchParams.get('model') ?? '';
|
|
150
|
-
if (!provider || !model) { sendJson(res, 400, { ok: false, reason: '
|
|
178
|
+
if (!provider || !model) { sendJson(res, 400, { ok: false, reason: t('err.missingRoute') }); return; }
|
|
151
179
|
const controller = new AbortController();
|
|
152
180
|
let timer;
|
|
153
181
|
try {
|
|
@@ -158,7 +186,7 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
158
186
|
const efforts = info?.reasoning?.efforts?.map((e) => e.id) ?? [];
|
|
159
187
|
sendJson(res, 200, { ok: true, efforts, default: info?.reasoning?.defaultEffort ?? null });
|
|
160
188
|
} catch {
|
|
161
|
-
sendJson(res, 200, { ok: false, reason: '
|
|
189
|
+
sendJson(res, 200, { ok: false, reason: t('err.effortCatalog') });
|
|
162
190
|
} finally {
|
|
163
191
|
clearTimeout(timer);
|
|
164
192
|
}
|
|
@@ -167,7 +195,7 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
167
195
|
|
|
168
196
|
if (path === '/dsh-prompt-predict/settings' && req.method === 'POST') {
|
|
169
197
|
const body = await readJson(req);
|
|
170
|
-
if (!body) { sendJson(res, 400, { ok: false, reason: '
|
|
198
|
+
if (!body) { sendJson(res, 400, { ok: false, reason: t('err.badJson') }); return; }
|
|
171
199
|
if (body.reset === true) {
|
|
172
200
|
clearSettings();
|
|
173
201
|
sendJson(res, 200, { ok: true, effective: effective(), saved: {} });
|
|
@@ -180,7 +208,7 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
180
208
|
patch.provider = AUTO;
|
|
181
209
|
patch.model = AUTO;
|
|
182
210
|
} else {
|
|
183
|
-
const problem = await validateRoute(body.provider, body.model);
|
|
211
|
+
const problem = await validateRoute(t, body.provider, body.model);
|
|
184
212
|
if (problem) { sendJson(res, 200, { ok: false, reason: problem }); return; }
|
|
185
213
|
patch.provider = body.provider;
|
|
186
214
|
patch.model = body.model;
|
|
@@ -188,14 +216,24 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
188
216
|
}
|
|
189
217
|
if (body.timeoutMs !== undefined) {
|
|
190
218
|
const v = Number(body.timeoutMs);
|
|
191
|
-
if (!Number.isSafeInteger(v) || v < 1000 || v > 60000) { sendJson(res, 200, { ok: false, reason: '
|
|
219
|
+
if (!Number.isSafeInteger(v) || v < 1000 || v > 60000) { sendJson(res, 200, { ok: false, reason: t('err.timeoutRange') }); return; }
|
|
192
220
|
patch.timeoutMs = v;
|
|
193
221
|
}
|
|
194
222
|
if (body.maxMessages !== undefined) {
|
|
195
223
|
const v = Number(body.maxMessages);
|
|
196
|
-
if (!Number.isSafeInteger(v) || v < 2 || v > 20) { sendJson(res, 200, { ok: false, reason: '
|
|
224
|
+
if (!Number.isSafeInteger(v) || v < 2 || v > 20) { sendJson(res, 200, { ok: false, reason: t('err.messagesRange') }); return; }
|
|
197
225
|
patch.maxMessages = v;
|
|
198
226
|
}
|
|
227
|
+
if (body.maxChars !== undefined) {
|
|
228
|
+
const v = Number(body.maxChars);
|
|
229
|
+
if (!Number.isSafeInteger(v) || v < 1000 || v > 20000) { sendJson(res, 200, { ok: false, reason: t('err.charsRange') }); return; }
|
|
230
|
+
patch.maxChars = v;
|
|
231
|
+
}
|
|
232
|
+
if (body.maxTokens !== undefined) {
|
|
233
|
+
const v = Number(body.maxTokens);
|
|
234
|
+
if (!Number.isSafeInteger(v) || v < 16 || v > 512) { sendJson(res, 200, { ok: false, reason: t('err.tokensRange') }); return; }
|
|
235
|
+
patch.maxTokens = v;
|
|
236
|
+
}
|
|
199
237
|
if (body.reasoningEffort !== undefined) {
|
|
200
238
|
const v = String(body.reasoningEffort);
|
|
201
239
|
if (v === 'auto') { patch.reasoningEffort = 'auto'; }
|
|
@@ -203,7 +241,7 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
203
241
|
const next = { ...effective(), ...patch };
|
|
204
242
|
// 跟随当前会话模型时模型不固定,无法按具体模型校验档位,直接按用户选择保存。
|
|
205
243
|
const pinned = routeOf(next.provider, next.model);
|
|
206
|
-
const problem = pinned ? await validateEffort(pinned.provider, pinned.model, v) : null;
|
|
244
|
+
const problem = pinned ? await validateEffort(t, lang, pinned.provider, pinned.model, v) : null;
|
|
207
245
|
if (problem) { sendJson(res, 200, { ok: false, reason: problem }); return; }
|
|
208
246
|
patch.reasoningEffort = v;
|
|
209
247
|
}
|
|
@@ -214,46 +252,88 @@ export function registerPredictRoute(ctx, cordisConfig = {}) {
|
|
|
214
252
|
}
|
|
215
253
|
|
|
216
254
|
if (path !== '/dsh-prompt-predict/suggest' || req.method !== 'POST') {
|
|
217
|
-
sendJson(res, 404, { ok: false, reason: '
|
|
255
|
+
sendJson(res, 404, { ok: false, reason: t('err.notFound') });
|
|
218
256
|
return;
|
|
219
257
|
}
|
|
220
258
|
|
|
221
259
|
const eff = effective();
|
|
222
260
|
if (eff.enabled === false) {
|
|
223
|
-
sendJson(res, 200, { ok: false, disabled: true, reason: '
|
|
261
|
+
sendJson(res, 200, { ok: false, disabled: true, reason: t('err.disabled') });
|
|
224
262
|
return;
|
|
225
263
|
}
|
|
226
264
|
|
|
227
265
|
const body = await readJson(req);
|
|
228
266
|
if (!body || typeof body.sessionId !== 'string' || body.sessionId.trim() === '') {
|
|
229
|
-
sendJson(res, 400, { ok: false, reason: '
|
|
267
|
+
sendJson(res, 400, { ok: false, reason: t('err.missingSessionId') });
|
|
230
268
|
return;
|
|
231
269
|
}
|
|
232
270
|
const session = ctx.sessions.get(body.sessionId);
|
|
233
271
|
if (!session) {
|
|
234
|
-
sendJson(res, 404, { ok: false, reason: '
|
|
272
|
+
sendJson(res, 404, { ok: false, reason: t('err.sessionNotFound') });
|
|
235
273
|
return;
|
|
236
274
|
}
|
|
237
275
|
|
|
276
|
+
const startedAt = Date.now();
|
|
238
277
|
const abort = new AbortController();
|
|
239
278
|
const timeout = setTimeout(() => abort.abort('prediction timeout'), Number(eff.timeoutMs) || 12000);
|
|
240
279
|
const onClose = () => { if (!res.writableEnded) abort.abort('client disconnected'); };
|
|
241
280
|
res.on('close', onClose);
|
|
281
|
+
const base = {
|
|
282
|
+
signal: abort.signal,
|
|
283
|
+
maxTokens: Number(eff.maxTokens) || 64,
|
|
284
|
+
maxMessages: Number(eff.maxMessages) || 8,
|
|
285
|
+
maxChars: Number(eff.maxChars) || 6000,
|
|
286
|
+
route: routeFrom(body, eff),
|
|
287
|
+
t,
|
|
288
|
+
};
|
|
289
|
+
const effort = typeof body.reasoningEffort === 'string' ? body.reasoningEffort
|
|
290
|
+
: (eff.reasoningEffort && eff.reasoningEffort !== 'auto' ? eff.reasoningEffort : undefined);
|
|
291
|
+
// 上一条建议:客户端可以显式传 avoid(reroll 时带当前这条),否则用本会话记忆的那条。
|
|
292
|
+
const previous = (typeof body.avoid === 'string' && body.avoid.trim())
|
|
293
|
+
? body.avoid.trim()
|
|
294
|
+
: lastSuggestion.get(session.id);
|
|
295
|
+
// reroll = 用户点了「换一条」:首轮就带 avoid,别白跑一次
|
|
296
|
+
const reroll = body.reroll === true && !!previous;
|
|
297
|
+
const firstExtra = reroll ? { avoid: previous, forceDifferent: true } : {};
|
|
298
|
+
let effortDropped = false;
|
|
299
|
+
let dedupRetry = false;
|
|
242
300
|
try {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
301
|
+
let result;
|
|
302
|
+
try {
|
|
303
|
+
result = await predictNextPrompt(ctx, session, { ...base, reasoningEffort: effort, ...firstExtra });
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// 降级:思考档位与真实模型不兼容(「跟随会话模型」时最常见)就丢掉档位再试一次。
|
|
306
|
+
// 不做这步的话,用户看到的是「设置都对、但永远没有建议」——而失败在客户端是静默的,最难排查。
|
|
307
|
+
if (!effort) throw error;
|
|
308
|
+
effortDropped = true;
|
|
309
|
+
result = await predictNextPrompt(ctx, session, { ...base, ...firstExtra });
|
|
310
|
+
}
|
|
311
|
+
// 去重:模型会「复读」,连续两轮给同一条时要求换一条(一次为限,失败就保留原建议)。
|
|
312
|
+
if (previous && result.suggestion === previous) {
|
|
313
|
+
try {
|
|
314
|
+
const retry = await predictNextPrompt(ctx, session, {
|
|
315
|
+
...base,
|
|
316
|
+
reasoningEffort: effortDropped ? undefined : effort,
|
|
317
|
+
avoid: previous,
|
|
318
|
+
forceDifferent: true,
|
|
319
|
+
});
|
|
320
|
+
if (retry.suggestion && retry.suggestion !== previous) { result = retry; dedupRetry = true; }
|
|
321
|
+
} catch { /* 换一条失败就保留原建议,不打断用户 */ }
|
|
322
|
+
}
|
|
323
|
+
lastSuggestion.set(session.id, result.suggestion);
|
|
324
|
+
sendJson(res, 200, {
|
|
325
|
+
ok: true,
|
|
326
|
+
suggestion: result.suggestion,
|
|
327
|
+
provider: result.route.provider,
|
|
328
|
+
model: result.route.model,
|
|
329
|
+
ms: Date.now() - startedAt,
|
|
330
|
+
effortDropped,
|
|
331
|
+
dedupRetry,
|
|
253
332
|
});
|
|
254
|
-
sendJson(res, 200, { ok: true, suggestion: result.suggestion, provider: result.route.provider, model: result.route.model });
|
|
255
333
|
} catch (error) {
|
|
256
|
-
if (!res.writableEnded)
|
|
334
|
+
if (!res.writableEnded) {
|
|
335
|
+
sendJson(res, 200, { ok: false, reason: String(error?.message ?? error), ms: Date.now() - startedAt });
|
|
336
|
+
}
|
|
257
337
|
} finally {
|
|
258
338
|
clearTimeout(timeout);
|
|
259
339
|
res.off('close', onClose);
|
package/lib/messages.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 端面向用户的文案(中英双语)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么 Host 也要有一份:设置页里显示的校验错误、预测失败原因都由 Host 生成,
|
|
5
|
+
* 客户端只是把 `reason` 原样显示出来。只做客户端 i18n 的话,切到英文后
|
|
6
|
+
* 「设置里的报错还是中文」——正是要修的那个不一致。
|
|
7
|
+
*
|
|
8
|
+
* 语言从哪来:由客户端在请求头 `x-dsh-locale` 里带上当前语言。
|
|
9
|
+
* 不读 profile 的 cordis.patch.yml,因为**默认情况下那里根本没有条目**
|
|
10
|
+
* (用户没手动切过语言时靠浏览器语言自动判定,不落盘)——按文件读只会在
|
|
11
|
+
* 最常见的情形下退回中文,等于没做。
|
|
12
|
+
*
|
|
13
|
+
* 这里只放 UI 文案;发给模型的提示词(predict.mjs 里的 framing / avoidHint)不在此列。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MESSAGES = {
|
|
17
|
+
zh: {
|
|
18
|
+
'err.notFound': 'not found',
|
|
19
|
+
'err.unauthorized': 'unauthorized',
|
|
20
|
+
'err.badJson': '请求体不是合法 JSON',
|
|
21
|
+
'err.missingRoute': '缺少 provider 或 model',
|
|
22
|
+
'err.missingSessionId': '缺少 sessionId',
|
|
23
|
+
'err.sessionNotFound': 'Session 不存在',
|
|
24
|
+
'err.disabled': '提示词联想已关闭',
|
|
25
|
+
'err.timeoutRange': '超时需在 1–60 秒之间',
|
|
26
|
+
'err.messagesRange': '上下文条数需在 2–20 之间',
|
|
27
|
+
'err.charsRange': '上下文预算需在 1000–20000 字符之间',
|
|
28
|
+
'err.tokensRange': '输出上限需在 16–512 token 之间',
|
|
29
|
+
'err.effortUnsupported': '不支持的思考深度',
|
|
30
|
+
'err.modelUnknown': 'provider "{provider}" 没有模型 "{model}"',
|
|
31
|
+
'err.providerUnavailable': 'provider "{provider}" 不可用:{detail}',
|
|
32
|
+
'err.effortNoLevels': '模型 "{model}" 未公布可调思考深度,请选「跟随模型默认」',
|
|
33
|
+
'err.effortLevelUnsupported': '模型 "{model}" 不支持思考深度 "{effort}"(可选:{options})',
|
|
34
|
+
'err.effortUnknown': '暂时无法确认模型支持的思考深度,请选「跟随模型默认」后保存',
|
|
35
|
+
'err.effortCatalog': '暂时无法获取该模型支持的思考深度',
|
|
36
|
+
'err.effortQueryTimeout': '模型能力查询超时',
|
|
37
|
+
'err.noHistory': '没有可用于预测的会话文本',
|
|
38
|
+
'err.noRoute': '无法确定联想所用模型,请在「设置 → 提示词联想」里显式选择提供商与模型',
|
|
39
|
+
'err.callFailed': '模型调用{kind}',
|
|
40
|
+
'err.noSuggestion': '模型未生成有效建议{detail}',
|
|
41
|
+
'err.reasoningOnly': '(模型只返回 reasoning:{text})',
|
|
42
|
+
},
|
|
43
|
+
en: {
|
|
44
|
+
'err.notFound': 'not found',
|
|
45
|
+
'err.unauthorized': 'unauthorized',
|
|
46
|
+
'err.badJson': 'Request body is not valid JSON',
|
|
47
|
+
'err.missingRoute': 'Missing provider or model',
|
|
48
|
+
'err.missingSessionId': 'Missing sessionId',
|
|
49
|
+
'err.sessionNotFound': 'Session not found',
|
|
50
|
+
'err.disabled': 'Prompt prediction is turned off',
|
|
51
|
+
'err.timeoutRange': 'Timeout must be between 1 and 60 seconds',
|
|
52
|
+
'err.messagesRange': 'Context size must be between 2 and 20 messages',
|
|
53
|
+
'err.charsRange': 'Context budget must be between 1000 and 20000 characters',
|
|
54
|
+
'err.tokensRange': 'Output cap must be between 16 and 512 tokens',
|
|
55
|
+
'err.effortUnsupported': 'Unsupported reasoning effort',
|
|
56
|
+
'err.modelUnknown': 'Provider "{provider}" has no model "{model}"',
|
|
57
|
+
'err.providerUnavailable': 'Provider "{provider}" is unavailable: {detail}',
|
|
58
|
+
'err.effortNoLevels': 'Model "{model}" publishes no adjustable reasoning levels; choose "Follow the model default"',
|
|
59
|
+
'err.effortLevelUnsupported': 'Model "{model}" does not support reasoning level "{effort}" (available: {options})',
|
|
60
|
+
'err.effortUnknown': 'Cannot confirm the supported reasoning levels right now; choose "Follow the model default" and save',
|
|
61
|
+
'err.effortCatalog': 'Cannot fetch the supported reasoning levels for this model right now',
|
|
62
|
+
'err.effortQueryTimeout': 'Model capability query timed out',
|
|
63
|
+
'err.noHistory': 'No conversation text available for prediction',
|
|
64
|
+
'err.noRoute': 'Cannot determine which model to use; pick a provider and model under Settings → Prompt suggestions',
|
|
65
|
+
'err.callFailed': 'Model call {kind}',
|
|
66
|
+
'err.noSuggestion': 'Model produced no usable suggestion{detail}',
|
|
67
|
+
'err.reasoningOnly': ' (model returned only reasoning: {text})',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** 任意输入 → 'zh' | 'en';认不出来一律按中文(插件的原始语言)。 */
|
|
72
|
+
export function normalizeLang(value) {
|
|
73
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
74
|
+
const text = String(raw ?? '').trim().toLowerCase();
|
|
75
|
+
if (!text) return 'zh';
|
|
76
|
+
if (text.startsWith('zh')) return 'zh';
|
|
77
|
+
if (text.startsWith('en')) return 'en';
|
|
78
|
+
return 'zh';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 列表分隔符也要跟着语言走:中文用顿号,英文用逗号。 */
|
|
82
|
+
export function listSeparator(lang) {
|
|
83
|
+
return lang === 'zh' ? '、' : ', ';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 生成翻译函数:{name} 占位符由自己做,词序交给各语言的模板。 */
|
|
87
|
+
export function makeT(lang) {
|
|
88
|
+
const dict = MESSAGES[lang] ?? MESSAGES.zh;
|
|
89
|
+
return function t(key, vars) {
|
|
90
|
+
let text = dict[key] ?? MESSAGES.zh[key] ?? key;
|
|
91
|
+
if (vars) {
|
|
92
|
+
text = text.replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined || vars[k] === null ? m : String(vars[k])));
|
|
93
|
+
}
|
|
94
|
+
return text;
|
|
95
|
+
};
|
|
96
|
+
}
|
package/lib/predict.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// 提示词预测核心:会话裁剪 → 调当前会话模型 → 归一化为一条可直接发送的建议。
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { makeT } from './messages.mjs';
|
|
4
|
+
|
|
5
|
+
// 默认按中文;Host 路由会按请求带来的语言换一个 t 进来。
|
|
6
|
+
const defaultT = makeT('zh');
|
|
3
7
|
|
|
4
8
|
const DEFAULT_SYSTEM = `你是输入框预测器。根据当前对话,预测用户下一条最可能发给助手的消息。
|
|
5
9
|
规则:
|
|
@@ -55,7 +59,11 @@ export function normalizeSuggestion(input, maxChars = 180) {
|
|
|
55
59
|
return text;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
/**
|
|
63
|
+
* 解析某次预测实际使用的路由:优先会话自身选用的模型,其次全局默认。
|
|
64
|
+
* 导出给 host 端复用——设置页展示「会话实际模型」时要用同一套解析,避免两处口径不一致。
|
|
65
|
+
*/
|
|
66
|
+
export function resolveRoute(ctx, session) {
|
|
59
67
|
try {
|
|
60
68
|
const header = session?.requestHeader?.();
|
|
61
69
|
const config = header?.config;
|
|
@@ -66,6 +74,15 @@ function selectionFor(ctx, session) {
|
|
|
66
74
|
return ctx.agentDefaultModel.currentSelection();
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
/** 拼「避免重复」提示:prefer 只作偏好,force 要求必须换一条。 */
|
|
78
|
+
function avoidHint(avoid, forceDifferent) {
|
|
79
|
+
if (typeof avoid !== 'string' || !avoid.trim()) return '';
|
|
80
|
+
const text = avoid.trim().slice(0, 200);
|
|
81
|
+
return forceDifferent
|
|
82
|
+
? `\n\n必须给出与下面这条**不同**的建议(用户已经看过它了,不要原样重复、也不要只改标点):\n${text}`
|
|
83
|
+
: `\n\n上一轮已经给过这条建议;如果它仍是当前最合适的可以再次给出,否则请换一条更贴合当前进展的:\n${text}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/** 生成一条建议。 */
|
|
70
87
|
export async function predictNextPrompt(ctx, session, {
|
|
71
88
|
signal,
|
|
@@ -75,12 +92,15 @@ export async function predictNextPrompt(ctx, session, {
|
|
|
75
92
|
system = DEFAULT_SYSTEM,
|
|
76
93
|
route: routeOverride,
|
|
77
94
|
reasoningEffort,
|
|
95
|
+
avoid,
|
|
96
|
+
forceDifferent = false,
|
|
97
|
+
t = defaultT,
|
|
78
98
|
} = {}) {
|
|
79
99
|
const history = recentMessages(session?.deriveMessages?.() ?? [], { maxMessages, maxChars });
|
|
80
|
-
if (history.length === 0) throw new Error('
|
|
81
|
-
const route = routeOverride ??
|
|
100
|
+
if (history.length === 0) throw new Error(t('err.noHistory'));
|
|
101
|
+
const route = routeOverride ?? resolveRoute(ctx, session);
|
|
82
102
|
if (typeof route?.provider !== 'string' || typeof route?.model !== 'string') {
|
|
83
|
-
throw new Error('
|
|
103
|
+
throw new Error(t('err.noRoute'));
|
|
84
104
|
}
|
|
85
105
|
const framedHistory = history.map((item) => {
|
|
86
106
|
const label = item.role === 'user' ? '用户' : '助手';
|
|
@@ -94,7 +114,7 @@ export async function predictNextPrompt(ctx, session, {
|
|
|
94
114
|
source: { kind: 'dsh-prompt-predict' },
|
|
95
115
|
content: [{
|
|
96
116
|
type: 'text',
|
|
97
|
-
text: `以下是当前会话最近内容:\n\n${framedHistory}\n\n
|
|
117
|
+
text: `以下是当前会话最近内容:\n\n${framedHistory}\n\n请预测我下一条最可能输入的消息。只输出预测内容。${avoidHint(avoid, forceDifferent)}`,
|
|
98
118
|
}],
|
|
99
119
|
}];
|
|
100
120
|
let output = '';
|
|
@@ -119,12 +139,12 @@ export async function predictNextPrompt(ctx, session, {
|
|
|
119
139
|
}
|
|
120
140
|
if (finish?.kind === 'error' || finish?.kind === 'aborted') {
|
|
121
141
|
const failure = finish.failure;
|
|
122
|
-
throw new Error(failure?.message ?? failure?.code ??
|
|
142
|
+
throw new Error(failure?.message ?? failure?.code ?? t('err.callFailed', { kind: finish.kind }));
|
|
123
143
|
}
|
|
124
144
|
const suggestion = normalizeSuggestion(output);
|
|
125
145
|
if (!suggestion) {
|
|
126
|
-
const detail = reasoning.trim() ?
|
|
127
|
-
throw new Error(
|
|
146
|
+
const detail = reasoning.trim() ? t('err.reasoningOnly', { text: normalizeSuggestion(reasoning, 80) }) : '';
|
|
147
|
+
throw new Error(t('err.noSuggestion', { detail }));
|
|
128
148
|
}
|
|
129
149
|
return { suggestion, route, historySize: history.length };
|
|
130
150
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-prompt-predict",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Codex-style composer predictions for DeepSeek Harness:
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "输入框提示词联想插件:助手回复结束后预测你的下一句话,以灰色 ghost text 浮在输入框里,Tab 采纳、Esc 忽略 | Codex-style composer prompt predictions for DeepSeek Harness: your next message ghosted into the composer, accepted with Tab.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
"lib",
|
|
16
16
|
"client",
|
|
17
17
|
"cordis.patch.yml",
|
|
18
|
-
"README.md"
|
|
18
|
+
"README.md",
|
|
19
|
+
"README.zh.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"CHANGELOG.zh.md"
|
|
19
22
|
],
|
|
20
23
|
"scripts": {
|
|
21
|
-
"test": "node test/predict.mjs",
|
|
24
|
+
"test": "node test/predict.mjs && node test/client-render.mjs",
|
|
22
25
|
"pack": "pnpm pack"
|
|
23
26
|
},
|
|
24
27
|
"repository": {
|