pi-web-ui 0.66.0 → 0.67.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/dist/server/agent-service.js +192 -99
- package/dist/server/client-state.js +37 -17
- package/dist/server/dsh/dsh-agent-service.js +14 -1
- package/dist/server/edit-soft-tool.js +289 -0
- package/dist/server/index.js +3 -0
- package/dist/server/marker-service.js +26 -43
- package/dist/server/markers/builtins/rename.js +2 -56
- package/dist/server/markers/index.js +2 -6
- package/dist/server/prompt-composer.js +180 -0
- package/dist/server/settings-service.js +38 -7
- package/dist/server/system-prompt-soul.js +41 -0
- package/dist/server/terminals.js +5 -5
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-Bu0PEPj5.js → TerminalPanel-B-4FU7T5.js} +1 -1
- package/web/dist/assets/{index-DiO-MN6k.css → index-BGIkWTsd.css} +1 -1
- package/web/dist/assets/index-CqXS2SQU.js +332 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DoOQgKox.js +0 -326
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
@@ -3,22 +3,18 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { registerMarker } from "./registry.js";
|
|
5
5
|
import { todoMarker } from "./builtins/todo.js";
|
|
6
|
-
import { servicesMarker } from "./builtins/services.js";
|
|
7
6
|
import { notifyMarker } from "./builtins/notify.js";
|
|
8
|
-
import { renameMarker
|
|
7
|
+
import { renameMarker } from "./builtins/rename.js";
|
|
9
8
|
let initialized = false;
|
|
10
9
|
export function ensureMarkersRegistered() {
|
|
11
10
|
if (initialized)
|
|
12
11
|
return;
|
|
13
12
|
registerMarker(todoMarker);
|
|
14
|
-
registerMarker(servicesMarker);
|
|
15
13
|
registerMarker(notifyMarker);
|
|
16
14
|
registerMarker(renameMarker);
|
|
17
|
-
registerMarker(renameAliasMarker);
|
|
18
|
-
registerMarker(titleAliasMarker);
|
|
19
15
|
initialized = true;
|
|
20
16
|
}
|
|
21
|
-
export { todoMarker,
|
|
17
|
+
export { todoMarker, notifyMarker, renameMarker };
|
|
22
18
|
export * from "./marker.js";
|
|
23
19
|
export * from "./registry.js";
|
|
24
20
|
export * from "./store.js";
|