pi-web-ui 0.18.0 → 0.19.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 -21
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +78 -78
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +806 -3
- package/dist/server/index.js +24 -0
- package/extensions/webui.ts +184 -184
- package/package.json +87 -86
- package/web/dist/assets/index-CdMheYs9.css +41 -0
- package/web/dist/assets/{index-BBgamkcH.js → index-CqoLzUOT.js} +53 -53
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-BwmO7t7g.css +0 -41
|
@@ -14,7 +14,8 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
15
15
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
18
19
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
19
20
|
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
20
21
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
@@ -211,6 +212,23 @@ function looksLikeText(buf) {
|
|
|
211
212
|
* payloads by data length (identical lengths within the same ms are far too
|
|
212
213
|
* unlikely to matter).
|
|
213
214
|
*/
|
|
215
|
+
/** System prompt for the goal-wizard session. The wizard asks the user a few
|
|
216
|
+
* questions (via its goal_ask tool) to scope a raw requirement into a precise,
|
|
217
|
+
* reviewable goal, then emits ONLY the final goal text as its last message. */
|
|
218
|
+
function wizardPrompt(draft) {
|
|
219
|
+
return [
|
|
220
|
+
`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
|
|
221
|
+
``,
|
|
222
|
+
`# User's raw requirement`, // eslint-disable-line no-regex-spaces
|
|
223
|
+
draft,
|
|
224
|
+
``,
|
|
225
|
+
`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
|
|
226
|
+
`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
|
|
227
|
+
`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
|
|
228
|
+
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
|
|
229
|
+
`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
|
|
230
|
+
].join("\n");
|
|
231
|
+
}
|
|
214
232
|
function contentFingerprint(m) {
|
|
215
233
|
const content = m.content;
|
|
216
234
|
if (!Array.isArray(content) || content.length === 0)
|
|
@@ -405,6 +423,15 @@ export class WebUIContext {
|
|
|
405
423
|
this.emit({ type: "dialog_closed", id });
|
|
406
424
|
}
|
|
407
425
|
}
|
|
426
|
+
/** Close every pending dialog as cancelled (used when a goal wizard aborts —
|
|
427
|
+
* its unanswered browser dialogs must vanish, not linger). */
|
|
428
|
+
cancelPendingDialogs() {
|
|
429
|
+
for (const [id, resolve] of this.pendingDialogs) {
|
|
430
|
+
this.pendingDialogs.delete(id);
|
|
431
|
+
resolve(null);
|
|
432
|
+
this.emit({ type: "dialog_closed", id });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
408
435
|
// -- inert TUI-only affordances ------------------------------------------
|
|
409
436
|
onTerminalInput = () => () => { };
|
|
410
437
|
setWorkingMessage = () => { };
|
|
@@ -639,6 +666,28 @@ class ClientStateStore {
|
|
|
639
666
|
].slice(0, 30);
|
|
640
667
|
this.save();
|
|
641
668
|
}
|
|
669
|
+
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
670
|
+
getGoalPrefs(clientId) {
|
|
671
|
+
const s = this.load()[clientId];
|
|
672
|
+
if (!s?.goalPrefs)
|
|
673
|
+
return undefined;
|
|
674
|
+
return {
|
|
675
|
+
reviewModel: s.goalPrefs.reviewModel ?? null,
|
|
676
|
+
maxRounds: s.goalPrefs.maxRounds ?? 0,
|
|
677
|
+
locked: s.goalPrefs.locked ?? true,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/** Persist the client's goal/review preferences (model choice, rounds, lock). */
|
|
681
|
+
saveGoalPrefs(clientId, prefs) {
|
|
682
|
+
const all = this.load();
|
|
683
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
684
|
+
state.goalPrefs = {
|
|
685
|
+
reviewModel: prefs?.reviewModel ?? null,
|
|
686
|
+
maxRounds: prefs?.maxRounds ?? 0,
|
|
687
|
+
locked: prefs?.locked ?? true,
|
|
688
|
+
};
|
|
689
|
+
this.save();
|
|
690
|
+
}
|
|
642
691
|
}
|
|
643
692
|
/** Cap on simultaneously open conversations of ONE project (each keeps a full
|
|
644
693
|
* runtime alive; conversations of other projects keep their own lists). */
|
|
@@ -694,6 +743,51 @@ export class ClientSession {
|
|
|
694
743
|
* top bar applies to every chat, not just the one that set it. Seeded by
|
|
695
744
|
* the first conversation and reused by later ones. */
|
|
696
745
|
sharedModelRuntime;
|
|
746
|
+
// -----------------------------------------------------------------------
|
|
747
|
+
// Goal / review state. When a goal is active, every finished agent run
|
|
748
|
+
// (agent_end) is checked by an ISOLATED reviewer agent; a failing review
|
|
749
|
+
// injects its feedback back into the main session to revise. All goal
|
|
750
|
+
// mutation goes through setGoal/clearGoal so UI state stays consistent.
|
|
751
|
+
// -----------------------------------------------------------------------
|
|
752
|
+
goal = {
|
|
753
|
+
goal: null,
|
|
754
|
+
reviewModel: null,
|
|
755
|
+
maxRounds: 0, // 0 = unlimited (keep revising until the goal passes)
|
|
756
|
+
locked: true,
|
|
757
|
+
reviewing: false,
|
|
758
|
+
round: 0,
|
|
759
|
+
status: "",
|
|
760
|
+
verdict: "pending",
|
|
761
|
+
wizard: {
|
|
762
|
+
active: false,
|
|
763
|
+
draft: "",
|
|
764
|
+
model: null,
|
|
765
|
+
step: 0,
|
|
766
|
+
maxSteps: 6,
|
|
767
|
+
status: "",
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
/** Guard: only one review may run at a time (agent_end fires per turn and
|
|
771
|
+
* review is async). */
|
|
772
|
+
goalReviewing = false;
|
|
773
|
+
/** Guard: the goal wizard and the review loop are mutually exclusive — a
|
|
774
|
+
* wizard in flight stops review triggers (and vice versa). */
|
|
775
|
+
goalWizardRunning = false;
|
|
776
|
+
/** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
|
|
777
|
+
* the in-flight goal_ask dialog to resolve as cancelled and (via the run
|
|
778
|
+
* signal) stops the wizard session's agent run. Recreated per wizard. */
|
|
779
|
+
wizardAbort = null;
|
|
780
|
+
/** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
|
|
781
|
+
* (abort the run), not just flip a flag. */
|
|
782
|
+
wizardSession = null;
|
|
783
|
+
/** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
|
|
784
|
+
* startGoalWizard reads this after the run to avoid setting a goal. */
|
|
785
|
+
wizardCancelled = false;
|
|
786
|
+
/** Idle-timeout for the wizard: if no answer arrives within this window (a
|
|
787
|
+
* dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
|
|
788
|
+
static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
789
|
+
/** Absolute deadline for the whole wizard session (model latency guard). */
|
|
790
|
+
static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
|
|
697
791
|
/** The active conversation (all session operations target it). */
|
|
698
792
|
get conv() {
|
|
699
793
|
const conv = this.convs.get(this.activeId);
|
|
@@ -745,6 +839,13 @@ export class ClientSession {
|
|
|
745
839
|
static async create(clientId, cwd, stateStore) {
|
|
746
840
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
747
841
|
const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
|
|
842
|
+
// Restore last-used goal/review preferences so model & rounds survive reload.
|
|
843
|
+
const gPrefs = stateStore.getGoalPrefs(clientId);
|
|
844
|
+
if (gPrefs) {
|
|
845
|
+
cs.goal.reviewModel = gPrefs.reviewModel;
|
|
846
|
+
cs.goal.maxRounds = gPrefs.maxRounds;
|
|
847
|
+
cs.goal.locked = gPrefs.locked;
|
|
848
|
+
}
|
|
748
849
|
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(), {
|
|
749
850
|
cwd,
|
|
750
851
|
agentDir,
|
|
@@ -835,6 +936,9 @@ export class ClientSession {
|
|
|
835
936
|
// Reconnect: same for the slash-command catalog (the picker needs it even
|
|
836
937
|
// before the client asks).
|
|
837
938
|
void this.pushSlashCommands();
|
|
939
|
+
// Reconnect: push the remembered goal prefs (model choice, rounds cap,
|
|
940
|
+
// locked) so the goal bar restores them on reload — "全局记忆".
|
|
941
|
+
this.emitGoalStatus();
|
|
838
942
|
}
|
|
839
943
|
detachSink(send) {
|
|
840
944
|
this.sinks.delete(send);
|
|
@@ -956,7 +1060,46 @@ export class ClientSession {
|
|
|
956
1060
|
break;
|
|
957
1061
|
// A run finished or a new entry was persisted — keep the session list fresh
|
|
958
1062
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
959
|
-
case "agent_end":
|
|
1063
|
+
case "agent_end": {
|
|
1064
|
+
this.scheduleSessionsRefresh();
|
|
1065
|
+
const g = this.goal;
|
|
1066
|
+
// Manual interrupt (Stop button / abort): the last assistant message
|
|
1067
|
+
// carries stopReason "aborted". A half-finished run should NOT be
|
|
1068
|
+
// reviewed (it would fail and inject a revision, only to be stopped
|
|
1069
|
+
// again → an endless review loop). Clear the goal so the review loop
|
|
1070
|
+
// stops too, then let the user give a fresh instruction.
|
|
1071
|
+
const aborted = event.messages.some((m) => {
|
|
1072
|
+
const a = m;
|
|
1073
|
+
return a.role === "assistant" && a.stopReason === "aborted";
|
|
1074
|
+
});
|
|
1075
|
+
if (aborted) {
|
|
1076
|
+
if (g.goal) {
|
|
1077
|
+
this.goal.goal = null;
|
|
1078
|
+
this.goal.reviewing = false;
|
|
1079
|
+
this.goal.verdict = "pending";
|
|
1080
|
+
this.goal.feedback = undefined;
|
|
1081
|
+
this.goal.status = "已手动停止,目标审查已中止";
|
|
1082
|
+
this.emitGoalStatus();
|
|
1083
|
+
this.emit({
|
|
1084
|
+
type: "notice",
|
|
1085
|
+
level: "warning",
|
|
1086
|
+
text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
// Goal review hook: after the run finished normally, if a goal is
|
|
1092
|
+
// active (and it belonged to the ACTIVE conversation) and we're not
|
|
1093
|
+
// already mid-review, spawn the isolated reviewer.
|
|
1094
|
+
if (g.goal &&
|
|
1095
|
+
!g.reviewing &&
|
|
1096
|
+
!this.goalWizardRunning &&
|
|
1097
|
+
conv.id === this.activeId &&
|
|
1098
|
+
!this.disposed) {
|
|
1099
|
+
void this.runGoalReview(conv);
|
|
1100
|
+
}
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
960
1103
|
case "entry_appended":
|
|
961
1104
|
this.scheduleSessionsRefresh();
|
|
962
1105
|
break;
|
|
@@ -1122,11 +1265,12 @@ export class ClientSession {
|
|
|
1122
1265
|
* Never throws / never crashes the server: spawn errors (ENOENT etc.)
|
|
1123
1266
|
* resolve with code -1 so callers can report them as notices.
|
|
1124
1267
|
*/
|
|
1125
|
-
runAsync(cmd, args, timeoutMs) {
|
|
1268
|
+
runAsync(cmd, args, timeoutMs, cwd) {
|
|
1126
1269
|
return new Promise((resolve) => {
|
|
1127
1270
|
let p;
|
|
1128
1271
|
try {
|
|
1129
1272
|
p = spawn(cmd, args, {
|
|
1273
|
+
...(cwd ? { cwd } : {}),
|
|
1130
1274
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1131
1275
|
// Windows: npm and friends are .cmd shims — Node can only exec
|
|
1132
1276
|
// them through the shell (otherwise spawn npm → ENOENT).
|
|
@@ -3055,6 +3199,665 @@ export class ClientSession {
|
|
|
3055
3199
|
});
|
|
3056
3200
|
}
|
|
3057
3201
|
}
|
|
3202
|
+
// ---------------------------------------------------------------------------
|
|
3203
|
+
// Goal / review
|
|
3204
|
+
// ---------------------------------------------------------------------------
|
|
3205
|
+
/** Push the current goal status to the client (the goal bar UI). */
|
|
3206
|
+
emitGoalStatus() {
|
|
3207
|
+
this.emit({ type: "goal_status", status: { ...this.goal } });
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
3211
|
+
* applied to the CURRENT active conversation of this project; reviews check
|
|
3212
|
+
* whatever run finishes next (agent_end).
|
|
3213
|
+
*/
|
|
3214
|
+
async setGoal(goalText, opts) {
|
|
3215
|
+
const text = (goalText ?? "").trim();
|
|
3216
|
+
if (!text) {
|
|
3217
|
+
await this.clearGoal();
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
this.goal.goal = text;
|
|
3221
|
+
// Model & rounds preference semantics ("全局记忆"):
|
|
3222
|
+
// - reviewModel undefined → keep the remembered choice; empty → main model.
|
|
3223
|
+
// - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
|
|
3224
|
+
if (opts?.reviewModel !== undefined)
|
|
3225
|
+
this.goal.reviewModel = opts.reviewModel || null;
|
|
3226
|
+
if (typeof opts?.maxRounds === "number") {
|
|
3227
|
+
const mr = Math.round(opts.maxRounds);
|
|
3228
|
+
this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
3229
|
+
}
|
|
3230
|
+
if (opts?.locked !== undefined)
|
|
3231
|
+
this.goal.locked = opts.locked;
|
|
3232
|
+
// Persist the chosen preferences so they survive reload.
|
|
3233
|
+
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
3234
|
+
reviewModel: this.goal.reviewModel,
|
|
3235
|
+
maxRounds: this.goal.maxRounds,
|
|
3236
|
+
locked: this.goal.locked,
|
|
3237
|
+
});
|
|
3238
|
+
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
3239
|
+
this.goal.round = 0;
|
|
3240
|
+
this.goal.reviewing = false;
|
|
3241
|
+
this.goal.verdict = "pending";
|
|
3242
|
+
this.goal.feedback = undefined;
|
|
3243
|
+
this.goal.wizard.active = false;
|
|
3244
|
+
this.goal.wizard.status = "";
|
|
3245
|
+
this.goal.status = "目标已设,等待生成…";
|
|
3246
|
+
this.emitGoalStatus();
|
|
3247
|
+
this.emit({
|
|
3248
|
+
type: "notice",
|
|
3249
|
+
level: "info",
|
|
3250
|
+
text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
3251
|
+
});
|
|
3252
|
+
// Auto-start generation right after setting the goal (unless this setGoal is
|
|
3253
|
+
// the wizard's internal one, which kicks off itself). This makes the direct
|
|
3254
|
+
// goal-bar path behave like the AI-提炼 path: set a target → agent begins.
|
|
3255
|
+
if (opts?.autoStart !== false) {
|
|
3256
|
+
try {
|
|
3257
|
+
const s = this.conv.session;
|
|
3258
|
+
await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
|
|
3259
|
+
}
|
|
3260
|
+
catch {
|
|
3261
|
+
// Best-effort; the user can still prompt manually.
|
|
3262
|
+
}
|
|
3263
|
+
this.flushSnapshot();
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
/**
|
|
3267
|
+
* Collaborative target wizard. Turns a raw user requirement into a refined
|
|
3268
|
+
* goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
|
|
3269
|
+
* in-memory session, so its model choice is its own) that questions the user
|
|
3270
|
+
* via `goal_ask` (multiple-choice + free-text, bridged to the browser through
|
|
3271
|
+
* the existing select/input dialog), converging on a goal, then auto-sets it.
|
|
3272
|
+
* Mutually exclusive with the review loop.
|
|
3273
|
+
*/
|
|
3274
|
+
async startGoalWizard(text, opts) {
|
|
3275
|
+
const draft = (text ?? "").trim();
|
|
3276
|
+
if (!draft)
|
|
3277
|
+
return;
|
|
3278
|
+
if (this.goalWizardRunning) {
|
|
3279
|
+
this.emit({
|
|
3280
|
+
type: "notice",
|
|
3281
|
+
level: "warning",
|
|
3282
|
+
text: "已有目标调研进行中,请等它完成…",
|
|
3283
|
+
});
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
if (this.goalReviewing) {
|
|
3287
|
+
this.emit({
|
|
3288
|
+
type: "notice",
|
|
3289
|
+
level: "warning",
|
|
3290
|
+
text: "正在审查中,无法开始目标调研,请稍等…",
|
|
3291
|
+
});
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
// Questions are NOT capped (调研不限制) — the wizard converges on its own;
|
|
3295
|
+
// the idle- and total-timeouts are the only guards. maxSteps is purely a
|
|
3296
|
+
// soft UI indicator, not a hard stop.
|
|
3297
|
+
const maxSteps = 20;
|
|
3298
|
+
this.goalWizardRunning = true;
|
|
3299
|
+
this.wizardCancelled = false;
|
|
3300
|
+
this.wizardAbort = new AbortController();
|
|
3301
|
+
this.wizardSession = null;
|
|
3302
|
+
this.goal.wizard.active = true;
|
|
3303
|
+
this.goal.wizard.draft = draft;
|
|
3304
|
+
this.goal.wizard.model = opts?.wizardModel ?? null;
|
|
3305
|
+
// Remember the model choice (and persist rounds/lock) — global memory.
|
|
3306
|
+
if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
|
|
3307
|
+
this.goal.reviewModel = opts.wizardModel || null;
|
|
3308
|
+
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
3309
|
+
reviewModel: this.goal.reviewModel,
|
|
3310
|
+
maxRounds: this.goal.maxRounds,
|
|
3311
|
+
locked: this.goal.locked,
|
|
3312
|
+
});
|
|
3313
|
+
this.goal.wizard.step = 0;
|
|
3314
|
+
this.goal.wizard.maxSteps = maxSteps;
|
|
3315
|
+
this.goal.wizard.status = "调研中…";
|
|
3316
|
+
this.goal.status = "目标调研中…";
|
|
3317
|
+
this.emitGoalStatus();
|
|
3318
|
+
// Idle-timeout: cancel the wizard if no question is answered within the
|
|
3319
|
+
// window (a stale dialog with no user response must not run forever). A
|
|
3320
|
+
// fresh timer is armed for each question; cleared once the run ends.
|
|
3321
|
+
const ac = this.wizardAbort;
|
|
3322
|
+
let idleTimer = null;
|
|
3323
|
+
const armIdle = () => {
|
|
3324
|
+
if (idleTimer)
|
|
3325
|
+
clearTimeout(idleTimer);
|
|
3326
|
+
idleTimer = setTimeout(() => {
|
|
3327
|
+
if (!ac.signal.aborted) {
|
|
3328
|
+
this.wizardCancelled = true;
|
|
3329
|
+
ac.abort(new Error("目标调研超时(等待回答过久)"));
|
|
3330
|
+
}
|
|
3331
|
+
}, ClientSession.WIZARD_IDLE_TIMEOUT_MS);
|
|
3332
|
+
idleTimer.unref?.();
|
|
3333
|
+
};
|
|
3334
|
+
const clearIdle = () => {
|
|
3335
|
+
if (idleTimer) {
|
|
3336
|
+
clearTimeout(idleTimer);
|
|
3337
|
+
idleTimer = null;
|
|
3338
|
+
}
|
|
3339
|
+
};
|
|
3340
|
+
armIdle();
|
|
3341
|
+
// Total-duration guard: hard cap on the whole wizard session (model
|
|
3342
|
+
// latency / unexpected loops must not run forever).
|
|
3343
|
+
const totalTimer = setTimeout(() => {
|
|
3344
|
+
if (!ac.signal.aborted) {
|
|
3345
|
+
this.wizardCancelled = true;
|
|
3346
|
+
ac.abort(new Error("目标调研超过总时长上限"));
|
|
3347
|
+
}
|
|
3348
|
+
}, ClientSession.WIZARD_MAX_TOTAL_MS);
|
|
3349
|
+
totalTimer.unref?.();
|
|
3350
|
+
this.emit({
|
|
3351
|
+
type: "notice",
|
|
3352
|
+
level: "info",
|
|
3353
|
+
text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
|
|
3354
|
+
});
|
|
3355
|
+
// The main conversation to show wizard progress cards in.
|
|
3356
|
+
let mainSession = this.session;
|
|
3357
|
+
try {
|
|
3358
|
+
const conv = this.conv;
|
|
3359
|
+
mainSession = conv.session;
|
|
3360
|
+
}
|
|
3361
|
+
catch {
|
|
3362
|
+
// no active conversation yet
|
|
3363
|
+
}
|
|
3364
|
+
let refinedGoal = "";
|
|
3365
|
+
try {
|
|
3366
|
+
const wmSpec = opts?.wizardModel
|
|
3367
|
+
? this.resolveReviewModel(opts.wizardModel)
|
|
3368
|
+
: null; // reuse the honest "provider/id" parser
|
|
3369
|
+
const services = await createAgentSessionServices({
|
|
3370
|
+
cwd: this.cwd,
|
|
3371
|
+
agentDir: this.agentDir,
|
|
3372
|
+
modelRuntime: await ModelRuntime.create({
|
|
3373
|
+
authPath: join(this.agentDir, "auth.json"),
|
|
3374
|
+
modelsPath: join(this.agentDir, "models.json"),
|
|
3375
|
+
}),
|
|
3376
|
+
});
|
|
3377
|
+
let model;
|
|
3378
|
+
if (wmSpec)
|
|
3379
|
+
model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
|
|
3380
|
+
if (!model) {
|
|
3381
|
+
const mainModel = mainSession.model;
|
|
3382
|
+
if (mainModel?.provider && mainModel.id)
|
|
3383
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
3384
|
+
}
|
|
3385
|
+
// The wizard asks the user questions via this tool; each call bridges one
|
|
3386
|
+
// select/input dialog to the browser and returns the user's answer.
|
|
3387
|
+
let qStep = 0;
|
|
3388
|
+
const goalAsk = defineTool({
|
|
3389
|
+
name: "goal_ask",
|
|
3390
|
+
label: "Ask the user",
|
|
3391
|
+
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.",
|
|
3392
|
+
parameters: Type.Object({
|
|
3393
|
+
question: Type.String({ description: "The question to ask" }),
|
|
3394
|
+
options: Type.Optional(Type.Array(Type.String())),
|
|
3395
|
+
}),
|
|
3396
|
+
// ONE question at a time. Sequential execution prevents the agent from
|
|
3397
|
+
// firing parallel goal_ask calls whose dialogs would overwrite each other
|
|
3398
|
+
// in the single browser modal (leaving earlier ones deadlocked — the
|
|
3399
|
+
// reported "调研卡住").
|
|
3400
|
+
executionMode: "sequential",
|
|
3401
|
+
execute: async (_id, params, _sig, _onUpdate, ctx) => {
|
|
3402
|
+
qStep += 1;
|
|
3403
|
+
if (qStep > maxSteps) {
|
|
3404
|
+
return {
|
|
3405
|
+
content: [
|
|
3406
|
+
{
|
|
3407
|
+
type: "text",
|
|
3408
|
+
text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
|
|
3409
|
+
},
|
|
3410
|
+
],
|
|
3411
|
+
details: {},
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
// Show the question in the main flow BEFORE blocking on the dialog, so
|
|
3415
|
+
// the user sees the wizard working even before answering.
|
|
3416
|
+
this.goal.wizard.step = qStep;
|
|
3417
|
+
this.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
|
|
3418
|
+
this.emitGoalStatus();
|
|
3419
|
+
try {
|
|
3420
|
+
armIdle();
|
|
3421
|
+
const isChoice = !!(params.options && params.options.length > 0);
|
|
3422
|
+
await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
|
|
3423
|
+
// Resolve the pending dialog as cancelled if the wizard is aborted.
|
|
3424
|
+
let aborted = false;
|
|
3425
|
+
const onAbort = () => {
|
|
3426
|
+
aborted = true;
|
|
3427
|
+
};
|
|
3428
|
+
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
3429
|
+
const choose = isChoice
|
|
3430
|
+
? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
|
|
3431
|
+
: ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
|
|
3432
|
+
const ans = (await choose);
|
|
3433
|
+
ac.signal.removeEventListener("abort", onAbort);
|
|
3434
|
+
if (aborted || ac.signal.aborted) {
|
|
3435
|
+
return {
|
|
3436
|
+
content: [
|
|
3437
|
+
{
|
|
3438
|
+
type: "text",
|
|
3439
|
+
text: "(调研已取消,请不要继续提问,直接结束对话)",
|
|
3440
|
+
},
|
|
3441
|
+
],
|
|
3442
|
+
details: {},
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
if (ans === undefined || ans === null || ans === false || ans === "") {
|
|
3446
|
+
return {
|
|
3447
|
+
content: [
|
|
3448
|
+
{
|
|
3449
|
+
type: "text",
|
|
3450
|
+
text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
|
|
3451
|
+
},
|
|
3452
|
+
],
|
|
3453
|
+
details: {},
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
// Record the answer in the flow too (instant append, main session idle).
|
|
3457
|
+
await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
|
|
3458
|
+
return {
|
|
3459
|
+
content: [{ type: "text", text: `用户回答:${ans}` }],
|
|
3460
|
+
details: {},
|
|
3461
|
+
};
|
|
3462
|
+
}
|
|
3463
|
+
catch (err) {
|
|
3464
|
+
return {
|
|
3465
|
+
content: [
|
|
3466
|
+
{
|
|
3467
|
+
type: "text",
|
|
3468
|
+
text: ac.signal.aborted
|
|
3469
|
+
? "(调研已取消,请不要继续提问,直接结束对话)"
|
|
3470
|
+
: `提问失败:${err.message}`,
|
|
3471
|
+
},
|
|
3472
|
+
],
|
|
3473
|
+
details: {},
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
},
|
|
3477
|
+
});
|
|
3478
|
+
const srv = await createAgentSessionFromServices({
|
|
3479
|
+
services,
|
|
3480
|
+
sessionManager: SessionManager.inMemory(this.cwd),
|
|
3481
|
+
customTools: [goalAsk],
|
|
3482
|
+
...(model ? { model } : {}),
|
|
3483
|
+
});
|
|
3484
|
+
const wizard = srv.session;
|
|
3485
|
+
this.wizardSession = wizard;
|
|
3486
|
+
await wizard.bindExtensions({ mode: "rpc", uiContext: this.webUi });
|
|
3487
|
+
// Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
|
|
3488
|
+
// wizard's agent run (not just mark it).
|
|
3489
|
+
if (!ac.signal.aborted) {
|
|
3490
|
+
ac.signal.addEventListener("abort", () => {
|
|
3491
|
+
void wizard.abort().catch(() => { });
|
|
3492
|
+
// Close the unanswered browser dialog(s) the wizard may have up.
|
|
3493
|
+
this.webUi.cancelPendingDialogs();
|
|
3494
|
+
}, { once: true });
|
|
3495
|
+
}
|
|
3496
|
+
await wizard.prompt(wizardPrompt(draft));
|
|
3497
|
+
refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
|
|
3498
|
+
// The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
|
|
3499
|
+
// if it didn't follow, strip a leading preamble line and keep the rest.
|
|
3500
|
+
const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
|
|
3501
|
+
if (goalMatch) {
|
|
3502
|
+
refinedGoal = goalMatch[1].trim();
|
|
3503
|
+
}
|
|
3504
|
+
else {
|
|
3505
|
+
const lines = refinedGoal.split("\n").filter((l) => l.trim());
|
|
3506
|
+
if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
|
|
3507
|
+
// First line looks like preamble (no sentence-ending punctuation).
|
|
3508
|
+
refinedGoal = lines.slice(1).join(" ").trim();
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
await srv.session.dispose();
|
|
3512
|
+
}
|
|
3513
|
+
catch (err) {
|
|
3514
|
+
this.emit({
|
|
3515
|
+
type: "notice",
|
|
3516
|
+
level: "error",
|
|
3517
|
+
text: `目标调研失败:${err.message}`,
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3520
|
+
finally {
|
|
3521
|
+
clearIdle();
|
|
3522
|
+
clearTimeout(totalTimer);
|
|
3523
|
+
this.goalWizardRunning = false;
|
|
3524
|
+
this.goal.wizard.active = false;
|
|
3525
|
+
this.goal.wizard.step = 0;
|
|
3526
|
+
this.goal.wizard.status = "";
|
|
3527
|
+
this.wizardSession = null;
|
|
3528
|
+
this.emitGoalStatus();
|
|
3529
|
+
}
|
|
3530
|
+
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
|
|
3531
|
+
if (ac.signal.aborted || this.wizardCancelled) {
|
|
3532
|
+
this.emit({
|
|
3533
|
+
type: "notice",
|
|
3534
|
+
level: "info",
|
|
3535
|
+
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
|
|
3536
|
+
});
|
|
3537
|
+
this.wizardAbort = null;
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
if (!refinedGoal.trim()) {
|
|
3541
|
+
this.emit({
|
|
3542
|
+
type: "notice",
|
|
3543
|
+
level: "warning",
|
|
3544
|
+
text: "调研未产出有效目标,请重试",
|
|
3545
|
+
});
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
3549
|
+
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
3550
|
+
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
3551
|
+
// without it the review is single-shot).
|
|
3552
|
+
const wantLocked = opts?.locked === undefined ? true : opts.locked;
|
|
3553
|
+
await this.setGoal(refinedGoal, {
|
|
3554
|
+
reviewModel: this.goal.reviewModel ?? undefined,
|
|
3555
|
+
maxRounds: opts?.maxRounds,
|
|
3556
|
+
locked: wantLocked,
|
|
3557
|
+
// The wizard kicks off generation itself below — avoid a double kick.
|
|
3558
|
+
autoStart: false,
|
|
3559
|
+
});
|
|
3560
|
+
const g2 = this.goal;
|
|
3561
|
+
this.wizardCancelled = false;
|
|
3562
|
+
this.wizardAbort = null;
|
|
3563
|
+
this.emit({
|
|
3564
|
+
type: "notice",
|
|
3565
|
+
level: "info",
|
|
3566
|
+
text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
3567
|
+
});
|
|
3568
|
+
// Kick the main agent into generating right away (no manual "开始吧").
|
|
3569
|
+
// The kick-off is a user message so it appears in the flow and triggers a
|
|
3570
|
+
// normal turn; the finishing agent_end then runs the review loop.
|
|
3571
|
+
try {
|
|
3572
|
+
await mainSession.sendUserMessage(`【目标已设定】\n\n${g2.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
3573
|
+
}
|
|
3574
|
+
catch {
|
|
3575
|
+
// Generation kick-off is best-effort; the user can still prompt manually.
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
/** Persist goal/review preference defaults (model, rounds cap, locked) without
|
|
3579
|
+
* touching the active goal — so changes in the goal bar are remembered across
|
|
3580
|
+
* reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
|
|
3581
|
+
async setGoalPrefs(opts) {
|
|
3582
|
+
if (opts?.reviewModel !== undefined)
|
|
3583
|
+
this.goal.reviewModel = opts.reviewModel || null;
|
|
3584
|
+
if (typeof opts?.maxRounds === "number") {
|
|
3585
|
+
const mr = Math.round(opts.maxRounds);
|
|
3586
|
+
this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
3587
|
+
}
|
|
3588
|
+
if (opts?.locked !== undefined)
|
|
3589
|
+
this.goal.locked = opts.locked;
|
|
3590
|
+
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
3591
|
+
reviewModel: this.goal.reviewModel,
|
|
3592
|
+
maxRounds: this.goal.maxRounds,
|
|
3593
|
+
locked: this.goal.locked,
|
|
3594
|
+
});
|
|
3595
|
+
this.emitGoalStatus();
|
|
3596
|
+
}
|
|
3597
|
+
/** Clear the active goal (cancels the review loop AND aborts a running
|
|
3598
|
+
* goal wizard — truly terminating its in-flight dialog + agent run). */
|
|
3599
|
+
async clearGoal() {
|
|
3600
|
+
this.goal.goal = null;
|
|
3601
|
+
this.goal.reviewing = false;
|
|
3602
|
+
this.goal.verdict = "pending";
|
|
3603
|
+
this.goal.feedback = undefined;
|
|
3604
|
+
this.goal.wizard.active = false;
|
|
3605
|
+
this.goal.wizard.status = "";
|
|
3606
|
+
this.goal.status = "";
|
|
3607
|
+
this.emitGoalStatus();
|
|
3608
|
+
// Abort a running wizard for real (✗ in the goal bar while scoping).
|
|
3609
|
+
if (this.goalWizardRunning || this.wizardAbort || this.wizardSession) {
|
|
3610
|
+
this.wizardCancelled = true;
|
|
3611
|
+
this.webUi.cancelPendingDialogs();
|
|
3612
|
+
this.wizardAbort?.abort();
|
|
3613
|
+
const ws2 = this.wizardSession;
|
|
3614
|
+
this.wizardSession = null;
|
|
3615
|
+
if (ws2) {
|
|
3616
|
+
await ws2.abort().catch(() => { });
|
|
3617
|
+
ws2.dispose();
|
|
3618
|
+
}
|
|
3619
|
+
this.wizardAbort = null;
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
/** Build a "provider/id" or null for the reviewer model, validating it exists. */
|
|
3623
|
+
resolveReviewModel(spec) {
|
|
3624
|
+
if (!spec)
|
|
3625
|
+
return null;
|
|
3626
|
+
const slash = spec.indexOf("/");
|
|
3627
|
+
if (slash <= 0 || slash === spec.length - 1)
|
|
3628
|
+
return null;
|
|
3629
|
+
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
|
|
3630
|
+
}
|
|
3631
|
+
/**
|
|
3632
|
+
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
3633
|
+
* to report, regardless of which model it runs on.
|
|
3634
|
+
*/
|
|
3635
|
+
reviewerPrompt(goal, round, maxRounds, output, gitDiff) {
|
|
3636
|
+
return [
|
|
3637
|
+
`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
|
|
3638
|
+
``,
|
|
3639
|
+
`# Goal`, // eslint-disable-line no-regex-spaces
|
|
3640
|
+
goal,
|
|
3641
|
+
``,
|
|
3642
|
+
`# Agent's final output`, // eslint-disable-line no-regex-spaces
|
|
3643
|
+
output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
|
|
3644
|
+
``,
|
|
3645
|
+
`# Git diff (if any)`, // eslint-disable-line no-regex-spaces
|
|
3646
|
+
gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
|
|
3647
|
+
``,
|
|
3648
|
+
`This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
|
|
3649
|
+
``,
|
|
3650
|
+
`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
|
|
3651
|
+
`{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
|
|
3652
|
+
`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
|
|
3653
|
+
`The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
|
|
3654
|
+
].join("\n");
|
|
3655
|
+
}
|
|
3656
|
+
/** Insert a wizard progress card into the MAIN conversation flow and render it
|
|
3657
|
+
* IMMEDIATELY (the main session is idle while the wizard runs in its own
|
|
3658
|
+
* session, so — unlike nextTurn, which queues until the next user prompt —
|
|
3659
|
+
* sending without a delivery option appends + persists + emits at once). */
|
|
3660
|
+
async pushWizardCard(sess, text, details) {
|
|
3661
|
+
try {
|
|
3662
|
+
await sess.sendCustomMessage({
|
|
3663
|
+
customType: "goal-wizard",
|
|
3664
|
+
content: [{ type: "text", text }],
|
|
3665
|
+
display: true,
|
|
3666
|
+
details: { type: "goal-wizard", ...details },
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
catch {
|
|
3670
|
+
// Non-fatal
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
/** Run a git diff (unstaged + staged) in the workspace, or "" when not a repo. */
|
|
3674
|
+
async gitDiff() {
|
|
3675
|
+
try {
|
|
3676
|
+
const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, this.cwd);
|
|
3677
|
+
if (code !== 0)
|
|
3678
|
+
return "";
|
|
3679
|
+
return out.slice(0, 60_000);
|
|
3680
|
+
}
|
|
3681
|
+
catch {
|
|
3682
|
+
return "";
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* The review loop: build an ISOLATED reviewer session (own fresh
|
|
3687
|
+
* AgentSession + own ModelRuntime so the reviewer truly runs on a different
|
|
3688
|
+
* model without touching the main session), ask it to judge the goal, then:
|
|
3689
|
+
* - pass → set status "已通过", insert a verdict card, end the loop;
|
|
3690
|
+
* - fail → inject the feedback as a user message into the main session
|
|
3691
|
+
* to steer a revision; the next agent_end re-reviews with the
|
|
3692
|
+
* same round budget.
|
|
3693
|
+
* Guarded so it never runs two reviews concurrently.
|
|
3694
|
+
*/
|
|
3695
|
+
async runGoalReview(conv) {
|
|
3696
|
+
// The review is bound to the conversation that just ran — but the user may
|
|
3697
|
+
// have switched to another conversation meanwhile. Reviews only make sense
|
|
3698
|
+
// for the conversation that generated output, so track it locally.
|
|
3699
|
+
const mainConv = this.convs.get(conv.id) ?? conv;
|
|
3700
|
+
const mainSession = mainConv.session;
|
|
3701
|
+
const g = this.goal;
|
|
3702
|
+
if (!g.goal ||
|
|
3703
|
+
this.goalReviewing ||
|
|
3704
|
+
this.goalWizardRunning ||
|
|
3705
|
+
this.disposed)
|
|
3706
|
+
return;
|
|
3707
|
+
// Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
|
|
3708
|
+
// object field) through the entire async body, so capture it here.
|
|
3709
|
+
const goalText = g.goal;
|
|
3710
|
+
// Cap rounds: single-shot (locked=false) always exactly one review.
|
|
3711
|
+
// For locked goals, maxRounds 0 = unlimited (keep revising until pass).
|
|
3712
|
+
const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
|
|
3713
|
+
if (g.locked && g.maxRounds > 0 && g.round >= budget) {
|
|
3714
|
+
this.goal.status = `已达最大轮数(${budget}),停止审查`;
|
|
3715
|
+
this.goal.reviewing = false;
|
|
3716
|
+
this.emitGoalStatus();
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
this.goalReviewing = true;
|
|
3720
|
+
g.reviewing = true;
|
|
3721
|
+
g.round += 1;
|
|
3722
|
+
g.verdict = "pending";
|
|
3723
|
+
g.feedback = undefined;
|
|
3724
|
+
g.status = `审查中(第 ${g.round} 轮)…`;
|
|
3725
|
+
this.emitGoalStatus();
|
|
3726
|
+
// Collect the review inputs.
|
|
3727
|
+
let finalText = "";
|
|
3728
|
+
try {
|
|
3729
|
+
finalText = mainSession.getLastAssistantText() ?? "";
|
|
3730
|
+
}
|
|
3731
|
+
catch {
|
|
3732
|
+
finalText = "";
|
|
3733
|
+
}
|
|
3734
|
+
const diff = await this.gitDiff();
|
|
3735
|
+
let reviewerVerdict = "fail";
|
|
3736
|
+
let reviewerFeedback = "(审查无法完成)";
|
|
3737
|
+
try {
|
|
3738
|
+
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
3739
|
+
const services = await createAgentSessionServices({
|
|
3740
|
+
cwd: this.cwd,
|
|
3741
|
+
agentDir: this.agentDir,
|
|
3742
|
+
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
3743
|
+
// one used by the main conversations, so its model choice is its own.
|
|
3744
|
+
modelRuntime: await ModelRuntime.create({
|
|
3745
|
+
authPath: join(this.agentDir, "auth.json"),
|
|
3746
|
+
modelsPath: join(this.agentDir, "models.json"),
|
|
3747
|
+
}),
|
|
3748
|
+
});
|
|
3749
|
+
// Model resolution: explicit reviewer model, else the main session's
|
|
3750
|
+
// current model (so a goal works even when no reviewer model is given).
|
|
3751
|
+
let model;
|
|
3752
|
+
if (rmSpec) {
|
|
3753
|
+
model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
|
|
3754
|
+
}
|
|
3755
|
+
if (!model) {
|
|
3756
|
+
const mainModel = mainSession.model;
|
|
3757
|
+
if (mainModel?.provider && mainModel.id) {
|
|
3758
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
const srv = await createAgentSessionFromServices({
|
|
3762
|
+
services,
|
|
3763
|
+
sessionManager: SessionManager.inMemory(this.cwd),
|
|
3764
|
+
...(model ? { model } : {}),
|
|
3765
|
+
});
|
|
3766
|
+
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
3767
|
+
const reviewer = srv.session;
|
|
3768
|
+
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff));
|
|
3769
|
+
// Parse the reviewer's final output (expected to be a JSON object).
|
|
3770
|
+
const raw = reviewer.getLastAssistantText() ?? "";
|
|
3771
|
+
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
3772
|
+
if (m) {
|
|
3773
|
+
reviewerVerdict = m[1];
|
|
3774
|
+
const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
3775
|
+
reviewerFeedback = fm?.[1] ?? "";
|
|
3776
|
+
}
|
|
3777
|
+
else {
|
|
3778
|
+
// No JSON — assume fail with the raw output as feedback.
|
|
3779
|
+
reviewerVerdict = "fail";
|
|
3780
|
+
reviewerFeedback = raw.slice(0, 2000);
|
|
3781
|
+
}
|
|
3782
|
+
await srv.session.dispose();
|
|
3783
|
+
}
|
|
3784
|
+
catch (err) {
|
|
3785
|
+
reviewerVerdict = "fail";
|
|
3786
|
+
reviewerFeedback = `审查过程中出错:${err.message}`;
|
|
3787
|
+
}
|
|
3788
|
+
this.goalReviewing = false;
|
|
3789
|
+
g.reviewing = false;
|
|
3790
|
+
g.verdict = reviewerVerdict;
|
|
3791
|
+
g.feedback = reviewerFeedback;
|
|
3792
|
+
const round = g.round;
|
|
3793
|
+
// Display cap: 0 means "unlimited" (keep revising until pass).
|
|
3794
|
+
const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
|
|
3795
|
+
const verdict = reviewerVerdict;
|
|
3796
|
+
const feedback = reviewerFeedback;
|
|
3797
|
+
/** Format "round/cap" for user-facing strings; cap 0 → 不限. */
|
|
3798
|
+
const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
|
|
3799
|
+
if (verdict === "pass") {
|
|
3800
|
+
g.status = "✅ 已通过目标审查";
|
|
3801
|
+
this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
|
|
3802
|
+
g.goal = null; // a passed goal is done and cleared
|
|
3803
|
+
this.emitGoalStatus();
|
|
3804
|
+
// Pass = the review result goes straight into the conversation as an
|
|
3805
|
+
// ordinary user message (NO separate goal-review card). It both tells the
|
|
3806
|
+
// USER the outcome and hands the main agent back out of "goal mode", so a
|
|
3807
|
+
// follow-up instruction like "发布" is a normal request — not a confirm echo.
|
|
3808
|
+
try {
|
|
3809
|
+
await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
3810
|
+
}
|
|
3811
|
+
catch {
|
|
3812
|
+
// Best-effort.
|
|
3813
|
+
}
|
|
3814
|
+
this.flushSnapshot();
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
// Failure: if rounds remain, steer a revision; else report the loop done.
|
|
3818
|
+
// For unlimited (budget=0) isLastRound is always false → keeps revising.
|
|
3819
|
+
const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
|
|
3820
|
+
if (!isLastRound) {
|
|
3821
|
+
g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
|
|
3822
|
+
this.emit({
|
|
3823
|
+
type: "notice",
|
|
3824
|
+
level: "warning",
|
|
3825
|
+
text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
|
|
3826
|
+
});
|
|
3827
|
+
// Inject the reviewer's feedback into the main session to revise (this IS
|
|
3828
|
+
// the fail review result, as an ordinary user message — no separate card).
|
|
3829
|
+
try {
|
|
3830
|
+
const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
|
|
3831
|
+
`审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
|
|
3832
|
+
await mainSession.sendUserMessage(steerText, {
|
|
3833
|
+
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
3834
|
+
});
|
|
3835
|
+
}
|
|
3836
|
+
catch (err) {
|
|
3837
|
+
g.status = `意见注入失败:${err.message}`;
|
|
3838
|
+
}
|
|
3839
|
+
this.emitGoalStatus();
|
|
3840
|
+
this.flushSnapshot();
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
// Rounds exhausted (finite cap reached / single-shot failed). Deliver the
|
|
3844
|
+
// fail result as an ordinary user message (no separate card), like the pass
|
|
3845
|
+
// and revise paths — the review result always lands in the conversation.
|
|
3846
|
+
g.status =
|
|
3847
|
+
g.locked && g.maxRounds > 0
|
|
3848
|
+
? `已达最大轮数(${g.maxRounds}),目标仍未通过`
|
|
3849
|
+
: `目标未通过(${capFmt(budgetForCard)})`;
|
|
3850
|
+
try {
|
|
3851
|
+
await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
3852
|
+
}
|
|
3853
|
+
catch {
|
|
3854
|
+
// Best-effort.
|
|
3855
|
+
}
|
|
3856
|
+
this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
|
|
3857
|
+
g.goal = null; // loop exhausted — clear the active goal
|
|
3858
|
+
this.emitGoalStatus();
|
|
3859
|
+
this.flushSnapshot();
|
|
3860
|
+
}
|
|
3058
3861
|
/** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
|
|
3059
3862
|
async setModel(modelId) {
|
|
3060
3863
|
try {
|