@yfwu2020/dsh-selection-explain 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -5
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1734 -77
- package/lib/index.js +513 -32
- package/lib/index.js.map +1 -1
- package/lib/settings.js +228 -0
- package/lib/settings.js.map +1 -0
- package/lib/types/index.d.ts +37 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +333 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +661 -10
- package/scripts/test-model-follow.mjs +193 -0
- package/scripts/test-route-cache.mjs +138 -0
- package/scripts/test-settings-behavior.mjs +188 -0
- package/scripts/test-settings-host.mjs +48 -0
- package/scripts/test-settings.mjs +329 -0
- package/src/client/index.js +1734 -77
- package/src/index.ts +549 -28
- package/src/settings.ts +310 -0
package/src/index.ts
CHANGED
|
@@ -32,6 +32,9 @@ import {
|
|
|
32
32
|
PROMPT_HEAD,
|
|
33
33
|
TRANSLATION_PROMPT,
|
|
34
34
|
} from './prompts.js'
|
|
35
|
+
// 设置页的字段声明与校验:与 Config 一一对应,GET /settings 直接把 spec 发给客户端,
|
|
36
|
+
// 客户端只负责画 —— 避免 UI 的边界和 host 的 clamp 各写一份、时间久了互相漂。
|
|
37
|
+
import { buildSettingsPayload, normalizeSettingsPatch, normalizeStoredSettings, PERSISTED_SETTING_KEYS, SETTINGS_KEYS, SETTINGS_GROUPS } from './settings.js'
|
|
35
38
|
|
|
36
39
|
/** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
|
|
37
40
|
export const name = '@yfwu2020/dsh-selection-explain'
|
|
@@ -66,12 +69,22 @@ const BODY_LIMIT = 512 * 1024
|
|
|
66
69
|
|
|
67
70
|
/** 插件配置。 */
|
|
68
71
|
export interface Config {
|
|
69
|
-
/** 固定 provider 路由;留空 =
|
|
72
|
+
/** 固定 provider 路由;留空 = 跟随主会话(用户在 DSH 里选的默认模型)。 */
|
|
70
73
|
provider: string
|
|
71
|
-
/** 固定 model id;留空 =
|
|
74
|
+
/** 固定 model id;留空 = 跟随主会话。 */
|
|
72
75
|
model: string
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* **追问**单独用的 provider;留空 = 跟首轮/次轮同一个(默认如此)。
|
|
78
|
+
*
|
|
79
|
+
* 为什么要能单独设:首轮/次轮要的是"读懂上下文",追问往往是"快速问一句",
|
|
80
|
+
* 两者对模型的要求不同(一个要准、一个要快)。
|
|
81
|
+
*/
|
|
82
|
+
chatProvider: string
|
|
83
|
+
/** **追问**单独用的 model id;留空 = 跟首轮/次轮同一个。 */
|
|
84
|
+
chatModel: string
|
|
85
|
+
/** **次轮详解**的推理档位(off / low / high / max);留空 = 适配器默认。 */
|
|
74
86
|
reasoningEffort: string
|
|
87
|
+
/** **首轮**(翻译 / 解读 / 注释)的推理档位。 */
|
|
75
88
|
translationReasoningEffort: string
|
|
76
89
|
historyMaxEntries: number
|
|
77
90
|
/** 采样温度。 */
|
|
@@ -116,7 +129,7 @@ export interface Config {
|
|
|
116
129
|
toolResultMaxChars: number
|
|
117
130
|
/** 文件类工具(read/grep/glob)的读取边界:'' = 会话工作目录,'*' = 不限制,其他 = 该目录 */
|
|
118
131
|
toolReadRoot: string
|
|
119
|
-
/**
|
|
132
|
+
/** 追问(对话小窗)用的推理档位:通常比次轮详解轻,响应更快。 */
|
|
120
133
|
chatReasoningEffort: string
|
|
121
134
|
/**
|
|
122
135
|
* 侧边栏 HTML 预览里的划词桥(默认开)。
|
|
@@ -128,15 +141,26 @@ export interface Config {
|
|
|
128
141
|
* 关掉 = 侧边栏网页里不弹「解读」按钮。
|
|
129
142
|
*/
|
|
130
143
|
bridgeSidebarPreview: boolean
|
|
144
|
+
/** 是否显示右下角悬浮胶囊(最近一次划词的状态入口)。 */
|
|
145
|
+
pillEnabled: boolean
|
|
146
|
+
/**
|
|
147
|
+
* 静置多久把胶囊收成小球(毫秒)。
|
|
148
|
+
*
|
|
149
|
+
* 只在**没装费用胶囊**时生效 —— 有费用胶囊时两者并排,收球会让它们错位。
|
|
150
|
+
* 默认 10 秒:短于这个数会显得"刚看一眼就没了",长了又失去"收起来不挡视线"的意义。
|
|
151
|
+
*/
|
|
152
|
+
pillIdleMs: number
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
/** 配置 schema(缺省值即推荐值)。 */
|
|
134
156
|
export const Config = z.object({
|
|
135
157
|
provider: z.string().default(''),
|
|
136
158
|
model: z.string().default(''),
|
|
159
|
+
chatProvider: z.string().default(''),
|
|
160
|
+
chatModel: z.string().default(''),
|
|
137
161
|
reasoningEffort: z.string().default('high'),
|
|
138
|
-
/**
|
|
139
|
-
translationReasoningEffort: z.string().default('
|
|
162
|
+
/** 首轮单独一档:默认关闭思考,优先快速给出翻译、解读或注释。 */
|
|
163
|
+
translationReasoningEffort: z.string().default('off'),
|
|
140
164
|
/**
|
|
141
165
|
* 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
|
|
142
166
|
* 0 = 关闭历史。只留最近 N 个「划词条目」(每个条目内含它自己的全部追问轮次)。
|
|
@@ -191,6 +215,10 @@ export const Config = z.object({
|
|
|
191
215
|
* 侧边栏网页里选中文字不会再弹「解读」。
|
|
192
216
|
*/
|
|
193
217
|
bridgeSidebarPreview: z.boolean().default(true),
|
|
218
|
+
/** 悬浮胶囊开关(小窗设置页可改)。 */
|
|
219
|
+
pillEnabled: z.boolean().default(true),
|
|
220
|
+
/** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
|
|
221
|
+
pillIdleMs: z.number().min(2000).max(600000).default(10000),
|
|
194
222
|
})
|
|
195
223
|
|
|
196
224
|
/** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
|
|
@@ -273,9 +301,27 @@ interface BackgroundOptions {
|
|
|
273
301
|
marker: string
|
|
274
302
|
}
|
|
275
303
|
|
|
276
|
-
/**
|
|
304
|
+
/**
|
|
305
|
+
* ctx.sessionQuery 的宽松视图。
|
|
306
|
+
*
|
|
307
|
+
* · readSurface —— 会话的**消息表面**(对话背景用)。
|
|
308
|
+
* · readSession —— **原始事件**(带 data)。读会话模型必须走它:
|
|
309
|
+
* `model/selection` 在文档里写明是 log-only,"never enters derived model history",
|
|
310
|
+
* 所以它**不会**出现在 readSurface 的 surface 里(实测:用它读到的永远是空)。
|
|
311
|
+
*/
|
|
277
312
|
interface SessionQueryLike {
|
|
278
313
|
readSurface?: (sessionId: string) => Promise<{ events?: readonly unknown[] }>
|
|
314
|
+
readSession?: (sessionId: string) => Promise<{ events?: readonly unknown[] }>
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* ctx.sessions 的宽松视图 —— 只用来做"会话有没有新事件"的零成本探测。
|
|
319
|
+
*
|
|
320
|
+
* `seq` 是 Session 上的 getter(= 日志长度,每次 append 都会涨),同步读、不碰日志;
|
|
321
|
+
* 拿它跟上次解析时记下的值一比,就知道"结论有没有可能变"(见 liveSeqOf)。
|
|
322
|
+
*/
|
|
323
|
+
interface SessionsLike {
|
|
324
|
+
get?: (sessionId: string) => { seq?: number } | undefined
|
|
279
325
|
}
|
|
280
326
|
|
|
281
327
|
/** 解析出的模型路由。 */
|
|
@@ -284,6 +330,9 @@ interface Route {
|
|
|
284
330
|
model: string
|
|
285
331
|
}
|
|
286
332
|
|
|
333
|
+
/** 会话自己记的模型选择(与 Route 同形,单独取名是为了在注释里说清"来源不同")。 */
|
|
334
|
+
type SessionModel = Route
|
|
335
|
+
|
|
287
336
|
/** 写一条 SSE 事件。 */
|
|
288
337
|
function sse(res: ServerResponse, payload: unknown): void {
|
|
289
338
|
if (res.writableEnded) return
|
|
@@ -1133,8 +1182,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1133
1182
|
const config: Config = {
|
|
1134
1183
|
provider: rawConfig?.provider ?? '',
|
|
1135
1184
|
model: rawConfig?.model ?? '',
|
|
1185
|
+
chatProvider: rawConfig?.chatProvider ?? '',
|
|
1186
|
+
chatModel: rawConfig?.chatModel ?? '',
|
|
1136
1187
|
reasoningEffort: rawConfig?.reasoningEffort ?? 'high',
|
|
1137
|
-
translationReasoningEffort: rawConfig?.translationReasoningEffort ?? '
|
|
1188
|
+
translationReasoningEffort: rawConfig?.translationReasoningEffort ?? 'off',
|
|
1138
1189
|
historyMaxEntries: rawConfig?.historyMaxEntries ?? 20,
|
|
1139
1190
|
temperature: rawConfig?.temperature ?? -1,
|
|
1140
1191
|
maxTokens: rawConfig?.maxTokens ?? 0,
|
|
@@ -1158,6 +1209,90 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1158
1209
|
toolReadRoot: rawConfig?.toolReadRoot ?? '',
|
|
1159
1210
|
chatReasoningEffort: rawConfig?.chatReasoningEffort ?? 'high',
|
|
1160
1211
|
bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
|
|
1212
|
+
pillEnabled: rawConfig?.pillEnabled ?? true,
|
|
1213
|
+
pillIdleMs: rawConfig?.pillIdleMs ?? 10000,
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* 出厂默认值(**不含**用户在设置页改过的部分)。
|
|
1218
|
+
*
|
|
1219
|
+
* 为什么单独留一份:设置页的「恢复默认」要能把值写回这里,
|
|
1220
|
+
* 而"这一项改过没有"也要拿它对比 —— 只有一个 config 的话,改完就再也说不出原值是什么。
|
|
1221
|
+
* 注意它取的是 rawConfig(cordis.patch.yml 里的配置),所以 yml 里写过的值也算"默认":
|
|
1222
|
+
* 用户在设置页恢复默认,应该回到 yml 写的那个数,而不是硬编码的出厂数。
|
|
1223
|
+
*/
|
|
1224
|
+
const configDefaults: Record<string, unknown> = { ...config } as unknown as Record<string, unknown>
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* 设置页写入的覆盖值(**只存改过的键**)。
|
|
1228
|
+
*
|
|
1229
|
+
* 落盘在插件自己的目录(与 history.json / pill.json 同级),不碰 cordis.patch.yml:
|
|
1230
|
+
* 那个文件是**装配清单**,让 UI 去改它既容易写坏结构,也要重启才生效。
|
|
1231
|
+
* 这里的内存值即刻生效(下方所有 config.x 都是每次请求现读的),落盘只为跨重启保留。
|
|
1232
|
+
*/
|
|
1233
|
+
let settingsOverrides: Record<string, unknown> = {}
|
|
1234
|
+
let settingsLoaded = false
|
|
1235
|
+
let settingsTimer: ReturnType<typeof setTimeout> | null = null
|
|
1236
|
+
const settingsFile = (): string =>
|
|
1237
|
+
join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'settings.json')
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* 把覆盖值套用到 config 上。
|
|
1241
|
+
*
|
|
1242
|
+
* 只接受 SETTINGS_KEYS 里的键,并且**类型必须与默认值一致** ——
|
|
1243
|
+
* 文件是手工可编辑的(也可能被旧版本写坏),不能让它把 boolean 写成字符串,
|
|
1244
|
+
* 那会让 `if (config.tools)` 这种判断悄悄变成永真。
|
|
1245
|
+
*/
|
|
1246
|
+
function applyOverrides(patch: Record<string, unknown>): void {
|
|
1247
|
+
const target = config as unknown as Record<string, unknown>
|
|
1248
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1249
|
+
if (!SETTINGS_KEYS.includes(key) && !PERSISTED_SETTING_KEYS.includes(key)) continue
|
|
1250
|
+
if (typeof value !== typeof configDefaults[key]) continue
|
|
1251
|
+
target[key] = value
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** 读设置覆盖文件(进程内只读一次,与 loadHistory / loadPillPos 同节奏)。 */
|
|
1256
|
+
async function loadSettings(): Promise<void> {
|
|
1257
|
+
if (settingsLoaded) return
|
|
1258
|
+
settingsLoaded = true
|
|
1259
|
+
try {
|
|
1260
|
+
const parsed = JSON.parse(await readFile(settingsFile(), 'utf8')) as { values?: unknown }
|
|
1261
|
+
const raw = parsed?.values
|
|
1262
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1263
|
+
const values = normalizeStoredSettings(raw)
|
|
1264
|
+
settingsOverrides = values
|
|
1265
|
+
applyOverrides(values)
|
|
1266
|
+
}
|
|
1267
|
+
} catch {
|
|
1268
|
+
// 没存过 / 坏了都当作"没改过":出厂默认本来就是可用状态,没必要因此报错
|
|
1269
|
+
settingsOverrides = {}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** 落盘(防抖:拖数字输入框会连发多次,没必要每次都写文件)。 */
|
|
1274
|
+
function scheduleSettingsWrite(): void {
|
|
1275
|
+
if (settingsTimer) clearTimeout(settingsTimer)
|
|
1276
|
+
settingsTimer = setTimeout(() => {
|
|
1277
|
+
settingsTimer = null
|
|
1278
|
+
void (async () => {
|
|
1279
|
+
try {
|
|
1280
|
+
await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true })
|
|
1281
|
+
await writeFile(settingsFile(), JSON.stringify({ version: 1, values: settingsOverrides }, null, 0), 'utf8')
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
ctx.logger?.warn?.(`[${name}] 写入设置失败:${String((error as Error)?.message ?? error)}`)
|
|
1284
|
+
}
|
|
1285
|
+
})()
|
|
1286
|
+
}, 300)
|
|
1287
|
+
settingsTimer.unref?.()
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** 当前生效值(按 spec 的键序取,客户端可以直接对比 defaults 判断改过没有)。 */
|
|
1291
|
+
function currentSettings(): Record<string, unknown> {
|
|
1292
|
+
const target = config as unknown as Record<string, unknown>
|
|
1293
|
+
const values: Record<string, unknown> = {}
|
|
1294
|
+
for (const key of SETTINGS_KEYS) values[key] = target[key]
|
|
1295
|
+
return values
|
|
1161
1296
|
}
|
|
1162
1297
|
|
|
1163
1298
|
/** 兜底路由:第一次真实 LLM 调用后捕获。 */
|
|
@@ -1186,6 +1321,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1186
1321
|
let modelCache: { at: number; items: ModelChoice[] } = { at: 0, items: [] }
|
|
1187
1322
|
/** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
|
|
1188
1323
|
const leakyOnOff = new Set<string>()
|
|
1324
|
+
/**
|
|
1325
|
+
* 哪些 provider/model **根本不接受 "关" 档**(上游直接 400 invalid_request_error)。
|
|
1326
|
+
*
|
|
1327
|
+
* 和 leakyOnOff 分开记:那个是"能发但会泄漏",这个是"发了就报错"。
|
|
1328
|
+
* 记下来之后不再发 off,直接改用 low —— 这是"最接近关"的可接受档位。
|
|
1329
|
+
* 不记的话每次划词都要先失败一轮再重试:用户看到一次报错闪动 + 多一个来回的时延。
|
|
1330
|
+
*/
|
|
1331
|
+
const noOffRoutes = new Set<string>()
|
|
1189
1332
|
const leakyFile = (): string => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'leaky-off.json')
|
|
1190
1333
|
let leakyLoaded = false
|
|
1191
1334
|
|
|
@@ -1195,17 +1338,20 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1195
1338
|
leakyLoaded = true
|
|
1196
1339
|
try {
|
|
1197
1340
|
const raw = await readFile(leakyFile(), 'utf8')
|
|
1198
|
-
const parsed = JSON.parse(raw) as { routes?: unknown }
|
|
1341
|
+
const parsed = JSON.parse(raw) as { routes?: unknown; noOff?: unknown }
|
|
1199
1342
|
if (Array.isArray(parsed?.routes)) {
|
|
1200
1343
|
for (const item of parsed.routes) if (typeof item === 'string' && item) leakyOnOff.add(item)
|
|
1201
1344
|
}
|
|
1345
|
+
if (Array.isArray(parsed?.noOff)) {
|
|
1346
|
+
for (const item of parsed.noOff) if (typeof item === 'string' && item) noOffRoutes.add(item)
|
|
1347
|
+
}
|
|
1202
1348
|
} catch {
|
|
1203
1349
|
/* 没有这个文件是常态 */
|
|
1204
1350
|
}
|
|
1205
1351
|
}
|
|
1206
1352
|
|
|
1207
1353
|
function saveLeaky(): void {
|
|
1208
|
-
const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff) }, null, 0)
|
|
1354
|
+
const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff), noOff: Array.from(noOffRoutes) }, null, 0)
|
|
1209
1355
|
void writeFile(leakyFile(), body, 'utf8').catch((error: unknown) => {
|
|
1210
1356
|
ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String((error as Error)?.message ?? error)}`)
|
|
1211
1357
|
})
|
|
@@ -1217,6 +1363,13 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1217
1363
|
leakyOnOff.add(route)
|
|
1218
1364
|
if (leakyLoaded) saveLeaky()
|
|
1219
1365
|
}
|
|
1366
|
+
|
|
1367
|
+
/** 记住一个"不接受关档"的模型(内存 + 落盘):下次直接改用 low,不再先撞一次。 */
|
|
1368
|
+
function markNoOff(route: string): void {
|
|
1369
|
+
if (noOffRoutes.has(route)) return
|
|
1370
|
+
noOffRoutes.add(route)
|
|
1371
|
+
if (leakyLoaded) saveLeaky()
|
|
1372
|
+
}
|
|
1220
1373
|
const MODEL_CACHE_MS = 60_000
|
|
1221
1374
|
|
|
1222
1375
|
async function listModelChoices(force = false): Promise<ModelChoice[]> {
|
|
@@ -1283,13 +1436,36 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1283
1436
|
}
|
|
1284
1437
|
|
|
1285
1438
|
/** 请求里带 provider+model 时优先用它(并按清单校验;清单还没加载出来就只做形状校验)。 */
|
|
1286
|
-
const resolveRoute = (
|
|
1439
|
+
const resolveRoute = (
|
|
1440
|
+
override?: { provider?: string; model?: string },
|
|
1441
|
+
/**
|
|
1442
|
+
* **当前会话**自己的模型选择(见 sessionModelOf)。
|
|
1443
|
+
*
|
|
1444
|
+
* 为什么要有它:会话是各自选模型的(每个 Agent 一份 model-selection),
|
|
1445
|
+
* 而 `agentDefaultModel.currentSelection()` 是**全局默认**、与会话无关。
|
|
1446
|
+
* 以前只用后者,于是"跟随主会话"其实跟的是"全局默认"——
|
|
1447
|
+
* 两个会话用不同模型时,插件对两个会话都用同一个默认模型,跟谁都对不上。
|
|
1448
|
+
*
|
|
1449
|
+
* 优先级:显式 override(小窗胶囊选的)→ 插件配置(设置页选的)→ **本会话的模型** → 全局默认 → 第一个可用。
|
|
1450
|
+
* 会话没记过选择(新会话、或日志里没有 model/selection)时自然落回全局默认,行为与以前一致。
|
|
1451
|
+
*/
|
|
1452
|
+
sessionModel?: { provider?: string; model?: string } | null,
|
|
1453
|
+
/**
|
|
1454
|
+
* 这一轮是不是**追问**。
|
|
1455
|
+
*
|
|
1456
|
+
* 三个阶段沿用同一模型;旧 chatProvider/chatModel 仅保留配置兼容,不再参与路由。
|
|
1457
|
+
*/
|
|
1458
|
+
chat?: boolean,
|
|
1459
|
+
): Route | null => {
|
|
1287
1460
|
if (override?.provider && override?.model) {
|
|
1288
1461
|
const wanted = { provider: override.provider, model: override.model }
|
|
1289
1462
|
const known = modelCache.items.some((item) => item.provider === wanted.provider && item.model === wanted.model)
|
|
1290
1463
|
if (known || modelCache.items.length === 0) return wanted
|
|
1291
1464
|
}
|
|
1292
1465
|
if (config.provider && config.model) return { provider: config.provider, model: config.model }
|
|
1466
|
+
if (sessionModel?.provider && sessionModel?.model) {
|
|
1467
|
+
return { provider: sessionModel.provider, model: sessionModel.model }
|
|
1468
|
+
}
|
|
1293
1469
|
const service = ctx.get('agentDefaultModel') as AgentDefaultModelConfig | undefined
|
|
1294
1470
|
const selection = service?.currentSelection?.()
|
|
1295
1471
|
if (selection?.provider && selection?.model) {
|
|
@@ -1324,6 +1500,37 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1324
1500
|
/** 会话背景短缓存:同一会话 10 秒内复用(连续划词不重复读日志)。 */
|
|
1325
1501
|
const backgroundCache = new Map<string, { at: number; events: readonly unknown[] }>()
|
|
1326
1502
|
|
|
1503
|
+
/**
|
|
1504
|
+
* 「本会话在用哪个模型」的短缓存 —— 只给按秒轮询的 `/route` 用(见 sessionModelOf)。
|
|
1505
|
+
*
|
|
1506
|
+
* 为什么需要:`readSession` 会把整份日志重放成一个 Session、再逐条深拷贝一遍
|
|
1507
|
+
* (本机实测 1.5MB 的会话一次 80~125ms)。跟随状态下**小窗一开着**就要每 2 秒问一次,
|
|
1508
|
+
* 不缓存等于每 2 秒在宿主进程里重放一次会话日志。
|
|
1509
|
+
*
|
|
1510
|
+
* 变更探测用 live 会话的 `seq`(同步、零成本,见 liveSeqOf):没涨就直接回缓存;
|
|
1511
|
+
* 涨了(主会话正在连续对话时每轮都在涨)也至少隔 SESSION_MODEL_MIN_MS 才重读一次,
|
|
1512
|
+
* 否则"小窗开着 + 主会话在聊"时每 2 秒照样重放。非 live(磁盘上的旧会话,读不到 seq)按 TTL 兜底。
|
|
1513
|
+
*/
|
|
1514
|
+
const sessionModelCache = new Map<string, { at: number; seq: number | null; value: SessionModel | null }>()
|
|
1515
|
+
/** 同一会话正在飞的读取(读可能比轮询间隔还慢,叠起来没意义)。 */
|
|
1516
|
+
const sessionModelPending = new Map<string, Promise<SessionModel | null>>()
|
|
1517
|
+
/** live 会话两次重读之间的最短间隔(挡住"事件一直在涨"时的连续重放)。 */
|
|
1518
|
+
const SESSION_MODEL_MIN_MS = 3_000
|
|
1519
|
+
/** 读不到 seq(非 live)时的兜底 TTL。 */
|
|
1520
|
+
const SESSION_MODEL_TTL_MS = 10_000
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* live 会话的事件数(`Session.seq` = 日志长度,每次 append 都会涨)。
|
|
1524
|
+
*
|
|
1525
|
+
* 这是**零成本**的同步读取:不用读日志、不用重放,就能判断"会话有没有新事件"。
|
|
1526
|
+
* 拿不到(会话不在内存里 / 老内核没有 sessions 服务)返回 null,调用方退回 TTL。
|
|
1527
|
+
*/
|
|
1528
|
+
const liveSeqOf = (sessionId: string): number | null => {
|
|
1529
|
+
const sessions = ctx.get('sessions') as SessionsLike | undefined
|
|
1530
|
+
const seq = sessions?.get?.(sessionId)?.seq
|
|
1531
|
+
return typeof seq === 'number' ? seq : null
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1327
1534
|
/** 读取当前会话的模型界面事件(live 优先;失败/超时一律降级为「无背景」)。 */
|
|
1328
1535
|
const readSurfaceEvents = async (sessionId: string): Promise<readonly unknown[]> => {
|
|
1329
1536
|
const cached = backgroundCache.get(sessionId)
|
|
@@ -1346,6 +1553,92 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1346
1553
|
}
|
|
1347
1554
|
}
|
|
1348
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* 真读一次:拿会话原始事件里的最后一条 `model/selection`。
|
|
1558
|
+
*
|
|
1559
|
+
* 来源:会话事件流里的 `model/selection`(每条 = 一次"后续请求用这个模型"的选择,
|
|
1560
|
+
* 形如 `{ type:'model/selection', data:{ provider, model, reasoningEffort? } }`)。
|
|
1561
|
+
* 取**最后一条** = 会话当前在用的那个;一条都没有(新会话)就返回 null,调用方落回全局默认。
|
|
1562
|
+
*/
|
|
1563
|
+
const readSessionModel = async (sessionId: string): Promise<SessionModel | null> => {
|
|
1564
|
+
if (!sessionId) return null
|
|
1565
|
+
const sessionQuery = ctx.get('sessionQuery') as SessionQueryLike | undefined
|
|
1566
|
+
if (typeof sessionQuery?.readSession !== 'function') return null
|
|
1567
|
+
let events: readonly unknown[] = []
|
|
1568
|
+
try {
|
|
1569
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000))
|
|
1570
|
+
const log = await Promise.race([sessionQuery.readSession(sessionId), timeout])
|
|
1571
|
+
events = log && Array.isArray((log as { events?: unknown }).events) ? ((log as { events: readonly unknown[] }).events) : []
|
|
1572
|
+
} catch {
|
|
1573
|
+
return null
|
|
1574
|
+
}
|
|
1575
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1576
|
+
const event = events[i]
|
|
1577
|
+
if (!event || typeof event !== 'object') continue
|
|
1578
|
+
const typed = event as { type?: unknown; data?: unknown }
|
|
1579
|
+
if (typed.type !== 'model/selection') continue
|
|
1580
|
+
const data = (typed.data ?? {}) as { provider?: unknown; model?: unknown }
|
|
1581
|
+
if (typeof data.provider === 'string' && data.provider && typeof data.model === 'string' && data.model) {
|
|
1582
|
+
return { provider: data.provider, model: data.model }
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return null
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* 记下这次的结论(连同"读到它时的 seq"):下次轮询就能靠 seq 判断结论还有效没。
|
|
1590
|
+
*
|
|
1591
|
+
* 顺手也把"用户动作路径"读到的值种进缓存 —— 那是**此刻**那一份,
|
|
1592
|
+
* 种进来只会让轮询更少重读、更不容易报旧值。
|
|
1593
|
+
*/
|
|
1594
|
+
const rememberSessionModel = (sessionId: string, seq: number | null, value: SessionModel | null): void => {
|
|
1595
|
+
sessionModelCache.set(sessionId, { at: Date.now(), seq, value })
|
|
1596
|
+
if (sessionModelCache.size > 8) {
|
|
1597
|
+
const oldest = sessionModelCache.keys().next().value
|
|
1598
|
+
if (oldest !== undefined) sessionModelCache.delete(oldest)
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* 读**当前会话**自己的模型选择。
|
|
1604
|
+
*
|
|
1605
|
+
* `useCache` 只给按秒轮询的 `/route` 打开;用户动作路径(analyze / models / ping)一律直读,
|
|
1606
|
+
* 拿到的必须是**此刻**那一份 —— 不能让人刚在主会话里换完模型、划词却按旧模型解析。
|
|
1607
|
+
* 直读的结果会顺手种进缓存(见 rememberSessionModel)。
|
|
1608
|
+
*/
|
|
1609
|
+
const sessionModelOf = async (sessionId: string, useCache = false): Promise<SessionModel | null> => {
|
|
1610
|
+
if (!sessionId) return null
|
|
1611
|
+
if (!useCache) {
|
|
1612
|
+
// seq 在读**之前**取:读的过程中若又落了事件,缓存就应当被视为旧值(宁可下次重读)
|
|
1613
|
+
const seqNow = liveSeqOf(sessionId)
|
|
1614
|
+
const value = await readSessionModel(sessionId)
|
|
1615
|
+
rememberSessionModel(sessionId, seqNow, value)
|
|
1616
|
+
return value
|
|
1617
|
+
}
|
|
1618
|
+
const seq = liveSeqOf(sessionId)
|
|
1619
|
+
const cached = sessionModelCache.get(sessionId)
|
|
1620
|
+
if (cached) {
|
|
1621
|
+
// 事件没涨 → 结论不可能变。只对 live 会话成立:`seq` 是同步的,每次 append 都会涨。
|
|
1622
|
+
if (seq !== null && cached.seq === seq) return cached.value
|
|
1623
|
+
// 涨了也不立刻重读:主会话正在连续对话时事件一直在涨,否则每 2 秒照样重放一次日志。
|
|
1624
|
+
const floor = seq === null ? SESSION_MODEL_TTL_MS : SESSION_MODEL_MIN_MS
|
|
1625
|
+
if (Date.now() - cached.at < floor) return cached.value
|
|
1626
|
+
}
|
|
1627
|
+
// 同一会话的轮询叠在一起(读可能比轮询间隔还慢)时共用一个在飞的请求
|
|
1628
|
+
const pending = sessionModelPending.get(sessionId)
|
|
1629
|
+
if (pending) return pending
|
|
1630
|
+
const task = readSessionModel(sessionId)
|
|
1631
|
+
.then((value) => {
|
|
1632
|
+
rememberSessionModel(sessionId, seq, value)
|
|
1633
|
+
return value
|
|
1634
|
+
})
|
|
1635
|
+
.finally(() => {
|
|
1636
|
+
sessionModelPending.delete(sessionId)
|
|
1637
|
+
})
|
|
1638
|
+
sessionModelPending.set(sessionId, task)
|
|
1639
|
+
return task
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1349
1642
|
/** 组装会话背景文本。 */
|
|
1350
1643
|
const loadSessionBackground = async (
|
|
1351
1644
|
sessionId: string,
|
|
@@ -1449,6 +1742,105 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1449
1742
|
pillPosTimer.unref?.()
|
|
1450
1743
|
}
|
|
1451
1744
|
|
|
1745
|
+
/**
|
|
1746
|
+
* 小窗的**尺寸**(拖角改过大小之后记下来)。
|
|
1747
|
+
*
|
|
1748
|
+
* 只记尺寸,**不记位置**:小窗每次开窗都由 client 按选区重新锚定
|
|
1749
|
+
* (落在选区下方,放不下就翻到上方,再夹进视口)—— 那套锚定逻辑是既有的设计,
|
|
1750
|
+
* 位置一旦被"记住"就等于架空了它,开窗不再跟着划词的地方走。
|
|
1751
|
+
* 尺寸则是用户明确调过的东西,值得跨开窗保留。
|
|
1752
|
+
*/
|
|
1753
|
+
const panelSizeFile = (): string =>
|
|
1754
|
+
join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'panel.json')
|
|
1755
|
+
|
|
1756
|
+
let panelSize: { w: number; h: number } | null = null
|
|
1757
|
+
let panelSizeLoaded = false
|
|
1758
|
+
let panelSizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* 尺寸校验:两个数都得是有限值,并夹到一个合理的区间。
|
|
1762
|
+
*
|
|
1763
|
+
* client 那边已经有 min/max 了,但文件是手工可编辑的(也可能被旧版本写坏),
|
|
1764
|
+
* 不能让一个 5px 宽的小窗把界面弄成没法用。
|
|
1765
|
+
*/
|
|
1766
|
+
function normalizePanelSize(input: unknown): { w: number; h: number } | null {
|
|
1767
|
+
const value = input as { w?: unknown; h?: unknown } | null | undefined
|
|
1768
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : NaN)
|
|
1769
|
+
const w = num(value?.w)
|
|
1770
|
+
const h = num(value?.h)
|
|
1771
|
+
if (!Number.isFinite(w) || !Number.isFinite(h)) return null
|
|
1772
|
+
const clamp = (n: number, lo: number, hi: number): number => Math.round(Math.min(hi, Math.max(lo, n)))
|
|
1773
|
+
return { w: clamp(w, 280, 4000), h: clamp(h, 200, 4000) }
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
async function loadPanelSize(): Promise<void> {
|
|
1777
|
+
if (panelSizeLoaded) return
|
|
1778
|
+
panelSizeLoaded = true
|
|
1779
|
+
try {
|
|
1780
|
+
const parsed = JSON.parse(await readFile(panelSizeFile(), 'utf8')) as { size?: unknown }
|
|
1781
|
+
panelSize = normalizePanelSize(parsed?.size)
|
|
1782
|
+
} catch {
|
|
1783
|
+
panelSize = null // 没存过 / 坏了都当作"没调过"
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function schedulePanelSizeWrite(): void {
|
|
1788
|
+
if (panelSizeTimer) clearTimeout(panelSizeTimer)
|
|
1789
|
+
panelSizeTimer = setTimeout(() => {
|
|
1790
|
+
panelSizeTimer = null
|
|
1791
|
+
void (async () => {
|
|
1792
|
+
try {
|
|
1793
|
+
await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true })
|
|
1794
|
+
await writeFile(panelSizeFile(), JSON.stringify({ version: 1, size: panelSize }, null, 0), 'utf8')
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
ctx.logger?.warn?.(`[${name}] 写入小窗尺寸失败:${String((error as Error)?.message ?? error)}`)
|
|
1797
|
+
}
|
|
1798
|
+
})()
|
|
1799
|
+
}, 300)
|
|
1800
|
+
panelSizeTimer.unref?.()
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* 小窗尺寸:GET 读回来(挂载时恢复)、POST 存({w,h})或清({clear:true},回到默认尺寸)。
|
|
1805
|
+
*/
|
|
1806
|
+
const handlePanelSize = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1807
|
+
void (async () => {
|
|
1808
|
+
await loadPanelSize()
|
|
1809
|
+
if (req.method === 'GET') {
|
|
1810
|
+
sendJson(res, 200, { ok: true, size: panelSize })
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
if (req.method !== 'POST') {
|
|
1814
|
+
sendJson(res, 405, { ok: false, error: '只支持 GET / POST' })
|
|
1815
|
+
return
|
|
1816
|
+
}
|
|
1817
|
+
const raw = await readBody(req, 4_000)
|
|
1818
|
+
let body: { size?: unknown; clear?: unknown } = {}
|
|
1819
|
+
try {
|
|
1820
|
+
body = raw ? (JSON.parse(raw) as { size?: unknown; clear?: unknown }) : {}
|
|
1821
|
+
} catch {
|
|
1822
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
if (body.clear === true) {
|
|
1826
|
+
panelSize = null
|
|
1827
|
+
schedulePanelSizeWrite()
|
|
1828
|
+
sendJson(res, 200, { ok: true, size: null })
|
|
1829
|
+
return
|
|
1830
|
+
}
|
|
1831
|
+
const next = normalizePanelSize(body.size ?? body)
|
|
1832
|
+
if (!next) {
|
|
1833
|
+
sendJson(res, 400, { ok: false, error: 'size 需要 { w, h } 两个有限数值' })
|
|
1834
|
+
return
|
|
1835
|
+
}
|
|
1836
|
+
panelSize = next
|
|
1837
|
+
schedulePanelSizeWrite()
|
|
1838
|
+
sendJson(res, 200, { ok: true, size: panelSize })
|
|
1839
|
+
})().catch((error: unknown) => {
|
|
1840
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1841
|
+
})
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1452
1844
|
/**
|
|
1453
1845
|
* 胶囊位置:GET 读回来(挂载时恢复)、POST 存({right,y})或清({clear:true},吸附回右下角时调)。
|
|
1454
1846
|
*/
|
|
@@ -1503,7 +1895,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1503
1895
|
if (typeof entry?.key !== 'string' || entry.key.length === 0) continue
|
|
1504
1896
|
history.set(entry.key, {
|
|
1505
1897
|
key: entry.key,
|
|
1506
|
-
|
|
1898
|
+
// 按可设置的最高选区长度保留,调低当前限制也不截断已有历史。
|
|
1899
|
+
text: clampText(entry.text, 20000),
|
|
1507
1900
|
context: clampText(entry.context, 20000),
|
|
1508
1901
|
label: clampText(entry.label, 60),
|
|
1509
1902
|
at: typeof entry.at === 'number' ? entry.at : Date.now(),
|
|
@@ -1555,7 +1948,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1555
1948
|
const previous = history.get(key)
|
|
1556
1949
|
const entry: HistoryEntry = {
|
|
1557
1950
|
key,
|
|
1558
|
-
text: clampText(input.text,
|
|
1951
|
+
text: clampText(input.text, 20000),
|
|
1559
1952
|
context: clampText(input.context, 20000),
|
|
1560
1953
|
label: clampText(input.label, 60),
|
|
1561
1954
|
at: Date.now(),
|
|
@@ -1902,11 +2295,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
1902
2295
|
|
|
1903
2296
|
const handleHistory = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1904
2297
|
void (async () => {
|
|
2298
|
+
await loadSettings()
|
|
1905
2299
|
await loadHistory()
|
|
1906
|
-
if (config.historyMaxEntries <= 0) {
|
|
1907
|
-
sendJson(res, 200, { ok: true, entries: [] })
|
|
1908
|
-
return
|
|
1909
|
-
}
|
|
1910
2300
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1911
2301
|
if (req.method === 'DELETE') {
|
|
1912
2302
|
// 删除一条(?key=)或清空(?all=1)。
|
|
@@ -2002,7 +2392,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2002
2392
|
} catch (thrown) {
|
|
2003
2393
|
error = String((thrown as Error)?.message ?? thrown)
|
|
2004
2394
|
}
|
|
2005
|
-
|
|
2395
|
+
// 会话级:设置页的「跟随主会话」要显示**当前会话**在用的模型
|
|
2396
|
+
let modelsSessionId = ''
|
|
2397
|
+
try {
|
|
2398
|
+
modelsSessionId = url.searchParams.get('sessionId') ?? ''
|
|
2399
|
+
} catch {
|
|
2400
|
+
/* 解析不了就当没带 */
|
|
2401
|
+
}
|
|
2402
|
+
const current = resolveRoute(undefined, await sessionModelOf(modelsSessionId))
|
|
2006
2403
|
sendJson(res, 200, {
|
|
2007
2404
|
ok: true,
|
|
2008
2405
|
current,
|
|
@@ -2020,6 +2417,90 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2020
2417
|
})
|
|
2021
2418
|
}
|
|
2022
2419
|
|
|
2420
|
+
/**
|
|
2421
|
+
* 设置页的读写口。
|
|
2422
|
+
*
|
|
2423
|
+
* GET → { groups, values, defaults }:spec 一并下发,客户端只负责画
|
|
2424
|
+
* POST → { values: {...} }:归一 → 立刻生效(改内存)→ 防抖落盘
|
|
2425
|
+
* POST { reset: true } :清空覆盖,回到 cordis.patch.yml / 出厂默认
|
|
2426
|
+
*
|
|
2427
|
+
* 为什么不是"写回 cordis.patch.yml":那是装配清单,
|
|
2428
|
+
* 让 UI 去改它既要人肉重启、又可能把 YAML 结构写坏;这里落在插件自己的目录,改完即时生效。
|
|
2429
|
+
*/
|
|
2430
|
+
const handleSettings = (req: IncomingMessage, res: ServerResponse): void => {
|
|
2431
|
+
void (async () => {
|
|
2432
|
+
await loadSettings()
|
|
2433
|
+
if (req.method === 'POST') {
|
|
2434
|
+
const raw = await readBody(req, 200_000)
|
|
2435
|
+
let body: { values?: unknown; reset?: unknown } = {}
|
|
2436
|
+
try {
|
|
2437
|
+
body = raw ? (JSON.parse(raw) as { values?: unknown; reset?: unknown }) : {}
|
|
2438
|
+
} catch {
|
|
2439
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
2440
|
+
return
|
|
2441
|
+
}
|
|
2442
|
+
if (body.reset === true) {
|
|
2443
|
+
settingsOverrides = {}
|
|
2444
|
+
applyOverrides(configDefaults)
|
|
2445
|
+
scheduleSettingsWrite()
|
|
2446
|
+
sendJson(res, 200, { ok: true, values: currentSettings(), rejected: [] })
|
|
2447
|
+
return
|
|
2448
|
+
}
|
|
2449
|
+
const result = normalizeSettingsPatch(body.values)
|
|
2450
|
+
if (Object.keys(result.values).length === 0 && result.rejected.length > 0) {
|
|
2451
|
+
sendJson(res, 400, { ok: false, error: '没有可写入的字段', rejected: result.rejected })
|
|
2452
|
+
return
|
|
2453
|
+
}
|
|
2454
|
+
// 合并而不是覆盖:只发一个键的补丁不该把其它改过的项抹掉
|
|
2455
|
+
settingsOverrides = { ...settingsOverrides, ...result.values }
|
|
2456
|
+
applyOverrides(result.values)
|
|
2457
|
+
scheduleSettingsWrite()
|
|
2458
|
+
sendJson(res, 200, { ok: true, values: currentSettings(), rejected: result.rejected })
|
|
2459
|
+
return
|
|
2460
|
+
}
|
|
2461
|
+
sendJson(res, 200, { ok: true, ...buildSettingsPayload(currentSettings(), configDefaults) })
|
|
2462
|
+
})().catch((error: unknown) => {
|
|
2463
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
2464
|
+
})
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/**
|
|
2468
|
+
* 极轻的"这一轮会用哪个模型"查询。
|
|
2469
|
+
*
|
|
2470
|
+
* GET /selection-explain/api/route?sessionId=…[&chat=1]
|
|
2471
|
+
*
|
|
2472
|
+
* 为什么单开一条:设置页要**实时**显示当前会话在用的模型,
|
|
2473
|
+
* 而 /models 会去列 provider 与模型(42 个,还要端点发现)、/ping 要枚举工具表并探 DNS ——
|
|
2474
|
+
* 两者都不适合按秒轮询。这条只做一次会话模型解析 + 一次 resolveRoute。
|
|
2475
|
+
*
|
|
2476
|
+
* 也是唯一走 `sessionModelOf(…, true)`(短缓存)的地方:客户端在小窗开着时每 2 秒问一次,
|
|
2477
|
+
* 不能每拍都重放一次会话日志(见 sessionModelCache 的注释)。
|
|
2478
|
+
*/
|
|
2479
|
+
const handleRoute = (req: IncomingMessage, res: ServerResponse): void => {
|
|
2480
|
+
void (async () => {
|
|
2481
|
+
await loadSettings()
|
|
2482
|
+
let sessionId = ''
|
|
2483
|
+
let chat = false
|
|
2484
|
+
try {
|
|
2485
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
2486
|
+
sessionId = url.searchParams.get('sessionId') ?? ''
|
|
2487
|
+
chat = url.searchParams.get('chat') === '1'
|
|
2488
|
+
} catch {
|
|
2489
|
+
/* 解析不了就当没带 */
|
|
2490
|
+
}
|
|
2491
|
+
const route = resolveRoute(undefined, await sessionModelOf(sessionId, true), chat)
|
|
2492
|
+
sendJson(res, 200, {
|
|
2493
|
+
ok: true,
|
|
2494
|
+
provider: route?.provider ?? '',
|
|
2495
|
+
model: route?.model ?? '',
|
|
2496
|
+
// 是不是"跟首轮/次轮同一个":客户端据此把追问下拉显示成「跟随首轮 / 次轮」
|
|
2497
|
+
chatFollows: true,
|
|
2498
|
+
})
|
|
2499
|
+
})().catch((error: unknown) => {
|
|
2500
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
2501
|
+
})
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2023
2504
|
const handlePing = (req: IncomingMessage, res: ServerResponse): void => {
|
|
2024
2505
|
const sessionQuery = ctx.get('sessionQuery') as SessionQueryLike | undefined
|
|
2025
2506
|
// 可以带 ?sessionId=xxx:工具是**按会话作用域**注册的,不带 id 只能看到全局视图(里面没有 web_search)
|
|
@@ -2032,6 +2513,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2032
2513
|
// 小窗能调哪些工具:白名单是配置,实际放行还要看本机能不能抓网页(见下)。
|
|
2033
2514
|
// 用 async 包一层:探测 DNS 是异步的,出错也要给出结构化响应(和 history 路由同一写法)。
|
|
2034
2515
|
void (async () => {
|
|
2516
|
+
// 先把用户改过的设置套上:下面报的每一条都必须是**当前生效**的值
|
|
2517
|
+
await loadSettings()
|
|
2035
2518
|
const whitelist = config.toolNames
|
|
2036
2519
|
.split(',')
|
|
2037
2520
|
.map((item) => item.trim())
|
|
@@ -2073,7 +2556,7 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2073
2556
|
sendJson(res, 200, {
|
|
2074
2557
|
ok: true,
|
|
2075
2558
|
plugin: name,
|
|
2076
|
-
route: resolveRoute(),
|
|
2559
|
+
route: resolveRoute(undefined, await sessionModelOf(pingSessionId)),
|
|
2077
2560
|
reasoningEffort: config.reasoningEffort,
|
|
2078
2561
|
modelsRoute: `${API_PREFIX}/models`,
|
|
2079
2562
|
history: { maxEntries: config.historyMaxEntries, file: historyFile() },
|
|
@@ -2105,6 +2588,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2105
2588
|
sessionContext: config.sessionContext && typeof sessionQuery?.readSurface === 'function',
|
|
2106
2589
|
// 侧边栏 HTML 预览里的划词桥开关:客户端据此决定要不要往预览帧注入桥脚本
|
|
2107
2590
|
bridgeSidebarPreview: config.bridgeSidebarPreview,
|
|
2591
|
+
// 悬浮胶囊的两个偏好:跟着 ping 一起下发,省得客户端为它单开一次请求
|
|
2592
|
+
pill: { enabled: config.pillEnabled, idleMs: config.pillIdleMs },
|
|
2108
2593
|
limits: {
|
|
2109
2594
|
maxSelectionChars: config.maxSelectionChars,
|
|
2110
2595
|
maxContextChars: config.maxContextChars,
|
|
@@ -2414,7 +2899,9 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2414
2899
|
typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model
|
|
2415
2900
|
? { provider: body.provider, model: body.model }
|
|
2416
2901
|
: undefined
|
|
2417
|
-
|
|
2902
|
+
// 名字避开下面的 isChat(那里判的是"提示词用追问那套",此处判的是"模型用追问那个")
|
|
2903
|
+
const isChatRound = typeof body.question === 'string' && body.question.trim().length > 0
|
|
2904
|
+
const route = resolveRoute(override, await sessionModelOf(sessionIdOf(body)), isChatRound)
|
|
2418
2905
|
if (!route) {
|
|
2419
2906
|
sendJson(res, 503, { error: '尚未确定模型路由:请先在设置里选择默认模型后重试' })
|
|
2420
2907
|
return
|
|
@@ -2669,11 +3156,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2669
3156
|
text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
|
|
2670
3157
|
})
|
|
2671
3158
|
}
|
|
2672
|
-
|
|
3159
|
+
let requestEffort = isChat
|
|
2673
3160
|
? bodyEffort || config.chatReasoningEffort || effort
|
|
2674
3161
|
: stage === 'detail'
|
|
2675
3162
|
? effort
|
|
2676
3163
|
: config.translationReasoningEffort || effort
|
|
3164
|
+
// 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
|
|
3165
|
+
// 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
|
|
3166
|
+
if (requestEffort === 'off' && noOffRoutes.has(routeKey)) requestEffort = 'low'
|
|
2677
3167
|
|
|
2678
3168
|
sse(res, {
|
|
2679
3169
|
type: 'start',
|
|
@@ -2742,10 +3232,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2742
3232
|
rounds: 0,
|
|
2743
3233
|
error: '',
|
|
2744
3234
|
}
|
|
2745
|
-
const toolRounds = config.maxToolRounds
|
|
2746
|
-
// 档位被上游拒绝时(400/401/500
|
|
3235
|
+
const toolRounds = config.maxToolRounds
|
|
3236
|
+
// 档位被上游拒绝时(400/401/500)重试一次:清单里声明的档位是**建议值**,
|
|
2747
3237
|
// 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
|
|
2748
|
-
|
|
3238
|
+
//
|
|
3239
|
+
// 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
|
|
3240
|
+
// 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
|
|
3241
|
+
// 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
|
|
3242
|
+
const canRetryEffort = requestEffort.length > 0
|
|
2749
3243
|
let effortRetried = false
|
|
2750
3244
|
let roundEffort = requestEffort
|
|
2751
3245
|
for (let round = 0; round <= toolRounds; round += 1) {
|
|
@@ -2870,13 +3364,24 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2870
3364
|
// (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
|
|
2871
3365
|
if (canRetryEffort && !effortRetried && roundText.length === 0) {
|
|
2872
3366
|
effortRetried = true
|
|
2873
|
-
|
|
3367
|
+
const rejected = roundEffort
|
|
3368
|
+
if (rejected === 'off') {
|
|
3369
|
+
// "关"被拒:记下这个 route,改用 low(最接近关的可接受档位),并让重试也用 low。
|
|
3370
|
+
// 只去掉档位(交给供应商默认)在有些模型上等于开了深度思考 —— 又慢又可能把思考写进正文。
|
|
3371
|
+
markNoOff(routeKey)
|
|
3372
|
+
requestEffort = 'low'
|
|
3373
|
+
roundEffort = 'low'
|
|
3374
|
+
} else {
|
|
3375
|
+
roundEffort = ''
|
|
3376
|
+
}
|
|
2874
3377
|
errored = false
|
|
2875
3378
|
sse(res, {
|
|
2876
3379
|
type: 'notice',
|
|
2877
3380
|
code: 'effort-rejected',
|
|
2878
|
-
tier:
|
|
2879
|
-
text:
|
|
3381
|
+
tier: rejected,
|
|
3382
|
+
text: rejected === 'off'
|
|
3383
|
+
? '该模型不支持「关」档,已自动改用「低」档(以后不再尝试关档)'
|
|
3384
|
+
: `「${rejected}」档位该模型不支持,已按默认档位重试`,
|
|
2880
3385
|
})
|
|
2881
3386
|
round -= 1
|
|
2882
3387
|
continue
|
|
@@ -2923,6 +3428,8 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2923
3428
|
answer = answer.slice(0, Math.max(0, answer.length - roundText.length))
|
|
2924
3429
|
sse(res, { type: 'drop', chars: roundText.length, text: roundText.slice(-2000), round })
|
|
2925
3430
|
}
|
|
3431
|
+
// 有的模型不带工具定义也会返回调用;执行端同样遵守开关和轮数上限。
|
|
3432
|
+
if (wrapUp || toolSchemas.length === 0) break
|
|
2926
3433
|
// 执行工具→把结果回灌,再让模型继续(上限 maxToolRounds 轮)
|
|
2927
3434
|
toolMessages.push(
|
|
2928
3435
|
createAssistantMessage({
|
|
@@ -2948,7 +3455,9 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
2948
3455
|
const rawPath = (parsed as { file_path?: unknown; path?: unknown })?.file_path ?? (parsed as { path?: unknown })?.path
|
|
2949
3456
|
const wantPath = typeof rawPath === 'string' ? rawPath : ''
|
|
2950
3457
|
const execution =
|
|
2951
|
-
|
|
3458
|
+
!toolSchemas.some((schema) => schema.name === call.name)
|
|
3459
|
+
? { content: [{ type: 'text' as const, text: `(调用已拒绝:${call.name} 不在本次允许的工具中)` }], isError: true }
|
|
3460
|
+
: FS_TOOL_NAMES.includes(call.name) && !isInsideRoot(readRoot, wantPath)
|
|
2952
3461
|
? {
|
|
2953
3462
|
content: [
|
|
2954
3463
|
{
|
|
@@ -3105,6 +3614,14 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3105
3614
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/analyze`, handler: handleAnalyze }),
|
|
3106
3615
|
`${name}: analyze route`,
|
|
3107
3616
|
)
|
|
3617
|
+
ctx.effect(
|
|
3618
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/settings`, handler: handleSettings }),
|
|
3619
|
+
`${name}: settings route`,
|
|
3620
|
+
)
|
|
3621
|
+
ctx.effect(
|
|
3622
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/route`, handler: handleRoute }),
|
|
3623
|
+
`${name}: route route`,
|
|
3624
|
+
)
|
|
3108
3625
|
ctx.effect(
|
|
3109
3626
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/ping`, handler: handlePing }),
|
|
3110
3627
|
`${name}: ping route`,
|
|
@@ -3113,6 +3630,10 @@ export function apply(ctx: Context, rawConfig: Config): void {
|
|
|
3113
3630
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }),
|
|
3114
3631
|
`${name}: history route`,
|
|
3115
3632
|
)
|
|
3633
|
+
ctx.effect(
|
|
3634
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/panel`, handler: handlePanelSize }),
|
|
3635
|
+
`${name}: panel box route`,
|
|
3636
|
+
)
|
|
3116
3637
|
ctx.effect(
|
|
3117
3638
|
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }),
|
|
3118
3639
|
`${name}: pill position route`,
|