@yfwu2020/dsh-selection-explain 0.7.0 → 0.8.3
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 +77 -6
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1440 -74
- package/lib/index.js +390 -29
- 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 +55 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +3 -2
- package/scripts/smoke-panel.mjs +46 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +643 -7
- package/scripts/test-format.mjs +93 -0
- 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 +1440 -74
- package/src/index.ts +427 -24
- package/src/settings.ts +310 -0
package/lib/index.js
CHANGED
|
@@ -21,8 +21,29 @@ import z from '@deepseek-ai/schemastery';
|
|
|
21
21
|
// 提示词与写作规则(纯文本常量)单独成模块:本文件是 2400+ 行的 host 全量逻辑,
|
|
22
22
|
// 提示词放这里既难找也容易误改。组装逻辑仍在下方(按 stage / kind 选一套)。
|
|
23
23
|
import { CHAT_SYSTEM_PROMPT, CODE_PROMPT, DETAIL_PROMPT, TRANSLATION_PROMPT, } from './prompts.js';
|
|
24
|
+
// 设置页的字段声明与校验:与 Config 一一对应,GET /settings 直接把 spec 发给客户端,
|
|
25
|
+
// 客户端只负责画 —— 避免 UI 的边界和 host 的 clamp 各写一份、时间久了互相漂。
|
|
26
|
+
import { buildSettingsPayload, normalizeSettingsPatch, normalizeStoredSettings, PERSISTED_SETTING_KEYS, SETTINGS_KEYS } from './settings.js';
|
|
24
27
|
/** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
|
|
25
28
|
export const name = '@yfwu2020/dsh-selection-explain';
|
|
29
|
+
/**
|
|
30
|
+
* 升格时那条「上下文注入」消息的 `source.kind` —— 生产者**自有**的来源标识。
|
|
31
|
+
*
|
|
32
|
+
* 会话格式 v4 起(本机实测内核 0.2.0-rc.2),消息来源必须是生产者自报的 kind;
|
|
33
|
+
* v3 那种 `plugin` 包装(`kind: 'plugin'` 配一个 `plugin` 字段)已经退役,会被
|
|
34
|
+
* v4 准入当场拒掉:
|
|
35
|
+
* SessionFormatError: format v4 message requires a producer-owned source kind
|
|
36
|
+
* 症状极具迷惑性:`↗ 升格` 的 HTTP 请求返回 `ok: true`(那时只写进了内存里的会话面),
|
|
37
|
+
* 随后那轮 opener 运行失败、整个 artifact 一个字都没落盘 —— 新会话只剩表头。
|
|
38
|
+
* 离线测试也照样全绿(校验发生在真实内核里,不在构建期),所以另配了
|
|
39
|
+
* scripts/test-format.mjs 钉住这个 shape。
|
|
40
|
+
*
|
|
41
|
+
* 第三方插件的规范形态是 `plugin:<插件名>`:内核 v3→v4 迁移把那种 `plugin` 包装
|
|
42
|
+
* 重写成 `plugin:<完整插件名>`(`producerKind()` 对不在同名表里的插件就是这么算的),
|
|
43
|
+
* 所以这里写的与迁移结果逐字节一致 —— 新旧会话归因相同,且 v3 内核下同样合法
|
|
44
|
+
* (直写的 kind 迁移时原样保留)。
|
|
45
|
+
*/
|
|
46
|
+
export const PLUGIN_SOURCE_KIND = `plugin:${name}`;
|
|
26
47
|
/** 路由注册与模型调用所需的服务。 */
|
|
27
48
|
export const inject = ['webServer', 'llm'];
|
|
28
49
|
/** 路由前缀。 */
|
|
@@ -33,9 +54,11 @@ const BODY_LIMIT = 512 * 1024;
|
|
|
33
54
|
export const Config = z.object({
|
|
34
55
|
provider: z.string().default(''),
|
|
35
56
|
model: z.string().default(''),
|
|
57
|
+
chatProvider: z.string().default(''),
|
|
58
|
+
chatModel: z.string().default(''),
|
|
36
59
|
reasoningEffort: z.string().default('high'),
|
|
37
|
-
/**
|
|
38
|
-
translationReasoningEffort: z.string().default('
|
|
60
|
+
/** 首轮单独一档:默认关闭思考,优先快速给出翻译、解读或注释。 */
|
|
61
|
+
translationReasoningEffort: z.string().default('off'),
|
|
39
62
|
/**
|
|
40
63
|
* 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
|
|
41
64
|
* 0 = 关闭历史。只留最近 N 个「划词条目」(每个条目内含它自己的全部追问轮次)。
|
|
@@ -90,6 +113,10 @@ export const Config = z.object({
|
|
|
90
113
|
* 侧边栏网页里选中文字不会再弹「解读」。
|
|
91
114
|
*/
|
|
92
115
|
bridgeSidebarPreview: z.boolean().default(true),
|
|
116
|
+
/** 悬浮胶囊开关(小窗设置页可改)。 */
|
|
117
|
+
pillEnabled: z.boolean().default(true),
|
|
118
|
+
/** 静置收球时长(毫秒)——默认 10 秒,见 Config 注释。 */
|
|
119
|
+
pillIdleMs: z.number().min(2000).max(600000).default(10000),
|
|
93
120
|
});
|
|
94
121
|
/** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
|
|
95
122
|
const SEARCH_TOOL_NAMES = ['advanced_search', 'platform_search', 'web_search'];
|
|
@@ -899,8 +926,10 @@ export function apply(ctx, rawConfig) {
|
|
|
899
926
|
const config = {
|
|
900
927
|
provider: rawConfig?.provider ?? '',
|
|
901
928
|
model: rawConfig?.model ?? '',
|
|
929
|
+
chatProvider: rawConfig?.chatProvider ?? '',
|
|
930
|
+
chatModel: rawConfig?.chatModel ?? '',
|
|
902
931
|
reasoningEffort: rawConfig?.reasoningEffort ?? 'high',
|
|
903
|
-
translationReasoningEffort: rawConfig?.translationReasoningEffort ?? '
|
|
932
|
+
translationReasoningEffort: rawConfig?.translationReasoningEffort ?? 'off',
|
|
904
933
|
historyMaxEntries: rawConfig?.historyMaxEntries ?? 20,
|
|
905
934
|
temperature: rawConfig?.temperature ?? -1,
|
|
906
935
|
maxTokens: rawConfig?.maxTokens ?? 0,
|
|
@@ -924,7 +953,91 @@ export function apply(ctx, rawConfig) {
|
|
|
924
953
|
toolReadRoot: rawConfig?.toolReadRoot ?? '',
|
|
925
954
|
chatReasoningEffort: rawConfig?.chatReasoningEffort ?? 'high',
|
|
926
955
|
bridgeSidebarPreview: rawConfig?.bridgeSidebarPreview ?? true,
|
|
956
|
+
pillEnabled: rawConfig?.pillEnabled ?? true,
|
|
957
|
+
pillIdleMs: rawConfig?.pillIdleMs ?? 10000,
|
|
927
958
|
};
|
|
959
|
+
/**
|
|
960
|
+
* 出厂默认值(**不含**用户在设置页改过的部分)。
|
|
961
|
+
*
|
|
962
|
+
* 为什么单独留一份:设置页的「恢复默认」要能把值写回这里,
|
|
963
|
+
* 而"这一项改过没有"也要拿它对比 —— 只有一个 config 的话,改完就再也说不出原值是什么。
|
|
964
|
+
* 注意它取的是 rawConfig(cordis.patch.yml 里的配置),所以 yml 里写过的值也算"默认":
|
|
965
|
+
* 用户在设置页恢复默认,应该回到 yml 写的那个数,而不是硬编码的出厂数。
|
|
966
|
+
*/
|
|
967
|
+
const configDefaults = { ...config };
|
|
968
|
+
/**
|
|
969
|
+
* 设置页写入的覆盖值(**只存改过的键**)。
|
|
970
|
+
*
|
|
971
|
+
* 落盘在插件自己的目录(与 history.json / pill.json 同级),不碰 cordis.patch.yml:
|
|
972
|
+
* 那个文件是**装配清单**,让 UI 去改它既容易写坏结构,也要重启才生效。
|
|
973
|
+
* 这里的内存值即刻生效(下方所有 config.x 都是每次请求现读的),落盘只为跨重启保留。
|
|
974
|
+
*/
|
|
975
|
+
let settingsOverrides = {};
|
|
976
|
+
let settingsLoaded = false;
|
|
977
|
+
let settingsTimer = null;
|
|
978
|
+
const settingsFile = () => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'settings.json');
|
|
979
|
+
/**
|
|
980
|
+
* 把覆盖值套用到 config 上。
|
|
981
|
+
*
|
|
982
|
+
* 只接受 SETTINGS_KEYS 里的键,并且**类型必须与默认值一致** ——
|
|
983
|
+
* 文件是手工可编辑的(也可能被旧版本写坏),不能让它把 boolean 写成字符串,
|
|
984
|
+
* 那会让 `if (config.tools)` 这种判断悄悄变成永真。
|
|
985
|
+
*/
|
|
986
|
+
function applyOverrides(patch) {
|
|
987
|
+
const target = config;
|
|
988
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
989
|
+
if (!SETTINGS_KEYS.includes(key) && !PERSISTED_SETTING_KEYS.includes(key))
|
|
990
|
+
continue;
|
|
991
|
+
if (typeof value !== typeof configDefaults[key])
|
|
992
|
+
continue;
|
|
993
|
+
target[key] = value;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/** 读设置覆盖文件(进程内只读一次,与 loadHistory / loadPillPos 同节奏)。 */
|
|
997
|
+
async function loadSettings() {
|
|
998
|
+
if (settingsLoaded)
|
|
999
|
+
return;
|
|
1000
|
+
settingsLoaded = true;
|
|
1001
|
+
try {
|
|
1002
|
+
const parsed = JSON.parse(await readFile(settingsFile(), 'utf8'));
|
|
1003
|
+
const raw = parsed?.values;
|
|
1004
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1005
|
+
const values = normalizeStoredSettings(raw);
|
|
1006
|
+
settingsOverrides = values;
|
|
1007
|
+
applyOverrides(values);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// 没存过 / 坏了都当作"没改过":出厂默认本来就是可用状态,没必要因此报错
|
|
1012
|
+
settingsOverrides = {};
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** 落盘(防抖:拖数字输入框会连发多次,没必要每次都写文件)。 */
|
|
1016
|
+
function scheduleSettingsWrite() {
|
|
1017
|
+
if (settingsTimer)
|
|
1018
|
+
clearTimeout(settingsTimer);
|
|
1019
|
+
settingsTimer = setTimeout(() => {
|
|
1020
|
+
settingsTimer = null;
|
|
1021
|
+
void (async () => {
|
|
1022
|
+
try {
|
|
1023
|
+
await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true });
|
|
1024
|
+
await writeFile(settingsFile(), JSON.stringify({ version: 1, values: settingsOverrides }, null, 0), 'utf8');
|
|
1025
|
+
}
|
|
1026
|
+
catch (error) {
|
|
1027
|
+
ctx.logger?.warn?.(`[${name}] 写入设置失败:${String(error?.message ?? error)}`);
|
|
1028
|
+
}
|
|
1029
|
+
})();
|
|
1030
|
+
}, 300);
|
|
1031
|
+
settingsTimer.unref?.();
|
|
1032
|
+
}
|
|
1033
|
+
/** 当前生效值(按 spec 的键序取,客户端可以直接对比 defaults 判断改过没有)。 */
|
|
1034
|
+
function currentSettings() {
|
|
1035
|
+
const target = config;
|
|
1036
|
+
const values = {};
|
|
1037
|
+
for (const key of SETTINGS_KEYS)
|
|
1038
|
+
values[key] = target[key];
|
|
1039
|
+
return values;
|
|
1040
|
+
}
|
|
928
1041
|
/** 兜底路由:第一次真实 LLM 调用后捕获。 */
|
|
929
1042
|
let captured = null;
|
|
930
1043
|
ctx.on('llm/stream', (options, next) => {
|
|
@@ -1022,7 +1135,25 @@ export function apply(ctx, rawConfig) {
|
|
|
1022
1135
|
return items;
|
|
1023
1136
|
}
|
|
1024
1137
|
/** 请求里带 provider+model 时优先用它(并按清单校验;清单还没加载出来就只做形状校验)。 */
|
|
1025
|
-
const resolveRoute = (override
|
|
1138
|
+
const resolveRoute = (override,
|
|
1139
|
+
/**
|
|
1140
|
+
* **当前会话**自己的模型选择(见 sessionModelOf)。
|
|
1141
|
+
*
|
|
1142
|
+
* 为什么要有它:会话是各自选模型的(每个 Agent 一份 model-selection),
|
|
1143
|
+
* 而 `agentDefaultModel.currentSelection()` 是**全局默认**、与会话无关。
|
|
1144
|
+
* 以前只用后者,于是"跟随主会话"其实跟的是"全局默认"——
|
|
1145
|
+
* 两个会话用不同模型时,插件对两个会话都用同一个默认模型,跟谁都对不上。
|
|
1146
|
+
*
|
|
1147
|
+
* 优先级:显式 override(小窗胶囊选的)→ 插件配置(设置页选的)→ **本会话的模型** → 全局默认 → 第一个可用。
|
|
1148
|
+
* 会话没记过选择(新会话、或日志里没有 model/selection)时自然落回全局默认,行为与以前一致。
|
|
1149
|
+
*/
|
|
1150
|
+
sessionModel,
|
|
1151
|
+
/**
|
|
1152
|
+
* 这一轮是不是**追问**。
|
|
1153
|
+
*
|
|
1154
|
+
* 三个阶段沿用同一模型;旧 chatProvider/chatModel 仅保留配置兼容,不再参与路由。
|
|
1155
|
+
*/
|
|
1156
|
+
chat) => {
|
|
1026
1157
|
if (override?.provider && override?.model) {
|
|
1027
1158
|
const wanted = { provider: override.provider, model: override.model };
|
|
1028
1159
|
const known = modelCache.items.some((item) => item.provider === wanted.provider && item.model === wanted.model);
|
|
@@ -1031,6 +1162,9 @@ export function apply(ctx, rawConfig) {
|
|
|
1031
1162
|
}
|
|
1032
1163
|
if (config.provider && config.model)
|
|
1033
1164
|
return { provider: config.provider, model: config.model };
|
|
1165
|
+
if (sessionModel?.provider && sessionModel?.model) {
|
|
1166
|
+
return { provider: sessionModel.provider, model: sessionModel.model };
|
|
1167
|
+
}
|
|
1034
1168
|
const service = ctx.get('agentDefaultModel');
|
|
1035
1169
|
const selection = service?.currentSelection?.();
|
|
1036
1170
|
if (selection?.provider && selection?.model) {
|
|
@@ -1061,6 +1195,35 @@ export function apply(ctx, rawConfig) {
|
|
|
1061
1195
|
const resultKeyOf = (provider, model, sessionId, backgroundText, text, context) => createHash('sha1').update([provider, model, sessionId, backgroundText, text, context].join('\u0000')).digest('hex');
|
|
1062
1196
|
/** 会话背景短缓存:同一会话 10 秒内复用(连续划词不重复读日志)。 */
|
|
1063
1197
|
const backgroundCache = new Map();
|
|
1198
|
+
/**
|
|
1199
|
+
* 「本会话在用哪个模型」的短缓存 —— 只给按秒轮询的 `/route` 用(见 sessionModelOf)。
|
|
1200
|
+
*
|
|
1201
|
+
* 为什么需要:`readSession` 会把整份日志重放成一个 Session、再逐条深拷贝一遍
|
|
1202
|
+
* (本机实测 1.5MB 的会话一次 80~125ms)。跟随状态下**小窗一开着**就要每 2 秒问一次,
|
|
1203
|
+
* 不缓存等于每 2 秒在宿主进程里重放一次会话日志。
|
|
1204
|
+
*
|
|
1205
|
+
* 变更探测用 live 会话的 `seq`(同步、零成本,见 liveSeqOf):没涨就直接回缓存;
|
|
1206
|
+
* 涨了(主会话正在连续对话时每轮都在涨)也至少隔 SESSION_MODEL_MIN_MS 才重读一次,
|
|
1207
|
+
* 否则"小窗开着 + 主会话在聊"时每 2 秒照样重放。非 live(磁盘上的旧会话,读不到 seq)按 TTL 兜底。
|
|
1208
|
+
*/
|
|
1209
|
+
const sessionModelCache = new Map();
|
|
1210
|
+
/** 同一会话正在飞的读取(读可能比轮询间隔还慢,叠起来没意义)。 */
|
|
1211
|
+
const sessionModelPending = new Map();
|
|
1212
|
+
/** live 会话两次重读之间的最短间隔(挡住"事件一直在涨"时的连续重放)。 */
|
|
1213
|
+
const SESSION_MODEL_MIN_MS = 3_000;
|
|
1214
|
+
/** 读不到 seq(非 live)时的兜底 TTL。 */
|
|
1215
|
+
const SESSION_MODEL_TTL_MS = 10_000;
|
|
1216
|
+
/**
|
|
1217
|
+
* live 会话的事件数(`Session.seq` = 日志长度,每次 append 都会涨)。
|
|
1218
|
+
*
|
|
1219
|
+
* 这是**零成本**的同步读取:不用读日志、不用重放,就能判断"会话有没有新事件"。
|
|
1220
|
+
* 拿不到(会话不在内存里 / 老内核没有 sessions 服务)返回 null,调用方退回 TTL。
|
|
1221
|
+
*/
|
|
1222
|
+
const liveSeqOf = (sessionId) => {
|
|
1223
|
+
const sessions = ctx.get('sessions');
|
|
1224
|
+
const seq = sessions?.get?.(sessionId)?.seq;
|
|
1225
|
+
return typeof seq === 'number' ? seq : null;
|
|
1226
|
+
};
|
|
1064
1227
|
/** 读取当前会话的模型界面事件(live 优先;失败/超时一律降级为「无背景」)。 */
|
|
1065
1228
|
const readSurfaceEvents = async (sessionId) => {
|
|
1066
1229
|
const cached = backgroundCache.get(sessionId);
|
|
@@ -1086,6 +1249,99 @@ export function apply(ctx, rawConfig) {
|
|
|
1086
1249
|
return [];
|
|
1087
1250
|
}
|
|
1088
1251
|
};
|
|
1252
|
+
/**
|
|
1253
|
+
* 真读一次:拿会话原始事件里的最后一条 `model/selection`。
|
|
1254
|
+
*
|
|
1255
|
+
* 来源:会话事件流里的 `model/selection`(每条 = 一次"后续请求用这个模型"的选择,
|
|
1256
|
+
* 形如 `{ type:'model/selection', data:{ provider, model, reasoningEffort? } }`)。
|
|
1257
|
+
* 取**最后一条** = 会话当前在用的那个;一条都没有(新会话)就返回 null,调用方落回全局默认。
|
|
1258
|
+
*/
|
|
1259
|
+
const readSessionModel = async (sessionId) => {
|
|
1260
|
+
if (!sessionId)
|
|
1261
|
+
return null;
|
|
1262
|
+
const sessionQuery = ctx.get('sessionQuery');
|
|
1263
|
+
if (typeof sessionQuery?.readSession !== 'function')
|
|
1264
|
+
return null;
|
|
1265
|
+
let events = [];
|
|
1266
|
+
try {
|
|
1267
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 2000));
|
|
1268
|
+
const log = await Promise.race([sessionQuery.readSession(sessionId), timeout]);
|
|
1269
|
+
events = log && Array.isArray(log.events) ? (log.events) : [];
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1275
|
+
const event = events[i];
|
|
1276
|
+
if (!event || typeof event !== 'object')
|
|
1277
|
+
continue;
|
|
1278
|
+
const typed = event;
|
|
1279
|
+
if (typed.type !== 'model/selection')
|
|
1280
|
+
continue;
|
|
1281
|
+
const data = (typed.data ?? {});
|
|
1282
|
+
if (typeof data.provider === 'string' && data.provider && typeof data.model === 'string' && data.model) {
|
|
1283
|
+
return { provider: data.provider, model: data.model };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return null;
|
|
1287
|
+
};
|
|
1288
|
+
/**
|
|
1289
|
+
* 记下这次的结论(连同"读到它时的 seq"):下次轮询就能靠 seq 判断结论还有效没。
|
|
1290
|
+
*
|
|
1291
|
+
* 顺手也把"用户动作路径"读到的值种进缓存 —— 那是**此刻**那一份,
|
|
1292
|
+
* 种进来只会让轮询更少重读、更不容易报旧值。
|
|
1293
|
+
*/
|
|
1294
|
+
const rememberSessionModel = (sessionId, seq, value) => {
|
|
1295
|
+
sessionModelCache.set(sessionId, { at: Date.now(), seq, value });
|
|
1296
|
+
if (sessionModelCache.size > 8) {
|
|
1297
|
+
const oldest = sessionModelCache.keys().next().value;
|
|
1298
|
+
if (oldest !== undefined)
|
|
1299
|
+
sessionModelCache.delete(oldest);
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
/**
|
|
1303
|
+
* 读**当前会话**自己的模型选择。
|
|
1304
|
+
*
|
|
1305
|
+
* `useCache` 只给按秒轮询的 `/route` 打开;用户动作路径(analyze / models / ping)一律直读,
|
|
1306
|
+
* 拿到的必须是**此刻**那一份 —— 不能让人刚在主会话里换完模型、划词却按旧模型解析。
|
|
1307
|
+
* 直读的结果会顺手种进缓存(见 rememberSessionModel)。
|
|
1308
|
+
*/
|
|
1309
|
+
const sessionModelOf = async (sessionId, useCache = false) => {
|
|
1310
|
+
if (!sessionId)
|
|
1311
|
+
return null;
|
|
1312
|
+
if (!useCache) {
|
|
1313
|
+
// seq 在读**之前**取:读的过程中若又落了事件,缓存就应当被视为旧值(宁可下次重读)
|
|
1314
|
+
const seqNow = liveSeqOf(sessionId);
|
|
1315
|
+
const value = await readSessionModel(sessionId);
|
|
1316
|
+
rememberSessionModel(sessionId, seqNow, value);
|
|
1317
|
+
return value;
|
|
1318
|
+
}
|
|
1319
|
+
const seq = liveSeqOf(sessionId);
|
|
1320
|
+
const cached = sessionModelCache.get(sessionId);
|
|
1321
|
+
if (cached) {
|
|
1322
|
+
// 事件没涨 → 结论不可能变。只对 live 会话成立:`seq` 是同步的,每次 append 都会涨。
|
|
1323
|
+
if (seq !== null && cached.seq === seq)
|
|
1324
|
+
return cached.value;
|
|
1325
|
+
// 涨了也不立刻重读:主会话正在连续对话时事件一直在涨,否则每 2 秒照样重放一次日志。
|
|
1326
|
+
const floor = seq === null ? SESSION_MODEL_TTL_MS : SESSION_MODEL_MIN_MS;
|
|
1327
|
+
if (Date.now() - cached.at < floor)
|
|
1328
|
+
return cached.value;
|
|
1329
|
+
}
|
|
1330
|
+
// 同一会话的轮询叠在一起(读可能比轮询间隔还慢)时共用一个在飞的请求
|
|
1331
|
+
const pending = sessionModelPending.get(sessionId);
|
|
1332
|
+
if (pending)
|
|
1333
|
+
return pending;
|
|
1334
|
+
const task = readSessionModel(sessionId)
|
|
1335
|
+
.then((value) => {
|
|
1336
|
+
rememberSessionModel(sessionId, seq, value);
|
|
1337
|
+
return value;
|
|
1338
|
+
})
|
|
1339
|
+
.finally(() => {
|
|
1340
|
+
sessionModelPending.delete(sessionId);
|
|
1341
|
+
});
|
|
1342
|
+
sessionModelPending.set(sessionId, task);
|
|
1343
|
+
return task;
|
|
1344
|
+
};
|
|
1089
1345
|
/** 组装会话背景文本。 */
|
|
1090
1346
|
const loadSessionBackground = async (sessionId, options) => {
|
|
1091
1347
|
const events = await readSurfaceEvents(sessionId);
|
|
@@ -1201,7 +1457,8 @@ export function apply(ctx, rawConfig) {
|
|
|
1201
1457
|
continue;
|
|
1202
1458
|
history.set(entry.key, {
|
|
1203
1459
|
key: entry.key,
|
|
1204
|
-
|
|
1460
|
+
// 按可设置的最高选区长度保留,调低当前限制也不截断已有历史。
|
|
1461
|
+
text: clampText(entry.text, 20000),
|
|
1205
1462
|
context: clampText(entry.context, 20000),
|
|
1206
1463
|
label: clampText(entry.label, 60),
|
|
1207
1464
|
at: typeof entry.at === 'number' ? entry.at : Date.now(),
|
|
@@ -1256,7 +1513,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1256
1513
|
const previous = history.get(key);
|
|
1257
1514
|
const entry = {
|
|
1258
1515
|
key,
|
|
1259
|
-
text: clampText(input.text,
|
|
1516
|
+
text: clampText(input.text, 20000),
|
|
1260
1517
|
context: clampText(input.context, 20000),
|
|
1261
1518
|
label: clampText(input.label, 60),
|
|
1262
1519
|
at: Date.now(),
|
|
@@ -1605,11 +1862,8 @@ export function apply(ctx, rawConfig) {
|
|
|
1605
1862
|
};
|
|
1606
1863
|
const handleHistory = (req, res) => {
|
|
1607
1864
|
void (async () => {
|
|
1865
|
+
await loadSettings();
|
|
1608
1866
|
await loadHistory();
|
|
1609
|
-
if (config.historyMaxEntries <= 0) {
|
|
1610
|
-
sendJson(res, 200, { ok: true, entries: [] });
|
|
1611
|
-
return;
|
|
1612
|
-
}
|
|
1613
1867
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1614
1868
|
if (req.method === 'DELETE') {
|
|
1615
1869
|
// 删除一条(?key=)或清空(?all=1)。
|
|
@@ -1707,7 +1961,15 @@ export function apply(ctx, rawConfig) {
|
|
|
1707
1961
|
catch (thrown) {
|
|
1708
1962
|
error = String(thrown?.message ?? thrown);
|
|
1709
1963
|
}
|
|
1710
|
-
|
|
1964
|
+
// 会话级:设置页的「跟随主会话」要显示**当前会话**在用的模型
|
|
1965
|
+
let modelsSessionId = '';
|
|
1966
|
+
try {
|
|
1967
|
+
modelsSessionId = url.searchParams.get('sessionId') ?? '';
|
|
1968
|
+
}
|
|
1969
|
+
catch {
|
|
1970
|
+
/* 解析不了就当没带 */
|
|
1971
|
+
}
|
|
1972
|
+
const current = resolveRoute(undefined, await sessionModelOf(modelsSessionId));
|
|
1711
1973
|
sendJson(res, 200, {
|
|
1712
1974
|
ok: true,
|
|
1713
1975
|
current,
|
|
@@ -1724,6 +1986,90 @@ export function apply(ctx, rawConfig) {
|
|
|
1724
1986
|
},
|
|
1725
1987
|
});
|
|
1726
1988
|
}
|
|
1989
|
+
/**
|
|
1990
|
+
* 设置页的读写口。
|
|
1991
|
+
*
|
|
1992
|
+
* GET → { groups, values, defaults }:spec 一并下发,客户端只负责画
|
|
1993
|
+
* POST → { values: {...} }:归一 → 立刻生效(改内存)→ 防抖落盘
|
|
1994
|
+
* POST { reset: true } :清空覆盖,回到 cordis.patch.yml / 出厂默认
|
|
1995
|
+
*
|
|
1996
|
+
* 为什么不是"写回 cordis.patch.yml":那是装配清单,
|
|
1997
|
+
* 让 UI 去改它既要人肉重启、又可能把 YAML 结构写坏;这里落在插件自己的目录,改完即时生效。
|
|
1998
|
+
*/
|
|
1999
|
+
const handleSettings = (req, res) => {
|
|
2000
|
+
void (async () => {
|
|
2001
|
+
await loadSettings();
|
|
2002
|
+
if (req.method === 'POST') {
|
|
2003
|
+
const raw = await readBody(req, 200_000);
|
|
2004
|
+
let body = {};
|
|
2005
|
+
try {
|
|
2006
|
+
body = raw ? JSON.parse(raw) : {};
|
|
2007
|
+
}
|
|
2008
|
+
catch {
|
|
2009
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (body.reset === true) {
|
|
2013
|
+
settingsOverrides = {};
|
|
2014
|
+
applyOverrides(configDefaults);
|
|
2015
|
+
scheduleSettingsWrite();
|
|
2016
|
+
sendJson(res, 200, { ok: true, values: currentSettings(), rejected: [] });
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
const result = normalizeSettingsPatch(body.values);
|
|
2020
|
+
if (Object.keys(result.values).length === 0 && result.rejected.length > 0) {
|
|
2021
|
+
sendJson(res, 400, { ok: false, error: '没有可写入的字段', rejected: result.rejected });
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
// 合并而不是覆盖:只发一个键的补丁不该把其它改过的项抹掉
|
|
2025
|
+
settingsOverrides = { ...settingsOverrides, ...result.values };
|
|
2026
|
+
applyOverrides(result.values);
|
|
2027
|
+
scheduleSettingsWrite();
|
|
2028
|
+
sendJson(res, 200, { ok: true, values: currentSettings(), rejected: result.rejected });
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
sendJson(res, 200, { ok: true, ...buildSettingsPayload(currentSettings(), configDefaults) });
|
|
2032
|
+
})().catch((error) => {
|
|
2033
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
|
|
2034
|
+
});
|
|
2035
|
+
};
|
|
2036
|
+
/**
|
|
2037
|
+
* 极轻的"这一轮会用哪个模型"查询。
|
|
2038
|
+
*
|
|
2039
|
+
* GET /selection-explain/api/route?sessionId=…[&chat=1]
|
|
2040
|
+
*
|
|
2041
|
+
* 为什么单开一条:设置页要**实时**显示当前会话在用的模型,
|
|
2042
|
+
* 而 /models 会去列 provider 与模型(42 个,还要端点发现)、/ping 要枚举工具表并探 DNS ——
|
|
2043
|
+
* 两者都不适合按秒轮询。这条只做一次会话模型解析 + 一次 resolveRoute。
|
|
2044
|
+
*
|
|
2045
|
+
* 也是唯一走 `sessionModelOf(…, true)`(短缓存)的地方:客户端在小窗开着时每 2 秒问一次,
|
|
2046
|
+
* 不能每拍都重放一次会话日志(见 sessionModelCache 的注释)。
|
|
2047
|
+
*/
|
|
2048
|
+
const handleRoute = (req, res) => {
|
|
2049
|
+
void (async () => {
|
|
2050
|
+
await loadSettings();
|
|
2051
|
+
let sessionId = '';
|
|
2052
|
+
let chat = false;
|
|
2053
|
+
try {
|
|
2054
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
2055
|
+
sessionId = url.searchParams.get('sessionId') ?? '';
|
|
2056
|
+
chat = url.searchParams.get('chat') === '1';
|
|
2057
|
+
}
|
|
2058
|
+
catch {
|
|
2059
|
+
/* 解析不了就当没带 */
|
|
2060
|
+
}
|
|
2061
|
+
const route = resolveRoute(undefined, await sessionModelOf(sessionId, true), chat);
|
|
2062
|
+
sendJson(res, 200, {
|
|
2063
|
+
ok: true,
|
|
2064
|
+
provider: route?.provider ?? '',
|
|
2065
|
+
model: route?.model ?? '',
|
|
2066
|
+
// 是不是"跟首轮/次轮同一个":客户端据此把追问下拉显示成「跟随首轮 / 次轮」
|
|
2067
|
+
chatFollows: true,
|
|
2068
|
+
});
|
|
2069
|
+
})().catch((error) => {
|
|
2070
|
+
sendJson(res, 500, { ok: false, error: String(error?.message ?? error) });
|
|
2071
|
+
});
|
|
2072
|
+
};
|
|
1727
2073
|
const handlePing = (req, res) => {
|
|
1728
2074
|
const sessionQuery = ctx.get('sessionQuery');
|
|
1729
2075
|
// 可以带 ?sessionId=xxx:工具是**按会话作用域**注册的,不带 id 只能看到全局视图(里面没有 web_search)
|
|
@@ -1737,6 +2083,8 @@ export function apply(ctx, rawConfig) {
|
|
|
1737
2083
|
// 小窗能调哪些工具:白名单是配置,实际放行还要看本机能不能抓网页(见下)。
|
|
1738
2084
|
// 用 async 包一层:探测 DNS 是异步的,出错也要给出结构化响应(和 history 路由同一写法)。
|
|
1739
2085
|
void (async () => {
|
|
2086
|
+
// 先把用户改过的设置套上:下面报的每一条都必须是**当前生效**的值
|
|
2087
|
+
await loadSettings();
|
|
1740
2088
|
const whitelist = config.toolNames
|
|
1741
2089
|
.split(',')
|
|
1742
2090
|
.map((item) => item.trim())
|
|
@@ -1780,7 +2128,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1780
2128
|
sendJson(res, 200, {
|
|
1781
2129
|
ok: true,
|
|
1782
2130
|
plugin: name,
|
|
1783
|
-
route: resolveRoute(),
|
|
2131
|
+
route: resolveRoute(undefined, await sessionModelOf(pingSessionId)),
|
|
1784
2132
|
reasoningEffort: config.reasoningEffort,
|
|
1785
2133
|
modelsRoute: `${API_PREFIX}/models`,
|
|
1786
2134
|
history: { maxEntries: config.historyMaxEntries, file: historyFile() },
|
|
@@ -1812,6 +2160,8 @@ export function apply(ctx, rawConfig) {
|
|
|
1812
2160
|
sessionContext: config.sessionContext && typeof sessionQuery?.readSurface === 'function',
|
|
1813
2161
|
// 侧边栏 HTML 预览里的划词桥开关:客户端据此决定要不要往预览帧注入桥脚本
|
|
1814
2162
|
bridgeSidebarPreview: config.bridgeSidebarPreview,
|
|
2163
|
+
// 悬浮胶囊的两个偏好:跟着 ping 一起下发,省得客户端为它单开一次请求
|
|
2164
|
+
pill: { enabled: config.pillEnabled, idleMs: config.pillIdleMs },
|
|
1815
2165
|
limits: {
|
|
1816
2166
|
maxSelectionChars: config.maxSelectionChars,
|
|
1817
2167
|
maxContextChars: config.maxContextChars,
|
|
@@ -1845,7 +2195,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1845
2195
|
};
|
|
1846
2196
|
/**
|
|
1847
2197
|
* 把划词解读小窗「升格」成正式会话(建在来源会话同一个项目下):
|
|
1848
|
-
* ① 建会话 → ②
|
|
2198
|
+
* ① 建会话 → ② 注入上下文消息(选中文字/所在位置/上下文片段,生产者自有 kind 的 context 消息)
|
|
1849
2199
|
* → ③ 把首轮解读写成助手消息 → ④ 把追问逐轮写成真实的 user/assistant 历史消息
|
|
1850
2200
|
* → ⑤ 送一条短开场消息让 agent 接手(这样整段历史才会落盘)。
|
|
1851
2201
|
* 这样新会话里看到的就是一段真实的多轮对话,而不是一大坨打包文本。
|
|
@@ -1962,7 +2312,10 @@ export function apply(ctx, rawConfig) {
|
|
|
1962
2312
|
live.append?.('step/end', { turn, step: 1 });
|
|
1963
2313
|
live.append?.('turn/end', { turn, reason: { kind: 'completed' } });
|
|
1964
2314
|
};
|
|
1965
|
-
// 第 1
|
|
2315
|
+
// 第 1 轮:上下文消息(生产者自有的 source.kind + snapshot 呈现)+ 首轮解读
|
|
2316
|
+
// ⚠️ kind 必须是 PLUGIN_SOURCE_KIND(`plugin:<插件名>`):会话格式 v4 起不再
|
|
2317
|
+
// 接受退役的 `plugin` 包装(`kind: 'plugin'` 加一个 `plugin` 字段),否则整场升格
|
|
2318
|
+
// 会死在 "format v4 message requires a producer-owned source kind" 上(见文件头常量注释)。
|
|
1966
2319
|
const contextBody = [
|
|
1967
2320
|
`选中文字:${text}`,
|
|
1968
2321
|
`所在位置:${label}`,
|
|
@@ -1976,8 +2329,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1976
2329
|
id: `sel-ctx-${stamp}`,
|
|
1977
2330
|
role: 'user',
|
|
1978
2331
|
source: {
|
|
1979
|
-
kind:
|
|
1980
|
-
plugin: name,
|
|
2332
|
+
kind: PLUGIN_SOURCE_KIND,
|
|
1981
2333
|
form: 'snapshot',
|
|
1982
2334
|
sections: [
|
|
1983
2335
|
{ name: '划词解读 · 选中文字', text },
|
|
@@ -2081,7 +2433,9 @@ export function apply(ctx, rawConfig) {
|
|
|
2081
2433
|
const override = typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model
|
|
2082
2434
|
? { provider: body.provider, model: body.model }
|
|
2083
2435
|
: undefined;
|
|
2084
|
-
|
|
2436
|
+
// 名字避开下面的 isChat(那里判的是"提示词用追问那套",此处判的是"模型用追问那个")
|
|
2437
|
+
const isChatRound = typeof body.question === 'string' && body.question.trim().length > 0;
|
|
2438
|
+
const route = resolveRoute(override, await sessionModelOf(sessionIdOf(body)), isChatRound);
|
|
2085
2439
|
if (!route) {
|
|
2086
2440
|
sendJson(res, 503, { error: '尚未确定模型路由:请先在设置里选择默认模型后重试' });
|
|
2087
2441
|
return;
|
|
@@ -2384,7 +2738,7 @@ export function apply(ctx, rawConfig) {
|
|
|
2384
2738
|
rounds: 0,
|
|
2385
2739
|
error: '',
|
|
2386
2740
|
};
|
|
2387
|
-
const toolRounds = config.maxToolRounds
|
|
2741
|
+
const toolRounds = config.maxToolRounds;
|
|
2388
2742
|
// 档位被上游拒绝时(400/401/500)去掉档位重试一次:清单里声明的档位是**建议值**,
|
|
2389
2743
|
// 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
|
|
2390
2744
|
const canRetryEffort = isChat && bodyEffort.length > 0;
|
|
@@ -2567,6 +2921,9 @@ export function apply(ctx, rawConfig) {
|
|
|
2567
2921
|
answer = answer.slice(0, Math.max(0, answer.length - roundText.length));
|
|
2568
2922
|
sse(res, { type: 'drop', chars: roundText.length, text: roundText.slice(-2000), round });
|
|
2569
2923
|
}
|
|
2924
|
+
// 有的模型不带工具定义也会返回调用;执行端同样遵守开关和轮数上限。
|
|
2925
|
+
if (wrapUp || toolSchemas.length === 0)
|
|
2926
|
+
break;
|
|
2570
2927
|
// 执行工具→把结果回灌,再让模型继续(上限 maxToolRounds 轮)
|
|
2571
2928
|
toolMessages.push(createAssistantMessage({
|
|
2572
2929
|
content: blocks,
|
|
@@ -2591,17 +2948,19 @@ export function apply(ctx, rawConfig) {
|
|
|
2591
2948
|
// 只认 path 的话 read 的 file_path 会是 undefined → 边界检查被静默绕过(实测踩过)。
|
|
2592
2949
|
const rawPath = parsed?.file_path ?? parsed?.path;
|
|
2593
2950
|
const wantPath = typeof rawPath === 'string' ? rawPath : '';
|
|
2594
|
-
const execution =
|
|
2595
|
-
? {
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2951
|
+
const execution = !toolSchemas.some((schema) => schema.name === call.name)
|
|
2952
|
+
? { content: [{ type: 'text', text: `(调用已拒绝:${call.name} 不在本次允许的工具中)` }], isError: true }
|
|
2953
|
+
: FS_TOOL_NAMES.includes(call.name) && !isInsideRoot(readRoot, wantPath)
|
|
2954
|
+
? {
|
|
2955
|
+
content: [
|
|
2956
|
+
{
|
|
2957
|
+
type: 'text',
|
|
2958
|
+
text: `(越界已拒绝:文件类工具只能访问当前项目目录${readRoot ? `(${readRoot})` : ''},请求的路径是 ${wantPath || '(未给)'})`,
|
|
2959
|
+
},
|
|
2960
|
+
],
|
|
2961
|
+
isError: true,
|
|
2962
|
+
}
|
|
2963
|
+
: await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent);
|
|
2605
2964
|
// 结果也回传:小窗里能看见"查了什么、命中什么、链接是什么",否则工具是个黑盒
|
|
2606
2965
|
const digest = digestToolResult(execution.content);
|
|
2607
2966
|
sse(res, {
|
|
@@ -2753,6 +3112,8 @@ export function apply(ctx, rawConfig) {
|
|
|
2753
3112
|
}
|
|
2754
3113
|
};
|
|
2755
3114
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/analyze`, handler: handleAnalyze }), `${name}: analyze route`);
|
|
3115
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/settings`, handler: handleSettings }), `${name}: settings route`);
|
|
3116
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/route`, handler: handleRoute }), `${name}: route route`);
|
|
2756
3117
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/ping`, handler: handlePing }), `${name}: ping route`);
|
|
2757
3118
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }), `${name}: history route`);
|
|
2758
3119
|
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/pill`, handler: handlePillPos }), `${name}: pill position route`);
|