zen-gitsync 2.18.7 → 2.18.9
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/package.json +4 -1
- package/src/cli/ai/agent.js +7 -0
- package/src/cli/ai/runtime.test.js +22 -1
- package/src/cli/ai/tools.js +1 -1
- package/src/cli/ai/tools.test.js +1 -1
- package/src/cli/ai/transport.js +8 -3
- package/src/config.js +48 -6
- package/src/mcp/tools/workbench.js +1 -1
- package/src/ui/public/assets/{AgentView-am93H7C2.css → AgentView-CV_8sYX6.css} +1 -1
- package/src/ui/public/assets/AgentView-DxmnqJxE.js +1 -0
- package/src/ui/public/assets/{AppVersionBadge-D7X53Bf1.js → AppVersionBadge-C4OhcUi8.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-yXxfIs57.js → BranchSelector-CrLieQyJ.js} +1 -1
- package/src/ui/public/assets/{CommitForm-C8Ez91_c.js → CommitForm-KGQisnPQ.js} +2 -2
- package/src/ui/public/assets/{CommonDialog-BUuLsgNS.js → CommonDialog-j_53ORvj.js} +1 -1
- package/src/ui/public/assets/CopySessionButton-y5Rzmact.js +20 -0
- package/src/ui/public/assets/EditorView-CkZb2kw5.js +1 -0
- package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BuU5kQG8.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-CTBEooj5.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
- package/src/ui/public/assets/{LogList-Bq5aLoA-.js → LogList-BxH6QNlK.js} +4 -4
- package/src/ui/public/assets/{MindmapView-CSysrruz.js → MindmapView-D9_qjJsR.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Cgf5zRxT.js → MonitorView-z_fsnE8Y.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CrLWTK20.js → ProjectStartupButton-CFB_JpYG.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-BYI8lkjQ.js +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-CJZFzQPc.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-aSx1ikZ8.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-DEljf14g.js → SourceMapView-D6cSweDt.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-Du5MOe8A.js → SvgIcon-5AlG3IfR.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-oOQwaadU.js → UserInputNode-BKP869D6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-D4UDurhF.js +6 -0
- package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-Csn085FF.js +19 -0
- package/src/ui/public/assets/agentConversations-DyzaxUXm.js +10 -0
- package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
- package/src/ui/public/assets/index-C7Y9odvZ.js +57 -0
- package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
- package/src/ui/public/assets/useOrchestrator-C236G5P5.js +1 -0
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +8 -0
- package/src/ui/server/routes/config.js +13 -0
- package/src/ui/server/routes/fileOpen.js +57 -1
- package/src/ui/server/routes/fileOpen.test.js +1 -1
- package/src/ui/server/routes/workbench/agentChat.js +106 -12
- package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
- package/src/ui/server/routes/workbench/agentChatShared.test.js +148 -57
- package/src/ui/server/routes/workbench/agentEngines.js +8 -4
- package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +105 -8
- package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
- package/src/ui/server/routes/workbench/chatPrompts.js +115 -0
- package/src/ui/server/routes/workbench/chatPrompts.test.js +114 -0
- package/src/ui/server/routes/workbench/executorModels.js +30 -5
- package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
- package/src/ui/server/routes/workbench/index.js +1 -1
- package/src/ui/server/routes/workbench/jobActivity.js +3 -3
- package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
- package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
- package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
- package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
- package/src/ui/server/routes/workbench/taskRunner.js +311 -24
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
- package/src/ui/public/assets/AgentView-BlG0PdkI.js +0 -1
- package/src/ui/public/assets/CopySessionButton-Do4vHJm6.js +0 -20
- package/src/ui/public/assets/EditorView-DK2dYRVb.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BlIG_9G4.js +0 -1
- package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DXzxg5_z.js +0 -8
- package/src/ui/public/assets/_plugin-vue_export-helper-CDrhhAKC.js +0 -19
- package/src/ui/public/assets/agentConversations-BKjyYn-u.js +0 -8
- package/src/ui/public/assets/configStore-D0oycG71.js +0 -1
- package/src/ui/public/assets/index-DjaZpz97.js +0 -57
- package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// Web 端(g ui 服务端)的扩展层(Skill 清单 + MCP 工具)加载缓存。
|
|
16
|
+
//
|
|
17
|
+
// 背景:AgentExtensions.load 原先只有 CLI 的 `g ai` 调(agent.js 启动时一次),
|
|
18
|
+
// 而 Web 智能体面板的 runAgentTurn 与定时任务的执行体共用同一个函数却不加载
|
|
19
|
+
// 扩展 —— 表现就是「MCP 广场显示已安装,对话框里就是没有那些工具」
|
|
20
|
+
// (2026-10-12 用户实测:agent 只能自己读 mcp.json 按 HTTP 协议直调)。
|
|
21
|
+
// 这个模块把同一份加载逻辑接到 Web 侧,并按 cwd 做进程内缓存。
|
|
22
|
+
//
|
|
23
|
+
// 为什么按 cwd 缓存而不是每轮加载:
|
|
24
|
+
// - MCP 握手不便宜:stdio 要起子进程(npx 首次还要下载包,实测 20s+),
|
|
25
|
+
// HTTP 也要 initialize + tools/list 两个往返。每轮都付一次的话,
|
|
26
|
+
// 装了两三个 MCP 的用户每轮对话都要先等好几秒。
|
|
27
|
+
// - CLI 的口径是「进程启动时加载一次,/cwd 切换时重载」。Web 没有进程重启
|
|
28
|
+
// 这个自然边界,用缓存等价实现:同一 cwd 只加载一次,配置变化靠失效。
|
|
29
|
+
//
|
|
30
|
+
// 缓存什么时候失效:
|
|
31
|
+
// - 广场安装/卸载(agentMarketplace.js 调 invalidateWebExtensions)——
|
|
32
|
+
// 不接这一路的话,刚装的 MCP 要等 g gui 重启才在对话里生效;
|
|
33
|
+
// - 手动编辑 ~/.zen-gitsync/ai/mcp.json(不走广场的那种)—— 下次 g gui
|
|
34
|
+
// 重启生效,与 CLI「下次启动时生效」一致。
|
|
35
|
+
//
|
|
36
|
+
// 生命周期:stdio 子进程走 cli/cleanup.js 的 trackChild,不在 g ui 自己的
|
|
37
|
+
// runningProcesses 表里,shutdown 的 drainRunningProcesses 兜不到 —— 所以
|
|
38
|
+
// index.js 的优雅退出里显式调 closeWebExtensions(),否则 Ctrl+C 之后会留下
|
|
39
|
+
// 一堆 npx 孤儿子进程。
|
|
40
|
+
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import { AgentExtensions } from '../../../../cli/ai/extensions.js';
|
|
43
|
+
import { logger } from './shared.js';
|
|
44
|
+
|
|
45
|
+
/** cwd(绝对路径) → { extensions: AgentExtensions } */
|
|
46
|
+
const cache = new Map();
|
|
47
|
+
/** cwd → 进行中的加载。同 cwd 的并发首轮只允许一次真实加载(去重)。 */
|
|
48
|
+
const inflight = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* 加载期间被 invalidate 标记过的 cwd。任务收尾时看到自己在这张表里 →
|
|
51
|
+
* 不写回缓存、直接关掉刚建好的连接,只服务当前这一个调用方。
|
|
52
|
+
*
|
|
53
|
+
* 为什么需要它:不能改用"cache 里没有这个 key"来判断 —— 首次加载时缓存
|
|
54
|
+
* 本来就是空的,两种状态长得一模一样。2026-10-12 第一版就是这么写的,
|
|
55
|
+
* 后果是每次首轮加载完都把刚握好的 MCP 连接关掉(还不进缓存),
|
|
56
|
+
* 症状是"工具定义下发成功了、一调用就报连接已关闭"。
|
|
57
|
+
*/
|
|
58
|
+
const poisoned = new Set();
|
|
59
|
+
|
|
60
|
+
const normalizeCwd = (cwd) => {
|
|
61
|
+
try {
|
|
62
|
+
return path.resolve(cwd || process.cwd());
|
|
63
|
+
} catch {
|
|
64
|
+
return String(cwd || process.cwd());
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 取某个 cwd 的扩展层;没有则加载(同 cwd 并发只加载一次)。
|
|
70
|
+
*
|
|
71
|
+
* 失败不抛:AgentExtensions.load 内部已对 skill / MCP 各自降级,这里再包一层
|
|
72
|
+
* 是防读取路径之外的意外把整轮对话打挂 —— 扩展是增量能力,不该让用户消息发不出去。
|
|
73
|
+
*
|
|
74
|
+
* @param {{ cwd?: string, locale?: string }} [options]
|
|
75
|
+
* @returns {Promise<AgentExtensions>}
|
|
76
|
+
*/
|
|
77
|
+
export async function getWebExtensions({ cwd, locale } = {}) {
|
|
78
|
+
const key = normalizeCwd(cwd);
|
|
79
|
+
const hit = cache.get(key);
|
|
80
|
+
if (hit) return hit.extensions;
|
|
81
|
+
const pending = inflight.get(key);
|
|
82
|
+
if (pending) return pending;
|
|
83
|
+
|
|
84
|
+
const task = (async () => {
|
|
85
|
+
const extensions = await AgentExtensions.load({
|
|
86
|
+
cwd: key,
|
|
87
|
+
locale,
|
|
88
|
+
onWarn: (message) => logger.warn(`[agentExtensions] ${message}`),
|
|
89
|
+
}).catch((err) => {
|
|
90
|
+
logger.warn(`[agentExtensions] 加载失败(${key}),本轮按无扩展继续: ${err?.message || err}`);
|
|
91
|
+
return new AgentExtensions();
|
|
92
|
+
});
|
|
93
|
+
// 加载期间被 invalidate 过 → 不把这份刚建好的连接写回缓存(下一个调用方
|
|
94
|
+
// 会按新配置重新加载),直接关掉它,只服务当前这一个调用方。
|
|
95
|
+
// has → delete → set 之间没有 await,整段是原子的,不会被 invalidate 插进来。
|
|
96
|
+
if (poisoned.has(key)) {
|
|
97
|
+
poisoned.delete(key);
|
|
98
|
+
await extensions.mcp?.close().catch(() => {});
|
|
99
|
+
} else {
|
|
100
|
+
cache.set(key, { extensions });
|
|
101
|
+
}
|
|
102
|
+
return extensions;
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
inflight.set(key, task);
|
|
106
|
+
try {
|
|
107
|
+
return await task;
|
|
108
|
+
} finally {
|
|
109
|
+
inflight.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 让扩展缓存失效(广场安装 / 卸载后调)。
|
|
115
|
+
*
|
|
116
|
+
* 先摘缓存再关旧连接 —— 反过来会让下一轮拿到正在关闭的连接。
|
|
117
|
+
* 传 cwd 只失效那一个目录(项目级安装),不传失效全部(全局安装影响所有目录)。
|
|
118
|
+
*
|
|
119
|
+
* @param {string} [cwd]
|
|
120
|
+
*/
|
|
121
|
+
export async function invalidateWebExtensions(cwd) {
|
|
122
|
+
// 受影响对象 = 缓存键 + 在途加载键。只遍历缓存的话,"加载前刚被失效过
|
|
123
|
+
// (缓存已空)、又在加载中被失效"这一下会漏掉在途那个,让它把按旧配置
|
|
124
|
+
// 建好的连接写回缓存。
|
|
125
|
+
const keys = cwd ? [normalizeCwd(cwd)] : [...new Set([...cache.keys(), ...inflight.keys()])];
|
|
126
|
+
const victims = [];
|
|
127
|
+
for (const key of keys) {
|
|
128
|
+
const entry = cache.get(key);
|
|
129
|
+
if (entry) {
|
|
130
|
+
cache.delete(key);
|
|
131
|
+
victims.push(entry.extensions);
|
|
132
|
+
}
|
|
133
|
+
// 有加载在进行中 → 标记它收尾时别写回缓存(见 getWebExtensions 里的 poisonous 分支)
|
|
134
|
+
if (inflight.has(key)) poisoned.add(key);
|
|
135
|
+
}
|
|
136
|
+
await Promise.all(victims.map(async (extensions) => {
|
|
137
|
+
// 正在跑的某一轮可能还持有这份对象:它的 in-flight tools/call 会收到
|
|
138
|
+
// "连接已关闭"(以错误字符串回喂给模型,不会打挂对话)。装/卸配置与对话
|
|
139
|
+
// 并发是罕见边角,为它做代际管理不值这个复杂度。
|
|
140
|
+
try {
|
|
141
|
+
await extensions.mcp?.close();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logger.warn(`[agentExtensions] 关闭旧连接失败: ${err?.message || err}`);
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 关掉全部缓存的 MCP 连接。g gui 优雅退出时调(见 index.js 的 shutdown)。
|
|
150
|
+
*/
|
|
151
|
+
export async function closeWebExtensions() {
|
|
152
|
+
const all = [...cache.values()];
|
|
153
|
+
cache.clear();
|
|
154
|
+
await Promise.all(all.map(async ({ extensions }) => {
|
|
155
|
+
try { await extensions.mcp?.close(); } catch (_) {}
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
@@ -44,6 +44,10 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
44
44
|
import { promisify } from 'node:util';
|
|
45
45
|
import open from 'open';
|
|
46
46
|
import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
|
|
47
|
+
import { logger } from './shared.js';
|
|
48
|
+
// 装/卸之后让 Web 端扩展缓存失效:否则刚装的 MCP/Skill 要等 g gui 重启
|
|
49
|
+
// 才在智能体对话里生效("广场显示已安装、对话框里没有工具"就是这么来的)。
|
|
50
|
+
import { invalidateWebExtensions } from './agentExtensions.js';
|
|
47
51
|
import { AI_SKILLS_DIR, AI_MCP_FILE, projectSkillsDir, projectMcpFile } from '../../../../paths.js';
|
|
48
52
|
|
|
49
53
|
const execFileAsync = promisify(execFile);
|
|
@@ -1097,6 +1101,11 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
|
|
|
1097
1101
|
|
|
1098
1102
|
// 装完立刻让缓存失效,避免刚装的条目在列表里还是"未安装"
|
|
1099
1103
|
cache.clear();
|
|
1104
|
+
// 同一用意再往下一层:Web 智能体对话的扩展缓存也要失效,否则新装的
|
|
1105
|
+
// MCP/Skill 要等 g gui 重启才在对话里出现。全局安装影响所有目录 → 全量失效;
|
|
1106
|
+
// 项目安装只影响该目录。关旧连接是异步的,不拖慢这个响应,失败只记账。
|
|
1107
|
+
invalidateWebExtensions(target === 'project' ? cwd : undefined)
|
|
1108
|
+
.catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
|
|
1100
1109
|
res.json({ success: true, target, cwd, item: installed });
|
|
1101
1110
|
}));
|
|
1102
1111
|
|
|
@@ -1135,6 +1144,9 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
|
|
|
1135
1144
|
const id = decodeURIComponent(String(req.params.id || ''));
|
|
1136
1145
|
const result = await uninstall({ type, id, target, cwd });
|
|
1137
1146
|
cache.clear();
|
|
1147
|
+
// 与安装同一用意:卸完也让对话侧的扩展缓存失效(口径见 install 路由里的注释)
|
|
1148
|
+
invalidateWebExtensions(target === 'project' ? cwd : undefined)
|
|
1149
|
+
.catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
|
|
1138
1150
|
res.json({ success: true, ...result });
|
|
1139
1151
|
}));
|
|
1140
1152
|
}
|
|
@@ -181,3 +181,19 @@ test('两个入口都把推理内容写进 assistant 历史消息', () => {
|
|
|
181
181
|
assert.match(code, /reasoning_content/, `${label} 没有把推理内容写进 assistant 历史消息`);
|
|
182
182
|
}
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
test('两个入口都把扩展工具叠加进请求,并按 owns() 分流', () => {
|
|
186
|
+
// 2026-10-12:Web 侧曾经**完全不加载**扩展层 —— MCP 广场显示"已安装",
|
|
187
|
+
// 对话框里却没有那些工具(agent 只能自己读 mcp.json 按 HTTP 直调)。
|
|
188
|
+
// 失效方式和这个文件的其它分叉一样:不报错,只是两个入口表现不一样。
|
|
189
|
+
// 所以钉住两个入口都要有:extraTools(叠加进请求)+ owns(按名字分流)。
|
|
190
|
+
// 只认代码行,注释里的提及不算。
|
|
191
|
+
for (const { label, file } of ENTRY_POINTS) {
|
|
192
|
+
const code = readFileSync(file, 'utf8')
|
|
193
|
+
.split(/\r?\n/)
|
|
194
|
+
.filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
|
195
|
+
.join('\n');
|
|
196
|
+
assert.match(code, /extraTools/, `${label} 没有把扩展工具叠加进请求(extraTools)`);
|
|
197
|
+
assert.match(code, /owns/, `${label} 没有按 owns() 分流扩展工具`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// 路由清单:
|
|
18
18
|
// GET /api/agent/sessions — 列出会话(仅 metadata;?cwd= 按项目过滤,空 cwd 视为当前项目)
|
|
19
19
|
// GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
|
|
20
|
+
// GET /api/agent/sessions/:id/image — 正文内嵌图片(按会话 cwd 解析,判定见 jobImage.js)
|
|
20
21
|
// DELETE /api/agent/sessions/:id — 删除会话
|
|
21
22
|
// PUT /api/agent/sessions/:id — 重命名会话
|
|
22
23
|
// POST /api/agent/chat — SSE 流式聊天(含工具调用;body.cwd 必须与服务端当前项目一致)
|
|
@@ -32,13 +33,19 @@ import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
|
|
|
32
33
|
import { createProjectListProvider } from './projectTool.js';
|
|
33
34
|
import { createDispatcher } from './dispatchInstruction.js';
|
|
34
35
|
import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
|
|
36
|
+
// 用户固定提示词(g ai 默认提示词 + 派发提示词)的解析 —— 两条引擎链路共用同一份
|
|
37
|
+
import { resolveChatPromptSection } from './chatPrompts.js';
|
|
35
38
|
// 「同一会话同一时刻只允许一个生成中的轮次」的闸,与定时任务调度器共享
|
|
36
39
|
// (见 sessionGate.js —— 拆出去是为了让调度器复用同一份判定口径)。
|
|
37
40
|
import { isSessionBusy, acquireSession, releaseSession } from './sessionGate.js';
|
|
38
41
|
import { createScheduler } from './scheduler.js';
|
|
39
42
|
import { registerScheduleRoutes } from './scheduleRoutes.js';
|
|
40
43
|
import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
|
|
41
|
-
import { nowIso, logger } from './shared.js';
|
|
44
|
+
import { nowIso, logger, fs, fsp, MAX_IMAGE_BYTES } from './shared.js';
|
|
45
|
+
// 正文内嵌图片的路径判定纯函数 —— 与任务对话的图片端点(workbench/index.js 的
|
|
46
|
+
// /api/workbench/jobs/:id/image)同一份:后缀白名单、root 内、防穿越。两份白名单
|
|
47
|
+
// 分叉时不会报错,只会让某一类图在任务对话里能看、在 Agent 对话里裂。
|
|
48
|
+
import { resolveJobImagePath, IMAGE_PATH_STATUS, IMAGE_PATH_ERRORS, isInsideRoot } from './jobImage.js';
|
|
42
49
|
// 请求预算的解析公式与 CLI `g ai` 共用同一份(cli/ai/context.js),Web 侧只管把
|
|
43
50
|
// 配置值喂进去、把结果传给 agentChat —— 2026-10-07 起每轮请求的字符预算可配。
|
|
44
51
|
import { resolveRequestBudget } from '../../../../cli/ai/context.js';
|
|
@@ -292,6 +299,75 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
292
299
|
res.json({ success: true, session });
|
|
293
300
|
}));
|
|
294
301
|
|
|
302
|
+
// GET /api/agent/sessions/:id/image?path=… —— 正文内嵌图片(模型写 )
|
|
303
|
+
//
|
|
304
|
+
// 与任务对话那一个端点(workbench/index.js 的 /api/workbench/jobs/:id/image)同一目的:
|
|
305
|
+
// 智能体在本机干活,"展示一张图"最自然的写法是贴一条本机路径,而对话流是 markdown
|
|
306
|
+
// 渲染 —— `<img src="c:\ws\repo\docs\a.png">` 在浏览器里永远是裂图。前端把这类路径
|
|
307
|
+
// 重写成这个 URL(utils/localImageSrc.ts 的 resolveAgentImages,四个 g ai 对话入口共用)。
|
|
308
|
+
//
|
|
309
|
+
// 权限边界 = 「这个会话的 cwd 内的图片文件」:
|
|
310
|
+
// · 判定复用 jobImage.js 的纯函数(后缀白名单 + root 内 + 防穿越);
|
|
311
|
+
// · 这里**再按 realpath 校验一次** —— lexical 判定挡不住会话目录里一个指向外部的
|
|
312
|
+
// 符号链接,而会话内容很可能是从网上 clone 来的;
|
|
313
|
+
// · 响应带 nosniff + CSP(sandbox):一份恶意 SVG 被直接在浏览器里打开时,它的脚本
|
|
314
|
+
// 不能跑在本应用的源上(<img> 里本来就不会执行,这里防的是直接访问)。
|
|
315
|
+
app.get('/api/agent/sessions/:sessionId/image', asyncRoute(async (req, res) => {
|
|
316
|
+
const session = await readSession(req.params.sessionId);
|
|
317
|
+
// 根 = 会话自己的 cwd(模型眼里"相对路径"的基准,就是它启动 g ai 的那个目录)。
|
|
318
|
+
// 历史存量会话没有 cwd 时退回当前项目路径 —— 与列表可见性的老会话包容口径一致
|
|
319
|
+
// (上面 sessionBelongsTo:没有 cwd 的老会话暂按当前项目可见)。
|
|
320
|
+
const root = String(session.cwd || '').trim()
|
|
321
|
+
|| (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
|
|
322
|
+
|
|
323
|
+
const resolved = resolveJobImagePath({ root, requested: req.query.path });
|
|
324
|
+
if (!resolved.ok) {
|
|
325
|
+
throw new HttpError(
|
|
326
|
+
IMAGE_PATH_STATUS[resolved.reason] || 400,
|
|
327
|
+
IMAGE_PATH_ERRORS[resolved.reason] || '图片路径不合法',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let realFile;
|
|
332
|
+
try {
|
|
333
|
+
realFile = await fsp.realpath(resolved.absPath);
|
|
334
|
+
} catch {
|
|
335
|
+
throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
|
|
336
|
+
}
|
|
337
|
+
let realRoot;
|
|
338
|
+
try {
|
|
339
|
+
realRoot = await fsp.realpath(root);
|
|
340
|
+
} catch {
|
|
341
|
+
realRoot = root; // 根目录都没了的话,下面那次 stat 也会失败,不必在这里另报一种错
|
|
342
|
+
}
|
|
343
|
+
if (!isInsideRoot(realRoot, realFile)) {
|
|
344
|
+
throw new HttpError(IMAGE_PATH_STATUS['outside-root'], IMAGE_PATH_ERRORS['outside-root']);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let stat;
|
|
348
|
+
try {
|
|
349
|
+
stat = await fsp.stat(realFile);
|
|
350
|
+
} catch {
|
|
351
|
+
throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
|
|
352
|
+
}
|
|
353
|
+
if (!stat.isFile()) throw new HttpError(404, '路径不是一个文件');
|
|
354
|
+
// 与附件同一个上限:正文里嵌一张 20MB 以上的图只会把页面拖死
|
|
355
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
356
|
+
throw new HttpError(413, `图片超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB,未加载`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// mime 按**后缀**给(不读文件头):后缀已过白名单,且这条内容只喂给 <img>
|
|
360
|
+
res.set('Content-Type', resolved.mime);
|
|
361
|
+
res.set('Content-Length', String(stat.size));
|
|
362
|
+
res.set('Content-Disposition', 'inline');
|
|
363
|
+
res.set('Cache-Control', 'private, max-age=300');
|
|
364
|
+
res.set('X-Content-Type-Options', 'nosniff');
|
|
365
|
+
res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
366
|
+
const stream = fs.createReadStream(realFile);
|
|
367
|
+
stream.on('error', () => res.end());
|
|
368
|
+
stream.pipe(res);
|
|
369
|
+
}));
|
|
370
|
+
|
|
295
371
|
// ════════════════════════════════════════════════════════════════════════
|
|
296
372
|
// §3. 删除会话
|
|
297
373
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -439,10 +515,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
439
515
|
return res.end();
|
|
440
516
|
}
|
|
441
517
|
// 引擎在会话**建立时就锁死**,中途不许换。
|
|
442
|
-
//
|
|
443
|
-
// codex exec resume
|
|
444
|
-
// 形状,喂给 claude
|
|
445
|
-
// 与工作台 taskExecutor.ts 注释里那条
|
|
518
|
+
// 为什么:各家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
|
|
519
|
+
// codex exec resume / codebuddy --resume 的 id 也互不认),而且历史消息格式不同
|
|
520
|
+
// (我们存的是 OpenAI chat completions 形状,喂给 claude 没有意义)。允许中途
|
|
521
|
+
// 切换的表现是"看起来切了,上下文却串了"—— 与工作台 taskExecutor.ts 注释里那条
|
|
522
|
+
// "续哪个执行器由上一轮 job.agent 决定"同源。
|
|
446
523
|
const requestedEngine = req.body?.engine === undefined
|
|
447
524
|
? null
|
|
448
525
|
: normalizeAgentEngine(req.body?.engine);
|
|
@@ -465,10 +542,10 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
465
542
|
source: 'web',
|
|
466
543
|
cwd: currentProjectCwd,
|
|
467
544
|
model: '',
|
|
468
|
-
// 引擎:gai(内置,默认)| claude | opencode | codex
|
|
545
|
+
// 引擎:gai(内置,默认)| claude | opencode | codex | codebuddy
|
|
469
546
|
engine: normalizeAgentEngine(req.body?.engine),
|
|
470
547
|
// 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
|
|
471
|
-
// codex thread_id)。g ai 用不到,留着空串。
|
|
548
|
+
// codex thread_id / codebuddy session_id)。g ai 用不到,留着空串。
|
|
472
549
|
engineSessionId: '',
|
|
473
550
|
createdAt: nowIso(),
|
|
474
551
|
updatedAt: nowIso(),
|
|
@@ -548,6 +625,18 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
548
625
|
}
|
|
549
626
|
}
|
|
550
627
|
|
|
628
|
+
// 用户固定提示词(g ai 默认提示词 + 派发提示词,见 chatPrompts.js)。
|
|
629
|
+
// 在这里解析**一次**、两条引擎链路共用:内置引擎走请求副本(runAgentTurn 的
|
|
630
|
+
// promptSection),外部 CLI 引擎没有副本可注入,只能拼进 prompt 前缀。
|
|
631
|
+
// 两处各解析一次的话,改设置后两条链路生效时机就会分叉。
|
|
632
|
+
// 取不到就当没配 —— 它是锦上添花,不该让用户这条消息发不出去。
|
|
633
|
+
let promptSection = '';
|
|
634
|
+
try {
|
|
635
|
+
promptSection = await resolveChatPromptSection({ cwd, locale });
|
|
636
|
+
} catch (err) {
|
|
637
|
+
logger.warn(`[agent/chat] 取用户固定提示词失败,本轮跳过注入: ${err?.message || err}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
551
640
|
// 推 meta
|
|
552
641
|
send({
|
|
553
642
|
type: 'meta',
|
|
@@ -599,6 +688,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
599
688
|
if (dirStatusBlock) {
|
|
600
689
|
promptPrefix = promptPrefix ? `${dirStatusBlock}\n\n${promptPrefix}` : dirStatusBlock;
|
|
601
690
|
}
|
|
691
|
+
// 用户固定提示词排在最前:它是每轮都成立的规则,工作区快照是这一刻的现场。
|
|
692
|
+
// 外部引擎那条链路的 system 提示词是 CLI 自己的,进不去 —— 只能靠这个前缀。
|
|
693
|
+
if (promptSection) {
|
|
694
|
+
promptPrefix = promptPrefix ? `${promptSection}\n\n${promptPrefix}` : promptSection;
|
|
695
|
+
}
|
|
602
696
|
// 图片附件转成文件路径交给 CLI(它们收路径不收 base64)。走同一套落盘校验。
|
|
603
697
|
const imageAttachments = images.map((u, i) => ({ name: `pasted-${i + 1}.png`, dataUrl: u }));
|
|
604
698
|
const imageFiles = imageAttachments.length > 0
|
|
@@ -654,7 +748,10 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
|
|
|
654
748
|
// 与 CLI turn.js 同一条口径。
|
|
655
749
|
requestBudget,
|
|
656
750
|
// 重试上一轮:不再压 user 消息,接着会话现有的尾巴跑
|
|
657
|
-
resume
|
|
751
|
+
resume,
|
|
752
|
+
// 用户固定提示词(上面已解析)。显式传 —— 不传的话 runAgentTurn 会自己再读一遍
|
|
753
|
+
// 配置文件,同一轮里读两次同一份配置纯属浪费
|
|
754
|
+
promptSection
|
|
658
755
|
});
|
|
659
756
|
}
|
|
660
757
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { PassThrough } from 'node:stream';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
|
|
@@ -476,3 +477,142 @@ test('dispatch_task:开了 allowDispatch 才注入,且工具结果说清落
|
|
|
476
477
|
globalThis.fetch = originalFetch;
|
|
477
478
|
}
|
|
478
479
|
});
|
|
480
|
+
|
|
481
|
+
// ── 正文内嵌图片端点(2026-10-12) ────────────────────────────────────────
|
|
482
|
+
//
|
|
483
|
+
// 模型"展示一张图"的写法是在正文里贴一条本机路径,而对话流是 markdown 渲染 ——
|
|
484
|
+
// 浏览器里那条地址永远是裂图。前端把路径重写成
|
|
485
|
+
// GET /api/agent/sessions/:id/image?path=...(utils/localImageSrc.ts 的
|
|
486
|
+
// resolveAgentImages),这里守服务端那一半:
|
|
487
|
+
// · 根 = 会话自己的 cwd;没有 cwd 的老会话退回当前项目路径
|
|
488
|
+
// · 后缀白名单 / root 内 / 防穿越(jobImage.js 的纯函数,与任务对话端点同一份)
|
|
489
|
+
// · realpath 双校验、单文件上限、nosniff + CSP 响应头
|
|
490
|
+
const { agentSessionStore } = await import('./agentSessionStore.js');
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 最小 res:PassThrough 是真流(stream.pipe 直接可用),外加路由用到的
|
|
494
|
+
* set / status / json。字节经 'data' 收齐,收尾等 'finish'。
|
|
495
|
+
*/
|
|
496
|
+
function makeImageRes() {
|
|
497
|
+
const chunks = [];
|
|
498
|
+
const headers = {};
|
|
499
|
+
const res = new PassThrough();
|
|
500
|
+
res.headers = headers;
|
|
501
|
+
res.statusCode = 200;
|
|
502
|
+
res.jsonBody = undefined;
|
|
503
|
+
res.set = (k, v) => { headers[String(k).toLowerCase()] = v; };
|
|
504
|
+
res.status = (code) => { res.statusCode = code; return res; };
|
|
505
|
+
res.json = (v) => { res.jsonBody = v; res.end(); };
|
|
506
|
+
res.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
507
|
+
res.bodyText = () => Buffer.concat(chunks).toString('utf-8');
|
|
508
|
+
return res;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const IMAGE_FINISH = (res) => new Promise((resolve) => res.on('finish', resolve));
|
|
512
|
+
|
|
513
|
+
/** 注册路由并取出图片端点的 handler(fake app 只认这一条) */
|
|
514
|
+
function captureImageHandler(projectPath) {
|
|
515
|
+
let handler;
|
|
516
|
+
const app = {
|
|
517
|
+
get(route, h) { if (route === '/api/agent/sessions/:sessionId/image') handler = h; },
|
|
518
|
+
post() {}, put() {}, delete() {}
|
|
519
|
+
};
|
|
520
|
+
registerAgentRoutes({
|
|
521
|
+
app,
|
|
522
|
+
getCurrentProjectPath: () => projectPath,
|
|
523
|
+
configManager: null,
|
|
524
|
+
snapshotter: STUB_SNAPSHOTTER
|
|
525
|
+
});
|
|
526
|
+
return handler;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const PNG_BYTES = Buffer.from('not-a-real-png-but-ext-is-whitelisted');
|
|
530
|
+
|
|
531
|
+
test('会话图片端点:按会话 cwd 读盘,响应带 nosniff + CSP', async () => {
|
|
532
|
+
const repo = mkdtempSync(path.join(SANDBOX, 'img-repo-'));
|
|
533
|
+
mkdirSync(path.join(repo, 'docs'), { recursive: true });
|
|
534
|
+
writeFileSync(path.join(repo, 'docs', 'a.png'), PNG_BYTES);
|
|
535
|
+
|
|
536
|
+
const sessionId = 'ag-imgtest-0001';
|
|
537
|
+
await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
|
|
538
|
+
|
|
539
|
+
const handler = captureImageHandler(path.resolve('active-project'));
|
|
540
|
+
const res = makeImageRes();
|
|
541
|
+
const finished = IMAGE_FINISH(res);
|
|
542
|
+
await handler({ params: { sessionId }, query: { path: 'docs/a.png' } }, res);
|
|
543
|
+
await finished;
|
|
544
|
+
|
|
545
|
+
assert.equal(res.statusCode, 200);
|
|
546
|
+
assert.equal(res.headers['content-type'], 'image/png');
|
|
547
|
+
assert.equal(res.headers['x-content-type-options'], 'nosniff');
|
|
548
|
+
assert.match(res.headers['content-security-policy'], /sandbox/);
|
|
549
|
+
assert.equal(res.bodyText(), PNG_BYTES.toString('utf-8'));
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('会话图片端点:绝对路径也认,但必须落在会话 cwd 内', async () => {
|
|
553
|
+
const repo = mkdtempSync(path.join(SANDBOX, 'img-repo2-'));
|
|
554
|
+
writeFileSync(path.join(repo, 'a.png'), PNG_BYTES);
|
|
555
|
+
const outside = path.join(SANDBOX, 'outside-a.png');
|
|
556
|
+
writeFileSync(outside, PNG_BYTES);
|
|
557
|
+
|
|
558
|
+
const sessionId = 'ag-imgtest-0002';
|
|
559
|
+
await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
|
|
560
|
+
const handler = captureImageHandler(path.resolve('active-project'));
|
|
561
|
+
|
|
562
|
+
// cwd 内的绝对路径 → 200
|
|
563
|
+
const ok = makeImageRes();
|
|
564
|
+
const okFinished = IMAGE_FINISH(ok);
|
|
565
|
+
await handler({ params: { sessionId }, query: { path: path.join(repo, 'a.png') } }, ok);
|
|
566
|
+
await okFinished;
|
|
567
|
+
assert.equal(ok.statusCode, 200);
|
|
568
|
+
assert.equal(ok.bodyText(), PNG_BYTES.toString('utf-8'));
|
|
569
|
+
|
|
570
|
+
// cwd 外的绝对路径 → 403(`..` 穿越与盘符外路径都走这条)
|
|
571
|
+
const bad = makeImageRes();
|
|
572
|
+
const badFinished = IMAGE_FINISH(bad);
|
|
573
|
+
await handler({ params: { sessionId }, query: { path: outside } }, bad);
|
|
574
|
+
await badFinished;
|
|
575
|
+
assert.equal(bad.statusCode, 403);
|
|
576
|
+
assert.match(bad.jsonBody.error, /仓库内/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('会话图片端点:坏后缀 415、文件不存在 404、会话不存在 404', async () => {
|
|
580
|
+
const repo = mkdtempSync(path.join(SANDBOX, 'img-repo3-'));
|
|
581
|
+
writeFileSync(path.join(repo, 'notes.txt'), 'hello');
|
|
582
|
+
const sessionId = 'ag-imgtest-0003';
|
|
583
|
+
await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
|
|
584
|
+
const handler = captureImageHandler(path.resolve('active-project'));
|
|
585
|
+
|
|
586
|
+
const call = async (params, query) => {
|
|
587
|
+
const res = makeImageRes();
|
|
588
|
+
const finished = IMAGE_FINISH(res);
|
|
589
|
+
await handler({ params, query }, res);
|
|
590
|
+
await finished;
|
|
591
|
+
return res;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const badExt = await call({ sessionId }, { path: 'notes.txt' });
|
|
595
|
+
assert.equal(badExt.statusCode, 415);
|
|
596
|
+
|
|
597
|
+
const missing = await call({ sessionId }, { path: 'docs/a.png' });
|
|
598
|
+
assert.equal(missing.statusCode, 404);
|
|
599
|
+
|
|
600
|
+
const noSession = await call({ sessionId: 'ag-imgtest-9999' }, { path: 'a.png' });
|
|
601
|
+
assert.equal(noSession.statusCode, 404);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('没有 cwd 的老会话:根退回当前项目路径(与列表可见性的包容口径一致)', async () => {
|
|
605
|
+
const proj = mkdtempSync(path.join(SANDBOX, 'img-proj-'));
|
|
606
|
+
writeFileSync(path.join(proj, 'b.png'), PNG_BYTES);
|
|
607
|
+
const sessionId = 'ag-imgtest-0004';
|
|
608
|
+
await agentSessionStore.write(sessionId, { sessionId, cwd: '', messages: [] });
|
|
609
|
+
|
|
610
|
+
const handler = captureImageHandler(proj);
|
|
611
|
+
const res = makeImageRes();
|
|
612
|
+
const finished = IMAGE_FINISH(res);
|
|
613
|
+
await handler({ params: { sessionId }, query: { path: 'b.png' } }, res);
|
|
614
|
+
await finished;
|
|
615
|
+
|
|
616
|
+
assert.equal(res.statusCode, 200);
|
|
617
|
+
assert.equal(res.headers['content-type'], 'image/png');
|
|
618
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 「和 g ai 对话时固定注入的两块提示词」—— 拼装规则(纯函数)+ 取数(读两处配置)。
|
|
16
|
+
//
|
|
17
|
+
// 两块分别来自两处,语义也不同,别混:
|
|
18
|
+
// ① g ai 智能体的默认提示词 —— config.json 的 agentDefaultPrompt,
|
|
19
|
+
// 在「智能体视图 → 设置」里编辑。只喂给 g ai 自己。
|
|
20
|
+
// ② 派发任务的提示词 —— orchestrator.json 的 defaultPrompt(+ 落点项目的 projectPrompts),
|
|
21
|
+
// 和工作台「预设提示词」是**同一份**(同一套读写接口,同一处解析)。
|
|
22
|
+
// 它的原生用途是"派发任务时附加在指令之前"(见 orchestratorStore.resolveDispatchPrompt);
|
|
23
|
+
// 这里把同一份也喂给对话中的 g ai —— 用户在 g ai 里说的"帮我改完就提交"
|
|
24
|
+
// 本来就依赖这些约束,让它在对话里也看得见。
|
|
25
|
+
//
|
|
26
|
+
// ⚠️ 为什么注入的是**每轮请求副本**而不是会话里的那条 system 消息:
|
|
27
|
+
// 会话的 system 消息只在首轮写入并落盘,之后改设置要新开会话才生效,而且会把
|
|
28
|
+
// 当时的配置永久腌进会话文件(回看历史会话时看到的是过期规则,还占上下文)。
|
|
29
|
+
// 注入请求副本则每轮现读、立即生效、不落盘 —— 与工作区快照/附件路径同一套口径
|
|
30
|
+
// (见 agentChat.js 的 injectRequestContext)。
|
|
31
|
+
//
|
|
32
|
+
// ⚠️ 解析失败一律当作"没有配置"(返回空串),绝不抛:配置坏了不该让用户发不出消息。
|
|
33
|
+
// 与 workspaceBlock 的兜底口径一致。
|
|
34
|
+
|
|
35
|
+
import configManager from '../../../../config.js';
|
|
36
|
+
import { logger } from './shared.js';
|
|
37
|
+
import { readOrchestrator, resolveDispatchPrompt } from './orchestratorStore.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 拼「用户固定提示词」这一节。
|
|
41
|
+
*
|
|
42
|
+
* 纯函数:只吃入参、不做 IO,单测不碰真实用户数据(与 promptParts.js / memoryContext.js
|
|
43
|
+
* 同一个理由)。取数在 resolveChatPromptSection。
|
|
44
|
+
*
|
|
45
|
+
* @param {object} input
|
|
46
|
+
* @param {string} [input.gaiPrompt] g ai 默认提示词('' = 没设)
|
|
47
|
+
* @param {string} [input.dispatchPrompt] 派发任务提示词(全局 + 落点项目,'' = 没设)
|
|
48
|
+
* @param {string} [input.locale] 'zh' | 'en' —— 与系统提示词同一套判定
|
|
49
|
+
* @returns {string} 两块都空时返回 ''(调用方据此完全跳过注入)
|
|
50
|
+
*/
|
|
51
|
+
export function buildChatPromptSection({ gaiPrompt = '', dispatchPrompt = '', locale = 'zh-CN' } = {}) {
|
|
52
|
+
const gai = typeof gaiPrompt === 'string' ? gaiPrompt.trim() : '';
|
|
53
|
+
const dispatch = typeof dispatchPrompt === 'string' ? dispatchPrompt.trim() : '';
|
|
54
|
+
if (!gai && !dispatch) return '';
|
|
55
|
+
|
|
56
|
+
const en = String(locale || '').startsWith('en');
|
|
57
|
+
const parts = [];
|
|
58
|
+
if (gai) parts.push(`${en ? '### g ai default prompt' : '### g ai 默认提示词'}\n${gai}`);
|
|
59
|
+
if (dispatch) {
|
|
60
|
+
// 括号里那句是给模型看的(也是给排查的人看的):这一块的来源与工作台是同一份,
|
|
61
|
+
// 不是对话专属的又一份配置 —— 模型据此知道"这些约束在派发出去的任务里同样成立"。
|
|
62
|
+
parts.push(en
|
|
63
|
+
? `### Dispatch prompt (same text attached to tasks dispatched from the workbench)\n${dispatch}`
|
|
64
|
+
: `### 派发任务的提示词(与工作台派发任务时附加的是同一份)\n${dispatch}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const head = en
|
|
68
|
+
? 'The user configured the following standing instructions in Settings. They apply to every turn of this conversation — follow them alongside your built-in rules.'
|
|
69
|
+
: '以下是用户在「设置」里配置的固定提示词,对本轮对话生效 —— 请与你的内置规则一并遵守。';
|
|
70
|
+
|
|
71
|
+
return `${head}\n\n${parts.join('\n\n')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 默认取数:config.json 的 agentDefaultPrompt(读不到 = 空) */
|
|
75
|
+
async function defaultLoadAgentPrompt() {
|
|
76
|
+
const cfg = await configManager.loadConfig();
|
|
77
|
+
return typeof cfg?.agentDefaultPrompt === 'string' ? cfg.agentDefaultPrompt : '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 默认取数:orchestrator.json 里按**落点项目**解析出的派发提示词(读不到 = 空) */
|
|
81
|
+
async function defaultLoadDispatchPrompt(cwd) {
|
|
82
|
+
const state = await readOrchestrator();
|
|
83
|
+
return resolveDispatchPrompt(state, cwd).text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析一次对话实际要注入的两块提示词。
|
|
88
|
+
*
|
|
89
|
+
* @param {object} input
|
|
90
|
+
* @param {string} [input.cwd] 本轮对话的工作目录(决定项目级那条派发提示词生不生效)
|
|
91
|
+
* @param {string} [input.locale]
|
|
92
|
+
* @param {Function} [input.loadAgentPrompt] 注入口(单测用;生产走 config.json)
|
|
93
|
+
* @param {Function} [input.loadDispatchPrompt] 注入口(同上;生产走 orchestrator.json)
|
|
94
|
+
* @returns {Promise<string>} 拼好的正文,'' = 不注入
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveChatPromptSection({
|
|
97
|
+
cwd = '',
|
|
98
|
+
locale = 'zh-CN',
|
|
99
|
+
loadAgentPrompt = defaultLoadAgentPrompt,
|
|
100
|
+
loadDispatchPrompt = defaultLoadDispatchPrompt,
|
|
101
|
+
} = {}) {
|
|
102
|
+
let gaiPrompt = '';
|
|
103
|
+
let dispatchPrompt = '';
|
|
104
|
+
try {
|
|
105
|
+
gaiPrompt = await loadAgentPrompt();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.warn(`[chatPrompts] 读 g ai 默认提示词失败,本轮跳过: ${err?.message || err}`);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
dispatchPrompt = await loadDispatchPrompt(cwd);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.warn(`[chatPrompts] 读派发提示词失败,本轮跳过: ${err?.message || err}`);
|
|
113
|
+
}
|
|
114
|
+
return buildChatPromptSection({ gaiPrompt, dispatchPrompt, locale });
|
|
115
|
+
}
|