pi-web-ui 0.66.0 → 0.68.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.
@@ -107,10 +107,13 @@ const DEFAULT_SETTINGS = {
107
107
  terminalToolsEnabled: true,
108
108
  terminalBash: false,
109
109
  terminalBashIdleMs: 15_000,
110
+ editSoftEnabled: false,
110
111
  thinkingWrap: false,
111
112
  toolsWrap: true,
112
113
  disabledPlugins: [],
113
114
  reviewPrompt: "",
115
+ quickPhrases: [],
116
+ quickPhrasesEnabled: true,
114
117
  };
115
118
  // ---------------------------------------------------------------------------
116
119
  // DshClientSession — 一个浏览器客户端
@@ -217,10 +220,13 @@ export class DshClientSession {
217
220
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
218
221
  terminalBash: savedSettings.terminalBash,
219
222
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
223
+ editSoftEnabled: savedSettings.editSoftEnabled,
220
224
  thinkingWrap: savedSettings.thinkingWrap,
221
225
  toolsWrap: savedSettings.toolsWrap,
222
226
  disabledPlugins: savedSettings.disabledPlugins ?? [],
223
227
  reviewPrompt: savedSettings.reviewPrompt,
228
+ quickPhrases: savedSettings.quickPhrases ?? [],
229
+ quickPhrasesEnabled: savedSettings.quickPhrasesEnabled ?? true,
224
230
  };
225
231
  }
226
232
  // 第一个 conversation = 新会话(每客户端独立 sessionId,避免多标签页/多
