pi-web-ui 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/agent-service.js +595 -106
- package/dist/server/index.js +14 -6
- package/dist/server/terminals.js +307 -39
- package/package.json +1 -1
- package/web/dist/assets/{index-CY_kI90Q.css → index-BnDkdKFN.css} +1 -1
- package/web/dist/assets/index-CgNeNC6a.js +129 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CNbO53e-.js +0 -129
|
@@ -909,6 +909,8 @@ class ClientStateStore {
|
|
|
909
909
|
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
910
910
|
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
911
911
|
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
912
|
+
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
913
|
+
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
912
914
|
};
|
|
913
915
|
}
|
|
914
916
|
/** Persist the client's settings-panel state (partial merge). */
|
|
@@ -927,12 +929,19 @@ class ClientStateStore {
|
|
|
927
929
|
cur.visionBridgePromptMode ??
|
|
928
930
|
"append",
|
|
929
931
|
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
932
|
+
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
933
|
+
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
930
934
|
};
|
|
931
935
|
this.save();
|
|
932
936
|
}
|
|
933
937
|
/** Named settings presets for a client (empty if never saved). */
|
|
934
938
|
getPresets(clientId) {
|
|
935
|
-
return this.load()[clientId]?.presets ?? []
|
|
939
|
+
return (this.load()[clientId]?.presets ?? []).map((p) => ({
|
|
940
|
+
...p,
|
|
941
|
+
// Older client-state files predate review settings.
|
|
942
|
+
reviewPrompt: p.reviewPrompt ?? "",
|
|
943
|
+
reviewDisabledSkills: p.reviewDisabledSkills ?? [],
|
|
944
|
+
}));
|
|
936
945
|
}
|
|
937
946
|
/** Persist the client's named settings presets. */
|
|
938
947
|
savePresets(clientId, presets) {
|
|
@@ -955,6 +964,102 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
|
955
964
|
* runtime alive; conversations of other projects keep their own lists). */
|
|
956
965
|
const MAX_OPEN_CONVERSATIONS = 8;
|
|
957
966
|
const DEFAULT_CONV_TITLE = "新对话";
|
|
967
|
+
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
968
|
+
export function makePersistentTerminalTools(terminals, cwd) {
|
|
969
|
+
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
970
|
+
const failIf = (error) => {
|
|
971
|
+
if (error)
|
|
972
|
+
throw new Error(error);
|
|
973
|
+
};
|
|
974
|
+
return [
|
|
975
|
+
defineTool({
|
|
976
|
+
name: "terminal_create",
|
|
977
|
+
label: "Create terminal",
|
|
978
|
+
description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output.",
|
|
979
|
+
promptSnippet: "create persistent interactive PTY terminals",
|
|
980
|
+
parameters: Type.Object({
|
|
981
|
+
terminalId: Type.String({ description: "Stable terminal name" }),
|
|
982
|
+
cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
|
|
983
|
+
cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
|
|
984
|
+
rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
|
|
985
|
+
}),
|
|
986
|
+
execute: async (_id, p) => {
|
|
987
|
+
const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
|
|
988
|
+
if (!info)
|
|
989
|
+
throw new Error(`创建终端失败:${p.terminalId}`);
|
|
990
|
+
return result(`终端已创建:${JSON.stringify(info)}`, info);
|
|
991
|
+
},
|
|
992
|
+
}),
|
|
993
|
+
defineTool({
|
|
994
|
+
name: "terminal_list",
|
|
995
|
+
label: "List terminals",
|
|
996
|
+
description: "List all persistent PTY terminals owned by this conversation.",
|
|
997
|
+
promptSnippet: "list persistent terminals",
|
|
998
|
+
parameters: Type.Object({}),
|
|
999
|
+
execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
|
|
1000
|
+
}),
|
|
1001
|
+
defineTool({
|
|
1002
|
+
name: "terminal_close",
|
|
1003
|
+
label: "Close terminal",
|
|
1004
|
+
description: "Close a persistent PTY and terminate its process tree.",
|
|
1005
|
+
parameters: Type.Object({ terminalId: Type.String() }),
|
|
1006
|
+
execute: async (_id, p) => {
|
|
1007
|
+
if (!terminals.has(p.terminalId))
|
|
1008
|
+
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1009
|
+
terminals.kill(p.terminalId);
|
|
1010
|
+
return result(`终端已关闭:${p.terminalId}`);
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
defineTool({
|
|
1014
|
+
name: "terminal_input",
|
|
1015
|
+
label: "Send terminal input",
|
|
1016
|
+
description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
|
|
1017
|
+
parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
|
|
1018
|
+
execute: async (_id, p) => {
|
|
1019
|
+
failIf(terminals.inputChecked(p.terminalId, p.data));
|
|
1020
|
+
return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
|
|
1021
|
+
},
|
|
1022
|
+
}),
|
|
1023
|
+
defineTool({
|
|
1024
|
+
name: "terminal_key",
|
|
1025
|
+
label: "Send terminal key",
|
|
1026
|
+
description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
|
|
1027
|
+
parameters: Type.Object({
|
|
1028
|
+
terminalId: Type.String(),
|
|
1029
|
+
key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
|
|
1030
|
+
modifiers: Type.Optional(Type.Object({
|
|
1031
|
+
ctrl: Type.Optional(Type.Boolean()),
|
|
1032
|
+
alt: Type.Optional(Type.Boolean()),
|
|
1033
|
+
shift: Type.Optional(Type.Boolean()),
|
|
1034
|
+
})),
|
|
1035
|
+
}),
|
|
1036
|
+
execute: async (_id, p) => {
|
|
1037
|
+
failIf(terminals.key(p.terminalId, p.key, p.modifiers));
|
|
1038
|
+
return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
|
|
1039
|
+
},
|
|
1040
|
+
}),
|
|
1041
|
+
defineTool({
|
|
1042
|
+
name: "terminal_read",
|
|
1043
|
+
label: "Read terminal output",
|
|
1044
|
+
description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
|
|
1045
|
+
parameters: Type.Object({
|
|
1046
|
+
terminalId: Type.String(),
|
|
1047
|
+
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
1048
|
+
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
|
|
1049
|
+
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
|
|
1050
|
+
}),
|
|
1051
|
+
execute: async (_id, p, signal) => {
|
|
1052
|
+
const cursor = p.cursor ?? 0;
|
|
1053
|
+
if (p.waitMs)
|
|
1054
|
+
await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
|
|
1055
|
+
const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
|
|
1056
|
+
if (!read)
|
|
1057
|
+
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1058
|
+
return result(JSON.stringify(read), read);
|
|
1059
|
+
},
|
|
1060
|
+
}),
|
|
1061
|
+
];
|
|
1062
|
+
}
|
|
958
1063
|
/** First user text in a session, truncated for the conversation list. */
|
|
959
1064
|
function conversationTitle(session) {
|
|
960
1065
|
try {
|
|
@@ -1014,30 +1119,19 @@ export class ClientSession {
|
|
|
1014
1119
|
// injects its feedback back into the main session to revise. All goal
|
|
1015
1120
|
// mutation goes through setGoal/clearGoal so UI state stays consistent.
|
|
1016
1121
|
// -----------------------------------------------------------------------
|
|
1017
|
-
|
|
1018
|
-
|
|
1122
|
+
/** Defaults remembered for newly-created conversations. Each conversation
|
|
1123
|
+
* receives its own GoalStatus, so reviews can run concurrently. */
|
|
1124
|
+
goalReviewPrefs = {
|
|
1019
1125
|
reviewModel: null,
|
|
1020
|
-
maxRounds: 0,
|
|
1126
|
+
maxRounds: 0,
|
|
1021
1127
|
locked: true,
|
|
1022
|
-
reviewing: false,
|
|
1023
|
-
round: 0,
|
|
1024
|
-
status: "",
|
|
1025
|
-
verdict: "pending",
|
|
1026
|
-
wizard: {
|
|
1027
|
-
active: false,
|
|
1028
|
-
draft: "",
|
|
1029
|
-
model: null,
|
|
1030
|
-
step: 0,
|
|
1031
|
-
maxSteps: 6,
|
|
1032
|
-
status: "",
|
|
1033
|
-
},
|
|
1034
1128
|
};
|
|
1035
|
-
/**
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1129
|
+
/** Goal state exposed by the goal bar for the ACTIVE conversation. */
|
|
1130
|
+
get goal() {
|
|
1131
|
+
return this.conv.goal;
|
|
1132
|
+
}
|
|
1133
|
+
/** The browser has one dialog at a time, so wizard UI plumbing remains
|
|
1134
|
+
* client-wide; review execution itself is per conversation. */
|
|
1041
1135
|
/** Settings-panel state (system prompt + disabled skills/extensions). The
|
|
1042
1136
|
* resource-loader overrides in makeRuntimeFactory() read this at every
|
|
1043
1137
|
* reload(), so session.reload() applies changes to the running runtime. */
|
|
@@ -1059,6 +1153,8 @@ export class ClientSession {
|
|
|
1059
1153
|
/** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
|
|
1060
1154
|
* (abort the run), not just flip a flag. */
|
|
1061
1155
|
wizardSession = null;
|
|
1156
|
+
/** Conversation that owns the one browser wizard currently in flight. */
|
|
1157
|
+
wizardOwnerId = null;
|
|
1062
1158
|
/** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
|
|
1063
1159
|
* startGoalWizard reads this after the run to avoid setting a goal. */
|
|
1064
1160
|
wizardCancelled = false;
|
|
@@ -1106,8 +1202,46 @@ export class ClientSession {
|
|
|
1106
1202
|
get session() {
|
|
1107
1203
|
return this.conv.session;
|
|
1108
1204
|
}
|
|
1109
|
-
/**
|
|
1110
|
-
|
|
1205
|
+
/** PTYs are owned by individual conversations; this getter targets the active one
|
|
1206
|
+
* for compatibility with the existing terminal-panel dispatch path. */
|
|
1207
|
+
get terminals() {
|
|
1208
|
+
return this.conv.terminals;
|
|
1209
|
+
}
|
|
1210
|
+
getTerminalManager(conversationId) {
|
|
1211
|
+
return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
|
|
1212
|
+
}
|
|
1213
|
+
getTerminalCwd(conversationId) {
|
|
1214
|
+
return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
|
|
1215
|
+
}
|
|
1216
|
+
makeTerminalManager(conversationId, cwd) {
|
|
1217
|
+
return new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
|
|
1218
|
+
}
|
|
1219
|
+
emitTerminal(conversationId, msg) {
|
|
1220
|
+
// Background conversations keep collecting output in their own PTY buffer.
|
|
1221
|
+
// Do not stream it into the active xterm; push the retained window on switch.
|
|
1222
|
+
if (msg.type === "terminal_output" && conversationId !== this.activeId)
|
|
1223
|
+
return;
|
|
1224
|
+
if (msg.type === "terminal_output" || msg.type === "terminal_exit" || msg.type === "terminal_list") {
|
|
1225
|
+
this.emit({ ...msg, conversationId });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
this.emit(msg);
|
|
1229
|
+
}
|
|
1230
|
+
pushTerminals(conversation = this.conv) {
|
|
1231
|
+
this.emit({
|
|
1232
|
+
type: "terminal_list",
|
|
1233
|
+
conversationId: conversation.id,
|
|
1234
|
+
terminals: conversation.terminals.list(),
|
|
1235
|
+
});
|
|
1236
|
+
for (const output of conversation.terminals.replay()) {
|
|
1237
|
+
this.emit({
|
|
1238
|
+
type: "terminal_output",
|
|
1239
|
+
conversationId: conversation.id,
|
|
1240
|
+
terminalId: output.terminalId,
|
|
1241
|
+
data: output.data,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1111
1245
|
/**
|
|
1112
1246
|
* Vision-bridge transcript cache (batch hash → text). A re-sent / re-asked
|
|
1113
1247
|
* prompt with the same images skips the vision API call entirely — editing
|
|
@@ -1181,11 +1315,15 @@ export class ClientSession {
|
|
|
1181
1315
|
// Restore last-used goal/review preferences so model & rounds survive reload.
|
|
1182
1316
|
const gPrefs = stateStore.getGoalPrefs(clientId);
|
|
1183
1317
|
if (gPrefs) {
|
|
1184
|
-
cs.
|
|
1185
|
-
|
|
1186
|
-
|
|
1318
|
+
cs.goalReviewPrefs = {
|
|
1319
|
+
reviewModel: gPrefs.reviewModel,
|
|
1320
|
+
maxRounds: gPrefs.maxRounds,
|
|
1321
|
+
locked: gPrefs.locked,
|
|
1322
|
+
};
|
|
1187
1323
|
}
|
|
1188
|
-
const
|
|
1324
|
+
const conversationId = cs.nextConversationId();
|
|
1325
|
+
const terminals = cs.makeTerminalManager(conversationId, cwd);
|
|
1326
|
+
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
|
|
1189
1327
|
cwd,
|
|
1190
1328
|
agentDir,
|
|
1191
1329
|
// Resume the most recent session for this project — the SDK default
|
|
@@ -1196,7 +1334,7 @@ export class ClientSession {
|
|
|
1196
1334
|
// First conversation = the resumed session; it also seeds the shared
|
|
1197
1335
|
// ModelRuntime that every later conversation reuses.
|
|
1198
1336
|
cs.sharedModelRuntime = runtime.services.modelRuntime;
|
|
1199
|
-
const conv = cs.makeConversation(runtime);
|
|
1337
|
+
const conv = cs.makeConversation(runtime, conversationId, terminals);
|
|
1200
1338
|
cs.convs.set(conv.id, conv);
|
|
1201
1339
|
cs.activeId = conv.id;
|
|
1202
1340
|
for (const d of runtime.diagnostics) {
|
|
@@ -1216,7 +1354,7 @@ export class ClientSession {
|
|
|
1216
1354
|
* (the model choice is client-wide), so later conversations reuse the
|
|
1217
1355
|
* instance created with the first one.
|
|
1218
1356
|
*/
|
|
1219
|
-
makeRuntimeFactory() {
|
|
1357
|
+
makeRuntimeFactory(terminals) {
|
|
1220
1358
|
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
1221
1359
|
const services = await createAgentSessionServices({
|
|
1222
1360
|
cwd: effectiveCwd,
|
|
@@ -1273,6 +1411,7 @@ export class ClientSession {
|
|
|
1273
1411
|
// abortBash() 只杀这些命令,agent run 与对话继续。
|
|
1274
1412
|
customTools: [
|
|
1275
1413
|
makeKillableBashTool(effectiveCwd, this.bashKills),
|
|
1414
|
+
...makePersistentTerminalTools(terminals, effectiveCwd),
|
|
1276
1415
|
],
|
|
1277
1416
|
})),
|
|
1278
1417
|
services,
|
|
@@ -1280,10 +1419,37 @@ export class ClientSession {
|
|
|
1280
1419
|
};
|
|
1281
1420
|
};
|
|
1282
1421
|
}
|
|
1422
|
+
/** Create independent goal state for one conversation. Preferences are
|
|
1423
|
+
* client-wide defaults, while goal text/review progress is not shared. */
|
|
1424
|
+
makeGoalStatus() {
|
|
1425
|
+
return {
|
|
1426
|
+
conversationId: null,
|
|
1427
|
+
goal: null,
|
|
1428
|
+
reviewModel: this.goalReviewPrefs.reviewModel,
|
|
1429
|
+
maxRounds: this.goalReviewPrefs.maxRounds,
|
|
1430
|
+
locked: this.goalReviewPrefs.locked,
|
|
1431
|
+
reviewing: false,
|
|
1432
|
+
round: 0,
|
|
1433
|
+
status: "",
|
|
1434
|
+
verdict: "pending",
|
|
1435
|
+
wizard: {
|
|
1436
|
+
active: false,
|
|
1437
|
+
draft: "",
|
|
1438
|
+
model: null,
|
|
1439
|
+
step: 0,
|
|
1440
|
+
maxSteps: 6,
|
|
1441
|
+
status: "",
|
|
1442
|
+
},
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
/** Allocate a stable conversation id before constructing its runtime/tools. */
|
|
1446
|
+
nextConversationId() {
|
|
1447
|
+
return `c${++this.convSeq}`;
|
|
1448
|
+
}
|
|
1283
1449
|
/** Wrap a fresh runtime as a new conversation record. */
|
|
1284
|
-
makeConversation(runtime) {
|
|
1450
|
+
makeConversation(runtime, id, terminals) {
|
|
1285
1451
|
return {
|
|
1286
|
-
id
|
|
1452
|
+
id,
|
|
1287
1453
|
title: conversationTitle(runtime.session),
|
|
1288
1454
|
runtime,
|
|
1289
1455
|
session: runtime.session,
|
|
@@ -1294,6 +1460,11 @@ export class ClientSession {
|
|
|
1294
1460
|
listed: false,
|
|
1295
1461
|
promptedSinceActive: false,
|
|
1296
1462
|
lastActiveAt: Date.now(),
|
|
1463
|
+
goal: this.makeGoalStatus(),
|
|
1464
|
+
goalGeneration: 0,
|
|
1465
|
+
goalReviewGeneration: 0,
|
|
1466
|
+
wizardRunning: false,
|
|
1467
|
+
terminals,
|
|
1297
1468
|
msgIds: new Map(),
|
|
1298
1469
|
nextMsgId: 1,
|
|
1299
1470
|
userSeqByTs: new Map(),
|
|
@@ -1336,14 +1507,15 @@ export class ClientSession {
|
|
|
1336
1507
|
// Reconnect: push the background-task list — it must survive reconnects
|
|
1337
1508
|
// and outlive the conversation that started the tasks.
|
|
1338
1509
|
this.emitBgServers();
|
|
1510
|
+
// PTYs are conversation-owned and survive a socket reconnect.
|
|
1511
|
+
this.pushTerminals();
|
|
1339
1512
|
}
|
|
1340
1513
|
detachSink(send) {
|
|
1341
1514
|
this.sinks.delete(send);
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1515
|
+
// PTYs intentionally survive a socket drop: they are owned by the
|
|
1516
|
+
// conversation and can be inspected after reconnecting. Only conversation
|
|
1517
|
+
// disposal or server shutdown kills them.
|
|
1344
1518
|
if (this.sinks.size === 0) {
|
|
1345
|
-
this.terminals.killAll();
|
|
1346
|
-
// No sockets → nobody to refresh; drop the dir watcher too.
|
|
1347
1519
|
this.unwatchDir();
|
|
1348
1520
|
}
|
|
1349
1521
|
}
|
|
@@ -1510,7 +1682,7 @@ export class ClientSession {
|
|
|
1510
1682
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
1511
1683
|
case "agent_end": {
|
|
1512
1684
|
this.scheduleSessionsRefresh();
|
|
1513
|
-
const g =
|
|
1685
|
+
const g = conv.goal;
|
|
1514
1686
|
// Manual interrupt (Stop button / abort): the last assistant message
|
|
1515
1687
|
// carries stopReason "aborted". A half-finished run should NOT be
|
|
1516
1688
|
// reviewed (it would fail and inject a revision, only to be stopped
|
|
@@ -1521,12 +1693,14 @@ export class ClientSession {
|
|
|
1521
1693
|
return a.role === "assistant" && a.stopReason === "aborted";
|
|
1522
1694
|
});
|
|
1523
1695
|
if (aborted) {
|
|
1524
|
-
if (g.goal) {
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1696
|
+
if (g.goal && g.conversationId === conv.id) {
|
|
1697
|
+
conv.goalGeneration += 1;
|
|
1698
|
+
g.conversationId = null;
|
|
1699
|
+
g.goal = null;
|
|
1700
|
+
g.reviewing = false;
|
|
1701
|
+
g.verdict = "pending";
|
|
1702
|
+
g.feedback = undefined;
|
|
1703
|
+
g.status = "已手动停止,目标审查已中止";
|
|
1530
1704
|
this.emitGoalStatus();
|
|
1531
1705
|
this.emit({
|
|
1532
1706
|
type: "notice",
|
|
@@ -1540,9 +1714,9 @@ export class ClientSession {
|
|
|
1540
1714
|
// active (and it belonged to the ACTIVE conversation) and we're not
|
|
1541
1715
|
// already mid-review, spawn the isolated reviewer.
|
|
1542
1716
|
if (g.goal &&
|
|
1717
|
+
g.conversationId === conv.id &&
|
|
1543
1718
|
!g.reviewing &&
|
|
1544
|
-
!
|
|
1545
|
-
conv.id === this.activeId &&
|
|
1719
|
+
!conv.wizardRunning &&
|
|
1546
1720
|
!this.disposed) {
|
|
1547
1721
|
void this.runGoalReview(conv);
|
|
1548
1722
|
}
|
|
@@ -2148,6 +2322,178 @@ export class ClientSession {
|
|
|
2148
2322
|
});
|
|
2149
2323
|
this.emit({ type: "models_config", providers: list });
|
|
2150
2324
|
}
|
|
2325
|
+
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
2326
|
+
static numMeta(v) {
|
|
2327
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
2328
|
+
}
|
|
2329
|
+
static boolMeta(v) {
|
|
2330
|
+
return typeof v === "boolean" ? v : undefined;
|
|
2331
|
+
}
|
|
2332
|
+
static strArrMeta(v) {
|
|
2333
|
+
return Array.isArray(v)
|
|
2334
|
+
? v.filter((x) => typeof x === "string")
|
|
2335
|
+
: undefined;
|
|
2336
|
+
}
|
|
2337
|
+
/** Best-effort extraction of model metadata from an OpenAI-compatible
|
|
2338
|
+
* /models `data[]` item. Most endpoints only return `{ id }` — the extra
|
|
2339
|
+
* fields (context_window / max_model_len / modalities / supports_vision /
|
|
2340
|
+
* reasoning / display_name) come from vLLM and other extended
|
|
2341
|
+
* implementations, and are filled into the form when present. */
|
|
2342
|
+
static parseOpenAiModel(m) {
|
|
2343
|
+
const r = (m ?? {});
|
|
2344
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
2345
|
+
const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
|
|
2346
|
+
(typeof r.display_name === "string" && r.display_name.trim()
|
|
2347
|
+
? r.display_name
|
|
2348
|
+
: undefined);
|
|
2349
|
+
const modalities = ClientSession.strArrMeta(r.modalities) ??
|
|
2350
|
+
ClientSession.strArrMeta(r.input_modalities);
|
|
2351
|
+
const vision = modalities?.includes("image") === true ||
|
|
2352
|
+
ClientSession.boolMeta(r.supports_vision) === true ||
|
|
2353
|
+
ClientSession.boolMeta(r.vision) === true ||
|
|
2354
|
+
ClientSession.strArrMeta(r.input)?.includes("image") === true;
|
|
2355
|
+
const reasoning = ClientSession.boolMeta(r.reasoning) === true ||
|
|
2356
|
+
ClientSession.boolMeta(r.supports_reasoning) === true ||
|
|
2357
|
+
modalities?.includes("reasoning") === true;
|
|
2358
|
+
const contextWindow = ClientSession.numMeta(r.context_window) ??
|
|
2359
|
+
ClientSession.numMeta(r.context_length) ??
|
|
2360
|
+
ClientSession.numMeta(r.max_model_len) ??
|
|
2361
|
+
ClientSession.numMeta(r.max_context_length);
|
|
2362
|
+
const maxTokens = ClientSession.numMeta(r.max_tokens) ??
|
|
2363
|
+
ClientSession.numMeta(r.max_output_tokens) ??
|
|
2364
|
+
ClientSession.numMeta(r.max_completion_tokens);
|
|
2365
|
+
return {
|
|
2366
|
+
id,
|
|
2367
|
+
...(name ? { name } : {}),
|
|
2368
|
+
...(reasoning ? { reasoning: true } : {}),
|
|
2369
|
+
...(vision ? { input: ["text", "image"] } : {}),
|
|
2370
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
2371
|
+
...(maxTokens ? { maxTokens } : {}),
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
/** google-generative-ai /models shape:
|
|
2375
|
+
* { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
|
|
2376
|
+
* outputTokenLimit, supportedGenerationMethods }] } */
|
|
2377
|
+
static parseGoogleModel(m) {
|
|
2378
|
+
const r = (m ?? {});
|
|
2379
|
+
const rawName = typeof r.name === "string" ? r.name : "";
|
|
2380
|
+
const id = rawName.replace(/^models\//, "");
|
|
2381
|
+
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
2382
|
+
return {
|
|
2383
|
+
id,
|
|
2384
|
+
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
2385
|
+
...(ClientSession.numMeta(r.inputTokenLimit)
|
|
2386
|
+
? { contextWindow: ClientSession.numMeta(r.inputTokenLimit) }
|
|
2387
|
+
: {}),
|
|
2388
|
+
...(ClientSession.numMeta(r.outputTokenLimit)
|
|
2389
|
+
? { maxTokens: ClientSession.numMeta(r.outputTokenLimit) }
|
|
2390
|
+
: {}),
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
/** Probe a custom provider's OpenAI-compatible /models endpoint (server-side
|
|
2394
|
+
* because the baseUrl is often a LAN/loopback host the browser can't reach
|
|
2395
|
+
* cross-origin) and return the advertised models. reqId is echoed back
|
|
2396
|
+
* in fetch_models_result so the UI can match concurrent requests. */
|
|
2397
|
+
async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
2398
|
+
const emitError = (error) => this.emit({ type: "fetch_models_result", reqId, ok: false, error });
|
|
2399
|
+
const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
|
|
2400
|
+
if (!base)
|
|
2401
|
+
return emitError("请先填写 baseUrl");
|
|
2402
|
+
let url;
|
|
2403
|
+
try {
|
|
2404
|
+
url = new URL(base);
|
|
2405
|
+
}
|
|
2406
|
+
catch {
|
|
2407
|
+
return emitError(`baseUrl 无效:${base}`);
|
|
2408
|
+
}
|
|
2409
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2410
|
+
return emitError("baseUrl 仅支持 http/https");
|
|
2411
|
+
}
|
|
2412
|
+
const headers = {};
|
|
2413
|
+
// Per-api auth conventions (mirror pi's built-in provider configs):
|
|
2414
|
+
// openai-*: Authorization: Bearer <key>
|
|
2415
|
+
// anthropic: x-api-key + anthropic-version
|
|
2416
|
+
// google: x-goog-api-key
|
|
2417
|
+
// authHeader=false → no auth header at all (custom gateways).
|
|
2418
|
+
if (apiKey?.trim() && authHeader !== false) {
|
|
2419
|
+
const key = apiKey.trim();
|
|
2420
|
+
if (api === "anthropic-messages") {
|
|
2421
|
+
headers["x-api-key"] = key;
|
|
2422
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
2423
|
+
}
|
|
2424
|
+
else if (api === "google-generative-ai") {
|
|
2425
|
+
headers["x-goog-api-key"] = key;
|
|
2426
|
+
}
|
|
2427
|
+
else {
|
|
2428
|
+
headers["Authorization"] = `Bearer ${key}`;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
const tryFetch = async (u) => {
|
|
2432
|
+
const ac = new AbortController();
|
|
2433
|
+
const timer = setTimeout(() => ac.abort(), 15000);
|
|
2434
|
+
try {
|
|
2435
|
+
return await fetch(u, { headers, signal: ac.signal });
|
|
2436
|
+
}
|
|
2437
|
+
catch (err) {
|
|
2438
|
+
if (err.name === "AbortError") {
|
|
2439
|
+
emitError("请求超时(15 秒)");
|
|
2440
|
+
}
|
|
2441
|
+
else {
|
|
2442
|
+
emitError(`请求失败:${err.message}`);
|
|
2443
|
+
}
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
finally {
|
|
2447
|
+
clearTimeout(timer);
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
let res = await tryFetch(`${base}/models`);
|
|
2451
|
+
// BaseUrls that omit the /v1 prefix (e.g. https://api.openai.com) 404 on
|
|
2452
|
+
// the bare path — retry under /v1.
|
|
2453
|
+
if (res && res.status === 404 && !/\/v\d+[a-z-]*$/.test(base)) {
|
|
2454
|
+
res = await tryFetch(`${base}/v1/models`);
|
|
2455
|
+
}
|
|
2456
|
+
if (!res)
|
|
2457
|
+
return;
|
|
2458
|
+
if (!res.ok) {
|
|
2459
|
+
let detail = "";
|
|
2460
|
+
try {
|
|
2461
|
+
detail = (await res.text()).slice(0, 200);
|
|
2462
|
+
}
|
|
2463
|
+
catch {
|
|
2464
|
+
// response body already consumed / not text — ignore
|
|
2465
|
+
}
|
|
2466
|
+
return emitError(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
|
|
2467
|
+
}
|
|
2468
|
+
let models = [];
|
|
2469
|
+
try {
|
|
2470
|
+
const json = (await res.json());
|
|
2471
|
+
const data = Array.isArray(json.data) ? json.data : null;
|
|
2472
|
+
if (data) {
|
|
2473
|
+
// OpenAI-compatible: { data: [{ id, context_window, modalities, … }] }
|
|
2474
|
+
models = data
|
|
2475
|
+
.map((m) => ClientSession.parseOpenAiModel(m))
|
|
2476
|
+
.filter((m) => m.id);
|
|
2477
|
+
}
|
|
2478
|
+
else if (Array.isArray(json.models)) {
|
|
2479
|
+
// Google: { models: [{ name: "models/…", displayName, … }] }
|
|
2480
|
+
models = json.models
|
|
2481
|
+
.map((m) => ClientSession.parseGoogleModel(m))
|
|
2482
|
+
.filter((m) => m.id);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
catch {
|
|
2486
|
+
return emitError("响应不是有效的 JSON");
|
|
2487
|
+
}
|
|
2488
|
+
// Dedupe by id (keep the first, most complete entry) and sort by id.
|
|
2489
|
+
const seen = new Set();
|
|
2490
|
+
models = models
|
|
2491
|
+
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
|
|
2492
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
2493
|
+
if (models.length === 0)
|
|
2494
|
+
return emitError("接口未返回任何模型");
|
|
2495
|
+
this.emit({ type: "fetch_models_result", reqId, ok: true, models });
|
|
2496
|
+
}
|
|
2151
2497
|
/** Upsert one provider into models.json and hot-reload the model runtime. */
|
|
2152
2498
|
async saveModelConfig(providerId, config) {
|
|
2153
2499
|
const pid = providerId.trim();
|
|
@@ -2531,6 +2877,7 @@ export class ClientSession {
|
|
|
2531
2877
|
* settings change. */
|
|
2532
2878
|
pushSettings() {
|
|
2533
2879
|
const disabledSkills = new Set(this.settings.disabledSkills);
|
|
2880
|
+
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
2534
2881
|
const disabledExts = new Set(this.settings.disabledExtensions);
|
|
2535
2882
|
try {
|
|
2536
2883
|
// Refresh the cache with the CURRENTLY loaded set (post-filter).
|
|
@@ -2577,6 +2924,9 @@ export class ClientSession {
|
|
|
2577
2924
|
const skills = [...this.knownSkills.values()]
|
|
2578
2925
|
.map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
|
|
2579
2926
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2927
|
+
const reviewSkills = [...this.knownSkills.values()]
|
|
2928
|
+
.map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
|
|
2929
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2580
2930
|
const extensions = [...this.knownExtensions.values()]
|
|
2581
2931
|
.map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
|
|
2582
2932
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -2589,6 +2939,8 @@ export class ClientSession {
|
|
|
2589
2939
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
2590
2940
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
2591
2941
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
2942
|
+
reviewPrompt: this.settings.reviewPrompt,
|
|
2943
|
+
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
2592
2944
|
// The built-in prompts, so the replace-mode editors can prefill the
|
|
2593
2945
|
// text they would otherwise replace (empty until the resource-loader
|
|
2594
2946
|
// has run once for the system prompt).
|
|
@@ -2598,6 +2950,7 @@ export class ClientSession {
|
|
|
2598
2950
|
disabledSkills: [...this.settings.disabledSkills],
|
|
2599
2951
|
disabledExtensions: [...this.settings.disabledExtensions],
|
|
2600
2952
|
skills,
|
|
2953
|
+
reviewSkills,
|
|
2601
2954
|
extensions,
|
|
2602
2955
|
presets: this.presets.map((p) => ({ ...p })),
|
|
2603
2956
|
},
|
|
@@ -2646,6 +2999,12 @@ export class ClientSession {
|
|
|
2646
2999
|
if (partial.visionBridgePrompt !== undefined) {
|
|
2647
3000
|
this.settings.visionBridgePrompt = partial.visionBridgePrompt;
|
|
2648
3001
|
}
|
|
3002
|
+
if (partial.reviewPrompt !== undefined) {
|
|
3003
|
+
this.settings.reviewPrompt = partial.reviewPrompt;
|
|
3004
|
+
}
|
|
3005
|
+
if (partial.reviewDisabledSkills !== undefined) {
|
|
3006
|
+
this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
|
|
3007
|
+
}
|
|
2649
3008
|
this.stateStore.saveSettings(this.clientId, this.settings);
|
|
2650
3009
|
this.pushSettings();
|
|
2651
3010
|
if (needsReload)
|
|
@@ -2664,6 +3023,8 @@ export class ClientSession {
|
|
|
2664
3023
|
customSystemPrompt: this.settings.customSystemPrompt,
|
|
2665
3024
|
disabledSkills: [...this.settings.disabledSkills],
|
|
2666
3025
|
disabledExtensions: [...this.settings.disabledExtensions],
|
|
3026
|
+
reviewPrompt: this.settings.reviewPrompt,
|
|
3027
|
+
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
2667
3028
|
};
|
|
2668
3029
|
const existing = this.presets.findIndex((p) => p.name === n);
|
|
2669
3030
|
if (existing >= 0)
|
|
@@ -2685,6 +3046,10 @@ export class ClientSession {
|
|
|
2685
3046
|
customSystemPrompt: p.customSystemPrompt,
|
|
2686
3047
|
disabledSkills: [...p.disabledSkills],
|
|
2687
3048
|
disabledExtensions: [...p.disabledExtensions],
|
|
3049
|
+
reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
|
|
3050
|
+
reviewDisabledSkills: [
|
|
3051
|
+
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|
|
3052
|
+
],
|
|
2688
3053
|
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
2689
3054
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
2690
3055
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
@@ -3685,7 +4050,7 @@ ${transcript}
|
|
|
3685
4050
|
this.clearAllToolWatchdogs(conv);
|
|
3686
4051
|
conv.toolStartTimes.clear();
|
|
3687
4052
|
await conv.runtime.dispose();
|
|
3688
|
-
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
|
|
4053
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
|
|
3689
4054
|
cwd: conv.cwd,
|
|
3690
4055
|
agentDir: this.agentDir,
|
|
3691
4056
|
sessionManager: SessionManager.continueRecent(conv.cwd),
|
|
@@ -3715,7 +4080,7 @@ ${transcript}
|
|
|
3715
4080
|
// this branch normally can't exist — kept as a safety net).
|
|
3716
4081
|
const isBlank = (c) => {
|
|
3717
4082
|
try {
|
|
3718
|
-
return c.session.getSessionStats().totalMessages === 0;
|
|
4083
|
+
return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
|
|
3719
4084
|
}
|
|
3720
4085
|
catch {
|
|
3721
4086
|
// session being replaced — treat as used so we don't switch onto it
|
|
@@ -3752,18 +4117,22 @@ ${transcript}
|
|
|
3752
4117
|
// conversation stays valid during the (async) runtime creation.
|
|
3753
4118
|
const displaced = this.displaceActive();
|
|
3754
4119
|
try {
|
|
3755
|
-
const
|
|
4120
|
+
const conversationId = this.nextConversationId();
|
|
4121
|
+
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
4122
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
3756
4123
|
cwd: this.cwd,
|
|
3757
4124
|
agentDir: this.agentDir,
|
|
3758
4125
|
sessionManager: SessionManager.create(this.cwd),
|
|
3759
4126
|
});
|
|
3760
|
-
const conv = this.makeConversation(runtime);
|
|
4127
|
+
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
3761
4128
|
this.convs.set(conv.id, conv);
|
|
3762
4129
|
this.activeId = conv.id;
|
|
3763
4130
|
if (displaced)
|
|
3764
4131
|
this.removeConversation(displaced.id);
|
|
3765
4132
|
await this.bindSession();
|
|
3766
4133
|
this.emitConversations();
|
|
4134
|
+
this.emitGoalStatus();
|
|
4135
|
+
this.pushTerminals();
|
|
3767
4136
|
// The new runtime re-discovered skills/templates — refresh the catalog
|
|
3768
4137
|
// so the picker stops showing the previous runtime's list.
|
|
3769
4138
|
void this.pushSlashCommands();
|
|
@@ -3783,16 +4152,30 @@ ${transcript}
|
|
|
3783
4152
|
*
|
|
3784
4153
|
* - still streaming → it becomes a background run: ensure it is listed;
|
|
3785
4154
|
* - idle + listed + continued → keep it (the user did continue it);
|
|
4155
|
+
* - any retained terminal state → keep it listed until the terminals are closed;
|
|
3786
4156
|
* - idle + listed + opened-but-not-continued, or never listed at all → the
|
|
3787
4157
|
* caller must drop it (returns it so removal happens only after the
|
|
3788
4158
|
* active conversation has been switched away).
|
|
3789
4159
|
*/
|
|
3790
4160
|
displaceActive() {
|
|
3791
4161
|
const conv = this.conv;
|
|
4162
|
+
// An isolated reviewer can keep working while the main session is idle;
|
|
4163
|
+
// retain that conversation so its review is not disposed when the user
|
|
4164
|
+
// switches away without sending another prompt.
|
|
4165
|
+
if (conv.goal.reviewing || conv.wizardRunning) {
|
|
4166
|
+
conv.listed = true;
|
|
4167
|
+
return null;
|
|
4168
|
+
}
|
|
3792
4169
|
if (conv.session.isStreaming) {
|
|
3793
4170
|
conv.listed = true;
|
|
3794
4171
|
return null;
|
|
3795
4172
|
}
|
|
4173
|
+
// Terminal state is a reason to keep an otherwise idle conversation alive:
|
|
4174
|
+
// switching chats must not kill a PTY the user or agent may still need.
|
|
4175
|
+
if (conv.terminals.list().length > 0) {
|
|
4176
|
+
conv.listed = true;
|
|
4177
|
+
return null;
|
|
4178
|
+
}
|
|
3796
4179
|
if (conv.listed && conv.promptedSinceActive)
|
|
3797
4180
|
return null;
|
|
3798
4181
|
return conv;
|
|
@@ -3806,6 +4189,7 @@ ${transcript}
|
|
|
3806
4189
|
return;
|
|
3807
4190
|
this.convs.delete(id);
|
|
3808
4191
|
this.clearAllToolWatchdogs(conv);
|
|
4192
|
+
conv.terminals.killAll();
|
|
3809
4193
|
conv.unsubscribe?.();
|
|
3810
4194
|
conv.unsubscribe = undefined;
|
|
3811
4195
|
void conv.runtime.dispose().catch(() => { });
|
|
@@ -3825,6 +4209,8 @@ ${transcript}
|
|
|
3825
4209
|
this.conv.lastActiveAt = Date.now();
|
|
3826
4210
|
this.webUi.refresh();
|
|
3827
4211
|
this.emitConversations();
|
|
4212
|
+
this.emitGoalStatus();
|
|
4213
|
+
this.pushTerminals();
|
|
3828
4214
|
// The switched-to conversation has its own runtime (own resource cache).
|
|
3829
4215
|
void this.pushSlashCommands();
|
|
3830
4216
|
this.flushSnapshot();
|
|
@@ -4399,12 +4785,14 @@ ${transcript}
|
|
|
4399
4785
|
}
|
|
4400
4786
|
else {
|
|
4401
4787
|
// First visit to this project: resume its most recent session.
|
|
4402
|
-
const
|
|
4788
|
+
const conversationId = this.nextConversationId();
|
|
4789
|
+
const terminals = this.makeTerminalManager(conversationId, abs);
|
|
4790
|
+
const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
4403
4791
|
cwd: abs,
|
|
4404
4792
|
agentDir: this.agentDir,
|
|
4405
4793
|
sessionManager: SessionManager.continueRecent(abs),
|
|
4406
4794
|
});
|
|
4407
|
-
const conv = this.makeConversation(newRuntime);
|
|
4795
|
+
const conv = this.makeConversation(newRuntime, conversationId, terminals);
|
|
4408
4796
|
this.convs.set(conv.id, conv);
|
|
4409
4797
|
this.activeId = conv.id;
|
|
4410
4798
|
if (displaced)
|
|
@@ -4416,6 +4804,7 @@ ${transcript}
|
|
|
4416
4804
|
}
|
|
4417
4805
|
await this.bindSession();
|
|
4418
4806
|
}
|
|
4807
|
+
this.pushTerminals();
|
|
4419
4808
|
this.conv.promptedSinceActive = false;
|
|
4420
4809
|
this.conv.lastActiveAt = Date.now();
|
|
4421
4810
|
this.cwd = abs;
|
|
@@ -4424,6 +4813,7 @@ ${transcript}
|
|
|
4424
4813
|
void this.pushProjects();
|
|
4425
4814
|
this.webUi.refresh();
|
|
4426
4815
|
this.emitConversations();
|
|
4816
|
+
this.emitGoalStatus();
|
|
4427
4817
|
// Skills / prompt templates are project-bound — refresh the catalog.
|
|
4428
4818
|
void this.pushSlashCommands();
|
|
4429
4819
|
this.emit({
|
|
@@ -4470,9 +4860,17 @@ ${transcript}
|
|
|
4470
4860
|
// ---------------------------------------------------------------------------
|
|
4471
4861
|
// Goal / review
|
|
4472
4862
|
// ---------------------------------------------------------------------------
|
|
4473
|
-
/** Push the
|
|
4863
|
+
/** Push the active conversation's goal status to the client (the goal bar
|
|
4864
|
+
* UI). Conversations without an active goal reflect the client's remembered
|
|
4865
|
+
* defaults; an existing goal keeps its own review settings. */
|
|
4474
4866
|
emitGoalStatus() {
|
|
4475
|
-
|
|
4867
|
+
const goal = this.goal;
|
|
4868
|
+
if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
|
|
4869
|
+
goal.reviewModel = this.goalReviewPrefs.reviewModel;
|
|
4870
|
+
goal.maxRounds = this.goalReviewPrefs.maxRounds;
|
|
4871
|
+
goal.locked = this.goalReviewPrefs.locked;
|
|
4872
|
+
}
|
|
4873
|
+
this.emit({ type: "goal_status", status: { ...goal } });
|
|
4476
4874
|
}
|
|
4477
4875
|
/**
|
|
4478
4876
|
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
@@ -4485,6 +4883,13 @@ ${transcript}
|
|
|
4485
4883
|
await this.clearGoal();
|
|
4486
4884
|
return;
|
|
4487
4885
|
}
|
|
4886
|
+
// A goal is scoped to the conversation that is active when it is set.
|
|
4887
|
+
// This prevents an agent_end from a newly-created/switched conversation
|
|
4888
|
+
// from consuming the previous conversation's goal.
|
|
4889
|
+
const goalConversationId = this.activeId;
|
|
4890
|
+
this.conv.goalGeneration += 1;
|
|
4891
|
+
this.goal.reviewing = false;
|
|
4892
|
+
this.goal.conversationId = goalConversationId;
|
|
4488
4893
|
this.goal.goal = text;
|
|
4489
4894
|
// Model & rounds preference semantics ("全局记忆"):
|
|
4490
4895
|
// - reviewModel undefined → keep the remembered choice; empty → main model.
|
|
@@ -4497,6 +4902,11 @@ ${transcript}
|
|
|
4497
4902
|
}
|
|
4498
4903
|
if (opts?.locked !== undefined)
|
|
4499
4904
|
this.goal.locked = opts.locked;
|
|
4905
|
+
this.goalReviewPrefs = {
|
|
4906
|
+
reviewModel: this.goal.reviewModel,
|
|
4907
|
+
maxRounds: this.goal.maxRounds,
|
|
4908
|
+
locked: this.goal.locked,
|
|
4909
|
+
};
|
|
4500
4910
|
// Persist the chosen preferences so they survive reload.
|
|
4501
4911
|
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
4502
4912
|
reviewModel: this.goal.reviewModel,
|
|
@@ -4537,7 +4947,7 @@ ${transcript}
|
|
|
4537
4947
|
* in-memory session, so its model choice is its own) that questions the user
|
|
4538
4948
|
* via `goal_ask` (multiple-choice + free-text, bridged to the browser through
|
|
4539
4949
|
* the existing select/input dialog), converging on a goal, then auto-sets it.
|
|
4540
|
-
* Mutually exclusive with the review loop.
|
|
4950
|
+
* Mutually exclusive with the review loop of the same conversation.
|
|
4541
4951
|
*/
|
|
4542
4952
|
async startGoalWizard(text, opts) {
|
|
4543
4953
|
if (this.quiesceBlocked())
|
|
@@ -4545,7 +4955,12 @@ ${transcript}
|
|
|
4545
4955
|
const draft = (text ?? "").trim();
|
|
4546
4956
|
if (!draft)
|
|
4547
4957
|
return;
|
|
4548
|
-
|
|
4958
|
+
// The wizard and its progress cards belong to the conversation that
|
|
4959
|
+
// launched it. If the user switches away, do not later set a goal on the
|
|
4960
|
+
// new active conversation while the wizard is still finishing.
|
|
4961
|
+
const wizardConversationId = this.activeId;
|
|
4962
|
+
const wizardConversation = this.conv;
|
|
4963
|
+
if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
|
|
4549
4964
|
this.emit({
|
|
4550
4965
|
type: "notice",
|
|
4551
4966
|
level: "warning",
|
|
@@ -4553,7 +4968,7 @@ ${transcript}
|
|
|
4553
4968
|
});
|
|
4554
4969
|
return;
|
|
4555
4970
|
}
|
|
4556
|
-
if (
|
|
4971
|
+
if (wizardConversation.goal.reviewing) {
|
|
4557
4972
|
this.emit({
|
|
4558
4973
|
type: "notice",
|
|
4559
4974
|
level: "warning",
|
|
@@ -4565,25 +4980,37 @@ ${transcript}
|
|
|
4565
4980
|
// the idle- and total-timeouts are the only guards. maxSteps is purely a
|
|
4566
4981
|
// soft UI indicator, not a hard stop.
|
|
4567
4982
|
const maxSteps = 20;
|
|
4568
|
-
|
|
4983
|
+
wizardConversation.wizardRunning = true;
|
|
4984
|
+
this.wizardOwnerId = wizardConversationId;
|
|
4569
4985
|
this.wizardCancelled = false;
|
|
4570
4986
|
this.wizardAbort = new AbortController();
|
|
4571
4987
|
this.wizardSession = null;
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4988
|
+
wizardConversation.goal.wizard.active = true;
|
|
4989
|
+
wizardConversation.goal.wizard.draft = draft;
|
|
4990
|
+
wizardConversation.goal.wizard.model = opts?.wizardModel ?? null;
|
|
4575
4991
|
// Remember the model choice (and persist rounds/lock) — global memory.
|
|
4576
4992
|
if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
|
|
4577
|
-
|
|
4993
|
+
wizardConversation.goal.reviewModel = opts.wizardModel || null;
|
|
4994
|
+
if (typeof opts?.maxRounds === "number") {
|
|
4995
|
+
const mr = Math.round(opts.maxRounds);
|
|
4996
|
+
wizardConversation.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
4997
|
+
}
|
|
4998
|
+
if (opts?.locked !== undefined)
|
|
4999
|
+
wizardConversation.goal.locked = opts.locked;
|
|
5000
|
+
this.goalReviewPrefs = {
|
|
5001
|
+
reviewModel: wizardConversation.goal.reviewModel,
|
|
5002
|
+
maxRounds: wizardConversation.goal.maxRounds,
|
|
5003
|
+
locked: wizardConversation.goal.locked,
|
|
5004
|
+
};
|
|
4578
5005
|
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
4579
|
-
reviewModel:
|
|
4580
|
-
maxRounds:
|
|
4581
|
-
locked:
|
|
5006
|
+
reviewModel: wizardConversation.goal.reviewModel,
|
|
5007
|
+
maxRounds: wizardConversation.goal.maxRounds,
|
|
5008
|
+
locked: wizardConversation.goal.locked,
|
|
4582
5009
|
});
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
5010
|
+
wizardConversation.goal.wizard.step = 0;
|
|
5011
|
+
wizardConversation.goal.wizard.maxSteps = maxSteps;
|
|
5012
|
+
wizardConversation.goal.wizard.status = "调研中…";
|
|
5013
|
+
wizardConversation.goal.status = "目标调研中…";
|
|
4587
5014
|
this.emitGoalStatus();
|
|
4588
5015
|
// Idle-timeout: cancel the wizard if no question is answered within the
|
|
4589
5016
|
// window (a stale dialog with no user response must not run forever). A
|
|
@@ -4623,21 +5050,14 @@ ${transcript}
|
|
|
4623
5050
|
text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
|
|
4624
5051
|
});
|
|
4625
5052
|
// The main conversation to show wizard progress cards in.
|
|
4626
|
-
|
|
4627
|
-
try {
|
|
4628
|
-
const conv = this.conv;
|
|
4629
|
-
mainSession = conv.session;
|
|
4630
|
-
}
|
|
4631
|
-
catch {
|
|
4632
|
-
// no active conversation yet
|
|
4633
|
-
}
|
|
5053
|
+
const mainSession = wizardConversation.session;
|
|
4634
5054
|
let refinedGoal = "";
|
|
4635
5055
|
try {
|
|
4636
5056
|
const wmSpec = opts?.wizardModel
|
|
4637
5057
|
? this.resolveReviewModel(opts.wizardModel)
|
|
4638
5058
|
: null; // reuse the honest "provider/id" parser
|
|
4639
5059
|
const services = await createAgentSessionServices({
|
|
4640
|
-
cwd:
|
|
5060
|
+
cwd: wizardConversation.cwd,
|
|
4641
5061
|
agentDir: this.agentDir,
|
|
4642
5062
|
modelRuntime: await ModelRuntime.create({
|
|
4643
5063
|
authPath: join(this.agentDir, "auth.json"),
|
|
@@ -4683,8 +5103,8 @@ ${transcript}
|
|
|
4683
5103
|
}
|
|
4684
5104
|
// Show the question in the main flow BEFORE blocking on the dialog, so
|
|
4685
5105
|
// the user sees the wizard working even before answering.
|
|
4686
|
-
|
|
4687
|
-
|
|
5106
|
+
wizardConversation.goal.wizard.step = qStep;
|
|
5107
|
+
wizardConversation.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
|
|
4688
5108
|
this.emitGoalStatus();
|
|
4689
5109
|
try {
|
|
4690
5110
|
armIdle();
|
|
@@ -4790,10 +5210,12 @@ ${transcript}
|
|
|
4790
5210
|
finally {
|
|
4791
5211
|
clearIdle();
|
|
4792
5212
|
clearTimeout(totalTimer);
|
|
4793
|
-
|
|
4794
|
-
this.
|
|
4795
|
-
|
|
4796
|
-
|
|
5213
|
+
wizardConversation.wizardRunning = false;
|
|
5214
|
+
if (this.wizardOwnerId === wizardConversationId)
|
|
5215
|
+
this.wizardOwnerId = null;
|
|
5216
|
+
wizardConversation.goal.wizard.active = false;
|
|
5217
|
+
wizardConversation.goal.wizard.step = 0;
|
|
5218
|
+
wizardConversation.goal.wizard.status = "";
|
|
4797
5219
|
this.wizardSession = null;
|
|
4798
5220
|
this.emitGoalStatus();
|
|
4799
5221
|
}
|
|
@@ -4815,19 +5237,27 @@ ${transcript}
|
|
|
4815
5237
|
});
|
|
4816
5238
|
return;
|
|
4817
5239
|
}
|
|
5240
|
+
if (this.activeId !== wizardConversationId) {
|
|
5241
|
+
this.emit({
|
|
5242
|
+
type: "notice",
|
|
5243
|
+
level: "info",
|
|
5244
|
+
text: "已切换对话,目标调研结果已丢弃",
|
|
5245
|
+
});
|
|
5246
|
+
return;
|
|
5247
|
+
}
|
|
4818
5248
|
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
4819
5249
|
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
4820
5250
|
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
4821
5251
|
// without it the review is single-shot).
|
|
4822
5252
|
const wantLocked = opts?.locked === undefined ? true : opts.locked;
|
|
4823
5253
|
await this.setGoal(refinedGoal, {
|
|
4824
|
-
reviewModel:
|
|
5254
|
+
reviewModel: wizardConversation.goal.reviewModel ?? undefined,
|
|
4825
5255
|
maxRounds: opts?.maxRounds,
|
|
4826
5256
|
locked: wantLocked,
|
|
4827
5257
|
// The wizard kicks off generation itself below — avoid a double kick.
|
|
4828
5258
|
autoStart: false,
|
|
4829
5259
|
});
|
|
4830
|
-
const g2 =
|
|
5260
|
+
const g2 = wizardConversation.goal;
|
|
4831
5261
|
this.wizardCancelled = false;
|
|
4832
5262
|
this.wizardAbort = null;
|
|
4833
5263
|
this.emit({
|
|
@@ -4857,6 +5287,11 @@ ${transcript}
|
|
|
4857
5287
|
}
|
|
4858
5288
|
if (opts?.locked !== undefined)
|
|
4859
5289
|
this.goal.locked = opts.locked;
|
|
5290
|
+
this.goalReviewPrefs = {
|
|
5291
|
+
reviewModel: this.goal.reviewModel,
|
|
5292
|
+
maxRounds: this.goal.maxRounds,
|
|
5293
|
+
locked: this.goal.locked,
|
|
5294
|
+
};
|
|
4860
5295
|
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
4861
5296
|
reviewModel: this.goal.reviewModel,
|
|
4862
5297
|
maxRounds: this.goal.maxRounds,
|
|
@@ -4867,6 +5302,9 @@ ${transcript}
|
|
|
4867
5302
|
/** Clear the active goal (cancels the review loop AND aborts a running
|
|
4868
5303
|
* goal wizard — truly terminating its in-flight dialog + agent run). */
|
|
4869
5304
|
async clearGoal() {
|
|
5305
|
+
this.conv.goalGeneration += 1;
|
|
5306
|
+
this.goal.reviewing = false;
|
|
5307
|
+
this.goal.conversationId = null;
|
|
4870
5308
|
this.goal.goal = null;
|
|
4871
5309
|
this.goal.reviewing = false;
|
|
4872
5310
|
this.goal.verdict = "pending";
|
|
@@ -4876,7 +5314,7 @@ ${transcript}
|
|
|
4876
5314
|
this.goal.status = "";
|
|
4877
5315
|
this.emitGoalStatus();
|
|
4878
5316
|
// Abort a running wizard for real (✗ in the goal bar while scoping).
|
|
4879
|
-
if (this.
|
|
5317
|
+
if (this.wizardOwnerId === this.activeId) {
|
|
4880
5318
|
this.wizardCancelled = true;
|
|
4881
5319
|
this.webUi.cancelPendingDialogs();
|
|
4882
5320
|
this.wizardAbort?.abort();
|
|
@@ -4902,7 +5340,7 @@ ${transcript}
|
|
|
4902
5340
|
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
4903
5341
|
* to report, regardless of which model it runs on.
|
|
4904
5342
|
*/
|
|
4905
|
-
reviewerPrompt(goal, round, maxRounds, output, gitDiff) {
|
|
5343
|
+
reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
|
|
4906
5344
|
return [
|
|
4907
5345
|
`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
|
|
4908
5346
|
``,
|
|
@@ -4916,6 +5354,9 @@ ${transcript}
|
|
|
4916
5354
|
gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
|
|
4917
5355
|
``,
|
|
4918
5356
|
`This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
|
|
5357
|
+
...(customPrompt.trim()
|
|
5358
|
+
? [``, `# Additional reviewer instructions`, customPrompt.trim()]
|
|
5359
|
+
: []),
|
|
4919
5360
|
``,
|
|
4920
5361
|
`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
|
|
4921
5362
|
`{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
|
|
@@ -4940,10 +5381,11 @@ ${transcript}
|
|
|
4940
5381
|
// Non-fatal
|
|
4941
5382
|
}
|
|
4942
5383
|
}
|
|
4943
|
-
/** Run a git diff (unstaged + staged) in
|
|
4944
|
-
|
|
5384
|
+
/** Run a git diff (unstaged + staged) in a conversation's workspace, or
|
|
5385
|
+
* "" when not a repo. */
|
|
5386
|
+
async gitDiff(cwd) {
|
|
4945
5387
|
try {
|
|
4946
|
-
const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000,
|
|
5388
|
+
const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, cwd);
|
|
4947
5389
|
if (code !== 0)
|
|
4948
5390
|
return "";
|
|
4949
5391
|
return out.slice(0, 60_000);
|
|
@@ -4960,33 +5402,59 @@ ${transcript}
|
|
|
4960
5402
|
* - fail → inject the feedback as a user message into the main session
|
|
4961
5403
|
* to steer a revision; the next agent_end re-reviews with the
|
|
4962
5404
|
* same round budget.
|
|
4963
|
-
* Guarded so
|
|
5405
|
+
* Guarded per conversation so separate conversations can review concurrently.
|
|
4964
5406
|
*/
|
|
5407
|
+
isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
5408
|
+
return (!this.disposed &&
|
|
5409
|
+
this.convs.get(conv.id) === conv &&
|
|
5410
|
+
conv.goal.conversationId === conv.id &&
|
|
5411
|
+
conv.goalGeneration === goalGeneration &&
|
|
5412
|
+
conv.goalReviewGeneration === reviewGeneration &&
|
|
5413
|
+
!!conv.goal.goal);
|
|
5414
|
+
}
|
|
5415
|
+
/** Drop the result of a review that became stale while it was awaiting the
|
|
5416
|
+
* reviewer model (most commonly because the user switched conversations). */
|
|
5417
|
+
discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
5418
|
+
if (conv.goalReviewGeneration !== reviewGeneration)
|
|
5419
|
+
return;
|
|
5420
|
+
if (conv.goalGeneration === goalGeneration &&
|
|
5421
|
+
conv.goal.conversationId === conv.id) {
|
|
5422
|
+
conv.goal.reviewing = false;
|
|
5423
|
+
conv.goal.status = "审查已中止,目标已更新或取消";
|
|
5424
|
+
this.emitGoalStatus();
|
|
5425
|
+
}
|
|
5426
|
+
}
|
|
4965
5427
|
async runGoalReview(conv) {
|
|
4966
|
-
// The review is bound to the conversation that just ran
|
|
4967
|
-
//
|
|
4968
|
-
//
|
|
5428
|
+
// The review is bound to the conversation that just ran. Capture both the
|
|
5429
|
+
// owner and a generation so a later switch/set/clear cannot let an old,
|
|
5430
|
+
// asynchronous reviewer mutate the new conversation's goal state.
|
|
4969
5431
|
const mainConv = this.convs.get(conv.id) ?? conv;
|
|
4970
5432
|
const mainSession = mainConv.session;
|
|
4971
|
-
const g =
|
|
5433
|
+
const g = conv.goal;
|
|
4972
5434
|
if (!g.goal ||
|
|
4973
|
-
|
|
4974
|
-
|
|
5435
|
+
g.conversationId !== conv.id ||
|
|
5436
|
+
g.reviewing ||
|
|
5437
|
+
conv.wizardRunning ||
|
|
4975
5438
|
this.disposed)
|
|
4976
5439
|
return;
|
|
5440
|
+
const goalGeneration = conv.goalGeneration;
|
|
5441
|
+
const reviewGeneration = ++conv.goalReviewGeneration;
|
|
4977
5442
|
// Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
|
|
4978
5443
|
// object field) through the entire async body, so capture it here.
|
|
4979
5444
|
const goalText = g.goal;
|
|
5445
|
+
// Capture review-only settings for this run. Changing settings while a
|
|
5446
|
+
// review is in flight affects the next review, never this one.
|
|
5447
|
+
const reviewPrompt = this.settings.reviewPrompt;
|
|
5448
|
+
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
4980
5449
|
// Cap rounds: single-shot (locked=false) always exactly one review.
|
|
4981
5450
|
// For locked goals, maxRounds 0 = unlimited (keep revising until pass).
|
|
4982
5451
|
const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
|
|
4983
5452
|
if (g.locked && g.maxRounds > 0 && g.round >= budget) {
|
|
4984
|
-
|
|
4985
|
-
|
|
5453
|
+
g.status = `已达最大轮数(${budget}),停止审查`;
|
|
5454
|
+
g.reviewing = false;
|
|
4986
5455
|
this.emitGoalStatus();
|
|
4987
5456
|
return;
|
|
4988
5457
|
}
|
|
4989
|
-
this.goalReviewing = true;
|
|
4990
5458
|
g.reviewing = true;
|
|
4991
5459
|
g.round += 1;
|
|
4992
5460
|
g.verdict = "pending";
|
|
@@ -5001,14 +5469,26 @@ ${transcript}
|
|
|
5001
5469
|
catch {
|
|
5002
5470
|
finalText = "";
|
|
5003
5471
|
}
|
|
5004
|
-
const diff = await this.gitDiff();
|
|
5472
|
+
const diff = await this.gitDiff(mainConv.cwd);
|
|
5473
|
+
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
5474
|
+
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
5475
|
+
return;
|
|
5476
|
+
}
|
|
5005
5477
|
let reviewerVerdict = "fail";
|
|
5006
5478
|
let reviewerFeedback = "(审查无法完成)";
|
|
5007
5479
|
try {
|
|
5008
5480
|
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
5009
5481
|
const services = await createAgentSessionServices({
|
|
5010
|
-
cwd:
|
|
5482
|
+
cwd: mainConv.cwd,
|
|
5011
5483
|
agentDir: this.agentDir,
|
|
5484
|
+
// The reviewer has its own skill allow/deny list. It deliberately does
|
|
5485
|
+
// not reuse the main session's disabledSkills setting.
|
|
5486
|
+
resourceLoaderOptions: {
|
|
5487
|
+
skillsOverride: (res) => ({
|
|
5488
|
+
...res,
|
|
5489
|
+
skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
|
|
5490
|
+
}),
|
|
5491
|
+
},
|
|
5012
5492
|
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
5013
5493
|
// one used by the main conversations, so its model choice is its own.
|
|
5014
5494
|
modelRuntime: await ModelRuntime.create({
|
|
@@ -5030,12 +5510,12 @@ ${transcript}
|
|
|
5030
5510
|
}
|
|
5031
5511
|
const srv = await createAgentSessionFromServices({
|
|
5032
5512
|
services,
|
|
5033
|
-
sessionManager: SessionManager.inMemory(
|
|
5513
|
+
sessionManager: SessionManager.inMemory(mainConv.cwd),
|
|
5034
5514
|
...(model ? { model } : {}),
|
|
5035
5515
|
});
|
|
5036
5516
|
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
5037
5517
|
const reviewer = srv.session;
|
|
5038
|
-
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff));
|
|
5518
|
+
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
|
|
5039
5519
|
// Parse the reviewer's final output (expected to be a JSON object).
|
|
5040
5520
|
const raw = reviewer.getLastAssistantText() ?? "";
|
|
5041
5521
|
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
@@ -5055,7 +5535,13 @@ ${transcript}
|
|
|
5055
5535
|
reviewerVerdict = "fail";
|
|
5056
5536
|
reviewerFeedback = `审查过程中出错:${err.message}`;
|
|
5057
5537
|
}
|
|
5058
|
-
|
|
5538
|
+
// The user may have switched chats or replaced/cleared the goal while the
|
|
5539
|
+
// isolated reviewer was running. Never apply a stale verdict or inject it
|
|
5540
|
+
// into the old session after that point.
|
|
5541
|
+
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
5542
|
+
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
5543
|
+
return;
|
|
5544
|
+
}
|
|
5059
5545
|
g.reviewing = false;
|
|
5060
5546
|
g.verdict = reviewerVerdict;
|
|
5061
5547
|
g.feedback = reviewerFeedback;
|
|
@@ -5069,6 +5555,7 @@ ${transcript}
|
|
|
5069
5555
|
if (verdict === "pass") {
|
|
5070
5556
|
g.status = "✅ 已通过目标审查";
|
|
5071
5557
|
this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
|
|
5558
|
+
g.conversationId = null;
|
|
5072
5559
|
g.goal = null; // a passed goal is done and cleared
|
|
5073
5560
|
this.emitGoalStatus();
|
|
5074
5561
|
// Pass = the review result goes straight into the conversation as an
|
|
@@ -5124,6 +5611,7 @@ ${transcript}
|
|
|
5124
5611
|
// Best-effort.
|
|
5125
5612
|
}
|
|
5126
5613
|
this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
|
|
5614
|
+
g.conversationId = null;
|
|
5127
5615
|
g.goal = null; // loop exhausted — clear the active goal
|
|
5128
5616
|
this.emitGoalStatus();
|
|
5129
5617
|
this.flushSnapshot();
|
|
@@ -5199,7 +5687,8 @@ ${transcript}
|
|
|
5199
5687
|
}
|
|
5200
5688
|
async dispose() {
|
|
5201
5689
|
this.disposed = true;
|
|
5202
|
-
this.
|
|
5690
|
+
for (const conv of this.convs.values())
|
|
5691
|
+
conv.terminals.killAll();
|
|
5203
5692
|
if (this.snapshotTimer) {
|
|
5204
5693
|
clearTimeout(this.snapshotTimer);
|
|
5205
5694
|
this.snapshotTimer = null;
|