@youweichen/pi-web-ui 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-service — 目标 / 审查循环 / 调研向导,从 agent-service.ts 抽出。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - setGoal/clearGoal/setGoalPrefs:目标状态机 + 偏好「全局记忆」(client-state.json)
|
|
6
|
+
* - runGoalReview:agent_end 后用 ISOLATED 审查会话(独立 ModelRuntime)判定
|
|
7
|
+
* pass/fail,fail 时把意见作为普通 user 消息注入主会话重改
|
|
8
|
+
* - startGoalWizard:AI 提炼——独立调研会话经 goal_ask 工具逐题提问(对话框桥接浏览器),
|
|
9
|
+
* 收敛出 GOAL: 后自动设为目标并触发生成
|
|
10
|
+
*
|
|
11
|
+
* 经 GoalHost 窄接口与 ClientSession 解耦(同 settings-service 模式):对话记录按
|
|
12
|
+
* 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
|
|
13
|
+
* 取消/git diff 等宿主能力走回调,便于独立测试。UI 文案直接中文(服务端 notice 约定)。
|
|
14
|
+
*/
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { parseModelSpec } from "./attachments.js";
|
|
19
|
+
/** System prompt for the goal-wizard session. The wizard asks the user a few
|
|
20
|
+
* questions (via its goal_ask tool) to scope a raw requirement into a precise,
|
|
21
|
+
* reviewable goal, then emits ONLY the final goal text as its last message. */
|
|
22
|
+
function wizardPrompt(draft) {
|
|
23
|
+
return [
|
|
24
|
+
`You are a goal-clarification wizard. The user has stated a raw requirement. Your job is to turn it into ONE precise, actionable goal that a coding agent can fully satisfy and that can be strictly reviewed.`, // eslint-disable-line max-len
|
|
25
|
+
``,
|
|
26
|
+
`# User's raw requirement`, // eslint-disable-line no-regex-spaces
|
|
27
|
+
draft,
|
|
28
|
+
``,
|
|
29
|
+
`Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details. Keep it concise — usually 2 to 4 questions: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
|
|
30
|
+
`Prefer multiple-choice (goal_ask with options) when you can offer clear choices; use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
|
|
31
|
+
`Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`, // eslint-disable-line max-len
|
|
32
|
+
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
|
|
33
|
+
`If the user cancels or stops answering (the tool reports a cancellation), still produce a sensible best-effort goal from what you already know.`, // eslint-disable-line max-len
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
export class GoalService {
|
|
37
|
+
host;
|
|
38
|
+
/** Defaults remembered for newly-created conversations. Each conversation
|
|
39
|
+
* receives its own GoalStatus, so reviews can run concurrently. */
|
|
40
|
+
prefs = {
|
|
41
|
+
reviewModel: null,
|
|
42
|
+
maxRounds: 0,
|
|
43
|
+
locked: true,
|
|
44
|
+
};
|
|
45
|
+
/** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
|
|
46
|
+
* the in-flight goal_ask dialog to resolve as cancelled and (via the run
|
|
47
|
+
* signal) stops the wizard session's agent run. Recreated per wizard. */
|
|
48
|
+
wizardAbort = null;
|
|
49
|
+
/** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
|
|
50
|
+
* (abort the run), not just flip a flag. */
|
|
51
|
+
wizardSession = null;
|
|
52
|
+
/** Conversation that owns the one browser wizard currently in flight. */
|
|
53
|
+
wizardOwnerId = null;
|
|
54
|
+
/** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
|
|
55
|
+
* startGoalWizard reads this after the run to avoid setting a goal. */
|
|
56
|
+
wizardCancelled = false;
|
|
57
|
+
/** Idle-timeout for the wizard: if no answer arrives within this window (a
|
|
58
|
+
* dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
|
|
59
|
+
static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
60
|
+
/** Absolute deadline for the whole wizard session (model latency guard). */
|
|
61
|
+
static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
|
|
62
|
+
constructor(host) {
|
|
63
|
+
this.host = host;
|
|
64
|
+
// Restore last-used goal/review preferences so model & rounds survive reload.
|
|
65
|
+
const gPrefs = host.stateStore.getGoalPrefs(host.clientId);
|
|
66
|
+
if (gPrefs) {
|
|
67
|
+
this.prefs = {
|
|
68
|
+
reviewModel: gPrefs.reviewModel,
|
|
69
|
+
maxRounds: gPrefs.maxRounds,
|
|
70
|
+
locked: gPrefs.locked,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Remembered defaults (model choice / rounds cap / lock). */
|
|
75
|
+
get reviewPrefs() {
|
|
76
|
+
return this.prefs;
|
|
77
|
+
}
|
|
78
|
+
/** Create independent goal state for one conversation. Preferences are
|
|
79
|
+
* client-wide defaults, while goal text/review progress is not shared. */
|
|
80
|
+
makeGoalStatus() {
|
|
81
|
+
return {
|
|
82
|
+
conversationId: null,
|
|
83
|
+
goal: null,
|
|
84
|
+
reviewModel: this.prefs.reviewModel,
|
|
85
|
+
maxRounds: this.prefs.maxRounds,
|
|
86
|
+
locked: this.prefs.locked,
|
|
87
|
+
reviewing: false,
|
|
88
|
+
round: 0,
|
|
89
|
+
status: "",
|
|
90
|
+
verdict: "pending",
|
|
91
|
+
wizard: {
|
|
92
|
+
active: false,
|
|
93
|
+
draft: "",
|
|
94
|
+
model: null,
|
|
95
|
+
step: 0,
|
|
96
|
+
maxSteps: 6,
|
|
97
|
+
status: "",
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Push the active conversation's goal status to the client (the goal bar
|
|
102
|
+
* restores remembered prefs when nothing is active). */
|
|
103
|
+
emitGoalStatus() {
|
|
104
|
+
const goal = this.host.activeConv().goal;
|
|
105
|
+
if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
|
|
106
|
+
goal.reviewModel = this.prefs.reviewModel;
|
|
107
|
+
goal.maxRounds = this.prefs.maxRounds;
|
|
108
|
+
goal.locked = this.prefs.locked;
|
|
109
|
+
}
|
|
110
|
+
this.host.emit({ type: "goal_status", status: { ...goal } });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
114
|
+
* applied to the CURRENT active conversation of this project; reviews check
|
|
115
|
+
* whatever run finishes next (agent_end).
|
|
116
|
+
*/
|
|
117
|
+
async setGoal(goalText, opts) {
|
|
118
|
+
const text = (goalText ?? "").trim();
|
|
119
|
+
if (!text) {
|
|
120
|
+
await this.clearGoal();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// A goal is scoped to the conversation that is active when it is set.
|
|
124
|
+
// This prevents an agent_end from a newly-created/switched conversation
|
|
125
|
+
// from consuming the previous conversation's goal.
|
|
126
|
+
const conv = this.host.activeConv();
|
|
127
|
+
const goalConversationId = this.host.activeConvId();
|
|
128
|
+
conv.goalGeneration += 1;
|
|
129
|
+
const goal = conv.goal;
|
|
130
|
+
goal.reviewing = false;
|
|
131
|
+
goal.conversationId = goalConversationId;
|
|
132
|
+
goal.goal = text;
|
|
133
|
+
// Model & rounds preference semantics ("全局记忆"):
|
|
134
|
+
// - reviewModel undefined → keep the remembered choice; empty → main model.
|
|
135
|
+
// - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
|
|
136
|
+
if (opts?.reviewModel !== undefined)
|
|
137
|
+
goal.reviewModel = opts.reviewModel || null;
|
|
138
|
+
if (typeof opts?.maxRounds === "number") {
|
|
139
|
+
const mr = Math.round(opts.maxRounds);
|
|
140
|
+
goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
141
|
+
}
|
|
142
|
+
if (opts?.locked !== undefined)
|
|
143
|
+
goal.locked = opts.locked;
|
|
144
|
+
this.prefs = {
|
|
145
|
+
reviewModel: goal.reviewModel,
|
|
146
|
+
maxRounds: goal.maxRounds,
|
|
147
|
+
locked: goal.locked,
|
|
148
|
+
};
|
|
149
|
+
// Persist the chosen preferences so they survive reload.
|
|
150
|
+
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
151
|
+
reviewModel: goal.reviewModel,
|
|
152
|
+
maxRounds: goal.maxRounds,
|
|
153
|
+
locked: goal.locked,
|
|
154
|
+
});
|
|
155
|
+
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
156
|
+
goal.round = 0;
|
|
157
|
+
goal.reviewing = false;
|
|
158
|
+
goal.verdict = "pending";
|
|
159
|
+
goal.feedback = undefined;
|
|
160
|
+
goal.wizard.active = false;
|
|
161
|
+
goal.wizard.status = "";
|
|
162
|
+
goal.status = "目标已设,等待生成…";
|
|
163
|
+
this.emitGoalStatus();
|
|
164
|
+
this.host.emit({
|
|
165
|
+
type: "notice",
|
|
166
|
+
level: "info",
|
|
167
|
+
text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
168
|
+
});
|
|
169
|
+
// Auto-start generation right after setting the goal (unless this setGoal is
|
|
170
|
+
// the wizard's internal one, which kicks off itself). This makes the direct
|
|
171
|
+
// goal-bar path behave like the AI-提炼 path: set a target → agent begins.
|
|
172
|
+
if (opts?.autoStart !== false) {
|
|
173
|
+
try {
|
|
174
|
+
const s = conv.session;
|
|
175
|
+
await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Best-effort; the user can still prompt manually.
|
|
179
|
+
}
|
|
180
|
+
this.host.flushSnapshot();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Collaborative target wizard. Turns a raw user requirement into a refined
|
|
185
|
+
* goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
|
|
186
|
+
* in-memory session, so its model choice is its own) that questions the user
|
|
187
|
+
* via `goal_ask` (multiple-choice + free-text, bridged to the browser through
|
|
188
|
+
* the existing select/input dialog), converging on a goal, then auto-sets it.
|
|
189
|
+
* Mutually exclusive with the review loop of the same conversation.
|
|
190
|
+
*/
|
|
191
|
+
async startGoalWizard(text, opts) {
|
|
192
|
+
if (this.host.quiesceBlocked())
|
|
193
|
+
return;
|
|
194
|
+
const draft = (text ?? "").trim();
|
|
195
|
+
if (!draft)
|
|
196
|
+
return;
|
|
197
|
+
// The wizard and its progress cards belong to the conversation that
|
|
198
|
+
// launched it. If the user switches away, do not later set a goal on the
|
|
199
|
+
// new active conversation while the wizard is still finishing.
|
|
200
|
+
const wizardConversationId = this.host.activeConvId();
|
|
201
|
+
const wizardConversation = this.host.activeConv();
|
|
202
|
+
if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
|
|
203
|
+
this.host.emit({
|
|
204
|
+
type: "notice",
|
|
205
|
+
level: "warning",
|
|
206
|
+
text: "已有目标调研进行中,请等它完成…",
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (wizardConversation.goal.reviewing) {
|
|
211
|
+
this.host.emit({
|
|
212
|
+
type: "notice",
|
|
213
|
+
level: "warning",
|
|
214
|
+
text: "正在审查中,无法开始目标调研,请稍等…",
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Questions are NOT capped (调研不限制) — the wizard converges on its own;
|
|
219
|
+
// the idle- and total-timeouts are the only guards. maxSteps is purely a
|
|
220
|
+
// soft UI indicator, not a hard stop.
|
|
221
|
+
const maxSteps = 20;
|
|
222
|
+
wizardConversation.wizardRunning = true;
|
|
223
|
+
this.wizardOwnerId = wizardConversationId;
|
|
224
|
+
this.wizardCancelled = false;
|
|
225
|
+
this.wizardAbort = new AbortController();
|
|
226
|
+
this.wizardSession = null;
|
|
227
|
+
const wgoal = wizardConversation.goal;
|
|
228
|
+
wgoal.wizard.active = true;
|
|
229
|
+
wgoal.wizard.draft = draft;
|
|
230
|
+
wgoal.wizard.model = opts?.wizardModel ?? null;
|
|
231
|
+
// Remember the model choice (and persist rounds/lock) — global memory.
|
|
232
|
+
if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
|
|
233
|
+
wgoal.reviewModel = opts.wizardModel || null;
|
|
234
|
+
if (typeof opts?.maxRounds === "number") {
|
|
235
|
+
const mr = Math.round(opts.maxRounds);
|
|
236
|
+
wgoal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
237
|
+
}
|
|
238
|
+
if (opts?.locked !== undefined)
|
|
239
|
+
wgoal.locked = opts.locked;
|
|
240
|
+
this.prefs = {
|
|
241
|
+
reviewModel: wgoal.reviewModel,
|
|
242
|
+
maxRounds: wgoal.maxRounds,
|
|
243
|
+
locked: wgoal.locked,
|
|
244
|
+
};
|
|
245
|
+
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
246
|
+
reviewModel: wgoal.reviewModel,
|
|
247
|
+
maxRounds: wgoal.maxRounds,
|
|
248
|
+
locked: wgoal.locked,
|
|
249
|
+
});
|
|
250
|
+
wgoal.wizard.step = 0;
|
|
251
|
+
wgoal.wizard.maxSteps = maxSteps;
|
|
252
|
+
wgoal.wizard.status = "调研中…";
|
|
253
|
+
wgoal.status = "目标调研中…";
|
|
254
|
+
this.emitGoalStatus();
|
|
255
|
+
// Idle-timeout: cancel the wizard if no question is answered within the
|
|
256
|
+
// window (a stale dialog with no user response must not run forever). A
|
|
257
|
+
// fresh timer is armed for each question; cleared once the run ends.
|
|
258
|
+
const ac = this.wizardAbort;
|
|
259
|
+
let idleTimer = null;
|
|
260
|
+
const armIdle = () => {
|
|
261
|
+
if (idleTimer)
|
|
262
|
+
clearTimeout(idleTimer);
|
|
263
|
+
idleTimer = setTimeout(() => {
|
|
264
|
+
if (!ac.signal.aborted) {
|
|
265
|
+
this.wizardCancelled = true;
|
|
266
|
+
ac.abort(new Error("目标调研超时(等待回答过久)"));
|
|
267
|
+
}
|
|
268
|
+
}, GoalService.WIZARD_IDLE_TIMEOUT_MS);
|
|
269
|
+
idleTimer.unref?.();
|
|
270
|
+
};
|
|
271
|
+
const clearIdle = () => {
|
|
272
|
+
if (idleTimer) {
|
|
273
|
+
clearTimeout(idleTimer);
|
|
274
|
+
idleTimer = null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
armIdle();
|
|
278
|
+
// Total-duration guard: hard cap on the whole wizard session (model
|
|
279
|
+
// latency / unexpected loops must not run forever).
|
|
280
|
+
const totalTimer = setTimeout(() => {
|
|
281
|
+
if (!ac.signal.aborted) {
|
|
282
|
+
this.wizardCancelled = true;
|
|
283
|
+
ac.abort(new Error("目标调研超过总时长上限"));
|
|
284
|
+
}
|
|
285
|
+
}, GoalService.WIZARD_MAX_TOTAL_MS);
|
|
286
|
+
totalTimer.unref?.();
|
|
287
|
+
this.host.emit({
|
|
288
|
+
type: "notice",
|
|
289
|
+
level: "info",
|
|
290
|
+
text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
|
|
291
|
+
});
|
|
292
|
+
// The main conversation to show wizard progress cards in.
|
|
293
|
+
const mainSession = wizardConversation.session;
|
|
294
|
+
let refinedGoal = "";
|
|
295
|
+
try {
|
|
296
|
+
const wmSpec = opts?.wizardModel
|
|
297
|
+
? this.resolveReviewModel(opts.wizardModel)
|
|
298
|
+
: null; // reuse the honest "provider/id" parser
|
|
299
|
+
const services = await createAgentSessionServices({
|
|
300
|
+
cwd: wizardConversation.cwd,
|
|
301
|
+
agentDir: this.host.agentDir,
|
|
302
|
+
modelRuntime: await ModelRuntime.create({
|
|
303
|
+
authPath: join(this.host.agentDir, "auth.json"),
|
|
304
|
+
modelsPath: join(this.host.agentDir, "models.json"),
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
let model;
|
|
308
|
+
if (wmSpec)
|
|
309
|
+
model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
|
|
310
|
+
if (!model) {
|
|
311
|
+
const mainModel = mainSession.model;
|
|
312
|
+
if (mainModel?.provider && mainModel.id)
|
|
313
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
314
|
+
}
|
|
315
|
+
// The wizard asks the user questions via this tool; each call bridges one
|
|
316
|
+
// select/input dialog to the browser and returns the user's answer.
|
|
317
|
+
let qStep = 0;
|
|
318
|
+
const goalAsk = defineTool({
|
|
319
|
+
name: "goal_ask",
|
|
320
|
+
label: "Ask the user",
|
|
321
|
+
description: "Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.",
|
|
322
|
+
parameters: Type.Object({
|
|
323
|
+
question: Type.String({ description: "The question to ask" }),
|
|
324
|
+
options: Type.Optional(Type.Array(Type.String())),
|
|
325
|
+
}),
|
|
326
|
+
// ONE question at a time. Sequential execution prevents the agent from
|
|
327
|
+
// firing parallel goal_ask calls whose dialogs would overwrite each other
|
|
328
|
+
// in the single browser modal (leaving earlier ones deadlocked — the
|
|
329
|
+
// reported "调研卡住").
|
|
330
|
+
executionMode: "sequential",
|
|
331
|
+
execute: async (_id, params, _sig, _onUpdate, ctx) => {
|
|
332
|
+
qStep += 1;
|
|
333
|
+
if (qStep > maxSteps) {
|
|
334
|
+
return {
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: "text",
|
|
338
|
+
text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
details: {},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
// Show the question in the main flow BEFORE blocking on the dialog, so
|
|
345
|
+
// the user sees the wizard working even before answering.
|
|
346
|
+
wgoal.wizard.step = qStep;
|
|
347
|
+
wgoal.wizard.status = `调研中:请回答第 ${qStep} 题`;
|
|
348
|
+
this.emitGoalStatus();
|
|
349
|
+
try {
|
|
350
|
+
armIdle();
|
|
351
|
+
const isChoice = !!(params.options && params.options.length > 0);
|
|
352
|
+
await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
|
|
353
|
+
// Resolve the pending dialog as cancelled if the wizard is aborted.
|
|
354
|
+
let aborted = false;
|
|
355
|
+
const onAbort = () => {
|
|
356
|
+
aborted = true;
|
|
357
|
+
};
|
|
358
|
+
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
359
|
+
const choose = isChoice
|
|
360
|
+
? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
|
|
361
|
+
: ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
|
|
362
|
+
const ans = (await choose);
|
|
363
|
+
ac.signal.removeEventListener("abort", onAbort);
|
|
364
|
+
if (aborted || ac.signal.aborted) {
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: "text",
|
|
369
|
+
text: "(调研已取消,请不要继续提问,直接结束对话)",
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
details: {},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (ans === undefined || ans === null || ans === false || ans === "") {
|
|
376
|
+
return {
|
|
377
|
+
content: [
|
|
378
|
+
{
|
|
379
|
+
type: "text",
|
|
380
|
+
text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
details: {},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Record the answer in the flow too (instant append, main session idle).
|
|
387
|
+
await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
|
|
388
|
+
return {
|
|
389
|
+
content: [{ type: "text", text: `用户回答:${ans}` }],
|
|
390
|
+
details: {},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
return {
|
|
395
|
+
content: [
|
|
396
|
+
{
|
|
397
|
+
type: "text",
|
|
398
|
+
text: ac.signal.aborted
|
|
399
|
+
? "(调研已取消,请不要继续提问,直接结束对话)"
|
|
400
|
+
: `提问失败:${err.message}`,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
details: {},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const srv = await createAgentSessionFromServices({
|
|
409
|
+
services,
|
|
410
|
+
sessionManager: SessionManager.inMemory(this.host.cwd()),
|
|
411
|
+
customTools: [goalAsk],
|
|
412
|
+
...(model ? { model } : {}),
|
|
413
|
+
});
|
|
414
|
+
const wizard = srv.session;
|
|
415
|
+
this.wizardSession = wizard;
|
|
416
|
+
await wizard.bindExtensions({ mode: "rpc", uiContext: this.host.webUi });
|
|
417
|
+
// Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
|
|
418
|
+
// wizard's agent run (not just mark it).
|
|
419
|
+
if (!ac.signal.aborted) {
|
|
420
|
+
ac.signal.addEventListener("abort", () => {
|
|
421
|
+
void wizard.abort().catch(() => { });
|
|
422
|
+
// Close the unanswered browser dialog(s) the wizard may have up.
|
|
423
|
+
this.host.webUi.cancelPendingDialogs();
|
|
424
|
+
}, { once: true });
|
|
425
|
+
}
|
|
426
|
+
await wizard.prompt(wizardPrompt(draft));
|
|
427
|
+
refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
|
|
428
|
+
// The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
|
|
429
|
+
// if it didn't follow, strip a leading preamble line and keep the rest.
|
|
430
|
+
const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
|
|
431
|
+
if (goalMatch) {
|
|
432
|
+
refinedGoal = goalMatch[1].trim();
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const lines = refinedGoal.split("\n").filter((l) => l.trim());
|
|
436
|
+
if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
|
|
437
|
+
// First line looks like preamble (no sentence-ending punctuation).
|
|
438
|
+
refinedGoal = lines.slice(1).join(" ").trim();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await srv.session.dispose();
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
this.host.emit({
|
|
445
|
+
type: "notice",
|
|
446
|
+
level: "error",
|
|
447
|
+
text: `目标调研失败:${err.message}`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
clearIdle();
|
|
452
|
+
clearTimeout(totalTimer);
|
|
453
|
+
wizardConversation.wizardRunning = false;
|
|
454
|
+
if (this.wizardOwnerId === wizardConversationId)
|
|
455
|
+
this.wizardOwnerId = null;
|
|
456
|
+
wgoal.wizard.active = false;
|
|
457
|
+
wgoal.wizard.step = 0;
|
|
458
|
+
wgoal.wizard.status = "";
|
|
459
|
+
this.wizardSession = null;
|
|
460
|
+
this.emitGoalStatus();
|
|
461
|
+
}
|
|
462
|
+
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
|
|
463
|
+
if (ac.signal.aborted || this.wizardCancelled) {
|
|
464
|
+
this.host.emit({
|
|
465
|
+
type: "notice",
|
|
466
|
+
level: "info",
|
|
467
|
+
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
|
|
468
|
+
});
|
|
469
|
+
this.wizardAbort = null;
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (!refinedGoal.trim()) {
|
|
473
|
+
this.host.emit({
|
|
474
|
+
type: "notice",
|
|
475
|
+
level: "warning",
|
|
476
|
+
text: "调研未产出有效目标,请重试",
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (this.host.activeConvId() !== wizardConversationId) {
|
|
481
|
+
this.host.emit({
|
|
482
|
+
type: "notice",
|
|
483
|
+
level: "info",
|
|
484
|
+
text: "已切换对话,目标调研结果已丢弃",
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
489
|
+
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
490
|
+
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
491
|
+
// without it the review is single-shot).
|
|
492
|
+
const wantLocked = opts?.locked === undefined ? true : opts.locked;
|
|
493
|
+
await this.setGoal(refinedGoal, {
|
|
494
|
+
reviewModel: wgoal.reviewModel ?? undefined,
|
|
495
|
+
maxRounds: opts?.maxRounds,
|
|
496
|
+
locked: wantLocked,
|
|
497
|
+
// The wizard kicks off generation itself below — avoid a double kick.
|
|
498
|
+
autoStart: false,
|
|
499
|
+
});
|
|
500
|
+
this.wizardCancelled = false;
|
|
501
|
+
this.wizardAbort = null;
|
|
502
|
+
this.host.emit({
|
|
503
|
+
type: "notice",
|
|
504
|
+
level: "info",
|
|
505
|
+
text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
506
|
+
});
|
|
507
|
+
// Kick the main agent into generating right away (no manual "开始吧").
|
|
508
|
+
// The kick-off is a user message so it appears in the flow and triggers a
|
|
509
|
+
// normal turn; the finishing agent_end then runs the review loop.
|
|
510
|
+
try {
|
|
511
|
+
await mainSession.sendUserMessage(`【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// Generation kick-off is best-effort; the user can still prompt manually.
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Persist goal/review preference defaults (model, rounds cap, locked) without
|
|
518
|
+
* touching the active goal — so changes in the goal bar are remembered across
|
|
519
|
+
* reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
|
|
520
|
+
async setGoalPrefs(opts) {
|
|
521
|
+
const goal = this.host.activeConv().goal;
|
|
522
|
+
if (opts?.reviewModel !== undefined)
|
|
523
|
+
goal.reviewModel = opts.reviewModel || null;
|
|
524
|
+
if (typeof opts?.maxRounds === "number") {
|
|
525
|
+
const mr = Math.round(opts.maxRounds);
|
|
526
|
+
goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
527
|
+
}
|
|
528
|
+
if (opts?.locked !== undefined)
|
|
529
|
+
goal.locked = opts.locked;
|
|
530
|
+
this.prefs = {
|
|
531
|
+
reviewModel: goal.reviewModel,
|
|
532
|
+
maxRounds: goal.maxRounds,
|
|
533
|
+
locked: goal.locked,
|
|
534
|
+
};
|
|
535
|
+
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
536
|
+
reviewModel: goal.reviewModel,
|
|
537
|
+
maxRounds: goal.maxRounds,
|
|
538
|
+
locked: goal.locked,
|
|
539
|
+
});
|
|
540
|
+
this.emitGoalStatus();
|
|
541
|
+
}
|
|
542
|
+
/** Clear the active goal (cancels the review loop AND aborts a running
|
|
543
|
+
* goal wizard — truly terminating its in-flight dialog + agent run). */
|
|
544
|
+
async clearGoal() {
|
|
545
|
+
const conv = this.host.activeConv();
|
|
546
|
+
conv.goalGeneration += 1;
|
|
547
|
+
const goal = conv.goal;
|
|
548
|
+
goal.reviewing = false;
|
|
549
|
+
goal.conversationId = null;
|
|
550
|
+
goal.goal = null;
|
|
551
|
+
goal.reviewing = false;
|
|
552
|
+
goal.verdict = "pending";
|
|
553
|
+
goal.feedback = undefined;
|
|
554
|
+
goal.wizard.active = false;
|
|
555
|
+
goal.wizard.status = "";
|
|
556
|
+
goal.status = "";
|
|
557
|
+
this.emitGoalStatus();
|
|
558
|
+
// Abort a running wizard for real (✗ in the goal bar while scoping).
|
|
559
|
+
if (this.wizardOwnerId === this.host.activeConvId()) {
|
|
560
|
+
this.wizardCancelled = true;
|
|
561
|
+
this.host.webUi.cancelPendingDialogs();
|
|
562
|
+
this.wizardAbort?.abort();
|
|
563
|
+
const ws2 = this.wizardSession;
|
|
564
|
+
this.wizardSession = null;
|
|
565
|
+
if (ws2) {
|
|
566
|
+
await ws2.abort().catch(() => { });
|
|
567
|
+
ws2.dispose();
|
|
568
|
+
}
|
|
569
|
+
this.wizardAbort = null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* agent_end hook. `aborted` = the finished run ended by manual stop; in that
|
|
574
|
+
* case any active goal of THIS conversation is cleared so the review loop
|
|
575
|
+
* stops too (a half-finished run must not be reviewed — endless loop).
|
|
576
|
+
* Otherwise, spawn the isolated reviewer if a goal is pending. Returns a
|
|
577
|
+
* notice text for the host to emit (manual-stop case), or null.
|
|
578
|
+
*/
|
|
579
|
+
onAgentEnd(conv, aborted) {
|
|
580
|
+
const g = conv.goal;
|
|
581
|
+
if (aborted) {
|
|
582
|
+
if (g.goal && g.conversationId === conv.id) {
|
|
583
|
+
conv.goalGeneration += 1;
|
|
584
|
+
g.conversationId = null;
|
|
585
|
+
g.goal = null;
|
|
586
|
+
g.reviewing = false;
|
|
587
|
+
g.verdict = "pending";
|
|
588
|
+
g.feedback = undefined;
|
|
589
|
+
g.status = "已手动停止,目标审查已中止";
|
|
590
|
+
this.emitGoalStatus();
|
|
591
|
+
return "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)";
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
// Goal review hook: after the run finished normally, if a goal is
|
|
596
|
+
// active (and it belonged to the ACTIVE conversation) and we're not
|
|
597
|
+
// already mid-review, spawn the isolated reviewer.
|
|
598
|
+
if (g.goal &&
|
|
599
|
+
g.conversationId === conv.id &&
|
|
600
|
+
!g.reviewing &&
|
|
601
|
+
!conv.wizardRunning &&
|
|
602
|
+
!this.host.isDisposed()) {
|
|
603
|
+
void this.runGoalReview(conv);
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
/** Build a "provider/id" or null for the reviewer model, validating it exists. */
|
|
608
|
+
resolveReviewModel(spec) {
|
|
609
|
+
return parseModelSpec(spec);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
613
|
+
* to report, regardless of which model it runs on.
|
|
614
|
+
*/
|
|
615
|
+
reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
|
|
616
|
+
return [
|
|
617
|
+
`You are a strict, independent goal-reviewer. Your ONLY job is to judge whether the agent's work fully satisfies the stated goal, by checking the agent's final output and, when present, its git diff.`, // eslint-disable-line max-len
|
|
618
|
+
``,
|
|
619
|
+
`# Goal`, // eslint-disable-line no-regex-spaces
|
|
620
|
+
goal,
|
|
621
|
+
``,
|
|
622
|
+
`# Agent's final output`, // eslint-disable-line no-regex-spaces
|
|
623
|
+
output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
|
|
624
|
+
``,
|
|
625
|
+
`# Git diff (if any)`, // eslint-disable-line no-regex-spaces
|
|
626
|
+
gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
|
|
627
|
+
``,
|
|
628
|
+
`This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
|
|
629
|
+
...(customPrompt.trim()
|
|
630
|
+
? [``, `# Additional reviewer instructions`, customPrompt.trim()]
|
|
631
|
+
: []),
|
|
632
|
+
``,
|
|
633
|
+
`Decide: does the work satisfy the goal? If yes, respond with ONLY a JSON object with this exact shape (no markdown fences, no extra text):`, // eslint-disable-line max-len
|
|
634
|
+
`{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
|
|
635
|
+
`If NO, respond with ONLY: {"verdict":"fail","feedback":"<concise, actionable list of what the agent must fix to satisfy the goal>"}`, // eslint-disable-line max-len
|
|
636
|
+
`The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
|
|
637
|
+
].join("\n");
|
|
638
|
+
}
|
|
639
|
+
/** Insert a wizard progress card into the MAIN conversation flow and render it
|
|
640
|
+
* IMMEDIATELY (the main session is idle while the wizard runs in its own
|
|
641
|
+
* session, so — unlike nextTurn, which queues until the next user prompt —
|
|
642
|
+
* sending without a delivery option appends + persists + emits at once). */
|
|
643
|
+
async pushWizardCard(sess, text, details) {
|
|
644
|
+
try {
|
|
645
|
+
await sess.sendCustomMessage({
|
|
646
|
+
customType: "goal-wizard",
|
|
647
|
+
content: [{ type: "text", text }],
|
|
648
|
+
display: true,
|
|
649
|
+
details: { type: "goal-wizard", ...details },
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Card insertion is cosmetic — never block the question flow on it.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
657
|
+
return (!this.host.isDisposed() &&
|
|
658
|
+
this.host.getConv(conv.id) === conv &&
|
|
659
|
+
conv.goal.conversationId === conv.id &&
|
|
660
|
+
conv.goalGeneration === goalGeneration &&
|
|
661
|
+
conv.goalReviewGeneration === reviewGeneration &&
|
|
662
|
+
!!conv.goal.goal);
|
|
663
|
+
}
|
|
664
|
+
/** Drop the result of a review that became stale while it was awaiting the
|
|
665
|
+
* reviewer model (most commonly because the user switched conversations). */
|
|
666
|
+
discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
667
|
+
if (conv.goalReviewGeneration !== reviewGeneration)
|
|
668
|
+
return;
|
|
669
|
+
if (conv.goalGeneration === goalGeneration &&
|
|
670
|
+
conv.goal.conversationId === conv.id) {
|
|
671
|
+
conv.goal.reviewing = false;
|
|
672
|
+
conv.goal.status = "审查已中止,目标已更新或取消";
|
|
673
|
+
this.emitGoalStatus();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
async runGoalReview(conv) {
|
|
677
|
+
// The review is bound to the conversation that just ran. Capture both the
|
|
678
|
+
// owner and a generation so a later switch/set/clear cannot let an old,
|
|
679
|
+
// asynchronous reviewer mutate the new conversation's goal state.
|
|
680
|
+
const mainConv = this.host.getConv(conv.id) ?? conv;
|
|
681
|
+
const mainSession = mainConv.session;
|
|
682
|
+
const g = conv.goal;
|
|
683
|
+
if (!g.goal ||
|
|
684
|
+
g.conversationId !== conv.id ||
|
|
685
|
+
g.reviewing ||
|
|
686
|
+
conv.wizardRunning ||
|
|
687
|
+
this.host.isDisposed())
|
|
688
|
+
return;
|
|
689
|
+
const goalGeneration = conv.goalGeneration;
|
|
690
|
+
const reviewGeneration = ++conv.goalReviewGeneration;
|
|
691
|
+
// Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
|
|
692
|
+
// object field) through the entire async body, so capture it here.
|
|
693
|
+
const goalText = g.goal;
|
|
694
|
+
// Capture review-only settings for this run. Changing settings while a
|
|
695
|
+
// review is in flight affects the next review, never this one.
|
|
696
|
+
const reviewPrefs = this.host.reviewSettings();
|
|
697
|
+
const reviewPrompt = reviewPrefs.reviewPrompt;
|
|
698
|
+
const reviewDisabledSkills = new Set(reviewPrefs.reviewDisabledSkills);
|
|
699
|
+
// Cap rounds: single-shot (locked=false) always exactly one review.
|
|
700
|
+
// For locked goals, maxRounds 0 = unlimited (keep revising until pass).
|
|
701
|
+
const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
|
|
702
|
+
if (g.locked && g.maxRounds > 0 && g.round >= budget) {
|
|
703
|
+
g.status = `已达最大轮数(${budget}),停止审查`;
|
|
704
|
+
g.reviewing = false;
|
|
705
|
+
this.emitGoalStatus();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
g.reviewing = true;
|
|
709
|
+
g.round += 1;
|
|
710
|
+
g.verdict = "pending";
|
|
711
|
+
g.feedback = undefined;
|
|
712
|
+
g.status = `审查中(第 ${g.round} 轮)…`;
|
|
713
|
+
this.emitGoalStatus();
|
|
714
|
+
// Collect the review inputs.
|
|
715
|
+
let finalText = "";
|
|
716
|
+
try {
|
|
717
|
+
finalText = mainSession.getLastAssistantText() ?? "";
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
finalText = "";
|
|
721
|
+
}
|
|
722
|
+
const diff = await this.host.gitDiff(mainConv.cwd);
|
|
723
|
+
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
724
|
+
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
let reviewerVerdict = "fail";
|
|
728
|
+
let reviewerFeedback = "(审查无法完成)";
|
|
729
|
+
try {
|
|
730
|
+
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
731
|
+
const services = await createAgentSessionServices({
|
|
732
|
+
cwd: mainConv.cwd,
|
|
733
|
+
agentDir: this.host.agentDir,
|
|
734
|
+
// The reviewer has its own skill allow/deny list. It deliberately does
|
|
735
|
+
// not reuse the main session's disabledSkills setting.
|
|
736
|
+
resourceLoaderOptions: {
|
|
737
|
+
skillsOverride: (res) => ({
|
|
738
|
+
...res,
|
|
739
|
+
skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
|
|
740
|
+
}),
|
|
741
|
+
},
|
|
742
|
+
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
743
|
+
// one used by the main conversations, so its model choice is its own.
|
|
744
|
+
modelRuntime: await ModelRuntime.create({
|
|
745
|
+
authPath: join(this.host.agentDir, "auth.json"),
|
|
746
|
+
modelsPath: join(this.host.agentDir, "models.json"),
|
|
747
|
+
}),
|
|
748
|
+
});
|
|
749
|
+
// Model resolution: explicit reviewer model, else the main session's
|
|
750
|
+
// current model (so a goal works even when no reviewer model is given).
|
|
751
|
+
let model;
|
|
752
|
+
if (rmSpec) {
|
|
753
|
+
model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
|
|
754
|
+
}
|
|
755
|
+
if (!model) {
|
|
756
|
+
const mainModel = mainSession.model;
|
|
757
|
+
if (mainModel?.provider && mainModel.id) {
|
|
758
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const srv = await createAgentSessionFromServices({
|
|
762
|
+
services,
|
|
763
|
+
sessionManager: SessionManager.inMemory(mainConv.cwd),
|
|
764
|
+
...(model ? { model } : {}),
|
|
765
|
+
});
|
|
766
|
+
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
767
|
+
const reviewer = srv.session;
|
|
768
|
+
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
|
|
769
|
+
// Parse the reviewer's final output (expected to be a JSON object).
|
|
770
|
+
const raw = reviewer.getLastAssistantText() ?? "";
|
|
771
|
+
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
772
|
+
if (m) {
|
|
773
|
+
reviewerVerdict = m[1];
|
|
774
|
+
const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
775
|
+
reviewerFeedback = fm?.[1] ?? "";
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
// No JSON — assume fail with the raw output as feedback.
|
|
779
|
+
reviewerVerdict = "fail";
|
|
780
|
+
reviewerFeedback = raw.slice(0, 2000);
|
|
781
|
+
}
|
|
782
|
+
await srv.session.dispose();
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
reviewerVerdict = "fail";
|
|
786
|
+
reviewerFeedback = `审查过程中出错:${err.message}`;
|
|
787
|
+
}
|
|
788
|
+
// The user may have switched chats or replaced/cleared the goal while the
|
|
789
|
+
// isolated reviewer was running. Never apply a stale verdict or inject it
|
|
790
|
+
// into the old session after that point.
|
|
791
|
+
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
792
|
+
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
g.reviewing = false;
|
|
796
|
+
g.verdict = reviewerVerdict;
|
|
797
|
+
g.feedback = reviewerFeedback;
|
|
798
|
+
const round = g.round;
|
|
799
|
+
// Display cap: 0 means "unlimited" (keep revising until pass).
|
|
800
|
+
const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
|
|
801
|
+
const verdict = reviewerVerdict;
|
|
802
|
+
const feedback = reviewerFeedback;
|
|
803
|
+
/** Format "round/cap" for user-facing strings; cap 0 → 不限. */
|
|
804
|
+
const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
|
|
805
|
+
if (verdict === "pass") {
|
|
806
|
+
g.status = "✅ 已通过目标审查";
|
|
807
|
+
this.host.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
|
|
808
|
+
g.conversationId = null;
|
|
809
|
+
g.goal = null; // a passed goal is done and cleared
|
|
810
|
+
this.emitGoalStatus();
|
|
811
|
+
// Pass = the review result goes straight into the conversation as an
|
|
812
|
+
// ordinary user message (NO separate goal-review card). It both tells the
|
|
813
|
+
// USER the outcome and hands the main agent back out of "goal mode", so a
|
|
814
|
+
// follow-up instruction like "发布" is a normal request — not a confirm echo.
|
|
815
|
+
try {
|
|
816
|
+
await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
// Best-effort.
|
|
820
|
+
}
|
|
821
|
+
this.host.flushSnapshot();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// Failure: if rounds remain, steer a revision; else report the loop done.
|
|
825
|
+
// For unlimited (budget=0) isLastRound is always false → keeps revising.
|
|
826
|
+
const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
|
|
827
|
+
if (!isLastRound) {
|
|
828
|
+
g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
|
|
829
|
+
this.host.emit({
|
|
830
|
+
type: "notice",
|
|
831
|
+
level: "warning",
|
|
832
|
+
text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
|
|
833
|
+
});
|
|
834
|
+
// Inject the reviewer's feedback into the main session to revise (this IS
|
|
835
|
+
// the fail review result, as an ordinary user message — no separate card).
|
|
836
|
+
try {
|
|
837
|
+
const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
|
|
838
|
+
`审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
|
|
839
|
+
await mainSession.sendUserMessage(steerText, {
|
|
840
|
+
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
g.status = `意见注入失败:${err.message}`;
|
|
845
|
+
}
|
|
846
|
+
this.emitGoalStatus();
|
|
847
|
+
this.host.flushSnapshot();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// Rounds exhausted (finite cap reached / single-shot failed). Deliver the
|
|
851
|
+
// fail result as an ordinary user message (no separate card), like the pass
|
|
852
|
+
// and revise paths — the review result always lands in the conversation.
|
|
853
|
+
g.status =
|
|
854
|
+
g.locked && g.maxRounds > 0
|
|
855
|
+
? `已达最大轮数(${g.maxRounds}),目标仍未通过`
|
|
856
|
+
: `目标未通过(${capFmt(budgetForCard)})`;
|
|
857
|
+
try {
|
|
858
|
+
await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// Best-effort.
|
|
862
|
+
}
|
|
863
|
+
this.host.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
|
|
864
|
+
g.conversationId = null;
|
|
865
|
+
g.goal = null; // loop exhausted — clear the active goal
|
|
866
|
+
this.emitGoalStatus();
|
|
867
|
+
this.host.flushSnapshot();
|
|
868
|
+
}
|
|
869
|
+
}
|