pi-web-ui 0.90.1 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-tool.ts —— 覆盖 SDK 内置 read:路径是目录时列出目录条目。
|
|
3
|
+
*
|
|
4
|
+
* 背景:SDK 内置 `read` 只处理文件,`read('server')` 直接抛
|
|
5
|
+
* `EISDIR: illegal operation on a directory, read`;模型想「看一眼这个目录」
|
|
6
|
+
* 只能改用 bash(`ls`)。SDK 自带的 `ls` 工具不在默认活跃集里
|
|
7
|
+
* (默认 `["read","bash","edit","write"]`),模型并不总能想到它。
|
|
8
|
+
*
|
|
9
|
+
* 做法(与 bash 覆盖同一机制):customTools 按 name 覆盖内置定义 —— 用
|
|
10
|
+
* `createReadToolDefinition(cwd)` 拿原实现当基底,只在「路径确实是目录」时
|
|
11
|
+
* 分流到 `createLsToolDefinition(cwd)`(排序、目录 `/` 后缀、条目/字节截断
|
|
12
|
+
* 与 SDK ls 完全一致);其余情况(文件、图片、路径不存在、读取报错)原样
|
|
13
|
+
* 转发基底,行为与内置完全一致。
|
|
14
|
+
*
|
|
15
|
+
* 开关:`readDirEnabled`(设置面板「工具」页,默认开)。**行为开关**不是
|
|
16
|
+
* ActiveSet 开关(read 本体不可关,关了 agent 就残了),因此不进
|
|
17
|
+
* tool-manager 的 AGENT_TOOL_CATALOG;每次调用实时读设置,改动即时生效。
|
|
18
|
+
*
|
|
19
|
+
* 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
|
|
20
|
+
* 返回文本(目录头)按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
|
|
21
|
+
*
|
|
22
|
+
* DSH 引擎无 customTool 注册面(工具来自 shipped preset),本覆盖只服务 pi 引擎。
|
|
23
|
+
*/
|
|
24
|
+
import { stat } from "node:fs/promises";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
27
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { bilingual, pick } from "./i18n.js";
|
|
29
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
30
|
+
/**
|
|
31
|
+
* 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
|
|
32
|
+
* resolveToCwd 的主要语义)。这里只是「猜」,猜不中(例如 macOS 截图名的
|
|
33
|
+
* 变体路径)就走内置实现,不会比现状更差。
|
|
34
|
+
*/
|
|
35
|
+
export function resolvePathForDirCheck(input, cwd) {
|
|
36
|
+
let p = String(input ?? "").replace(UNICODE_SPACES, " ");
|
|
37
|
+
if (p.startsWith("@"))
|
|
38
|
+
p = p.slice(1);
|
|
39
|
+
if (p === "~")
|
|
40
|
+
return homedir();
|
|
41
|
+
if (p.startsWith("~/") || p.startsWith("~\\"))
|
|
42
|
+
return join(homedir(), p.slice(2));
|
|
43
|
+
return isAbsolute(p) ? p : nodeResolve(cwd, p);
|
|
44
|
+
}
|
|
45
|
+
/** 路径是不是目录(不存在/无权限/非目录一律 false → 交回内置实现)。 */
|
|
46
|
+
export async function isDirectoryPath(absolutePath) {
|
|
47
|
+
try {
|
|
48
|
+
const st = await stat(absolutePath);
|
|
49
|
+
return st.isDirectory();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
|
|
57
|
+
* (会话工作区)。
|
|
58
|
+
*/
|
|
59
|
+
export function makeReadDirTool(fallbackCwd, options = {}) {
|
|
60
|
+
const base = createReadToolDefinition(fallbackCwd);
|
|
61
|
+
const ls = createLsToolDefinition(fallbackCwd);
|
|
62
|
+
const dirEnabled = options.dirEnabled ?? (() => true);
|
|
63
|
+
const getLang = options.getLang ?? (() => "en");
|
|
64
|
+
return defineTool({
|
|
65
|
+
...base,
|
|
66
|
+
description: bilingual(`${base.description} If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
|
|
67
|
+
promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
|
|
68
|
+
promptGuidelines: [
|
|
69
|
+
...(base.promptGuidelines ?? []),
|
|
70
|
+
bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
|
|
71
|
+
],
|
|
72
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
73
|
+
const input = (params ?? {});
|
|
74
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
75
|
+
if (path && dirEnabled()) {
|
|
76
|
+
const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
|
|
77
|
+
if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
|
|
78
|
+
const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
|
|
79
|
+
// 列目录本体完全复用 SDK 的 ls(排序/`/` 后缀/截断提示口径一致)。
|
|
80
|
+
const listed = await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx);
|
|
81
|
+
const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
|
|
82
|
+
// 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
|
|
83
|
+
// details 不需要 ls 的字段。
|
|
84
|
+
const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
|
|
85
|
+
return { content, details: undefined };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return base.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -111,12 +111,12 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
111
111
|
"Use it when the user asks for periodic checks, delayed reminders, or scheduled summaries — do NOT fake it with sleep loops (a sleeping script can never push a message back). " +
|
|
112
112
|
'Schedule accepts a 5-field cron ("0 * * * *" = hourly) or a relative delay ("in 30m", "in 1h", "30m"); minimum interval is 60s. ' +
|
|
113
113
|
"One-shot by default (recurring=false auto-deletes the task after it fires); pass recurring=true for repeats. " +
|
|
114
|
-
"
|
|
114
|
+
"The wake-up binds the originating conversation AND its persisted session file: after context compaction or a restart the scheduler re-binds to the same session automatically; if the original conversation is gone it first falls back to the project's active conversation (and visibly reports the move), and only runs headless (report in panel history) when no live conversation exists for the project. " +
|
|
115
115
|
"Manage with schedule_list / schedule_cancel; the user can also cancel from the background-tasks panel.", "在**当前对话**里创建一个定时唤醒:到指定 cron 时间或延迟后,调度器自动把你的 prompt 投回本对话,你继续执行并向用户汇报。" +
|
|
116
116
|
"用户要求定时巡检、延时提醒、定时汇总时用它 —— 不要用 sleep 死循环假装答应(休眠的脚本推不回任何消息)。" +
|
|
117
117
|
"时间写法收 5 字段 cron(“0 * * * *”=每小时)或相对延迟(“in 30m”“in 1h”“30m”);最短间隔 60s。" +
|
|
118
118
|
"默认单次(recurring=false 触发后自动删除);传 recurring=true 做周期任务。" +
|
|
119
|
-
"
|
|
119
|
+
"唤醒绑定发起对话与其落盘会话文件:上下文压缩或重启后自动重绑到同一会话;原对话不在时先回落到同项目的活跃对话(并明确提示迁移),同项目无存活对话才无头执行,报告留在调度面板历史里。" +
|
|
120
120
|
"用 schedule_list / schedule_cancel 管理;用户也可从后台任务面板取消。"),
|
|
121
121
|
promptSnippet: "schedule a wake-up in this conversation (cron or delay), auto-report on fire",
|
|
122
122
|
parameters: Type.Object({
|
|
@@ -154,16 +154,34 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
154
154
|
const labelRaw = typeof p.label === "string" ? p.label.trim().slice(0, 80) : "";
|
|
155
155
|
const name = labelRaw || prompt.split("\n")[0].slice(0, 40) || "定时任务";
|
|
156
156
|
const convId = (ownerConversationId ?? "").trim() || String(host.activeConversationId() ?? "").trim();
|
|
157
|
+
// 稳定绑定(issue #231):owner 对话的落盘会话文件 —— 压缩/重启后靠它重认同一会话,
|
|
158
|
+
// 内存对话 id(c1/c2…)只做首选唤醒键。宿主没实现 conversationInfo 时按原来只绑 id。
|
|
159
|
+
let bindCwd = cwd;
|
|
160
|
+
let bindSessionFile = "";
|
|
161
|
+
try {
|
|
162
|
+
const info = host.conversationInfo?.((ownerConversationId ?? "").trim() || convId || undefined);
|
|
163
|
+
if (info) {
|
|
164
|
+
if (String(info.cwd ?? "").trim())
|
|
165
|
+
bindCwd = String(info.cwd).trim();
|
|
166
|
+
bindSessionFile = String(info.sessionFile ?? "")
|
|
167
|
+
.trim()
|
|
168
|
+
.slice(0, 1024);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* 绑定快照尽力而为:拿不到稳定键就按原来的 id 绑定触发 */
|
|
173
|
+
}
|
|
157
174
|
const recurring = p.recurring === true;
|
|
158
175
|
let task;
|
|
159
176
|
try {
|
|
160
177
|
task = store.upsert({
|
|
161
178
|
name,
|
|
162
|
-
cwd,
|
|
179
|
+
cwd: bindCwd,
|
|
163
180
|
kind: parsed.kind,
|
|
164
181
|
spec: parsed.spec,
|
|
165
182
|
prompt,
|
|
166
183
|
conversationId: convId,
|
|
184
|
+
sessionFile: bindSessionFile,
|
|
167
185
|
oneShot: !recurring,
|
|
168
186
|
});
|
|
169
187
|
}
|
|
@@ -178,8 +196,8 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
|
|
|
178
196
|
: `Fires: ${parsed.kind === "cron" ? `cron ${parsed.spec}` : `every ${Math.round(Number(parsed.spec) / 1000)}s`}, next ${fmtTime(next, L)}${recurring ? " (recurring)" : " (one-shot, auto-deleted after firing)"}`;
|
|
179
197
|
const targetLine = convId
|
|
180
198
|
? L === "zh"
|
|
181
|
-
? `汇报:触发时自动唤醒本对话(${convId}
|
|
182
|
-
: `Reports: wakes this conversation (${convId}) on fire;
|
|
199
|
+
? `汇报:触发时自动唤醒本对话(${convId});压缩/重启后按会话文件自动重绑,原对话不在时先回落同项目活跃对话(明确提示),无存活对话才无头执行。`
|
|
200
|
+
: `Reports: wakes this conversation (${convId}) on fire; re-binds by session file after compaction/restart, falls back to the project's active conversation (with a visible note), headless only with no live conversation.`
|
|
183
201
|
: L === "zh"
|
|
184
202
|
? "汇报:无头执行,报告进调度面板历史(创建时没拿到对话 id)。"
|
|
185
203
|
: "Reports: headless run, report in panel history (no conversation id captured at creation).";
|
|
@@ -79,6 +79,11 @@ export function normalizeSchedulerInput(input, now = Date.now()) {
|
|
|
79
79
|
const conversationId = String(input.conversationId ?? "")
|
|
80
80
|
.trim()
|
|
81
81
|
.slice(0, 128);
|
|
82
|
+
// 落盘会话文件:稳定身份键(issue #231)。只做归一化(去首尾空白、封顶),
|
|
83
|
+
// 不校验存在性 —— 创建时刻文件可能还没落盘,触发时按它匹配不上即回落。
|
|
84
|
+
const sessionFile = String(input.sessionFile ?? "")
|
|
85
|
+
.trim()
|
|
86
|
+
.slice(0, 1024);
|
|
82
87
|
return {
|
|
83
88
|
id,
|
|
84
89
|
name,
|
|
@@ -92,6 +97,7 @@ export function normalizeSchedulerInput(input, now = Date.now()) {
|
|
|
92
97
|
thinkingLevel,
|
|
93
98
|
catchUp,
|
|
94
99
|
conversationId,
|
|
100
|
+
sessionFile,
|
|
95
101
|
oneShot: input.oneShot === true,
|
|
96
102
|
createdAt: now,
|
|
97
103
|
updatedAt: now,
|
|
@@ -152,6 +158,17 @@ function sanitizeHistory(v) {
|
|
|
152
158
|
}
|
|
153
159
|
return out.sort((a, b) => b.at - a.at).slice(0, SCHEDULER_HISTORY_MAX);
|
|
154
160
|
}
|
|
161
|
+
/** 落盘会话路径是否指向同一会话(纯函数,可单测)。
|
|
162
|
+
* Windows 上 `C:\a\b` 与 `C:/a/b`、尾部分隔符差异都算同一文件;
|
|
163
|
+
* 空串永不相等(没拿到稳定键的任务不配对)。 */
|
|
164
|
+
export function sameSessionFile(a, b) {
|
|
165
|
+
const x = String(a ?? "").trim();
|
|
166
|
+
const y = String(b ?? "").trim();
|
|
167
|
+
if (!x || !y)
|
|
168
|
+
return false;
|
|
169
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
170
|
+
return norm(x) === norm(y);
|
|
171
|
+
}
|
|
155
172
|
export class SchedulerStore {
|
|
156
173
|
dataDir;
|
|
157
174
|
opts;
|
|
@@ -230,6 +247,7 @@ export class SchedulerStore {
|
|
|
230
247
|
thinkingLevel: r.thinkingLevel,
|
|
231
248
|
catchUp: r.catchUp,
|
|
232
249
|
conversationId: r.conversationId,
|
|
250
|
+
sessionFile: r.sessionFile,
|
|
233
251
|
oneShot: r.oneShot,
|
|
234
252
|
});
|
|
235
253
|
task.createdAt = typeof r.createdAt === "number" && Number.isFinite(r.createdAt) ? r.createdAt : Date.now();
|
|
@@ -288,6 +306,7 @@ export class SchedulerStore {
|
|
|
288
306
|
thinkingLevel: t.thinkingLevel,
|
|
289
307
|
catchUp: t.catchUp,
|
|
290
308
|
conversationId: t.conversationId,
|
|
309
|
+
sessionFile: t.sessionFile ?? "",
|
|
291
310
|
oneShot: t.oneShot,
|
|
292
311
|
createdAt: t.createdAt,
|
|
293
312
|
updatedAt: t.updatedAt,
|
|
@@ -299,13 +318,20 @@ export class SchedulerStore {
|
|
|
299
318
|
})
|
|
300
319
|
.sort((a, b) => a.name.localeCompare(b.name, "zh"));
|
|
301
320
|
}
|
|
302
|
-
/** 新建或全量更新(同名 id 覆盖);返回归一化后的任务。
|
|
321
|
+
/** 新建或全量更新(同名 id 覆盖);返回归一化后的任务。
|
|
322
|
+
* issue #231:面板编辑不带绑定字段(conversationId/sessionFile 缺席)时保留旧绑定 ——
|
|
323
|
+
* 否则用户在面板改个名字就会把 AI 任务的会话绑定洗掉,下次触发直接变无头。
|
|
324
|
+
* 字段显式出现(含空串)仍按全量语义覆盖。 */
|
|
303
325
|
upsert(input) {
|
|
304
326
|
const prev = this.tasks.get(String(input.id ?? "").trim());
|
|
305
327
|
const task = normalizeSchedulerInput(input);
|
|
306
328
|
if (prev) {
|
|
307
329
|
task.createdAt = prev.createdAt;
|
|
308
330
|
task.updatedAt = Date.now();
|
|
331
|
+
if (input.conversationId === undefined && input.sessionFile === undefined) {
|
|
332
|
+
task.conversationId = prev.conversationId;
|
|
333
|
+
task.sessionFile = prev.sessionFile ?? "";
|
|
334
|
+
}
|
|
309
335
|
}
|
|
310
336
|
const history = prev?.history ? sanitizeHistory(prev.history) : [];
|
|
311
337
|
this.tasks.set(task.id, { ...task, history });
|
|
@@ -335,6 +361,39 @@ export class SchedulerStore {
|
|
|
335
361
|
this.changed();
|
|
336
362
|
return t;
|
|
337
363
|
}
|
|
364
|
+
/** 会话重绑定(issue #231):触发时按稳定键找到了新的对话句柄,把任务的
|
|
365
|
+
* 投递目标迁移过去(只改 conversationId/sessionFile/updatedAt,不碰下次触发
|
|
366
|
+
* 与历史)。无变化回 false(调用方免一次落盘+推送);未知任务回 false。 */
|
|
367
|
+
rebind(id, binding) {
|
|
368
|
+
const t = this.tasks.get(id);
|
|
369
|
+
if (!t)
|
|
370
|
+
return false;
|
|
371
|
+
let dirty = false;
|
|
372
|
+
if (binding.conversationId !== undefined) {
|
|
373
|
+
const v = String(binding.conversationId ?? "")
|
|
374
|
+
.trim()
|
|
375
|
+
.slice(0, 128);
|
|
376
|
+
if (v !== t.conversationId) {
|
|
377
|
+
t.conversationId = v;
|
|
378
|
+
dirty = true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (binding.sessionFile !== undefined) {
|
|
382
|
+
const v = String(binding.sessionFile ?? "")
|
|
383
|
+
.trim()
|
|
384
|
+
.slice(0, 1024);
|
|
385
|
+
if (v !== (t.sessionFile ?? "")) {
|
|
386
|
+
t.sessionFile = v;
|
|
387
|
+
dirty = true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!dirty)
|
|
391
|
+
return false;
|
|
392
|
+
t.updatedAt = Date.now();
|
|
393
|
+
this.save();
|
|
394
|
+
this.changed();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
338
397
|
async tick() {
|
|
339
398
|
const now = Date.now();
|
|
340
399
|
for (const task of this.tasks.values()) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI commit-message generation — pure prompt assembly + response cleanup.
|
|
3
|
+
*
|
|
4
|
+
* The git gathering lives in scm.ts (scmCommitContext); the one-off model
|
|
5
|
+
* call lives in agent-service (ClientSession.scmGenCommitMessage, same
|
|
6
|
+
* completeSimple path as the vision bridge). This module owns everything
|
|
7
|
+
* deterministic so it stays unit-testable:
|
|
8
|
+
*
|
|
9
|
+
* buildCommitMsgInput(ctx, fallbackLang) → user message (null = nothing
|
|
10
|
+
* to commit — the caller turns that into a friendly error)
|
|
11
|
+
* COMMITMSG_SYSTEM_PROMPT → system prompt
|
|
12
|
+
* sanitizeCommitMessage(raw) → the single-line subject that
|
|
13
|
+
* ends up in the commit input (models are told to emit exactly that, but
|
|
14
|
+
* fences/labels/quotes/newlines are stripped defensively)
|
|
15
|
+
*
|
|
16
|
+
* Language policy: the generated message follows the repository's own commit
|
|
17
|
+
* subjects (style AND language — a repo writing "feat(xxx): 描述" keeps
|
|
18
|
+
* getting that). Only a repo with no commits yet falls back to the UI
|
|
19
|
+
* language passed as fallbackLang.
|
|
20
|
+
*/
|
|
21
|
+
/** Hard cap for the generated subject — the commit input is single-line and
|
|
22
|
+
* this keeps a chatty model from filling the whole header row. */
|
|
23
|
+
export const MAX_COMMITMSG_LEN = 200;
|
|
24
|
+
/** File list cap inside the prompt (porcelain lines); the diffs carry the
|
|
25
|
+
* details, the list is orientation only. */
|
|
26
|
+
const MAX_FILE_LINES = 200;
|
|
27
|
+
export const COMMITMSG_SYSTEM_PROMPT = `You write git commit messages. You receive the pending changes of a repository and output exactly one commit message: a single subject line.
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
1. Match the style, format and language of the repository's recent commit subjects when provided — conventional commits ("type(scope): summary"), prefixes, punctuation, language: copy what the repo actually does.
|
|
31
|
+
2. Describe what the change does at a glance (the user-facing effect), not a file-by-file listing.
|
|
32
|
+
3. The staged diff is the subject of the commit. Describe staged changes when any exist; otherwise describe the unstaged and untracked changes shown.
|
|
33
|
+
4. One line. No quotes, no markdown, no code fences, no "Commit message:" label, no explanation. Under 72 characters when the repository's language allows it (CJK text may naturally be shorter).
|
|
34
|
+
5. Never invent scope names, issue numbers or details not visible in the provided material.
|
|
35
|
+
6. If the repository has no commits yet, write the message in the language stated in the request.
|
|
36
|
+
|
|
37
|
+
Output ONLY the subject line.`;
|
|
38
|
+
/**
|
|
39
|
+
* Assemble the effective system prompt from the settings-panel prefs (same
|
|
40
|
+
* contract as the vision bridge's buildVisionBridgePrompt): mode "append"
|
|
41
|
+
* puts custom text after the built-in default (empty custom = pure default);
|
|
42
|
+
* mode "replace" swaps in the custom text, but an empty custom still falls
|
|
43
|
+
* back to the default (never send an empty system prompt).
|
|
44
|
+
*/
|
|
45
|
+
export function buildCommitMsgPrompt(mode, custom) {
|
|
46
|
+
const text = custom?.trim() ?? "";
|
|
47
|
+
if (mode === "replace" && text)
|
|
48
|
+
return text;
|
|
49
|
+
if (text)
|
|
50
|
+
return `${COMMITMSG_SYSTEM_PROMPT}\n\n${text}`;
|
|
51
|
+
return COMMITMSG_SYSTEM_PROMPT;
|
|
52
|
+
}
|
|
53
|
+
/** "path → [add,del]" map → compact "+12/-3 path" lines (sorted by path). */
|
|
54
|
+
function formatNumStat(stat) {
|
|
55
|
+
return Object.keys(stat)
|
|
56
|
+
.sort()
|
|
57
|
+
.map((p) => `+${stat[p][0]}/-${stat[p][1]} ${p}`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Assemble the user message from the gathered context. Returns null when
|
|
61
|
+
* there is nothing to describe (no staged/unstaged diff and no untracked
|
|
62
|
+
* files) — the caller replies "no changes" instead of calling the model.
|
|
63
|
+
*/
|
|
64
|
+
export function buildCommitMsgInput(ctx, fallbackLang) {
|
|
65
|
+
const parts = [];
|
|
66
|
+
if (ctx.subjects.length > 0) {
|
|
67
|
+
parts.push("Recent commit subjects (newest first — style reference):");
|
|
68
|
+
parts.push(...ctx.subjects.map((s) => `- ${s}`));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
parts.push(fallbackLang === "zh"
|
|
72
|
+
? "(仓库还没有任何提交;请用中文写这条提交信息)"
|
|
73
|
+
: "(The repository has no commits yet; write this commit message in English.)");
|
|
74
|
+
}
|
|
75
|
+
if (ctx.files.length > 0) {
|
|
76
|
+
const shown = ctx.files.slice(0, MAX_FILE_LINES);
|
|
77
|
+
const more = ctx.files.length - shown.length;
|
|
78
|
+
parts.push("Changed files (porcelain status):");
|
|
79
|
+
parts.push(...shown.map((f) => `${f.x}${f.y} ${f.path}`));
|
|
80
|
+
if (more > 0)
|
|
81
|
+
parts.push(`… (+${more} more files)`);
|
|
82
|
+
}
|
|
83
|
+
if (ctx.stagedPatch.trim()) {
|
|
84
|
+
parts.push("Staged diff (these changes are the commit's subject):");
|
|
85
|
+
if (Object.keys(ctx.stagedStat).length > 0) {
|
|
86
|
+
parts.push(...formatNumStat(ctx.stagedStat));
|
|
87
|
+
}
|
|
88
|
+
parts.push(ctx.stagedPatch);
|
|
89
|
+
}
|
|
90
|
+
if (ctx.worktreePatch.trim()) {
|
|
91
|
+
parts.push("Unstaged diff (worktree only):");
|
|
92
|
+
if (Object.keys(ctx.worktreeStat).length > 0) {
|
|
93
|
+
parts.push(...formatNumStat(ctx.worktreeStat));
|
|
94
|
+
}
|
|
95
|
+
parts.push(ctx.worktreePatch);
|
|
96
|
+
}
|
|
97
|
+
const untracked = ctx.files.filter((f) => f.x === "?" && f.y === "?");
|
|
98
|
+
if (untracked.length > 0 && !ctx.stagedPatch.trim()) {
|
|
99
|
+
// Untracked content never appears in `git diff` — name the files so
|
|
100
|
+
// the model knows they are part of the change set (commit-all flow).
|
|
101
|
+
parts.push(`Untracked files (new, content not shown): ${untracked
|
|
102
|
+
.map((f) => f.path)
|
|
103
|
+
.join(", ")
|
|
104
|
+
.slice(0, 2000)}`);
|
|
105
|
+
}
|
|
106
|
+
if (!ctx.stagedPatch.trim() && !ctx.worktreePatch.trim() && untracked.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
return parts.join("\n");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Normalize whatever the model emitted into the single-line subject that goes
|
|
112
|
+
* into the commit input: drop fences, labels and wrapping quotes, keep only
|
|
113
|
+
* the first non-empty line, collapse whitespace, cap the length.
|
|
114
|
+
*/
|
|
115
|
+
export function sanitizeCommitMessage(raw) {
|
|
116
|
+
let text = raw.trim();
|
|
117
|
+
// ```…``` fences wrapping the whole answer.
|
|
118
|
+
const fence = text.match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/);
|
|
119
|
+
if (fence)
|
|
120
|
+
text = fence[1].trim();
|
|
121
|
+
// Leading labels some models add despite the instructions.
|
|
122
|
+
text = text.replace(/^(commit(\s+message)?|subject|提交信息|提交消息|提交说明)\s*[::]\s*/i, "");
|
|
123
|
+
// First non-empty line only — the input field is single-line by design.
|
|
124
|
+
const firstLine = text
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((l) => l.trim())
|
|
127
|
+
.find((l) => l.length > 0) ?? "";
|
|
128
|
+
let out = firstLine.replace(/\s+/g, " ").trim();
|
|
129
|
+
// Matching wrapping quotes.
|
|
130
|
+
if (out.length >= 2) {
|
|
131
|
+
const a = out[0];
|
|
132
|
+
const b = out[out.length - 1];
|
|
133
|
+
if ((a === '"' && b === '"') || (a === "'" && b === "'") || (a === "`" && b === "`")) {
|
|
134
|
+
out = out.slice(1, -1).trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (out.length > MAX_COMMITMSG_LEN)
|
|
138
|
+
out = `${out.slice(0, MAX_COMMITMSG_LEN - 1).trimEnd()}…`;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
package/dist/server/scm.js
CHANGED
|
@@ -295,3 +295,45 @@ export async function scmFileDiff(cwd, path, lang) {
|
|
|
295
295
|
export async function scmCommitDetail(cwd, hash, lang) {
|
|
296
296
|
return git(cwd, ["show", "--no-color", "--no-ext-diff", "--find-renames", "--format=fuller", "--stat", "--patch", hash], lang);
|
|
297
297
|
}
|
|
298
|
+
/* ------------------------------------------------------------------ */
|
|
299
|
+
/* AI commit-message context */
|
|
300
|
+
/* ------------------------------------------------------------------ */
|
|
301
|
+
/** Cap per patch (staged / worktree) fed to the commit-message prompt —
|
|
302
|
+
* enough for the model to see what most commits are about without letting
|
|
303
|
+
* one huge repo blow up the one-off completion request. */
|
|
304
|
+
const MAX_COMMITMSG_PATCH_CHARS = 12_000;
|
|
305
|
+
/** Cap one patch at MAX_COMMITMSG_PATCH_CHARS, keeping the head (early files
|
|
306
|
+
* matter most for a commit message) and marking the cut. */
|
|
307
|
+
function capPatch(text) {
|
|
308
|
+
if (text.length <= MAX_COMMITMSG_PATCH_CHARS)
|
|
309
|
+
return text;
|
|
310
|
+
return `${text.slice(0, MAX_COMMITMSG_PATCH_CHARS)}\n… (diff truncated)`;
|
|
311
|
+
}
|
|
312
|
+
/** Gather staged/worktree patches, numstats, status and recent subjects for
|
|
313
|
+
* the AI commit-message generator. Throws the usual git errors (including
|
|
314
|
+
* "not a git repository") — the caller maps them to friendly replies. */
|
|
315
|
+
export async function scmCommitContext(cwd, lang) {
|
|
316
|
+
const [stagedPatch, worktreePatch, stagedStatText, worktreeStatText, statusText, subjectText] = await Promise.all([
|
|
317
|
+
git(cwd, ["diff", "--cached", "--no-color", "--no-ext-diff", "--find-renames"], lang).catch(() => ""),
|
|
318
|
+
git(cwd, ["diff", "--no-color", "--no-ext-diff", "--find-renames"], lang).catch(() => ""),
|
|
319
|
+
git(cwd, ["diff", "--cached", "--numstat"], lang).catch(() => ""),
|
|
320
|
+
git(cwd, ["diff", "--numstat"], lang).catch(() => ""),
|
|
321
|
+
git(cwd, ["status", "--porcelain=v1", "--find-renames"], lang),
|
|
322
|
+
// Empty repos (no commits yet) make `git log` fail — that's a valid
|
|
323
|
+
// "no style reference" answer, not an error worth failing the request.
|
|
324
|
+
git(cwd, ["log", "-n", "15", "--pretty=format:%s"], lang).then((s) => s, () => ""),
|
|
325
|
+
]);
|
|
326
|
+
const subjects = subjectText
|
|
327
|
+
.split("\n")
|
|
328
|
+
.map((s) => s.trim())
|
|
329
|
+
.filter(Boolean);
|
|
330
|
+
return {
|
|
331
|
+
subjects,
|
|
332
|
+
emptyRepo: subjects.length === 0,
|
|
333
|
+
files: parseStatusFiles(statusText),
|
|
334
|
+
stagedStat: parseNumStat(stagedStatText),
|
|
335
|
+
worktreeStat: parseNumStat(worktreeStatText),
|
|
336
|
+
stagedPatch: capPatch(stagedPatch),
|
|
337
|
+
worktreePatch: capPatch(worktreePatch),
|
|
338
|
+
};
|
|
339
|
+
}
|
package/dist/server/serialize.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const TEXT_CAP = 200_000;
|
|
2
2
|
const TOOL_OUTPUT_CAP = 100_000;
|
|
3
3
|
const ARGS_CAP = 20_000;
|
|
4
|
+
/**
|
|
5
|
+
* toolResult.details 的体积上限。details 是给 UI 用的结构化元数据(如
|
|
6
|
+
* present_files 的卡片数据、ask_user_question 的答案),快照每 60ms 推一次,
|
|
7
|
+
* 不能无节制。超过上限时**整个丢掉**(而不是截断 —— 截断后的 JSON 不可解析,
|
|
8
|
+
* 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
|
|
9
|
+
*/
|
|
10
|
+
const TOOL_DETAILS_CAP = 64_000;
|
|
4
11
|
function truncate(s, cap) {
|
|
5
12
|
if (s.length <= cap)
|
|
6
13
|
return { text: s, truncated: false };
|
|
@@ -106,7 +113,7 @@ export function serializeMessage(m, seq) {
|
|
|
106
113
|
case "toolResult": {
|
|
107
114
|
const raw = m.content.map((c) => (c.type === "text" ? c.text : "[image result]")).join("\n");
|
|
108
115
|
const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
|
|
109
|
-
|
|
116
|
+
const msg = {
|
|
110
117
|
id: `t-${m.toolCallId}`,
|
|
111
118
|
role: "toolResult",
|
|
112
119
|
content: [{ type: "text", text, truncated }],
|
|
@@ -115,6 +122,19 @@ export function serializeMessage(m, seq) {
|
|
|
115
122
|
isError: m.isError,
|
|
116
123
|
timestamp: m.timestamp,
|
|
117
124
|
};
|
|
125
|
+
// 结构化元数据(tool result details)也要下发:present_files 的预览卡片
|
|
126
|
+
// 就靠它拿 kind/size/excerpt(前端不能靠再打一次 HTTP 才知类型),
|
|
127
|
+
// ask_user_question/todo_list 同理。体积封顶,超限整丢(见 TOOL_DETAILS_CAP)。
|
|
128
|
+
if (m.details !== undefined) {
|
|
129
|
+
try {
|
|
130
|
+
if (JSON.stringify(m.details).length <= TOOL_DETAILS_CAP)
|
|
131
|
+
msg.details = m.details;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return msg;
|
|
118
138
|
}
|
|
119
139
|
case "bashExecution": {
|
|
120
140
|
const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
|
|
@@ -10,7 +10,9 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
10
10
|
import { basename, dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeUiLayout, } from "./client-state.js";
|
|
13
|
+
import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
|
|
13
14
|
import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
|
|
15
|
+
import { COMMITMSG_SYSTEM_PROMPT } from "./scm-commitmsg.js";
|
|
14
16
|
import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
|
|
15
17
|
import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
16
18
|
export class SettingsService {
|
|
@@ -287,8 +289,10 @@ export class SettingsService {
|
|
|
287
289
|
terminalToolsEnabled: legacyTools.terminalToolsEnabled,
|
|
288
290
|
terminalBash: this.settings.terminalBash,
|
|
289
291
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
292
|
+
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
290
293
|
editSoftEnabled: legacyTools.editSoftEnabled,
|
|
291
294
|
questionnaireEnabled: legacyTools.questionnaireEnabled,
|
|
295
|
+
parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
|
|
292
296
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
293
297
|
devNoCache: this.settings.devNoCache ?? this.defaultDevNoCache(),
|
|
294
298
|
autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
|
|
@@ -298,6 +302,8 @@ export class SettingsService {
|
|
|
298
302
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
299
303
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
300
304
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
305
|
+
scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
|
|
306
|
+
scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
|
|
301
307
|
reviewPrompt: this.settings.reviewPrompt,
|
|
302
308
|
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
303
309
|
disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
|
|
@@ -310,6 +316,7 @@ export class SettingsService {
|
|
|
310
316
|
// 发给模型的工具 schema(name/description/parameters)—— 只读预览。
|
|
311
317
|
toolsSchema: promptSnap.toolsSchema,
|
|
312
318
|
visionBridgeDefaultPrompt: SYSTEM_PROMPT,
|
|
319
|
+
scmCommitMsgDefaultPrompt: COMMITMSG_SYSTEM_PROMPT,
|
|
313
320
|
visionModels: this.collectVisionModels(),
|
|
314
321
|
disabledSkills: [...this.settings.disabledSkills],
|
|
315
322
|
disabledExtensions: [...this.settings.disabledExtensions],
|
|
@@ -328,6 +335,8 @@ export class SettingsService {
|
|
|
328
335
|
subagentDefaultTemplates: DEFAULT_TEMPLATES.map((t) => t.name),
|
|
329
336
|
subagentDefaultModel: this.settings.subagentDefaultModel ?? null,
|
|
330
337
|
retryMaxAttempts: this.settings.retryMaxAttempts,
|
|
338
|
+
softCapTokens: this.settings.softCapTokens,
|
|
339
|
+
softCapByModel: { ...this.settings.softCapByModel },
|
|
331
340
|
subagentModels: this.collectSubagentModels(),
|
|
332
341
|
quickPhrases: [...this.settings.quickPhrases],
|
|
333
342
|
quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
|
|
@@ -450,10 +459,18 @@ export class SettingsService {
|
|
|
450
459
|
if (partial.terminalBashIdleMs !== undefined) {
|
|
451
460
|
this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
|
|
452
461
|
}
|
|
462
|
+
// read 读目录开关:覆盖定义每次调用实时读取,改动即时生效,无需 reload。
|
|
463
|
+
if (partial.readDirEnabled !== undefined) {
|
|
464
|
+
this.settings.readDirEnabled = partial.readDirEnabled;
|
|
465
|
+
}
|
|
453
466
|
// 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
|
|
454
467
|
if (partial.goalModeEnabled !== undefined) {
|
|
455
468
|
this.settings.goalModeEnabled = partial.goalModeEnabled;
|
|
456
469
|
}
|
|
470
|
+
// 同项目并行提醒开关:同上,发送入口逐轮实时读取,无需 reload。
|
|
471
|
+
if (partial.parallelReminderEnabled !== undefined) {
|
|
472
|
+
this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
|
|
473
|
+
}
|
|
457
474
|
if (partial.devNoCache !== undefined) {
|
|
458
475
|
this.settings.devNoCache = partial.devNoCache;
|
|
459
476
|
}
|
|
@@ -483,6 +500,12 @@ export class SettingsService {
|
|
|
483
500
|
if (partial.visionBridgePrompt !== undefined) {
|
|
484
501
|
this.settings.visionBridgePrompt = partial.visionBridgePrompt;
|
|
485
502
|
}
|
|
503
|
+
if (partial.scmCommitMsgPromptMode !== undefined) {
|
|
504
|
+
this.settings.scmCommitMsgPromptMode = partial.scmCommitMsgPromptMode;
|
|
505
|
+
}
|
|
506
|
+
if (partial.scmCommitMsgPrompt !== undefined) {
|
|
507
|
+
this.settings.scmCommitMsgPrompt = partial.scmCommitMsgPrompt;
|
|
508
|
+
}
|
|
486
509
|
if (partial.reviewPrompt !== undefined) {
|
|
487
510
|
this.settings.reviewPrompt = partial.reviewPrompt;
|
|
488
511
|
}
|
|
@@ -500,6 +523,18 @@ export class SettingsService {
|
|
|
500
523
|
this.settings.retryMaxAttempts = normalizeRetryMaxAttempts(partial.retryMaxAttempts);
|
|
501
524
|
this.host.applyRetryOverrides();
|
|
502
525
|
}
|
|
526
|
+
if (partial.softCapTokens !== undefined || partial.softCapByModel !== undefined) {
|
|
527
|
+
// 压缩软上限:持久化 + 即时重算各会话的 compaction reserveTokens
|
|
528
|
+
// 覆盖(SDK 每次自动压缩检查前都重读 getCompactionSettings,无需
|
|
529
|
+
// reload;见宿主 applyCompactionOverrides,issue #229)。
|
|
530
|
+
if (partial.softCapTokens !== undefined)
|
|
531
|
+
this.settings.softCapTokens = normalizeSoftCapTokens(partial.softCapTokens);
|
|
532
|
+
if (partial.softCapByModel !== undefined)
|
|
533
|
+
this.settings.softCapByModel = normalizeSoftCapByModel(partial.softCapByModel);
|
|
534
|
+
this.host.applyCompactionOverrides();
|
|
535
|
+
// 底栏标记线读快照的 contextUsage.softCap——推一次快照让在线页即时看到。
|
|
536
|
+
this.host.flushSnapshot();
|
|
537
|
+
}
|
|
503
538
|
if (partial.quickPhrases !== undefined) {
|
|
504
539
|
// 归一化:去空白/空项/重名,单条 ≤200 字,最多 30 条。纯 UI 偏好,不 reload。
|
|
505
540
|
const seen = new Set();
|
|
@@ -546,6 +581,8 @@ export class SettingsService {
|
|
|
546
581
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
547
582
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
548
583
|
retryMaxAttempts: this.settings.retryMaxAttempts,
|
|
584
|
+
softCapTokens: this.settings.softCapTokens,
|
|
585
|
+
softCapByModel: { ...this.settings.softCapByModel },
|
|
549
586
|
reviewPrompt: this.settings.reviewPrompt,
|
|
550
587
|
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
551
588
|
skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
|
|
@@ -591,11 +628,18 @@ export class SettingsService {
|
|
|
591
628
|
// 终端接管偏好随预设走;旧预设缺字段时保留当前值。
|
|
592
629
|
terminalBash: p.terminalBash ?? this.settings.terminalBash,
|
|
593
630
|
terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
|
|
631
|
+
// read 读目录是纯运行行为开关,不进预设——保留当前值。
|
|
632
|
+
readDirEnabled: this.settings.readDirEnabled !== false,
|
|
594
633
|
editSoftEnabled: presetLegacy.editSoftEnabled,
|
|
595
634
|
// 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
|
|
596
635
|
retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
|
|
636
|
+
// 压缩软上限同样随预设走(issue #229);旧预设缺字段时保留当前值。
|
|
637
|
+
softCapTokens: normalizeSoftCapTokens(p.softCapTokens ?? this.settings.softCapTokens),
|
|
638
|
+
softCapByModel: normalizeSoftCapByModel(p.softCapByModel ?? this.settings.softCapByModel),
|
|
597
639
|
// 问卷开关不进预设——保留当前值。
|
|
598
640
|
questionnaireEnabled: this.settings.questionnaireEnabled,
|
|
641
|
+
// 同项目并行提醒开关不进预设——保留当前值。
|
|
642
|
+
parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
|
|
599
643
|
// 目标模式总开关不进预设——保留当前值。
|
|
600
644
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
601
645
|
reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
|
|
@@ -614,6 +658,9 @@ export class SettingsService {
|
|
|
614
658
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
615
659
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
616
660
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
661
|
+
// 「AI 提交信息」提示词同样不进预设——保留当前值。
|
|
662
|
+
scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
|
|
663
|
+
scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
|
|
617
664
|
// 子代理默认模型也不进预设——保留当前值。
|
|
618
665
|
subagentDefaultModel: this.settings.subagentDefaultModel,
|
|
619
666
|
// 快捷短语是纯 UI 偏好,不进预设——保留当前值。
|