@@ -2053,6 +2059,7 @@ export class DshClientSession {
2053
2059
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2054
2060
  terminalBash: this.settings.terminalBash,
2055
2061
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2062
+ editSoftEnabled: this.settings.editSoftEnabled,
2056
2063
  thinkingWrap: this.settings.thinkingWrap,
2057
2064
  toolsWrap: this.settings.toolsWrap,
2058
2065
  visionBridgeEnabled: false,
@@ -2062,8 +2069,10 @@ export class DshClientSession {
2062
2069
  reviewPrompt: this.settings.reviewPrompt,
2063
2070
  reviewDisabledSkills: [],
2064
2071
  disabledPlugins: this.settings.disabledPlugins,
2065
- defaultSystemPrompt: "",
2072
+ promptTemplate: "",
2073
+ promptOverrides: {},
2066
2074
  effectiveSystemPrompt: this.settings.customSystemPrompt,
2075
+ promptSourceDefaults: {},
2067
2076
  visionBridgeDefaultPrompt: "",
2068
2077
  visionModels: [],
2069
2078
  skills: this.skillsCache,
@@ -2077,6 +2086,8 @@ export class DshClientSession {
2077
2086
  subagentDefaultTemplates: [],
2078
2087
  subagentDefaultModel: null,
2079
2088
  subagentModels: [],
2089
+ quickPhrases: [...this.settings.quickPhrases],
2090
+ quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2080
2091
  };
2081
2092
  this.emit({ type: "settings_state", settings });
2082
2093
  }
@@ -2095,6 +2106,8 @@ export class DshClientSession {
2095
2106
  this.settings.terminalBash = partial.terminalBash;
2096
2107
  if (partial.terminalBashIdleMs !== undefined)
2097
2108
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2109
+ if (partial.editSoftEnabled !== undefined)
2110
+ this.settings.editSoftEnabled = partial.editSoftEnabled;
2098
2111
  if (partial.thinkingWrap !== undefined)
2099
2112
  this.settings.thinkingWrap = partial.thinkingWrap;
2100
2113
  if (partial.toolsWrap !== undefined)
@@ -2103,6 +2116,15 @@ export class DshClientSession {
2103
2116
  this.settings.disabledPlugins = partial.disabledPlugins;
2104
2117
  if (partial.reviewPrompt !== undefined)
2105
2118
  this.settings.reviewPrompt = partial.reviewPrompt;
2119
+ if (partial.quickPhrases !== undefined) {
2120
+ const seen = new Set();
2121
+ this.settings.quickPhrases = (Array.isArray(partial.quickPhrases) ? partial.quickPhrases : [])
2122
+ .map((p) => String(p).trim().slice(0, 200))
2123
+ .filter((p) => p && !seen.has(p) && (seen.add(p), true))
2124
+ .slice(0, 30);
2125
+ }
2126
+ if (partial.quickPhrasesEnabled !== undefined)
2127
+ this.settings.quickPhrasesEnabled = partial.quickPhrasesEnabled;
2106
2128
  // 持久化(跨重连存活)。
2107
2129
  this.stateStore.saveSettings(this.clientId, {
2108
2130
  promptMode: this.settings.promptMode,
@@ -2112,10 +2134,13 @@ export class DshClientSession {
2112
2134
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2113
2135
  terminalBash: this.settings.terminalBash,
2114
2136
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2137
+ editSoftEnabled: this.settings.editSoftEnabled,
2115
2138
  thinkingWrap: this.settings.thinkingWrap,
2116
2139
  toolsWrap: this.settings.toolsWrap,
2117
2140
  disabledPlugins: this.settings.disabledPlugins,
2118
2141
  reviewPrompt: this.settings.reviewPrompt,
2142
+ quickPhrases: this.settings.quickPhrases,
2143
+ quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2119
2144
  });
2120
2145
  // 仅系统提示词变化才重启运行时(DSH_PERSONA 由 launcher env 注入);
2121
2146
  // 其他设置(开关/隐藏插件等)只存不回写运行时。
@@ -2161,11 +2186,14 @@ export class DshClientSession {
2161
2186
  name,
2162
2187
  promptMode: this.settings.promptMode,
2163
2188
  customSystemPrompt: this.settings.customSystemPrompt,
2189
+ promptTemplate: "",
2190
+ promptOverrides: {},
2164
2191
  disabledSkills: this.settings.disabledSkills,
2165
2192
  disabledExtensions: this.settings.disabledExtensions,
2166
2193
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2167
2194
  terminalBash: this.settings.terminalBash,
2168
2195
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2196
+ editSoftEnabled: this.settings.editSoftEnabled,
2169
2197
  visionBridgePromptMode: "append",
2170
2198
  visionBridgePrompt: "",
2171
2199
  reviewPrompt: this.settings.reviewPrompt,
@@ -2198,6 +2226,7 @@ export class DshClientSession {
2198
2226
  this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
2199
2227
  this.settings.terminalBash = preset.terminalBash;
2200
2228
  this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
2229
+ this.settings.editSoftEnabled = preset.editSoftEnabled ?? this.settings.editSoftEnabled;
2201
2230
  this.settings.reviewPrompt = preset.reviewPrompt ?? "";
2202
2231
  this.stateStore.saveSettings(this.clientId, {
2203
2232
  promptMode: this.settings.promptMode,
@@ -2207,6 +2236,7 @@ export class DshClientSession {
2207
2236
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2208
2237
  terminalBash: this.settings.terminalBash,
2209
2238
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2239
+ editSoftEnabled: this.settings.editSoftEnabled,
2210
2240
  thinkingWrap: this.settings.thinkingWrap,
2211
2241
  toolsWrap: this.settings.toolsWrap,
2212
2242
  disabledPlugins: this.settings.disabledPlugins,
@@ -0,0 +1,289 @@
1
+ /**
2
+ * edit_soft —— 一个「不严格要求缩进」的独立编辑工具(不覆盖内置 edit)。
3
+ *
4
+ * 背景:内置 `edit` 的 oldText 必须与文件恰好匹配(含缩进/空白)。某些语言
5
+ * (如 JS/JSON)缩进不是语法的一部分,模型给出 oldText 时常常在缩进上与文件
6
+ * 差几个空格/制表符,导致编辑失败。edit_soft 用「逐行核心 = 去掉行首+行尾空白」
7
+ * 做宽松匹配:只要每行的内容一致、缩进不同也能命中。
8
+ *
9
+ * 语义约定:
10
+ * - 先试精确子串匹配(与 edit 相同)。
11
+ * - 精确失败后再按行宽松匹配:oldText 拆成若干行、每行取 trim 后的核心,
12
+ * 在文件里找一段连续行,其核心序列与 oldText 完全一致(仅唯一匹配才写)。
13
+ * - 命中后按「整行替换」写入 newText **原样**(AI 给的缩进就是最终缩进),
14
+ * 只做必要的行尾换行平衡。
15
+ * - 若不支持片段(oldText 不是完整行)会在严格匹配阶段返回错误提示。
16
+ *
17
+ * 开关:设置面板「编辑」页 `editSoftEnabled`(默认关)。关闭时该工具从活跃集移除。
18
+ */
19
+ import { constants } from "node:fs";
20
+ import { access, readFile, writeFile } from "node:fs/promises";
21
+ import { homedir } from "node:os";
22
+ import { isAbsolute, join, resolve as nodeResolve } from "node:path";
23
+ import { Type } from "typebox";
24
+ import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
25
+ export const SOFT_EDIT_TOOL_NAME = "edit_soft";
26
+ const replaceEditSchema = Type.Object({
27
+ oldText: Type.String({
28
+ description: "要替换的文本。宽松匹配:每个非空行的内容(去掉首尾空白)需与文件中对应行一致;行首缩进(空格/制表符)的差异会被忽略。建议按整行/整块提供。",
29
+ }),
30
+ newText: Type.String({ description: "替换后的文本(原样写入,缩进即最终缩进)。" }),
31
+ }, {});
32
+ const editSoftSchema = Type.Object({
33
+ path: Type.String({ description: "要编辑的文件路径(相对或绝对)" }),
34
+ edits: Type.Array(replaceEditSchema, {
35
+ description: "一个或多个定向替换。每个 edit 相对原文件匹配(非增量);不要包含重叠/嵌套的 edit;同一块或相邻行请合并成一个 edit。",
36
+ }),
37
+ }, {});
38
+ function isSingleEditInput(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ return false;
41
+ const e = value;
42
+ return typeof e.oldText === "string" && typeof e.newText === "string";
43
+ }
44
+ /** 兼容几种模型把 edits 传成 JSON 字符串 / 单个对象的写法 + 遗留顶层 oldText/newText。 */
45
+ function prepareSoftEditArguments(input) {
46
+ if (!input || typeof input !== "object")
47
+ return input;
48
+ const args = input;
49
+ if (typeof args.edits === "string") {
50
+ try {
51
+ const parsed = JSON.parse(args.edits);
52
+ if (Array.isArray(parsed))
53
+ args.edits = parsed;
54
+ else if (isSingleEditInput(parsed))
55
+ args.edits = [parsed];
56
+ }
57
+ catch {
58
+ /* 保留原值,交给校验报错 */
59
+ }
60
+ }
61
+ else if (isSingleEditInput(args.edits)) {
62
+ args.edits = [args.edits];
63
+ }
64
+ const legacy = args;
65
+ if (typeof legacy.oldText === "string" && typeof legacy.newText === "string") {
66
+ const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
67
+ edits.push({ oldText: legacy.oldText, newText: legacy.newText });
68
+ const { oldText: _o, newText: _n, ...rest } = legacy;
69
+ return { ...rest, edits };
70
+ }
71
+ return args;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // 轻量 helpers(SDK 未导出,这里按等价语义重现)
75
+ // ---------------------------------------------------------------------------
76
+ function splitBom(content) {
77
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
78
+ }
79
+ function detectLineEnding(content) {
80
+ const crlfIdx = content.indexOf("\r\n");
81
+ const lfIdx = content.indexOf("\n");
82
+ if (lfIdx === -1)
83
+ return "\n";
84
+ if (crlfIdx === -1)
85
+ return "\n";
86
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
87
+ }
88
+ function normalizeToLF(text) {
89
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
90
+ }
91
+ function restoreLineEndings(text, ending) {
92
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
93
+ }
94
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
95
+ function normalizeShellPath(p) {
96
+ if (!p.startsWith("/") || p.startsWith("//") || p.includes("\\"))
97
+ return p;
98
+ const m = p.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
99
+ if (!m)
100
+ return p;
101
+ return `${m[1].toUpperCase()}:\\${(m[2] ?? "").replaceAll("/", "\\")}`;
102
+ }
103
+ /** 与 SDK resolveToCwd 等价:处理 ~、@ 前缀、Unicode 空格、Windows shell 路径。 */
104
+ function resolveToCwd(filePath, cwd) {
105
+ let p = filePath.replace(UNICODE_SPACES, " ");
106
+ if (p.startsWith("@"))
107
+ p = p.slice(1);
108
+ if (process.platform === "win32")
109
+ p = normalizeShellPath(p);
110
+ const home = homedir();
111
+ if (p === "~")
112
+ return home;
113
+ if (p.startsWith("~/"))
114
+ return join(home, p.slice(2));
115
+ return isAbsolute(p) ? nodeResolve(p) : nodeResolve(cwd, p);
116
+ }
117
+ function splitLineUnits(text) {
118
+ const units = [];
119
+ const re = /[^\n]*\n|[^\n]+/g;
120
+ let offset = 0;
121
+ let m;
122
+ while ((m = re.exec(text)) !== null) {
123
+ const chunk = m[0];
124
+ const newline = chunk.endsWith("\n") ? "\n" : "";
125
+ const raw = newline ? chunk.slice(0, -1) : chunk;
126
+ units.push({ raw, newline, start: offset, end: offset + chunk.length, core: raw.trim() });
127
+ offset += chunk.length;
128
+ }
129
+ return units;
130
+ }
131
+ /** oldText 拆成「各行核心」:去掉尾部空行(模型常带尾 \n),每行取 trim。 */
132
+ export function oldTextCores(oldTextLF) {
133
+ const parts = oldTextLF.split("\n");
134
+ if (parts.length > 0 && parts[parts.length - 1] === "")
135
+ parts.pop();
136
+ return parts.map((p) => p.trim());
137
+ }
138
+ /** NOT_FOUND / NOT_UNIQUE 用异常类型区分(与内置 edit 报错风格一致)。 */
139
+ class SoftEditMatchError extends Error {
140
+ constructor(message) {
141
+ super(message);
142
+ }
143
+ }
144
+ /**
145
+ * 命中一个 edit:先精确,后行核心宽松。返回原文件字符区间 + 待写入文本。
146
+ * spans 全部相对 LF 归一化后的原文件正文。
147
+ */
148
+ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIndex) {
149
+ if (oldTextLF.length === 0) {
150
+ throw new SoftEditMatchError(`edits[${editIndex}].oldText must not be empty in ${path}.`);
151
+ }
152
+ // 1) 精确子串匹配(等价普通 edit,支持片段)
153
+ const exactIdx = normalizedContent.indexOf(oldTextLF);
154
+ if (exactIdx !== -1) {
155
+ // 唯一性:精确匹配出现多次 → 报错(模型应提供更多上下文)
156
+ const occurrences = normalizedContent.split(oldTextLF).length - 1;
157
+ if (occurrences > 1) {
158
+ throw new SoftEditMatchError(`Found ${occurrences} occurrences of edits[${editIndex}].oldText in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
159
+ }
160
+ return { start: exactIdx, end: exactIdx + oldTextLF.length, insertText: newTextLF };
161
+ }
162
+ // 2) 行核心宽松匹配:逐行 trim 后序列一致(忽略缩进差异)
163
+ const cores = oldTextCores(oldTextLF);
164
+ if (cores.length === 0) {
165
+ throw new SoftEditMatchError(`edits[${editIndex}].oldText is effectively empty in ${path}.`);
166
+ }
167
+ const units = splitLineUnits(normalizedContent);
168
+ const k = cores.length;
169
+ const starts = [];
170
+ for (let i = 0; i + k <= units.length; i++) {
171
+ let ok = true;
172
+ for (let j = 0; j < k; j++) {
173
+ if (units[i + j].core !== cores[j]) {
174
+ ok = false;
175
+ break;
176
+ }
177
+ }
178
+ if (ok)
179
+ starts.push(i);
180
+ }
181
+ if (starts.length === 0) {
182
+ throw new SoftEditMatchError(`Could not find the text (exact or indentation-insensitive) in ${path}. The oldText must match the file's line content; leading-whitespace differences are ignored, but the actual content must be identical.`);
183
+ }
184
+ if (starts.length > 1) {
185
+ throw new SoftEditMatchError(`Found ${starts.length} indentation-insensitive occurrences of edits[${editIndex}].oldText in ${path}. The text must be unique. Please provide more context to make it unique.`);
186
+ }
187
+ const li = starts[0];
188
+ const ri = li + k;
189
+ const spanStart = units[li].start;
190
+ const spanEnd = units[ri - 1].end;
191
+ // 行尾换行平衡:若最后一行原本带 \n 而 newText 没带,补一个,避免与下一行粘连。
192
+ const lastNewline = units[ri - 1].newline;
193
+ let insertText = newTextLF;
194
+ if (lastNewline === "\n" && !insertText.endsWith("\n"))
195
+ insertText += "\n";
196
+ return { start: spanStart, end: spanEnd, insertText };
197
+ }
198
+ /** 对 LF 归一化内容应用一组 edit(含重叠检测),返回 {base,next}。 */
199
+ export function applySoftEdits(normalizedContent, edits, path) {
200
+ const replacements = [];
201
+ for (let i = 0; i < edits.length; i++) {
202
+ const oldTextLF = normalizeToLF(edits[i].oldText);
203
+ const newTextLF = normalizeToLF(edits[i].newText);
204
+ replacements.push(locateReplacement(normalizedContent, oldTextLF, newTextLF, path, i));
205
+ }
206
+ // 重叠检测
207
+ const sorted = [...replacements].sort((a, b) => a.start - b.start);
208
+ for (let i = 1; i < sorted.length; i++) {
209
+ if (sorted[i - 1].end > sorted[i].start) {
210
+ throw new SoftEditMatchError(`edits overlap in ${path}. Merge them into one edit or target disjoint regions.`);
211
+ }
212
+ }
213
+ // 逆序应用,保持左侧偏移稳定
214
+ let result = normalizedContent;
215
+ for (let i = replacements.length - 1; i >= 0; i--) {
216
+ const r = replacements[i];
217
+ result = result.slice(0, r.start) + r.insertText + result.slice(r.end);
218
+ }
219
+ if (result === normalizedContent) {
220
+ throw new SoftEditMatchError(`No changes made to ${path}. The replacement produced identical content.`);
221
+ }
222
+ return { baseContent: normalizedContent, newContent: result };
223
+ }
224
+ /**
225
+ * 生成 edit_soft 工具定义。与内置 edit 同结构执行体,但用宽松缩进匹配 + 原样 newText。
226
+ * cwd 仅供创建时固定;执行时优先 ctx.cwd(会话工作区)。
227
+ */
228
+ export function makeEditSoftTool(fallbackCwd) {
229
+ return defineTool({
230
+ name: SOFT_EDIT_TOOL_NAME,
231
+ label: "Edit (indentation-insensitive)",
232
+ description: "Edit a single file using text replacement that is tolerant of indentation. Every edits[].oldText is matched to the file by line content: leading whitespace (spaces/tabs) differences between your oldText and the file are ignored, so an edit does not fail just because the indentation differs. Use this when the built-in edit tool rejects your oldText due to a whitespace mismatch (common in JS/JSON/etc.). Prefer whole-line/whole-block oldText. newText is written exactly as provided.",
233
+ promptSnippet: "edit a file tolerating indentation differences (whitespace-insensitive match)",
234
+ promptGuidelines: [
235
+ "Use edit_soft when edit fails because the oldText indentation/spacing differs from the file",
236
+ "Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored",
237
+ "newText is written verbatim — the indentation you provide is the final indentation in the file",
238
+ "Keep edits[].oldText as small as possible while still being unique; merge nearby changes into one edit",
239
+ ],
240
+ parameters: editSoftSchema,
241
+ prepareArguments: prepareSoftEditArguments,
242
+ async execute(_toolCallId, input, signal, _onUpdate, ctx) {
243
+ const { path, edits } = input;
244
+ if (!Array.isArray(edits) || edits.length === 0) {
245
+ throw new Error("edit_soft input is invalid. edits must contain at least one replacement.");
246
+ }
247
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
248
+ const absolutePath = resolveToCwd(path, cwd);
249
+ return withFileMutationQueue(absolutePath, async () => {
250
+ const throwIfAborted = () => {
251
+ if (signal?.aborted)
252
+ throw new Error("Operation aborted");
253
+ };
254
+ throwIfAborted();
255
+ try {
256
+ await access(absolutePath, constants.R_OK | constants.W_OK);
257
+ }
258
+ catch (error) {
259
+ throwIfAborted();
260
+ const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
261
+ throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
262
+ }
263
+ throwIfAborted();
264
+ const buffer = await readFile(absolutePath);
265
+ const rawContent = buffer.toString("utf-8");
266
+ throwIfAborted();
267
+ const { bom, text: content } = splitBom(rawContent);
268
+ const originalEnding = detectLineEnding(content);
269
+ const normalizedContent = normalizeToLF(content);
270
+ const { baseContent, newContent } = applySoftEdits(normalizedContent, edits, path);
271
+ throwIfAborted();
272
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
273
+ await writeFile(absolutePath, finalContent, "utf-8");
274
+ throwIfAborted();
275
+ const diffResult = generateDiffString(baseContent, newContent);
276
+ const patch = generateUnifiedPatch(path, baseContent, newContent);
277
+ return {
278
+ content: [
279
+ {
280
+ type: "text",
281
+ text: `Successfully replaced ${edits.length} block(s) in ${path} (indentation-insensitive).`,
282
+ },
283
+ ],
284
+ details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
285
+ };
286
+ });
287
+ },
288
+ });
289
+ }
@@ -811,12 +811,15 @@ wss.on("connection", (ws) => {
811
811
  void cs.setSettings({
812
812
  promptMode: msg.promptMode,
813
813
  customSystemPrompt: msg.customSystemPrompt,
814
+ promptTemplate: msg.promptTemplate,
815
+ promptOverrides: msg.promptOverrides,
814
816
  disabledSkills: msg.disabledSkills,
815
817
  disabledExtensions: msg.disabledExtensions,
816
818
  disabledPlugins: msg.disabledPlugins,
817
819
  terminalToolsEnabled: msg.terminalToolsEnabled,
818
820
  terminalBash: msg.terminalBash,
819
821
  terminalBashIdleMs: msg.terminalBashIdleMs,
822
+ editSoftEnabled: msg.editSoftEnabled,
820
823
  thinkingWrap: msg.thinkingWrap,
821
824
  toolsWrap: msg.toolsWrap,
822
825
  visionBridgeEnabled: msg.visionBridgeEnabled,
@@ -828,6 +831,8 @@ wss.on("connection", (ws) => {
828
831
  reviewDisabledSkills: msg.reviewDisabledSkills,
829
832
  markersEnabled: msg.markersEnabled,
830
833
  disabledMarkers: msg.disabledMarkers,
834
+ quickPhrases: msg.quickPhrases,
835
+ quickPhrasesEnabled: msg.quickPhrasesEnabled,
831
836
  });
832
837
  break;
833
838
  case "extensions_reload":
@@ -4,7 +4,6 @@
4
4
  import { ensureMarkersRegistered, parseMarkers, getMarker, allMarkers, collectGuidance, listMarkerNames, } from "./markers/index.js";
5
5
  import { loadStateFromBranch, appendSnapshot } from "./markers/store.js";
6
6
  import { TODO_NAMESPACE, initTodoState, describeTodos } from "./markers/builtins/todo.js";
7
- import { SVC_NAMESPACE, initServiceState, describeServices } from "./markers/builtins/services.js";
8
7
  ensureMarkersRegistered();
9
8
  const DEFAULT_MARKERS = {
10
9
  markersEnabled: true,
@@ -31,11 +30,6 @@ export class MarkerService {
31
30
  isMarkerEnabled(name) {
32
31
  if (!this.settings.markersEnabled)
33
32
  return false;
34
- if ((name === "rename" || name === "title") && this.settings.disabledMarkers.includes("conv"))
35
- return false;
36
- if (name === "conv" &&
37
- (this.settings.disabledMarkers.includes("rename") || this.settings.disabledMarkers.includes("title")))
38
- return false;
39
33
  return !this.settings.disabledMarkers.includes(name);
40
34
  }
41
35
  buildGuidance() {
@@ -57,13 +51,10 @@ export class MarkerService {
57
51
  }
58
52
  toggleMarker(name, enabled) {
59
53
  const set = new Set(this.settings.disabledMarkers);
60
- const group = name === "conv" || name === "rename" || name === "title" ? ["conv", "rename", "title"] : [name];
61
- for (const n of group) {
62
- if (enabled)
63
- set.delete(n);
64
- else
65
- set.add(n);
66
- }
54
+ if (enabled)
55
+ set.delete(name);
56
+ else
57
+ set.add(name);
67
58
  this.settings.disabledMarkers = [...set];
68
59
  this.host.stateStore.saveMarkerSettings(this.host.clientId, this.settings);
69
60
  }
@@ -71,14 +62,7 @@ export class MarkerService {
71
62
  if (settings.markersEnabled !== undefined)
72
63
  this.settings.markersEnabled = !!settings.markersEnabled;
73
64
  if (settings.disabledMarkers !== undefined) {
74
- // 归一化 rename 别名:若 conv 被禁用则同步禁用别名
75
- const s = new Set(settings.disabledMarkers);
76
- if (s.has("conv") || s.has("rename") || s.has("title")) {
77
- s.add("conv");
78
- s.add("rename");
79
- s.add("title");
80
- }
81
- this.settings.disabledMarkers = [...s];
65
+ this.settings.disabledMarkers = [...new Set(settings.disabledMarkers)];
82
66
  }
83
67
  else {
84
68
  this.host.stateStore.saveMarkerSettings(this.host.clientId, this.settings);
@@ -122,7 +106,25 @@ export class MarkerService {
122
106
  appendSnapshot(mgr, namespace, state);
123
107
  }
124
108
  // -- parse & execute --
125
- async handleAssistantText(conversationId, text) {
109
+ /**
110
+ * 解析执行一条 assistant 终稿文本中的内联标记。
111
+ *
112
+ * 多个气泡(同一轮内前一段文本 + 后一段文本)的 message_end 事件会先后到达,
113
+ * 而 apply 是异步的——若并发执行会同时读到旧快照、分配重叠 id、后存覆盖前存。
114
+ * 因此按会话串行化:每个 conv 的处理链式排队,保证状态严格按文本顺序累积。
115
+ */
116
+ chains = new Map();
117
+ handleAssistantText(conversationId, text) {
118
+ const prev = this.chains.get(conversationId) ?? Promise.resolve();
119
+ const next = prev
120
+ .then(() => this.processAssistantText(conversationId, text))
121
+ .catch((e) => {
122
+ console.error("[markers] handleAssistantText failed:", e);
123
+ });
124
+ this.chains.set(conversationId, next);
125
+ return next;
126
+ }
127
+ async processAssistantText(conversationId, text) {
126
128
  if (!text || !this.settings.markersEnabled)
127
129
  return;
128
130
  const tokens = parseMarkers(text);
@@ -136,8 +138,6 @@ export class MarkerService {
136
138
  return st;
137
139
  if (ns === TODO_NAMESPACE)
138
140
  st = this.getState(conversationId, ns, initTodoState);
139
- else if (ns === SVC_NAMESPACE)
140
- st = this.getState(conversationId, ns, initServiceState);
141
141
  else {
142
142
  const marker = getMarker(ns);
143
143
  st = marker?.init ? marker.init() : {};
@@ -170,17 +170,10 @@ export class MarkerService {
170
170
  result = { applied: false, error: `执行异常: ${e?.message ?? String(e)}` };
171
171
  }
172
172
  if (result.applied) {
173
- if (token.tool !== "notify" && token.tool !== "conv" && token.tool !== "rename" && token.tool !== "title") {
173
+ // todo 落库;notify/conv 即时生效(通知已发 / 对话已重命名),无需快照。
174
+ if (token.tool !== "notify" && token.tool !== "conv") {
174
175
  dirty.add(token.tool);
175
176
  }
176
- else if (token.tool === "conv" || token.tool === "rename" || token.tool === "title") {
177
- // rename 不落库,已直接重命名
178
- }
179
- else if (token.tool === "notify") {
180
- // 通知不落库
181
- }
182
- if (token.tool === "todo" || token.tool === "svc")
183
- dirty.add(token.tool);
184
177
  }
185
178
  else if (result.error) {
186
179
  this.host.emit({
@@ -208,8 +201,6 @@ export class MarkerService {
208
201
  let state;
209
202
  if (m.name === TODO_NAMESPACE)
210
203
  state = this.getState(conversationId, m.name, initTodoState);
211
- else if (m.name === SVC_NAMESPACE)
212
- state = this.getState(conversationId, m.name, initServiceState);
213
204
  else {
214
205
  state = this.getState(conversationId, m.name, () => m.init?.() ?? {});
215
206
  if (state === undefined)
@@ -240,10 +231,6 @@ export class MarkerService {
240
231
  }
241
232
  }
242
233
  describe(conversationId, tool, includeDeleted = false) {
243
- if (tool === "svc") {
244
- const st = this.getState(conversationId, SVC_NAMESPACE, initServiceState);
245
- return describeServices(st);
246
- }
247
234
  const st = this.getState(conversationId, TODO_NAMESPACE, initTodoState);
248
235
  const visible = st.tasks.filter((t) => includeDeleted || t.status !== "deleted");
249
236
  if (visible.length === 0)
@@ -253,8 +240,6 @@ export class MarkerService {
253
240
  getRawState(conversationId, namespace) {
254
241
  if (namespace === TODO_NAMESPACE)
255
242
  return this.getState(conversationId, namespace, initTodoState);
256
- if (namespace === SVC_NAMESPACE)
257
- return this.getState(conversationId, namespace, initServiceState);
258
243
  const m = getMarker(namespace);
259
244
  return this.getState(conversationId, namespace, () => m?.init?.() ?? {});
260
245
  }
@@ -264,8 +249,6 @@ export class MarkerService {
264
249
  const seen = new Set();
265
250
  const out = [];
266
251
  for (const m of allMarkers()) {
267
- if (m.name === "rename" || m.name === "title")
268
- continue;
269
252
  if (seen.has(m.name))
270
253
  continue;
271
254
  seen.add(m.name);
@@ -3,15 +3,8 @@
3
3
  *
4
4
  * 需求:加个重命名当前对话 marker。
5
5
  *
6
- * 语法:
6
+ * 语法(唯一写法):
7
7
  * [[conv:rename:<新标题>]] 重命名当前对话
8
- * [[rename:set:<新标题>]] 同上(兼容别名)
9
- * [[title:rename:<新标题>]] 同上
10
- * [[rename:new:<新标题>]] 同上
11
- *
12
- * 兼容:rename 前缀的任意 op 都会被视为重命名,例如 [[rename:set:My Chat]]、
13
- * [[rename:to:My Chat]]。标题来自 args[0] + kwargs[text/name/title] 的
14
- * 拼接,未提供则报错。
15
8
  *
16
9
  * 持久化:通过宿主回调直接改对话标题(内存 + 磁盘 transcript session_info),
17
10
  * 不需要额外状态。
@@ -27,9 +20,7 @@ function extractTitle(token) {
27
20
  }
28
21
  export const renameMarker = {
29
22
  name: "conv",
30
- guidance: [
31
- "- 重命名当前对话:[[conv:rename:<新标题>]](或 [[rename:set:<标题>]])。标题尽量简短、能概括本次任务。首次回复用户问题后根据情况重命名当前会话",
32
- ],
23
+ guidance: ["- 重命名当前对话:[[conv:rename:<新标题>]](在了解了用户需求后尽早重命名对话)"],
33
24
  async apply(token, ctx) {
34
25
  if (token.op !== "rename") {
35
26
  return { applied: false, error: `conv 未知操作: ${token.op}(当前仅支持 conv:rename)` };
@@ -53,48 +44,3 @@ export const renameMarker = {
53
44
  overlay: undefined,
54
45
  init: () => undefined,
55
46
  };
56
- /** 别名:[[rename:set:标题]] 等同 [[conv:rename:标题]],方便模型直觉书写。 */
57
- export const renameAliasMarker = {
58
- name: "rename",
59
- guidance: ["- [[rename:set:<新标题>]] 同 [[conv:rename:<新标题>]]:重命名当前对话。"],
60
- async apply(token, ctx) {
61
- // 兼容任意 op:只要能取到标题就重命名
62
- const title = extractTitle(token) || token.op?.trim() || "";
63
- // 若 token 是 [[rename:My Title:]] 形式,op=My Title, args 空 —— 用 op 当标题
64
- const effective = title || token.op;
65
- if (!effective?.trim())
66
- return { applied: false, error: "rename 需要标题参数 [[rename:set:<新标题>]]" };
67
- const trimmed = effective.trim().slice(0, 80);
68
- if (!ctx.renameConversation)
69
- return { applied: false, error: "当前环境不支持重命名" };
70
- try {
71
- ctx.renameConversation(trimmed);
72
- ctx.notify(`已重命名为:${trimmed}`, "info", `Renamed to: ${trimmed}`);
73
- return { applied: true, feedback: `renamed to "${trimmed}"` };
74
- }
75
- catch (e) {
76
- return { applied: false, error: `重命名失败: ${e.message ?? String(e)}` };
77
- }
78
- },
79
- overlay: undefined,
80
- init: () => undefined,
81
- };
82
- /** title 前缀别名:[[title:rename:标题]] */
83
- export const titleAliasMarker = {
84
- name: "title",
85
- guidance: [],
86
- async apply(token, ctx) {
87
- if (token.op !== "rename")
88
- return { applied: false, error: `title 未知操作: ${token.op}` };
89
- const title = extractTitle(token);
90
- if (!title)
91
- return { applied: false, error: "title:rename 需要标题" };
92
- if (!ctx.renameConversation)
93
- return { applied: false, error: "当前环境不支持重命名" };
94
- ctx.renameConversation(title.slice(0, 80));
95
- ctx.notify(`已重命名为:${title.slice(0, 80)}`, "info", `Renamed to: ${title.slice(0, 80)}`);
96
- return { applied: true, feedback: `renamed` };
97
- },
98
- overlay: undefined,
99
- init: () => undefined,
100
- };