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.
@@ -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 `provider "${provider}" 没有模型 "${model}"`;
93
+ if (!models.some((m) => m.id === model)) return t('err.modelUnknown', { provider, model });
83
94
  return null;
84
95
  } catch (error) {
85
- return `provider "${provider}" 不可用:${String(error?.message ?? error)}`;
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('模型能力查询超时')); }, 3500);
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 `模型 "${model}" 未公布可调思考深度,请选「跟随模型默认」`;
102
- if (!efforts.includes(effort)) return `模型 "${model}" 不支持思考深度 "${effort}"(可选:${efforts.join('、')})`;
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: '缺少 provider 或 model' }); return; }
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: '请求体不是合法 JSON' }); return; }
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: '超时需在 1–60 秒之间' }); return; }
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: '上下文条数需在 2–20 之间' }); return; }
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: 'not found' });
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: '缺少 sessionId' });
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: 'Session 不存在' });
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
- const result = await predictNextPrompt(ctx, session, {
244
- signal: abort.signal,
245
- maxTokens: Number(eff.maxTokens) || 64,
246
- maxMessages: Number(eff.maxMessages) || 8,
247
- maxChars: Number(eff.maxChars) || 6000,
248
- route: (typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model)
249
- ? routeOf(body.provider, body.model)
250
- : routeOf(eff.provider, eff.model),
251
- reasoningEffort: typeof body.reasoningEffort === 'string' ? body.reasoningEffort
252
- : (eff.reasoningEffort && eff.reasoningEffort !== 'auto' ? eff.reasoningEffort : undefined),
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) sendJson(res, 200, { ok: false, reason: String(error?.message ?? error) });
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);
@@ -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
- function selectionFor(ctx, session) {
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 ?? selectionFor(ctx, session);
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 ?? `模型调用${finish.kind}`);
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() ? `(模型只返回 reasoning:${normalizeSuggestion(reasoning, 80)})` : '';
127
- throw new Error(`模型未生成有效建议${detail}`);
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.1.0",
4
- "description": "Codex-style composer predictions for DeepSeek Harness: suggest the user's next prompt after each reply and accept it with Tab.",
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": {