@steerable/agent-shell 0.6.20 → 0.6.21
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/host/runtime.js +0 -1
- package/dist/host/visible-terminal-exec.d.ts +0 -2
- package/dist/host/visible-terminal-exec.js +4 -8
- package/dist/local-backend/ai-suggestions.d.ts +32 -0
- package/dist/local-backend/ai-suggestions.js +160 -0
- package/dist/local-backend/coreloop-stream.js +1 -1
- package/dist/local-backend/mentioned-paths.d.ts +28 -0
- package/dist/local-backend/mentioned-paths.js +67 -0
- package/dist/local-backend/router.d.ts +8 -0
- package/dist/local-backend/router.js +113 -1
- package/dist/local-backend/turn-files.d.ts +57 -0
- package/dist/local-backend/turn-files.js +285 -0
- package/dist/main.js +3 -1
- package/dist/preload.cjs +30 -10
- package/dist/preload.js +33 -10
- package/dist/server/http-server.js +8 -0
- package/dist/server/sse-bus.d.ts +1 -1
- package/dist/sidecar/reverse-approval.d.ts +2 -0
- package/dist/sidecar/reverse-approval.js +2 -1
- package/dist/sidecar/reverse-ask-user.d.ts +2 -0
- package/dist/sidecar/reverse-ask-user.js +2 -1
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.js +27 -0
- package/package.json +4 -4
package/dist/host/runtime.js
CHANGED
|
@@ -68,7 +68,6 @@ export function createHostRuntime(options) {
|
|
|
68
68
|
const maybeExecInTerminal = createVisibleTerminalExec({
|
|
69
69
|
localExecutor,
|
|
70
70
|
terminalManager,
|
|
71
|
-
broadcast,
|
|
72
71
|
});
|
|
73
72
|
const toolRouter = new ToolRouter(localExecutor, localScriptRegistry, async (request) => {
|
|
74
73
|
const viaTerminal = await maybeExecInTerminal(request);
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { type LocalExecRequest, type LocalExecResult, type LocalExecutor } from '../local-executor.js';
|
|
2
2
|
import type { TerminalManager } from '../terminal-manager.js';
|
|
3
|
-
export type HostBroadcast = (channel: string, payload: unknown) => void;
|
|
4
3
|
export interface VisibleTerminalExecDeps {
|
|
5
4
|
localExecutor: LocalExecutor;
|
|
6
5
|
terminalManager: TerminalManager;
|
|
7
|
-
broadcast: HostBroadcast;
|
|
8
6
|
}
|
|
9
7
|
/**
|
|
10
8
|
* Map a raw PTY shell binary name (e.g. `powershell.exe`, `cmd.exe`, `zsh`) to
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 可见 PTY
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* broadcast 出口(Electron 窗口广播 / BS SSE 总线)。
|
|
2
|
+
* 可见 PTY 执行路由:shell 命令优先打进共享的可见终端,多行/heredoc 等
|
|
3
|
+
* sentinel 抓不住的形态回退 headless。Electron main 与 BS server 共用。
|
|
4
|
+
* 命令进入 PTY 时不通知 renderer 展开面板——终端只由用户手动打开。
|
|
6
5
|
*/
|
|
7
6
|
import log from 'electron-log';
|
|
8
7
|
import { GUI_LAUNCH_WAIT_MS, getConfiguredExecTimeoutMs, isGuiLaunchCommand, rewriteExeCommandIfNeeded, } from '../local-executor.js';
|
|
@@ -32,7 +31,7 @@ export function mapSessionShell(shell) {
|
|
|
32
31
|
* process.
|
|
33
32
|
*/
|
|
34
33
|
export function createVisibleTerminalExec(deps) {
|
|
35
|
-
const { localExecutor, terminalManager
|
|
34
|
+
const { localExecutor, terminalManager } = deps;
|
|
36
35
|
return async function maybeExecInTerminal(req) {
|
|
37
36
|
if (req.command) {
|
|
38
37
|
req.command = rewriteExeCommandIfNeeded(req.command);
|
|
@@ -73,9 +72,6 @@ export function createVisibleTerminalExec(deps) {
|
|
|
73
72
|
timeoutMs = getConfiguredExecTimeoutMs() ?? undefined;
|
|
74
73
|
}
|
|
75
74
|
const session = terminalManager.ensurePrimary({ cwd: req.cwd });
|
|
76
|
-
// Codex 式面板自动展开:命令进入可见 PTY 时通知 renderer 打开内嵌终端
|
|
77
|
-
// 面板。幂等——面板已打开时 renderer 端是 no-op。
|
|
78
|
-
broadcast('terminal:reveal', { sessionId: session.id });
|
|
79
75
|
const resolvedShell = mapSessionShell(session.shell);
|
|
80
76
|
// 项目模式(ToolRouter 会把项目根塞进 req.cwd):可见 PTY 是共享交互
|
|
81
77
|
// 会话,已有 session 不会随 ensurePrimary 改目录——显式 cd 过去。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
|
|
3
|
+
*
|
|
4
|
+
* 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
|
|
5
|
+
* 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
|
|
6
|
+
*
|
|
7
|
+
* 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
|
|
8
|
+
* 不要挂在 SSE 主流程里阻塞 `[DONE]`。
|
|
9
|
+
*/
|
|
10
|
+
export declare const SUGGESTED_REPLY_COUNT = 3;
|
|
11
|
+
/** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
|
|
12
|
+
export declare function cleanSuggestedReply(raw: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseSuggestedReplies(raw: string): string[];
|
|
17
|
+
/**
|
|
18
|
+
* 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
|
|
19
|
+
* 始终返回正好 3 条(输入全空时仍给通用三条)。
|
|
20
|
+
*/
|
|
21
|
+
export declare function fallbackSuggestedReplies(userText: string, assistantText: string): string[];
|
|
22
|
+
export interface GenerateSuggestedRepliesResult {
|
|
23
|
+
suggestions: string[];
|
|
24
|
+
/** true = LLM 失败或输出不合格,suggestions 来自启发式兜底。 */
|
|
25
|
+
usedFallback: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
|
|
29
|
+
*/
|
|
30
|
+
export declare function generateSuggestedReplies(userText: string, assistantText: string, opts?: {
|
|
31
|
+
perAttemptTimeoutMs?: number;
|
|
32
|
+
}): Promise<GenerateSuggestedRepliesResult>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合结束后生成 3 条「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
|
|
3
|
+
*
|
|
4
|
+
* 主路径走一次短 LLM 调用,根据本轮用户请求 + 助手回复写成用户口吻的短句。
|
|
5
|
+
* 失败时用启发式兜底(PPT / 计划 / 代码 / 通用),永不抛错。
|
|
6
|
+
*
|
|
7
|
+
* 调用方应该 fire-and-forget:先把兜底三条推上 UI,LLM 成功后再替换。
|
|
8
|
+
* 不要挂在 SSE 主流程里阻塞 `[DONE]`。
|
|
9
|
+
*/
|
|
10
|
+
import { llmService } from '../llm/index.js';
|
|
11
|
+
export const SUGGESTED_REPLY_COUNT = 3;
|
|
12
|
+
const MAX_SUGGESTION_CHARS = 36;
|
|
13
|
+
const USER_TEXT_LIMIT = 500;
|
|
14
|
+
const ASSISTANT_TEXT_LIMIT = 1600;
|
|
15
|
+
const SYSTEM_PROMPT = `你是对话追问建议助手。根据用户上一轮请求和助手刚刚完成的回复,生成 3 条用户可以点一下就发出去的下一轮输入。
|
|
16
|
+
|
|
17
|
+
要求:
|
|
18
|
+
1. 正好 3 条,彼此不重复
|
|
19
|
+
2. 每条 8-28 个字,用用户口吻,像用户会打的话
|
|
20
|
+
3. 紧扣刚刚完成的工作:改内容、改样式、继续深入、换方向;不要空泛的「再详细说说」
|
|
21
|
+
4. 如果助手已经邀请后续操作(例如「如需修改内容或调整样式」),把那些邀请写成具体可执行的短句
|
|
22
|
+
5. 不要编号、不要引号、不要解释
|
|
23
|
+
6. 只输出 JSON 字符串数组,例如 ["调整封面配色","把个人简介写得更具体","再加一页项目案例"]`;
|
|
24
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
25
|
+
if (timeoutMs <= 0)
|
|
26
|
+
return promise;
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
promise.then((value) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve(value);
|
|
34
|
+
}, (err) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(err);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function clip(text, limit) {
|
|
41
|
+
const chars = Array.from((text ?? '').trim());
|
|
42
|
+
if (chars.length <= limit)
|
|
43
|
+
return chars.join('');
|
|
44
|
+
return `${chars.slice(0, limit).join('')}…`;
|
|
45
|
+
}
|
|
46
|
+
/** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
|
|
47
|
+
export function cleanSuggestedReply(raw) {
|
|
48
|
+
let text = (raw ?? '').trim();
|
|
49
|
+
text = text.replace(/^[\s"'“”‘’「」『』《》【】]+|[\s"'“”‘’「」『』《》【】。.!!??,,;;::]+$/g, '');
|
|
50
|
+
text = text.replace(/^(?:\d+[\.\)、]|[-*•])\s*/, '');
|
|
51
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
52
|
+
if (text.length < 2)
|
|
53
|
+
return '';
|
|
54
|
+
const chars = Array.from(text);
|
|
55
|
+
if (chars.length > MAX_SUGGESTION_CHARS) {
|
|
56
|
+
text = chars.slice(0, MAX_SUGGESTION_CHARS).join('').trim();
|
|
57
|
+
}
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
function uniqueThree(candidates, extras = []) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const raw of [...candidates, ...extras]) {
|
|
64
|
+
const cleaned = cleanSuggestedReply(raw);
|
|
65
|
+
if (!cleaned || seen.has(cleaned))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(cleaned);
|
|
68
|
+
out.push(cleaned);
|
|
69
|
+
if (out.length === SUGGESTED_REPLY_COUNT)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 从模型原文抽出最多 3 条建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
|
|
76
|
+
*/
|
|
77
|
+
export function parseSuggestedReplies(raw) {
|
|
78
|
+
const trimmed = (raw ?? '').trim();
|
|
79
|
+
if (!trimmed)
|
|
80
|
+
return [];
|
|
81
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
82
|
+
const candidate = (fenced?.[1] ?? trimmed).trim();
|
|
83
|
+
const start = candidate.indexOf('[');
|
|
84
|
+
const end = candidate.lastIndexOf(']');
|
|
85
|
+
if (start >= 0 && end > start) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(candidate.slice(start, end + 1));
|
|
88
|
+
if (Array.isArray(parsed)) {
|
|
89
|
+
return uniqueThree(parsed.filter((item) => typeof item === 'string'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// 落到分行解析。
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const lines = candidate
|
|
97
|
+
.split(/\r?\n/)
|
|
98
|
+
.map((line) => line.trim())
|
|
99
|
+
.filter((line) => line.length > 0 && !/^```/.test(line));
|
|
100
|
+
const listLines = lines.filter((line) => /^(?:\d+[\.\)、]|[-*•])\s+/.test(line));
|
|
101
|
+
return uniqueThree(listLines.length > 0 ? listLines : lines);
|
|
102
|
+
}
|
|
103
|
+
const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
|
|
104
|
+
const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
|
|
105
|
+
const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
|
|
106
|
+
const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
|
|
107
|
+
const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
|
|
108
|
+
/**
|
|
109
|
+
* 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
|
|
110
|
+
* 始终返回正好 3 条(输入全空时仍给通用三条)。
|
|
111
|
+
*/
|
|
112
|
+
export function fallbackSuggestedReplies(userText, assistantText) {
|
|
113
|
+
const blob = `${userText}\n${assistantText}`;
|
|
114
|
+
if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
|
|
115
|
+
if (/修改内容|调整样式/.test(assistantText)) {
|
|
116
|
+
return uniqueThree(PPT_OFFER_FALLBACK, PPT_FALLBACK);
|
|
117
|
+
}
|
|
118
|
+
return uniqueThree(PPT_FALLBACK, GENERIC_FALLBACK);
|
|
119
|
+
}
|
|
120
|
+
if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
|
|
121
|
+
return uniqueThree(PLAN_FALLBACK, GENERIC_FALLBACK);
|
|
122
|
+
}
|
|
123
|
+
if (/```|单元测试|函数实现|补测试/.test(blob)) {
|
|
124
|
+
return uniqueThree(CODE_FALLBACK, GENERIC_FALLBACK);
|
|
125
|
+
}
|
|
126
|
+
return uniqueThree(GENERIC_FALLBACK);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 生成本轮 3 条追问建议。永不抛错,始终返回 3 条。
|
|
130
|
+
*/
|
|
131
|
+
export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
|
|
132
|
+
const fallback = fallbackSuggestedReplies(userText, assistantText);
|
|
133
|
+
const user = clip(userText, USER_TEXT_LIMIT);
|
|
134
|
+
const assistant = clip(assistantText, ASSISTANT_TEXT_LIMIT);
|
|
135
|
+
if (!user && !assistant) {
|
|
136
|
+
return { suggestions: fallback, usedFallback: true };
|
|
137
|
+
}
|
|
138
|
+
const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
|
|
139
|
+
try {
|
|
140
|
+
const result = await withTimeout(llmService.generate({
|
|
141
|
+
messages: [
|
|
142
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
143
|
+
{
|
|
144
|
+
role: 'user',
|
|
145
|
+
content: `用户请求:\n${user || '(空)'}\n\n助手回复:\n${assistant || '(空)'}`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
temperature: 0.6,
|
|
149
|
+
}), perAttemptTimeoutMs, 'ai-suggestions');
|
|
150
|
+
const parsed = parseSuggestedReplies(result.content ?? '');
|
|
151
|
+
const suggestions = uniqueThree(parsed, fallback);
|
|
152
|
+
if (suggestions.length === SUGGESTED_REPLY_COUNT && parsed.length > 0) {
|
|
153
|
+
return { suggestions, usedFallback: false };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.warn('[ai-suggestions] LLM 追问建议失败,用启发式兜底', err);
|
|
158
|
+
}
|
|
159
|
+
return { suggestions: fallback, usedFallback: true };
|
|
160
|
+
}
|
|
@@ -99,7 +99,7 @@ export async function streamCoreLoopTurn(options) {
|
|
|
99
99
|
streamRawChunks: options.streamRawChunks,
|
|
100
100
|
// W8: advertise the sidecar-hosted ask_user tool; the desktop answers
|
|
101
101
|
// `ask_user.request` reverse calls with the renderer question card
|
|
102
|
-
// (
|
|
102
|
+
// (AskUserPromptProvider). Under toolsViaHost the sidecar intercepts
|
|
103
103
|
// ask_user locally, so it never reaches the host's tool.invoke.
|
|
104
104
|
// 4.6a: background task streams opt out (options.askUser === false).
|
|
105
105
|
askUser: options.askUser ?? true,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话正文里提到的文件路径的存在性解析(「点击打开正文里的路径」用)。
|
|
3
|
+
*
|
|
4
|
+
* 渲染层从助手回答的行内代码里挑出「看着像路径」的字面量,批量送到这里
|
|
5
|
+
* 落地成绝对路径并 stat 验证。只有真实存在的条目会回给渲染层——路径是
|
|
6
|
+
* 模型写出来的自然语言片段,猜错在所难免,靠 stat 证伪而不是靠解析可靠。
|
|
7
|
+
*
|
|
8
|
+
* 相对路径的基准目录由调用方给(绑定项目的对话用项目根,否则用 home),
|
|
9
|
+
* 与 exec 工具缺省 cwd 的回落规则一致,这样模型写 `./out.bin` 时指向的
|
|
10
|
+
* 就是它当时真正落盘的位置。
|
|
11
|
+
*/
|
|
12
|
+
export interface ResolvedMentionedPath {
|
|
13
|
+
/** 渲染层送来的原始字面量(用于回填到对应的行内代码节点)。 */
|
|
14
|
+
candidate: string;
|
|
15
|
+
/** 落地后的绝对路径(点击打开直接用)。 */
|
|
16
|
+
path: string;
|
|
17
|
+
/** 目录会用文件管理器打开,渲染层据此换图标。 */
|
|
18
|
+
isDirectory: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 批量解析候选路径,只返回真实存在的条目。`baseDir` 是相对路径的基准
|
|
22
|
+
* 目录;`homeDir` 仅测试注入用,默认 os.homedir()。
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveMentionedPaths(options: {
|
|
25
|
+
candidates: readonly string[];
|
|
26
|
+
baseDir: string;
|
|
27
|
+
homeDir?: string;
|
|
28
|
+
}): Promise<ResolvedMentionedPath[]>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话正文里提到的文件路径的存在性解析(「点击打开正文里的路径」用)。
|
|
3
|
+
*
|
|
4
|
+
* 渲染层从助手回答的行内代码里挑出「看着像路径」的字面量,批量送到这里
|
|
5
|
+
* 落地成绝对路径并 stat 验证。只有真实存在的条目会回给渲染层——路径是
|
|
6
|
+
* 模型写出来的自然语言片段,猜错在所难免,靠 stat 证伪而不是靠解析可靠。
|
|
7
|
+
*
|
|
8
|
+
* 相对路径的基准目录由调用方给(绑定项目的对话用项目根,否则用 home),
|
|
9
|
+
* 与 exec 工具缺省 cwd 的回落规则一致,这样模型写 `./out.bin` 时指向的
|
|
10
|
+
* 就是它当时真正落盘的位置。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/** 单次请求的候选上限与单条长度上限:渲染层是不可信输入,挡住刷 stat。 */
|
|
16
|
+
const MAX_CANDIDATES = 64;
|
|
17
|
+
const MAX_CANDIDATE_LENGTH = 512;
|
|
18
|
+
/**
|
|
19
|
+
* 批量解析候选路径,只返回真实存在的条目。`baseDir` 是相对路径的基准
|
|
20
|
+
* 目录;`homeDir` 仅测试注入用,默认 os.homedir()。
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveMentionedPaths(options) {
|
|
23
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
24
|
+
const out = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const raw of options.candidates.slice(0, MAX_CANDIDATES)) {
|
|
27
|
+
if (typeof raw !== 'string')
|
|
28
|
+
continue;
|
|
29
|
+
const candidate = raw.trim();
|
|
30
|
+
if (!candidate || candidate.length > MAX_CANDIDATE_LENGTH)
|
|
31
|
+
continue;
|
|
32
|
+
// 换行/NUL 不可能是单个路径字面量,且会污染后续 stat。
|
|
33
|
+
if (/[\n\r\0]/.test(candidate))
|
|
34
|
+
continue;
|
|
35
|
+
if (seen.has(candidate))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(candidate);
|
|
38
|
+
const full = toAbsolute(candidate, options.baseDir, homeDir);
|
|
39
|
+
if (!full)
|
|
40
|
+
continue;
|
|
41
|
+
let stat;
|
|
42
|
+
try {
|
|
43
|
+
stat = await fs.stat(full);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 不存在/无权限:模型写错或文件已被删,静默丢弃。
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
50
|
+
continue;
|
|
51
|
+
out.push({ candidate, path: full, isDirectory: stat.isDirectory() });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** `~` 展开 → 绝对路径原样 → 其余按 baseDir 解析。 */
|
|
56
|
+
function toAbsolute(candidate, baseDir, homeDir) {
|
|
57
|
+
if (candidate === '~')
|
|
58
|
+
return homeDir;
|
|
59
|
+
if (candidate.startsWith('~/') || candidate.startsWith('~\\')) {
|
|
60
|
+
return path.resolve(path.join(homeDir, candidate.slice(2)));
|
|
61
|
+
}
|
|
62
|
+
if (path.isAbsolute(candidate))
|
|
63
|
+
return path.resolve(candidate);
|
|
64
|
+
if (!baseDir)
|
|
65
|
+
return null;
|
|
66
|
+
return path.resolve(baseDir, candidate);
|
|
67
|
+
}
|
|
@@ -62,6 +62,7 @@ export declare class LocalBackendRouter {
|
|
|
62
62
|
* `accumulatedContent += parsedData.content` 一行追加渲染。
|
|
63
63
|
* - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
|
|
64
64
|
* - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
|
|
65
|
+
* - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
|
|
65
66
|
* - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
|
|
66
67
|
* - 结束:标准 SSE `[DONE]` 串。
|
|
67
68
|
*/
|
|
@@ -158,6 +159,13 @@ export declare class LocalBackendRouter {
|
|
|
158
159
|
* the reverse-channel handler registered in main.ts.
|
|
159
160
|
*/
|
|
160
161
|
private handleCoreLoopTurn;
|
|
162
|
+
/**
|
|
163
|
+
* 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
|
|
164
|
+
* 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
|
|
165
|
+
* 之后渲染端已经不再监听这条流。
|
|
166
|
+
*/
|
|
167
|
+
private runSuggestedRepliesInBackground;
|
|
168
|
+
private publishSuggestedReplies;
|
|
161
169
|
/**
|
|
162
170
|
* 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
|
|
163
171
|
* 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import { getAppRootDir } from '../runtime.js';
|
|
5
|
+
import { getAppRootDir, shellOpenPath } from '../runtime.js';
|
|
6
6
|
import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
|
|
@@ -26,6 +26,7 @@ import { parseUserMessageTriggers } from './message-triggers.js';
|
|
|
26
26
|
import { findSkill, loadSkills, classifySkillOrigin, listSkillRoots } from './skill-loader.js';
|
|
27
27
|
import { installSkillFromDirectory } from './skill-install.js';
|
|
28
28
|
import { generateChatTitle } from './ai-title.js';
|
|
29
|
+
import { fallbackSuggestedReplies, generateSuggestedReplies, } from './ai-suggestions.js';
|
|
29
30
|
import { detectDeferredExecution } from './deferred-detector.js';
|
|
30
31
|
import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js';
|
|
31
32
|
import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
|
|
@@ -37,6 +38,8 @@ import { loadProjectRuleFiles } from '../project-rules.js';
|
|
|
37
38
|
import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
|
|
38
39
|
import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
|
|
39
40
|
import { matchPackBackendRoute } from './pack-backend-routes.js';
|
|
41
|
+
import { collectTurnFiles } from './turn-files.js';
|
|
42
|
+
import { resolveMentionedPaths } from './mentioned-paths.js';
|
|
40
43
|
/**
|
|
41
44
|
* 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
|
|
42
45
|
* 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
|
|
@@ -1642,6 +1645,37 @@ export class LocalBackendRouter {
|
|
|
1642
1645
|
},
|
|
1643
1646
|
};
|
|
1644
1647
|
}
|
|
1648
|
+
// 回合产物列表的「点击打开」:用系统默认应用打开本地路径(与 agent 的
|
|
1649
|
+
// local_open_path 工具同一宿主能力,这里给渲染层一个 HTTP 入口,CS/BS
|
|
1650
|
+
// 两种模式都经 localBackend.request 到达)。
|
|
1651
|
+
if (method === 'POST' && pathname === '/api/v2/local/open-path') {
|
|
1652
|
+
const payload = this.toRecord(request.body);
|
|
1653
|
+
const target = typeof payload.path === 'string' ? payload.path.trim() : '';
|
|
1654
|
+
if (!target || !path.isAbsolute(target)) {
|
|
1655
|
+
return { status: 400, data: { detail: 'absolute path is required' } };
|
|
1656
|
+
}
|
|
1657
|
+
const openError = await shellOpenPath(target);
|
|
1658
|
+
if (openError) {
|
|
1659
|
+
return { status: 200, data: { success: false, error: openError } };
|
|
1660
|
+
}
|
|
1661
|
+
return { status: 200, data: { success: true } };
|
|
1662
|
+
}
|
|
1663
|
+
// 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
|
|
1664
|
+
// 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
|
|
1665
|
+
// 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
|
|
1666
|
+
if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
|
|
1667
|
+
const payload = this.toRecord(request.body);
|
|
1668
|
+
const candidates = Array.isArray(payload.candidates)
|
|
1669
|
+
? payload.candidates.filter((item) => typeof item === 'string')
|
|
1670
|
+
: [];
|
|
1671
|
+
if (candidates.length === 0) {
|
|
1672
|
+
return { status: 200, data: { resolved: [] } };
|
|
1673
|
+
}
|
|
1674
|
+
const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
|
|
1675
|
+
const baseDir = (chatId ? this.resolveChatProject(chatId)?.folderPath : null) ?? os.homedir();
|
|
1676
|
+
const resolved = await resolveMentionedPaths({ candidates, baseDir });
|
|
1677
|
+
return { status: 200, data: { resolved } };
|
|
1678
|
+
}
|
|
1645
1679
|
// ─── 场景包路由(1.2:/api/v2/<包前缀>/* 由包经 pack-backend-routes
|
|
1646
1680
|
// 注册表贡献;在宿主自带路由之后、fallback 之前匹配,包不能遮蔽宿主
|
|
1647
1681
|
// 路由)。 ───
|
|
@@ -1696,6 +1730,7 @@ export class LocalBackendRouter {
|
|
|
1696
1730
|
* `accumulatedContent += parsedData.content` 一行追加渲染。
|
|
1697
1731
|
* - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
|
|
1698
1732
|
* - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
|
|
1733
|
+
* - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
|
|
1699
1734
|
* - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
|
|
1700
1735
|
* - 结束:标准 SSE `[DONE]` 串。
|
|
1701
1736
|
*/
|
|
@@ -2760,6 +2795,14 @@ export class LocalBackendRouter {
|
|
|
2760
2795
|
const live = registerLiveStream(chatId, { executedActions, timeline, children });
|
|
2761
2796
|
// 场景包的回合观察器(如识别包技能调用并广播产物更新)。
|
|
2762
2797
|
const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
|
|
2798
|
+
// 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
|
|
2799
|
+
// (项目根 + 包工作区),与沙箱是否启用无关——「完整权限」下根列表只是
|
|
2800
|
+
// 不再被强制,产物仍大概率落在这里;根之外的显式写由工具参数并集补。
|
|
2801
|
+
const turnStartedAtMs = Date.now();
|
|
2802
|
+
const turnFileRoots = [
|
|
2803
|
+
...collectPackExecWritableRoots(chatId),
|
|
2804
|
+
...(chatProject ? [chatProject.folderPath] : []),
|
|
2805
|
+
];
|
|
2763
2806
|
try {
|
|
2764
2807
|
const coreLoopOptions = {
|
|
2765
2808
|
supervisor,
|
|
@@ -3012,6 +3055,20 @@ export class LocalBackendRouter {
|
|
|
3012
3055
|
}
|
|
3013
3056
|
}
|
|
3014
3057
|
const durationMs = turnDurationMs(localStore.getTurnActive(chatId)?.startedAt, Date.now());
|
|
3058
|
+
// 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
|
|
3059
|
+
// 产物)。收集失败只记日志,永远不该拖垮回合收尾。
|
|
3060
|
+
let turnFiles = [];
|
|
3061
|
+
try {
|
|
3062
|
+
turnFiles = await collectTurnFiles({
|
|
3063
|
+
roots: turnFileRoots,
|
|
3064
|
+
sinceMs: turnStartedAtMs,
|
|
3065
|
+
actions: executedActions,
|
|
3066
|
+
projectRoot: chatProject?.folderPath ?? null,
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
catch (turnFilesErr) {
|
|
3070
|
+
console.warn('[local-backend] collect turn files failed', turnFilesErr);
|
|
3071
|
+
}
|
|
3015
3072
|
const assistant = localStore.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
|
|
3016
3073
|
completionStatus,
|
|
3017
3074
|
completionReason,
|
|
@@ -3022,6 +3079,7 @@ export class LocalBackendRouter {
|
|
|
3022
3079
|
traceId: outcomeTraceId,
|
|
3023
3080
|
...(durationMs != null ? { durationMs } : {}),
|
|
3024
3081
|
...(autoContinuations > 0 ? { autoContinuations } : {}),
|
|
3082
|
+
...(turnFiles.length > 0 ? { turnFiles } : {}),
|
|
3025
3083
|
}));
|
|
3026
3084
|
try {
|
|
3027
3085
|
recordInsightTurn({
|
|
@@ -3094,11 +3152,65 @@ export class LocalBackendRouter {
|
|
|
3094
3152
|
});
|
|
3095
3153
|
}
|
|
3096
3154
|
}
|
|
3155
|
+
// 产物文件列表赶在 message_id 之前发:前端按 message_id 把本轮队列
|
|
3156
|
+
// 归档到落库消息上,顺序保证产物列表随同一批次归档。
|
|
3157
|
+
if (turnFiles.length > 0) {
|
|
3158
|
+
emit(this.sseData({ type: 'turn_files', files: turnFiles }));
|
|
3159
|
+
}
|
|
3097
3160
|
emit(this.sseData({ type: 'message_id', messageId: assistant.id }));
|
|
3098
3161
|
emit('data: [DONE]\n\n');
|
|
3099
3162
|
this.runTitleGenInBackground(chatId, shouldGenerateTitle, firstUserMessageForTitle);
|
|
3163
|
+
this.runSuggestedRepliesInBackground({
|
|
3164
|
+
chatId,
|
|
3165
|
+
messageId: assistant.id,
|
|
3166
|
+
userText: firstUserMessageForTitle,
|
|
3167
|
+
assistantText,
|
|
3168
|
+
completionStatus,
|
|
3169
|
+
});
|
|
3100
3170
|
return { status: 200 };
|
|
3101
3171
|
}
|
|
3172
|
+
/**
|
|
3173
|
+
* 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
|
|
3174
|
+
* 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
|
|
3175
|
+
* 之后渲染端已经不再监听这条流。
|
|
3176
|
+
*/
|
|
3177
|
+
runSuggestedRepliesInBackground(args) {
|
|
3178
|
+
const { chatId, messageId, userText, assistantText, completionStatus } = args;
|
|
3179
|
+
if (completionStatus === 'cancelled' || completionStatus === 'failed')
|
|
3180
|
+
return;
|
|
3181
|
+
if (!assistantText.trim())
|
|
3182
|
+
return;
|
|
3183
|
+
const fallback = fallbackSuggestedReplies(userText, assistantText);
|
|
3184
|
+
this.publishSuggestedReplies(chatId, messageId, fallback);
|
|
3185
|
+
void (async () => {
|
|
3186
|
+
try {
|
|
3187
|
+
const result = await generateSuggestedReplies(userText, assistantText, {
|
|
3188
|
+
perAttemptTimeoutMs: 60_000,
|
|
3189
|
+
});
|
|
3190
|
+
if (result.usedFallback)
|
|
3191
|
+
return;
|
|
3192
|
+
const same = result.suggestions.length === fallback.length &&
|
|
3193
|
+
result.suggestions.every((item, i) => item === fallback[i]);
|
|
3194
|
+
if (same)
|
|
3195
|
+
return;
|
|
3196
|
+
this.publishSuggestedReplies(chatId, messageId, result.suggestions);
|
|
3197
|
+
}
|
|
3198
|
+
catch (err) {
|
|
3199
|
+
console.warn('[local-backend] suggested-replies failed', { chatId, err });
|
|
3200
|
+
}
|
|
3201
|
+
})();
|
|
3202
|
+
}
|
|
3203
|
+
publishSuggestedReplies(chatId, messageId, suggestions) {
|
|
3204
|
+
if (suggestions.length === 0)
|
|
3205
|
+
return;
|
|
3206
|
+
try {
|
|
3207
|
+
localStore.patchMessageMetadata(chatId, messageId, { suggestedReplies: suggestions });
|
|
3208
|
+
}
|
|
3209
|
+
catch (err) {
|
|
3210
|
+
console.warn('[local-backend] suggested-replies persist failed', { chatId, messageId, err });
|
|
3211
|
+
}
|
|
3212
|
+
this.broadcast?.('suggested-replies', { chatId, messageId, suggestions });
|
|
3213
|
+
}
|
|
3102
3214
|
/**
|
|
3103
3215
|
* 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
|
|
3104
3216
|
* 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合产物文件收集(「本轮写了哪些文件」列表)。
|
|
3
|
+
*
|
|
4
|
+
* 四个来源取并集:
|
|
5
|
+
*
|
|
6
|
+
* 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
|
|
7
|
+
* 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
|
|
8
|
+
* 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
|
|
9
|
+
* 工具参数解析。
|
|
10
|
+
* 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
|
|
11
|
+
* 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
|
|
12
|
+
* 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
|
|
13
|
+
* local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
|
|
14
|
+
* 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
|
|
15
|
+
* exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
|
|
16
|
+
* 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
|
|
17
|
+
* 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
|
|
18
|
+
* 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
|
|
19
|
+
*
|
|
20
|
+
* 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
|
|
21
|
+
* 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
|
|
22
|
+
* stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
|
|
23
|
+
* 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
|
|
24
|
+
*
|
|
25
|
+
* 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
|
|
26
|
+
* Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
|
|
27
|
+
* 的准确性不依赖 birthtime。
|
|
28
|
+
*
|
|
29
|
+
* 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
|
|
30
|
+
* 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
|
|
31
|
+
*/
|
|
32
|
+
export interface TurnFile {
|
|
33
|
+
/** 绝对路径(点击打开直接用)。 */
|
|
34
|
+
path: string;
|
|
35
|
+
/** created = 本轮新建;modified = 已有文件被改动。 */
|
|
36
|
+
kind: 'created' | 'modified';
|
|
37
|
+
/** 字节数(展示用)。 */
|
|
38
|
+
size: number;
|
|
39
|
+
}
|
|
40
|
+
/** 扫描入参里只需要工具行动的这几个字段(与 router 的 executedActions 行结构对齐)。 */
|
|
41
|
+
export interface TurnFileAction {
|
|
42
|
+
tool?: unknown;
|
|
43
|
+
arguments?: unknown;
|
|
44
|
+
success?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
|
|
48
|
+
* 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
|
|
49
|
+
* `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
|
|
50
|
+
*/
|
|
51
|
+
export declare function collectTurnFiles(options: {
|
|
52
|
+
roots: string[];
|
|
53
|
+
sinceMs: number;
|
|
54
|
+
actions?: readonly TurnFileAction[];
|
|
55
|
+
projectRoot?: string | null;
|
|
56
|
+
homeDir?: string;
|
|
57
|
+
}): Promise<TurnFile[]>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合产物文件收集(「本轮写了哪些文件」列表)。
|
|
3
|
+
*
|
|
4
|
+
* 四个来源取并集:
|
|
5
|
+
*
|
|
6
|
+
* 1. 工作区扫描(递归):回合的可写根(项目根 + 场景包工作区,与 exec
|
|
7
|
+
* 沙箱同源)里 mtime/birthtime ≥ 回合开始时间的文件。覆盖脚本/命令
|
|
8
|
+
* 间接写出的产物(如运行脚本生成的文档、报表)——这类路径无法从
|
|
9
|
+
* 工具参数解析。
|
|
10
|
+
* 2. 显式写工具参数:local_write_file / local_edit_file 的 path。覆盖
|
|
11
|
+
* 「完整权限」模式下写到可写根之外的路径(如 ~/Downloads)。
|
|
12
|
+
* 3. exec cwd 浅扫描(仅顶层一层、跳过点文件):local_exec_shell /
|
|
13
|
+
* local_run_snippet 的实际工作目录(显式 cwd;缺省按 executor 规则
|
|
14
|
+
* 回落到项目根或 home)。覆盖「脚本在 cwd 落盘」——无项目对话的
|
|
15
|
+
* exec 默认 cwd 是 home,产物(如 ~/季度报告.pdf)不在任何递归根里。
|
|
16
|
+
* 4. 命令/代码文本里的绝对路径字面量:exec 命令、snippet 代码中出现的
|
|
17
|
+
* 绝对路径(含 ~/ 前缀与引号包裹形式),stat + 时间水位线验证后并入。
|
|
18
|
+
* 覆盖脚本写到与 cwd 无关的任意位置(如 doc.save('/tmp/x/a.pdf'))。
|
|
19
|
+
*
|
|
20
|
+
* 来源 3/4 都是「猜候选、靠 stat + mtime/birthtime 水位线证伪」:命令里
|
|
21
|
+
* 提到的既有文件(ls /etc/hosts)时间戳旧、不会被误收;不存在的路径
|
|
22
|
+
* stat 即失败。因此解析不需要可靠——与沙箱围栏「命令内嵌绝对路径无法
|
|
23
|
+
* 可靠解析所以不拦」不同,展示面可以承受启发式,漏报才伤体验。
|
|
24
|
+
*
|
|
25
|
+
* 单趟末次扫描、无基线快照:kind 标签靠 birthtime(macOS/Windows/新版
|
|
26
|
+
* Linux 文件系统可用);birthtime 不可用时降级为 'modified',列表本身
|
|
27
|
+
* 的准确性不依赖 birthtime。
|
|
28
|
+
*
|
|
29
|
+
* 扫描是有界 best-effort:忽略依赖/构建缓存目录,深度与遍历条目数有硬顶,
|
|
30
|
+
* 超限直接截断返回已收集部分——产物列表是展示面,永远不该拖垮回合收尾。
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs/promises';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
/** 这些目录要么体量巨大(node_modules/target),要么是框架内部状态(.steerable),扫了只有噪音。 */
|
|
36
|
+
const IGNORED_DIR_NAMES = new Set([
|
|
37
|
+
'node_modules',
|
|
38
|
+
'.git',
|
|
39
|
+
'.hg',
|
|
40
|
+
'.svn',
|
|
41
|
+
'__pycache__',
|
|
42
|
+
'.venv',
|
|
43
|
+
'venv',
|
|
44
|
+
'.tox',
|
|
45
|
+
'.next',
|
|
46
|
+
'.nuxt',
|
|
47
|
+
'.cache',
|
|
48
|
+
'target',
|
|
49
|
+
'.steerable',
|
|
50
|
+
]);
|
|
51
|
+
const IGNORED_FILE_NAMES = new Set(['.DS_Store']);
|
|
52
|
+
/** 单次扫描的遍历上限:深度 10、条目 10 万、结果 100 条。 */
|
|
53
|
+
const MAX_DEPTH = 10;
|
|
54
|
+
const MAX_VISITED = 100_000;
|
|
55
|
+
const MAX_FILES = 100;
|
|
56
|
+
/** 显式写工具:参数里的 path 并入产物列表(覆盖可写根之外的写入)。 */
|
|
57
|
+
const WRITE_TOOL_NAMES = new Set(['local_write_file', 'local_edit_file']);
|
|
58
|
+
/** exec 类工具:cwd 浅扫描与命令文本路径字面量的来源。 */
|
|
59
|
+
const EXEC_TOOL_NAMES = new Set(['local_exec_shell', 'local_run_snippet']);
|
|
60
|
+
/** 每条命令/代码文本最多提取的路径字面量个数(防超长命令刷 stat)。 */
|
|
61
|
+
const MAX_PATH_LITERALS_PER_ACTION = 50;
|
|
62
|
+
/**
|
|
63
|
+
* Linux overlay/tmpfs 常把 mtime/birthtime 截到 1s。回合开始后立刻写出的
|
|
64
|
+
* 产物会 stat 成略早于 sinceMs,被水位线丢掉。1s 容差远小于一轮对话,
|
|
65
|
+
* 只影响展示列表,允许把回合前 1s 内的文件算进来。
|
|
66
|
+
*/
|
|
67
|
+
const TOUCHED_SLACK_MS = 1000;
|
|
68
|
+
/**
|
|
69
|
+
* 引号包裹的绝对路径字面量:python 代码里的 doc.save('/x/a.pdf')、shell
|
|
70
|
+
* 里的 "/x/a.pdf"。要求以 /、~/ 或 Windows 盘符开头、带扩展名结尾。
|
|
71
|
+
* 贪婪匹配到闭引号再回溯到最后一个点——目录名里带点(.venv/python3.11)
|
|
72
|
+
* 不会把路径截断在中间。
|
|
73
|
+
*/
|
|
74
|
+
const QUOTED_PATH_PATTERN = /['"]((?:~\/|\/|[A-Za-z]:[\\/])[^'"]{1,300}\.[A-Za-z0-9]{1,10})['"]/g;
|
|
75
|
+
/**
|
|
76
|
+
* 裸绝对路径字面量(无引号、无空格):shell 重定向/参数里常见的写法。
|
|
77
|
+
* 排除引号/空白/shell 元字符;URL 的 // 开头被 (?!\/) 挡掉;同样贪婪到
|
|
78
|
+
* token 尾再回溯到最后一个点,(?![A-Za-z0-9]) 保证扩展名不被截断。
|
|
79
|
+
*/
|
|
80
|
+
const BARE_PATH_PATTERN = /(?:~\/|\/(?!\/))[^\s"'`<>|;,()[\]{}\\*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])|[A-Za-z]:\\[^\s"'`<>|;,*?]{1,300}\.[A-Za-z0-9]{1,10}(?![A-Za-z0-9])/g;
|
|
81
|
+
/**
|
|
82
|
+
* 收集本轮产物文件。`sinceMs` 是回合开始的 epoch ms;`projectRoot` 用于把
|
|
83
|
+
* 写工具的相对路径参数解析成绝对路径(与 local-executor 的项目根解析一致)。
|
|
84
|
+
* `homeDir` 仅测试注入用,默认 os.homedir()——exec 缺省 cwd 的回落值。
|
|
85
|
+
*/
|
|
86
|
+
export async function collectTurnFiles(options) {
|
|
87
|
+
const { roots, sinceMs, projectRoot = null } = options;
|
|
88
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
89
|
+
const byPath = new Map();
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
await scanRoot(root, sinceMs, byPath);
|
|
92
|
+
}
|
|
93
|
+
const shallowRoots = new Set();
|
|
94
|
+
for (const action of options.actions ?? []) {
|
|
95
|
+
await collectWriteToolPath(action, sinceMs, projectRoot, byPath);
|
|
96
|
+
const cwd = execCwdOf(action, projectRoot, homeDir);
|
|
97
|
+
if (cwd && !isCoveredByRoots(cwd, roots))
|
|
98
|
+
shallowRoots.add(cwd);
|
|
99
|
+
await collectPathLiterals(action, sinceMs, homeDir, byPath);
|
|
100
|
+
}
|
|
101
|
+
for (const dir of shallowRoots) {
|
|
102
|
+
await scanShallow(dir, sinceMs, byPath);
|
|
103
|
+
}
|
|
104
|
+
return [...byPath.values()]
|
|
105
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
106
|
+
.slice(0, MAX_FILES);
|
|
107
|
+
}
|
|
108
|
+
async function scanRoot(root, sinceMs, out) {
|
|
109
|
+
const state = { visited: 0 };
|
|
110
|
+
await walk(path.resolve(root), sinceMs, 0, state, out);
|
|
111
|
+
}
|
|
112
|
+
async function walk(dir, sinceMs, depth, state, out) {
|
|
113
|
+
if (depth > MAX_DEPTH || state.visited >= MAX_VISITED)
|
|
114
|
+
return;
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 根目录被删/无权限:跳过该根,不影响其他根。
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (state.visited >= MAX_VISITED)
|
|
125
|
+
return;
|
|
126
|
+
state.visited += 1;
|
|
127
|
+
const full = path.join(dir, entry.name);
|
|
128
|
+
// 不跟随符号链接:避免链接环把扫描拖出根外。
|
|
129
|
+
if (entry.isSymbolicLink())
|
|
130
|
+
continue;
|
|
131
|
+
if (entry.isDirectory()) {
|
|
132
|
+
if (IGNORED_DIR_NAMES.has(entry.name))
|
|
133
|
+
continue;
|
|
134
|
+
await walk(full, sinceMs, depth + 1, state, out);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!entry.isFile() || IGNORED_FILE_NAMES.has(entry.name))
|
|
138
|
+
continue;
|
|
139
|
+
const file = await statTurnFile(full, sinceMs);
|
|
140
|
+
if (file)
|
|
141
|
+
out.set(full, file);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** stat 一个文件,命中「本轮触碰过」则返回 TurnFile,否则 null。 */
|
|
145
|
+
async function statTurnFile(full, sinceMs) {
|
|
146
|
+
let stat;
|
|
147
|
+
try {
|
|
148
|
+
stat = await fs.stat(full);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// 扫描窗口内被删掉的文件直接略过。
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
// 路径字面量可能指到目录(如 xxx.app 包);产物列表只收文件。
|
|
155
|
+
if (!stat.isFile())
|
|
156
|
+
return null;
|
|
157
|
+
const touched = stat.mtimeMs + TOUCHED_SLACK_MS >= sinceMs ||
|
|
158
|
+
stat.birthtimeMs + TOUCHED_SLACK_MS >= sinceMs;
|
|
159
|
+
if (!touched)
|
|
160
|
+
return null;
|
|
161
|
+
return { path: full, kind: kindOf(stat, sinceMs), size: stat.size };
|
|
162
|
+
}
|
|
163
|
+
function kindOf(stat, sinceMs) {
|
|
164
|
+
// birthtime 不可用的文件系统返回 0/负值——此时无法区分新建与修改,
|
|
165
|
+
// 统一标 modified(标签降级,不漏文件)。
|
|
166
|
+
return stat.birthtimeMs >= sinceMs ? 'created' : 'modified';
|
|
167
|
+
}
|
|
168
|
+
async function collectWriteToolPath(action, sinceMs, projectRoot, out) {
|
|
169
|
+
if (typeof action.tool !== 'string' || !WRITE_TOOL_NAMES.has(action.tool))
|
|
170
|
+
return;
|
|
171
|
+
// success === false 的调用没写成;undefined(流中未落定)按成功处理——
|
|
172
|
+
// 文件是否真存在由下面的 stat 把关。
|
|
173
|
+
if (action.success === false)
|
|
174
|
+
return;
|
|
175
|
+
const args = action.arguments;
|
|
176
|
+
if (!args || typeof args !== 'object')
|
|
177
|
+
return;
|
|
178
|
+
const raw = args.path;
|
|
179
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
180
|
+
return;
|
|
181
|
+
const full = path.isAbsolute(raw)
|
|
182
|
+
? path.normalize(raw)
|
|
183
|
+
: projectRoot
|
|
184
|
+
? path.resolve(projectRoot, raw)
|
|
185
|
+
: null;
|
|
186
|
+
if (!full || out.has(full))
|
|
187
|
+
return;
|
|
188
|
+
const file = await statTurnFile(full, sinceMs);
|
|
189
|
+
if (file)
|
|
190
|
+
out.set(full, file);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* exec 行动的实际工作目录(与 tool-router / local-executor 的解析规则对齐):
|
|
194
|
+
* 显式 cwd 展开 ~ 后按绝对/相对解析(相对在项目模式下按项目根、否则按
|
|
195
|
+
* 进程 cwd);缺省 cwd 回落到项目根,无项目时回落到 home——无项目对话里
|
|
196
|
+
* 脚本产物最常见的落点。
|
|
197
|
+
*/
|
|
198
|
+
function execCwdOf(action, projectRoot, homeDir) {
|
|
199
|
+
if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
|
|
200
|
+
return null;
|
|
201
|
+
const args = action.arguments;
|
|
202
|
+
if (!args || typeof args !== 'object')
|
|
203
|
+
return null;
|
|
204
|
+
const raw = args.cwd;
|
|
205
|
+
const cwdArg = typeof raw === 'string' ? raw.trim() : '';
|
|
206
|
+
if (!cwdArg)
|
|
207
|
+
return projectRoot ?? homeDir;
|
|
208
|
+
const expanded = cwdArg.startsWith('~') ? path.join(homeDir, cwdArg.slice(1)) : cwdArg;
|
|
209
|
+
if (path.isAbsolute(expanded))
|
|
210
|
+
return path.normalize(expanded);
|
|
211
|
+
return projectRoot ? path.resolve(projectRoot, expanded) : path.resolve(expanded);
|
|
212
|
+
}
|
|
213
|
+
/** cwd 已落在某个递归根之内时无需再浅扫(递归扫描已覆盖且更深)。 */
|
|
214
|
+
function isCoveredByRoots(cwd, roots) {
|
|
215
|
+
return roots.some((root) => {
|
|
216
|
+
const rel = path.relative(path.resolve(root), cwd);
|
|
217
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* exec cwd 的浅扫描:只看顶层一层、跳过点文件。home 顶层的变化几乎全是
|
|
222
|
+
* 工具状态(.zsh_history 之类),点文件过滤掉它们;真正的产物文档极少
|
|
223
|
+
* 以点开头。浅扫不递归——home 这种根递归起来既慢又全是无关变化。
|
|
224
|
+
*/
|
|
225
|
+
async function scanShallow(dir, sinceMs, out) {
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// cwd 被删/无权限:跳过,不影响其他来源。
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (entry.name.startsWith('.') || IGNORED_FILE_NAMES.has(entry.name))
|
|
236
|
+
continue;
|
|
237
|
+
if (entry.isSymbolicLink() || !entry.isFile())
|
|
238
|
+
continue;
|
|
239
|
+
const full = path.join(dir, entry.name);
|
|
240
|
+
if (out.has(full))
|
|
241
|
+
continue;
|
|
242
|
+
const file = await statTurnFile(full, sinceMs);
|
|
243
|
+
if (file)
|
|
244
|
+
out.set(full, file);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 从 exec 命令 / snippet 代码文本里提取绝对路径字面量,stat + 水位线
|
|
249
|
+
* 验证后并入。覆盖脚本写到与 cwd 无关的任意位置。local_run_script 的
|
|
250
|
+
* 脚本内容在注册表里、行动参数只有 scriptId,不在此覆盖(其 cwd 语义
|
|
251
|
+
* 与 exec_shell 相同,浅扫描已兜底常见落点)。
|
|
252
|
+
*/
|
|
253
|
+
async function collectPathLiterals(action, sinceMs, homeDir, out) {
|
|
254
|
+
if (typeof action.tool !== 'string' || !EXEC_TOOL_NAMES.has(action.tool))
|
|
255
|
+
return;
|
|
256
|
+
const args = action.arguments;
|
|
257
|
+
if (!args || typeof args !== 'object')
|
|
258
|
+
return;
|
|
259
|
+
const record = args;
|
|
260
|
+
const text = [record.command, record.code]
|
|
261
|
+
.filter((value) => typeof value === 'string')
|
|
262
|
+
.join('\n');
|
|
263
|
+
if (!text)
|
|
264
|
+
return;
|
|
265
|
+
const candidates = new Set();
|
|
266
|
+
for (const pattern of [QUOTED_PATH_PATTERN, BARE_PATH_PATTERN]) {
|
|
267
|
+
pattern.lastIndex = 0;
|
|
268
|
+
for (const match of text.matchAll(pattern)) {
|
|
269
|
+
if (candidates.size >= MAX_PATH_LITERALS_PER_ACTION)
|
|
270
|
+
break;
|
|
271
|
+
const literal = match[1] ?? match[0];
|
|
272
|
+
const expanded = literal.startsWith('~/')
|
|
273
|
+
? path.join(homeDir, literal.slice(2))
|
|
274
|
+
: literal;
|
|
275
|
+
candidates.add(path.normalize(expanded));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const full of candidates) {
|
|
279
|
+
if (out.has(full))
|
|
280
|
+
continue;
|
|
281
|
+
const file = await statTurnFile(full, sinceMs);
|
|
282
|
+
if (file)
|
|
283
|
+
out.set(full, file);
|
|
284
|
+
}
|
|
285
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -64,7 +64,7 @@ const RETRY_DELAYS = [2000, 5000, 10000, 30000];
|
|
|
64
64
|
// 导致模型退化成"假后台")。CS 的差异只剩广播(IPC)与生命周期。
|
|
65
65
|
const runtime = createHostRuntime({
|
|
66
66
|
broadcast: (channel, payload) => broadcastTerminalEvent(channel, payload),
|
|
67
|
-
// LocalBackendRouter
|
|
67
|
+
// LocalBackendRouter 的后台事件(chat-title-updated / suggested-replies)只推主窗口。
|
|
68
68
|
// 没拿到 mainWindow 时静默丢——title 是 nice-to-have,不该让启动顺序影响功能。
|
|
69
69
|
broadcastMain: (eventName, payload) => {
|
|
70
70
|
if (!mainWindow || mainWindow.isDestroyed())
|
|
@@ -544,9 +544,11 @@ function setupIpcHandlers() {
|
|
|
544
544
|
// validated and routed to the pending sidecar `approval.request` call;
|
|
545
545
|
// unknown requestIds (late/stale answers) are dropped.
|
|
546
546
|
ipcMain.handle('approval:decide', (_event, payload) => approvalBridge.decide(payload));
|
|
547
|
+
ipcMain.handle('approval:pending', () => approvalBridge.pending());
|
|
547
548
|
// W8: the renderer's ask-user card answers here; routed to the pending
|
|
548
549
|
// sidecar `ask_user.request` call (fail-open empty answers on mismatch).
|
|
549
550
|
ipcMain.handle('ask-user:answer', (_event, payload) => askUserBridge.answer(payload));
|
|
551
|
+
ipcMain.handle('ask-user:pending', () => askUserBridge.pending());
|
|
550
552
|
ipcMain.handle('local:update-safety-config', async (_event, config) => {
|
|
551
553
|
localExecutor.updateSafetyConfig(config);
|
|
552
554
|
return { success: true };
|
package/dist/preload.cjs
CHANGED
|
@@ -87,6 +87,25 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
87
87
|
import_electron.ipcRenderer.removeListener("chat-title-updated", handler);
|
|
88
88
|
};
|
|
89
89
|
},
|
|
90
|
+
/**
|
|
91
|
+
* 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
|
|
92
|
+
* 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
|
|
93
|
+
*/
|
|
94
|
+
onSuggestedReplies: (callback) => {
|
|
95
|
+
const handler = (_event, payload) => {
|
|
96
|
+
if (typeof payload?.chatId === "string" && typeof payload?.messageId === "string" && Array.isArray(payload.suggestions) && payload.suggestions.every((item) => typeof item === "string")) {
|
|
97
|
+
callback({
|
|
98
|
+
chatId: payload.chatId,
|
|
99
|
+
messageId: payload.messageId,
|
|
100
|
+
suggestions: payload.suggestions
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
import_electron.ipcRenderer.on("suggested-replies", handler);
|
|
105
|
+
return () => {
|
|
106
|
+
import_electron.ipcRenderer.removeListener("suggested-replies", handler);
|
|
107
|
+
};
|
|
108
|
+
},
|
|
90
109
|
/**
|
|
91
110
|
* 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
|
|
92
111
|
* 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
|
|
@@ -154,9 +173,9 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
154
173
|
},
|
|
155
174
|
/**
|
|
156
175
|
* W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
|
|
157
|
-
* 请求广播到 renderer(approval:request
|
|
158
|
-
* approval:decide
|
|
159
|
-
*
|
|
176
|
+
* 请求广播到 renderer(approval:request),输入区审批菜单应答后走
|
|
177
|
+
* approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
|
|
178
|
+
* 的请求。
|
|
160
179
|
*/
|
|
161
180
|
approval: {
|
|
162
181
|
onRequest: (callback) => {
|
|
@@ -173,13 +192,16 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
173
192
|
},
|
|
174
193
|
decide: async (decision) => {
|
|
175
194
|
await import_electron.ipcRenderer.invoke("approval:decide", decision);
|
|
195
|
+
},
|
|
196
|
+
pending: async () => {
|
|
197
|
+
return await import_electron.ipcRenderer.invoke("approval:pending");
|
|
176
198
|
}
|
|
177
199
|
},
|
|
178
200
|
/**
|
|
179
201
|
* W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
|
|
180
202
|
* 请求广播到 renderer(ask-user:request),问题卡片应答后走
|
|
181
|
-
* ask-user:answer
|
|
182
|
-
*
|
|
203
|
+
* ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
|
|
204
|
+
* 的请求。
|
|
183
205
|
*/
|
|
184
206
|
askUser: {
|
|
185
207
|
onRequest: (callback) => {
|
|
@@ -196,6 +218,9 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
196
218
|
},
|
|
197
219
|
answer: async (reply) => {
|
|
198
220
|
await import_electron.ipcRenderer.invoke("ask-user:answer", reply);
|
|
221
|
+
},
|
|
222
|
+
pending: async () => {
|
|
223
|
+
return await import_electron.ipcRenderer.invoke("ask-user:pending");
|
|
199
224
|
}
|
|
200
225
|
},
|
|
201
226
|
local: {
|
|
@@ -380,11 +405,6 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
380
405
|
const fn = (_event, session) => callback(session);
|
|
381
406
|
import_electron.ipcRenderer.on("terminal:spawned", fn);
|
|
382
407
|
return () => import_electron.ipcRenderer.removeListener("terminal:spawned", fn);
|
|
383
|
-
},
|
|
384
|
-
onReveal: (callback) => {
|
|
385
|
-
const fn = (_event, payload) => callback(payload);
|
|
386
|
-
import_electron.ipcRenderer.on("terminal:reveal", fn);
|
|
387
|
-
return () => import_electron.ipcRenderer.removeListener("terminal:reveal", fn);
|
|
388
408
|
}
|
|
389
409
|
}
|
|
390
410
|
};
|
package/dist/preload.js
CHANGED
|
@@ -103,6 +103,28 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
103
103
|
ipcRenderer.removeListener('chat-title-updated', handler);
|
|
104
104
|
};
|
|
105
105
|
},
|
|
106
|
+
/**
|
|
107
|
+
* 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
|
|
108
|
+
* 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
|
|
109
|
+
*/
|
|
110
|
+
onSuggestedReplies: (callback) => {
|
|
111
|
+
const handler = (_event, payload) => {
|
|
112
|
+
if (typeof payload?.chatId === 'string' &&
|
|
113
|
+
typeof payload?.messageId === 'string' &&
|
|
114
|
+
Array.isArray(payload.suggestions) &&
|
|
115
|
+
payload.suggestions.every((item) => typeof item === 'string')) {
|
|
116
|
+
callback({
|
|
117
|
+
chatId: payload.chatId,
|
|
118
|
+
messageId: payload.messageId,
|
|
119
|
+
suggestions: payload.suggestions,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
ipcRenderer.on('suggested-replies', handler);
|
|
124
|
+
return () => {
|
|
125
|
+
ipcRenderer.removeListener('suggested-replies', handler);
|
|
126
|
+
};
|
|
127
|
+
},
|
|
106
128
|
/**
|
|
107
129
|
* 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
|
|
108
130
|
* 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
|
|
@@ -171,9 +193,9 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
171
193
|
},
|
|
172
194
|
/**
|
|
173
195
|
* W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
|
|
174
|
-
* 请求广播到 renderer(approval:request
|
|
175
|
-
* approval:decide
|
|
176
|
-
*
|
|
196
|
+
* 请求广播到 renderer(approval:request),输入区审批菜单应答后走
|
|
197
|
+
* approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
|
|
198
|
+
* 的请求。
|
|
177
199
|
*/
|
|
178
200
|
approval: {
|
|
179
201
|
onRequest: (callback) => {
|
|
@@ -191,12 +213,15 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
191
213
|
decide: async (decision) => {
|
|
192
214
|
await ipcRenderer.invoke('approval:decide', decision);
|
|
193
215
|
},
|
|
216
|
+
pending: async () => {
|
|
217
|
+
return await ipcRenderer.invoke('approval:pending');
|
|
218
|
+
},
|
|
194
219
|
},
|
|
195
220
|
/**
|
|
196
221
|
* W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
|
|
197
222
|
* 请求广播到 renderer(ask-user:request),问题卡片应答后走
|
|
198
|
-
* ask-user:answer
|
|
199
|
-
*
|
|
223
|
+
* ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
|
|
224
|
+
* 的请求。
|
|
200
225
|
*/
|
|
201
226
|
askUser: {
|
|
202
227
|
onRequest: (callback) => {
|
|
@@ -214,6 +239,9 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
214
239
|
answer: async (reply) => {
|
|
215
240
|
await ipcRenderer.invoke('ask-user:answer', reply);
|
|
216
241
|
},
|
|
242
|
+
pending: async () => {
|
|
243
|
+
return await ipcRenderer.invoke('ask-user:pending');
|
|
244
|
+
},
|
|
217
245
|
},
|
|
218
246
|
local: {
|
|
219
247
|
selectDirectory: async (options) => {
|
|
@@ -398,11 +426,6 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
398
426
|
ipcRenderer.on('terminal:spawned', fn);
|
|
399
427
|
return () => ipcRenderer.removeListener('terminal:spawned', fn);
|
|
400
428
|
},
|
|
401
|
-
onReveal: (callback) => {
|
|
402
|
-
const fn = (_event, payload) => callback(payload);
|
|
403
|
-
ipcRenderer.on('terminal:reveal', fn);
|
|
404
|
-
return () => ipcRenderer.removeListener('terminal:reveal', fn);
|
|
405
|
-
},
|
|
406
429
|
},
|
|
407
430
|
};
|
|
408
431
|
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...packContributions });
|
|
@@ -230,11 +230,19 @@ export function createBsServer(deps) {
|
|
|
230
230
|
sendJson(res, 200, await approvalBridge.decide(body));
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
|
+
if (method === 'GET' && pathname === '/host/approval/pending') {
|
|
234
|
+
sendJson(res, 200, approvalBridge.pending());
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
233
237
|
// ─── ask_user(对齐 ask-user:answer IPC) ───
|
|
234
238
|
if (method === 'POST' && pathname === '/host/ask-user/answer') {
|
|
235
239
|
sendJson(res, 200, askUserBridge.answer(body));
|
|
236
240
|
return;
|
|
237
241
|
}
|
|
242
|
+
if (method === 'GET' && pathname === '/host/ask-user/pending') {
|
|
243
|
+
sendJson(res, 200, askUserBridge.pending());
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
238
246
|
// ─── local(对齐 local:* IPC) ───
|
|
239
247
|
if (method === 'POST' && pathname === '/host/local/exec-shell') {
|
|
240
248
|
const request = body;
|
package/dist/server/sse-bus.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BS 模式的 SSE 事件总线:替代 Electron 的 `webContents.send(channel, payload)`
|
|
3
3
|
* 广播。所有宿主事件(terminal:data、approval:request、chat-title-updated、
|
|
4
|
-
*
|
|
4
|
+
* suggested-replies、场景包事件……)经这一条 `GET /api/v2/events` 长连接推给浏览器,
|
|
5
5
|
* channel 名与 IPC 通道保持一致,前端 http-bridge 按名分发。
|
|
6
6
|
*/
|
|
7
7
|
import type { ServerResponse } from 'node:http';
|
|
@@ -43,6 +43,8 @@ export interface ApprovalBridgeDeps {
|
|
|
43
43
|
export interface ApprovalBridge {
|
|
44
44
|
/** Reverse-channel handler for the sidecar's `approval.request`. */
|
|
45
45
|
handler: SidecarReverseHandler;
|
|
46
|
+
/** Requests still awaiting a renderer decision, used after page refresh. */
|
|
47
|
+
pending: () => ApprovalPromptRequest[];
|
|
46
48
|
/**
|
|
47
49
|
* IPC entry point for the renderer's answer (`ipcMain.handle(
|
|
48
50
|
* 'approval:decide')`). Unknown requestIds are dropped (late answer to an
|
|
@@ -54,7 +54,7 @@ export function createApprovalBridge(deps) {
|
|
|
54
54
|
};
|
|
55
55
|
deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
|
|
56
56
|
return await new Promise((resolve) => {
|
|
57
|
-
pending.set(requestId, { resolve });
|
|
57
|
+
pending.set(requestId, { prompt, resolve });
|
|
58
58
|
try {
|
|
59
59
|
deps.broadcast('approval:request', prompt);
|
|
60
60
|
}
|
|
@@ -64,6 +64,7 @@ export function createApprovalBridge(deps) {
|
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
66
|
},
|
|
67
|
+
pending: () => Array.from(pending.values(), ({ prompt }) => prompt),
|
|
67
68
|
decide: (payload) => {
|
|
68
69
|
const p = (payload ?? {});
|
|
69
70
|
const requestId = typeof p.requestId === 'string' ? p.requestId : '';
|
|
@@ -26,6 +26,8 @@ export interface AskUserBridgeDeps {
|
|
|
26
26
|
export interface AskUserBridge {
|
|
27
27
|
/** 注册为 sidecar 的 `ask_user.request` 反向方法处理器。 */
|
|
28
28
|
handler: SidecarReverseHandler;
|
|
29
|
+
/** 返回仍在等待 renderer 应答的请求,供刷新后的页面恢复提问。 */
|
|
30
|
+
pending: () => AskUserPromptRequest[];
|
|
29
31
|
/** renderer `ask-user:answer` 的入口;返回是否匹配到待应答请求。 */
|
|
30
32
|
answer: (payload: unknown) => {
|
|
31
33
|
ok: boolean;
|
|
@@ -32,7 +32,7 @@ export function createAskUserBridge(deps) {
|
|
|
32
32
|
questions: questions,
|
|
33
33
|
};
|
|
34
34
|
return new Promise((resolve) => {
|
|
35
|
-
pending.set(requestId, { resolve });
|
|
35
|
+
pending.set(requestId, { prompt, resolve });
|
|
36
36
|
try {
|
|
37
37
|
deps.broadcast('ask-user:request', prompt);
|
|
38
38
|
}
|
|
@@ -43,6 +43,7 @@ export function createAskUserBridge(deps) {
|
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
45
|
},
|
|
46
|
+
pending: () => Array.from(pending.values(), ({ prompt }) => prompt),
|
|
46
47
|
answer: (payload) => {
|
|
47
48
|
const p = (payload ?? {});
|
|
48
49
|
const entry = typeof p.requestId === 'string' ? pending.get(p.requestId) : undefined;
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -239,6 +239,12 @@ export declare class LocalStore {
|
|
|
239
239
|
*/
|
|
240
240
|
deleteMessagesFrom(chatId: string, messageId: string): number;
|
|
241
241
|
addMessage(chatId: string, role: ChatMessageRecord['role'], content: string, messageMetadata?: string | null): ChatMessageRecord;
|
|
242
|
+
/**
|
|
243
|
+
* 浅合并助手消息的 JSON metadata(例如回合结束后写入 suggestedReplies)。
|
|
244
|
+
* 不碰 updated_at:这类补丁不是用户可见的会话活动。
|
|
245
|
+
* metadata 损坏或非对象时从空对象开始合。
|
|
246
|
+
*/
|
|
247
|
+
patchMessageMetadata(chatId: string, messageId: string, patch: Record<string, unknown>): ChatMessageRecord | null;
|
|
242
248
|
/**
|
|
243
249
|
* W1.2.1: replace the chat's whole message list with a branch projection.
|
|
244
250
|
* The desktop store is the UI projection of the ACTIVE framework record —
|
package/dist/storage/index.js
CHANGED
|
@@ -669,6 +669,33 @@ export class LocalStore {
|
|
|
669
669
|
const row = this.db.prepare(`SELECT * FROM chat_messages WHERE id = ?`).get(id);
|
|
670
670
|
return this.mapChatMessage(row);
|
|
671
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* 浅合并助手消息的 JSON metadata(例如回合结束后写入 suggestedReplies)。
|
|
674
|
+
* 不碰 updated_at:这类补丁不是用户可见的会话活动。
|
|
675
|
+
* metadata 损坏或非对象时从空对象开始合。
|
|
676
|
+
*/
|
|
677
|
+
patchMessageMetadata(chatId, messageId, patch) {
|
|
678
|
+
const msg = this.getMessage(chatId, messageId);
|
|
679
|
+
if (!msg)
|
|
680
|
+
return null;
|
|
681
|
+
let current = {};
|
|
682
|
+
if (msg.messageMetadata) {
|
|
683
|
+
try {
|
|
684
|
+
const parsed = JSON.parse(msg.messageMetadata);
|
|
685
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
686
|
+
current = parsed;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
current = {};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const next = JSON.stringify({ ...current, ...patch });
|
|
694
|
+
this.db
|
|
695
|
+
.prepare(`UPDATE chat_messages SET message_metadata = ? WHERE id = ? AND chat_id = ?`)
|
|
696
|
+
.run(next, messageId, chatId);
|
|
697
|
+
return this.getMessage(chatId, messageId);
|
|
698
|
+
}
|
|
672
699
|
/**
|
|
673
700
|
* W1.2.1: replace the chat's whole message list with a branch projection.
|
|
674
701
|
* The desktop store is the UI projection of the ACTIVE framework record —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.21",
|
|
4
4
|
"description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"electron-store": "^8.1.0",
|
|
42
42
|
"he": "^1.2.0",
|
|
43
43
|
"node-pty": "^1.1.0",
|
|
44
|
-
"@steerable/agent-
|
|
45
|
-
"@steerable/agent-
|
|
46
|
-
"@steerable/pack-sdk": "0.6.
|
|
44
|
+
"@steerable/agent-harness": "0.6.21",
|
|
45
|
+
"@steerable/agent-protocol": "0.6.21",
|
|
46
|
+
"@steerable/pack-sdk": "0.6.21"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/better-sqlite3": "^7.6.13",
|