@adminforth/agent 2.2.5 → 2.3.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/application/ports.d.ts +18 -0
- package/dist/application/runTurnUseCase.d.ts +17 -1
- package/dist/application/runTurnUseCase.js +145 -4
- package/dist/custom/ChatFooter.vue +61 -23
- package/dist/custom/ChatSurface.vue +1 -0
- package/dist/custom/composables/agentStore/useAgentChat.ts +122 -7
- package/dist/custom/composables/agentStore/useAgentSessions.ts +8 -5
- package/dist/custom/composables/agentStore/useAgentSteerQueue.ts +5 -1
- package/dist/custom/composables/useAgentStore.ts +83 -0
- package/dist/custom/conversation_area/MessageRenderer.vue +114 -29
- package/dist/custom/conversation_area/TextRenderer.vue +16 -6
- package/dist/custom/types.ts +7 -0
- package/dist/domain/agentEvents.d.ts +4 -0
- package/dist/domain/turnTypes.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/llm/agentModels.d.ts +1 -0
- package/dist/llm/agentRuntime.d.ts +14 -0
- package/dist/llm/agentRuntime.js +42 -3
- package/dist/llm/langGraphLlm.d.ts +8 -0
- package/dist/llm/langGraphLlm.js +12 -0
- package/dist/persistence/sessionStore.d.ts +30 -0
- package/dist/persistence/sessionStore.js +56 -1
- package/dist/transport/http/context.d.ts +4 -2
- package/dist/transport/http/coreEndpoints.js +35 -0
- package/dist/transport/http/sessionEndpoints.js +3 -0
- package/dist/transport/sse/sseWriter.js +12 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ export type LlmStreamInput = {
|
|
|
32
32
|
};
|
|
33
33
|
context: AgentTurnContext;
|
|
34
34
|
observability: AgentTurnObservability;
|
|
35
|
+
branchFromCheckpointId?: string;
|
|
35
36
|
};
|
|
36
37
|
/**
|
|
37
38
|
* The boundary between the application layer and the concrete LLM runtime
|
|
@@ -63,4 +64,21 @@ export interface LlmPort {
|
|
|
63
64
|
completionAdapter: AgentModeCompletionAdapter;
|
|
64
65
|
sessionId: string;
|
|
65
66
|
}): Promise<PendingInterrupt[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Return the session thread's latest (tip) checkpoint id, or null when the thread
|
|
69
|
+
* has no persisted state. Recorded on each turn so message editing can fork from
|
|
70
|
+
* the previous turn's checkpoint.
|
|
71
|
+
*/
|
|
72
|
+
getLatestCheckpointId(input: {
|
|
73
|
+
completionAdapter: AgentModeCompletionAdapter;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
}): Promise<string | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Drop the entire LangGraph checkpoint thread for a session. Used when editing the
|
|
78
|
+
* first message, where there is no earlier checkpoint to fork from.
|
|
79
|
+
*/
|
|
80
|
+
resetThreadCheckpoints(input: {
|
|
81
|
+
completionAdapter: AgentModeCompletionAdapter;
|
|
82
|
+
sessionId: string;
|
|
83
|
+
}): Promise<void>;
|
|
66
84
|
}
|
|
@@ -3,7 +3,7 @@ import type { AgentSessionStore } from "../persistence/sessionStore.js";
|
|
|
3
3
|
import type { SteerBuffer } from "../domain/steerBuffer.js";
|
|
4
4
|
import type { PluginOptions } from "../types.js";
|
|
5
5
|
import type { LlmPort } from "./ports.js";
|
|
6
|
-
import type { DebugSink, HandleTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
|
|
6
|
+
import type { DebugSink, HandleTurnInput, HandleEditTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
|
|
7
7
|
/**
|
|
8
8
|
* Build the provider-agnostic resume payload for a human-in-the-loop turn.
|
|
9
9
|
* Exported for unit testing; the LLM port wraps it into a LangGraph Command.
|
|
@@ -41,6 +41,13 @@ export type RunTurnUseCaseDeps = {
|
|
|
41
41
|
getAgentSystemPrompt: () => Promise<string>;
|
|
42
42
|
/** True when a persistent checkpointer is configured (not the in-memory MemorySaver). */
|
|
43
43
|
hasPersistentCheckpointer: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* True when `turnResource.checkpointIdField` is configured — the tip checkpoint id
|
|
46
|
+
* is then recorded per turn, enabling message editing / branching.
|
|
47
|
+
*/
|
|
48
|
+
turnCheckpointsEnabled: boolean;
|
|
49
|
+
/** Resource config used for the edit ownership check + turn lookups. */
|
|
50
|
+
sessionResource: PluginOptions["sessionResource"];
|
|
44
51
|
/** Factory for the per-turn debug sink; the concrete impl lives in the llm layer. */
|
|
45
52
|
createDebugSink: () => DebugSink;
|
|
46
53
|
};
|
|
@@ -62,10 +69,19 @@ export declare class RunTurnUseCase {
|
|
|
62
69
|
* MemorySaver the in-process cache is authoritative (single process, cannot be stale).
|
|
63
70
|
*/
|
|
64
71
|
private resolvePendingInterrupts;
|
|
72
|
+
/** Verify the session exists and belongs to the requesting admin user. */
|
|
73
|
+
private assertSessionOwnership;
|
|
74
|
+
/**
|
|
75
|
+
* Prepare an edit/branch turn: validate ownership, resolve the fork checkpoint from
|
|
76
|
+
* the previous turn, truncate the conversation after the edited turn, and reuse the
|
|
77
|
+
* edited turn's id for the regenerated response.
|
|
78
|
+
*/
|
|
79
|
+
private prepareEditTurn;
|
|
65
80
|
private prepareTurn;
|
|
66
81
|
private buildMessages;
|
|
67
82
|
private handleInterrupt;
|
|
68
83
|
private runAgentTurn;
|
|
69
84
|
runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput): Promise<RunAndPersistAgentResponseResult>;
|
|
70
85
|
handleTurn(input: HandleTurnInput): Promise<RunAndPersistAgentResponseResult>;
|
|
86
|
+
handleEditTurn(input: HandleEditTurnInput): Promise<RunAndPersistAgentResponseResult>;
|
|
71
87
|
}
|
|
@@ -14,7 +14,7 @@ var __asyncValues = (this && this.__asyncValues) || function (o) {
|
|
|
14
14
|
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
|
|
15
15
|
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
|
|
16
16
|
};
|
|
17
|
-
import { logger } from "adminforth";
|
|
17
|
+
import { logger, Filters } from "adminforth";
|
|
18
18
|
import { randomUUID } from "crypto";
|
|
19
19
|
import { VegaLiteStreamBuffer } from "../domain/vegaLiteStreamBuffer.js";
|
|
20
20
|
import { buildAgentTurnSystemPrompt } from "../domain/systemPrompt.js";
|
|
@@ -121,12 +121,88 @@ export class RunTurnUseCase {
|
|
|
121
121
|
return (_a = this.pendingInterrupts.get(sessionId)) !== null && _a !== void 0 ? _a : [];
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
|
+
/** Verify the session exists and belongs to the requesting admin user. */
|
|
125
|
+
assertSessionOwnership(sessionId, adminUser) {
|
|
126
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
127
|
+
const s = this.deps.sessionResource;
|
|
128
|
+
const session = yield this.deps.getAdminforth().resource(s.resourceId).get([Filters.EQ(s.idField, sessionId)]);
|
|
129
|
+
if (!session) {
|
|
130
|
+
throw new Error(`Session "${sessionId}" not found.`);
|
|
131
|
+
}
|
|
132
|
+
if (session[s.askerIdField] !== adminUser.pk) {
|
|
133
|
+
throw new Error(`Session "${sessionId}" does not belong to the current user.`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Prepare an edit/branch turn: validate ownership, resolve the fork checkpoint from
|
|
139
|
+
* the previous turn, truncate the conversation after the edited turn, and reuse the
|
|
140
|
+
* edited turn's id for the regenerated response.
|
|
141
|
+
*/
|
|
142
|
+
prepareEditTurn(input) {
|
|
143
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
144
|
+
const sequenceDebugSink = this.deps.createDebugSink();
|
|
145
|
+
const mode = this.resolveMode(input.modeName);
|
|
146
|
+
const editTurnId = input.editTurnId;
|
|
147
|
+
if (!this.deps.turnCheckpointsEnabled) {
|
|
148
|
+
throw new Error("Edit/fork requires checkpointIdField to be configured on turnResource.");
|
|
149
|
+
}
|
|
150
|
+
yield this.assertSessionOwnership(input.sessionId, input.adminUser);
|
|
151
|
+
const agentTurns = yield this.deps.sessions.getAgentTurns(input.sessionId);
|
|
152
|
+
const targetIndex = agentTurns.findIndex((turn) => turn.id === editTurnId);
|
|
153
|
+
if (targetIndex === -1) {
|
|
154
|
+
throw new Error(`Turn "${editTurnId}" not found in session "${input.sessionId}".`);
|
|
155
|
+
}
|
|
156
|
+
const previous = agentTurns[targetIndex - 1];
|
|
157
|
+
let branchFromCheckpointId;
|
|
158
|
+
if (previous) {
|
|
159
|
+
if (!previous.checkpointId) {
|
|
160
|
+
throw new Error(`Cannot edit this message: the previous turn has no stored checkpoint to branch from.`);
|
|
161
|
+
}
|
|
162
|
+
branchFromCheckpointId = previous.checkpointId;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// No earlier turn to fork from — drop the whole thread and start fresh.
|
|
166
|
+
yield this.deps.llm.resetThreadCheckpoints({
|
|
167
|
+
completionAdapter: mode.completionAdapter,
|
|
168
|
+
sessionId: input.sessionId,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Language-detection context: the kept user messages just before the edited turn.
|
|
172
|
+
const previousUserMessages = agentTurns
|
|
173
|
+
.slice(Math.max(0, targetIndex - 2), targetIndex)
|
|
174
|
+
.map((turn) => ({ text: turn.prompt }));
|
|
175
|
+
// Editing supersedes any in-flight turn state for this session.
|
|
176
|
+
this.deps.steerBuffer.clear(input.sessionId);
|
|
177
|
+
this.pendingInterrupts.delete(input.sessionId);
|
|
178
|
+
// Apply the edit + truncate (deletes later turns first, then rewrites the edited turn).
|
|
179
|
+
yield this.deps.sessions.editTurnAndTruncateAfter({
|
|
180
|
+
sessionId: input.sessionId,
|
|
181
|
+
turnId: editTurnId,
|
|
182
|
+
newPrompt: input.prompt,
|
|
183
|
+
});
|
|
184
|
+
yield this.deps.sessions.touchSession(input.sessionId);
|
|
185
|
+
return {
|
|
186
|
+
prompt: input.prompt,
|
|
187
|
+
sessionId: input.sessionId,
|
|
188
|
+
turnId: editTurnId,
|
|
189
|
+
previousUserMessages,
|
|
190
|
+
mode,
|
|
191
|
+
context: this.buildContext(input, editTurnId),
|
|
192
|
+
observability: { emit: input.emit, sequenceDebugSink },
|
|
193
|
+
branchFromCheckpointId,
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
124
197
|
prepareTurn(input) {
|
|
125
198
|
return __awaiter(this, void 0, void 0, function* () {
|
|
126
199
|
const sequenceDebugSink = this.deps.createDebugSink();
|
|
127
200
|
const mode = this.resolveMode(input.modeName);
|
|
128
201
|
const approvalDecision = getApprovalDecision(input);
|
|
129
202
|
const shouldResume = Boolean(approvalDecision);
|
|
203
|
+
if (input.editTurnId && !shouldResume) {
|
|
204
|
+
return this.prepareEditTurn(input);
|
|
205
|
+
}
|
|
130
206
|
let turnId;
|
|
131
207
|
let previousUserMessages = [];
|
|
132
208
|
let initialResponse;
|
|
@@ -228,6 +304,7 @@ export class RunTurnUseCase {
|
|
|
228
304
|
input: streamInput,
|
|
229
305
|
context: prepared.context,
|
|
230
306
|
observability: prepared.observability,
|
|
307
|
+
branchFromCheckpointId: prepared.branchFromCheckpointId,
|
|
231
308
|
});
|
|
232
309
|
const { emit } = prepared.observability;
|
|
233
310
|
const abortSignal = prepared.context.abortSignal;
|
|
@@ -285,9 +362,14 @@ export class RunTurnUseCase {
|
|
|
285
362
|
}
|
|
286
363
|
runAndPersistAgentResponse(input) {
|
|
287
364
|
return __awaiter(this, void 0, void 0, function* () {
|
|
288
|
-
var _a, _b;
|
|
365
|
+
var _a, _b, _c, _d;
|
|
289
366
|
const prepared = yield this.prepareTurn(input);
|
|
290
|
-
|
|
367
|
+
yield ((_b = (_a = prepared.observability).emit) === null || _b === void 0 ? void 0 : _b.call(_a, {
|
|
368
|
+
type: "turn-persisted",
|
|
369
|
+
sessionId: prepared.sessionId,
|
|
370
|
+
turnId: prepared.turnId,
|
|
371
|
+
}));
|
|
372
|
+
let fullResponse = (_c = prepared.initialResponse) !== null && _c !== void 0 ? _c : "";
|
|
291
373
|
let aborted = false;
|
|
292
374
|
let failed = false;
|
|
293
375
|
try {
|
|
@@ -295,7 +377,7 @@ export class RunTurnUseCase {
|
|
|
295
377
|
fullResponse += agentResponse.text;
|
|
296
378
|
}
|
|
297
379
|
catch (error) {
|
|
298
|
-
if (((
|
|
380
|
+
if (((_d = input.abortSignal) === null || _d === void 0 ? void 0 : _d.aborted) || isAbortError(error)) {
|
|
299
381
|
aborted = true;
|
|
300
382
|
logger.info(input.abortLogMessage);
|
|
301
383
|
}
|
|
@@ -305,11 +387,28 @@ export class RunTurnUseCase {
|
|
|
305
387
|
logger.error(`${input.failureLogMessage}:\n${fullResponse}`);
|
|
306
388
|
}
|
|
307
389
|
}
|
|
390
|
+
// Record the turn's tip checkpoint id ONLY on success, so message editing can
|
|
391
|
+
// fork from it. On abort/failure leave the previous value untouched (undefined =
|
|
392
|
+
// don't overwrite). Never let a checkpoint read break response persistence.
|
|
393
|
+
let checkpointId;
|
|
394
|
+
if (!aborted && !failed && this.deps.turnCheckpointsEnabled) {
|
|
395
|
+
try {
|
|
396
|
+
checkpointId = yield this.deps.llm.getLatestCheckpointId({
|
|
397
|
+
completionAdapter: prepared.mode.completionAdapter,
|
|
398
|
+
sessionId: prepared.sessionId,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
logger.warn(`Failed to read latest checkpoint id for turn "${prepared.turnId}": ${getErrorMessage(error)}`);
|
|
403
|
+
checkpointId = undefined;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
308
406
|
prepared.observability.sequenceDebugSink.flush();
|
|
309
407
|
yield this.deps.sessions.saveTurnResponse({
|
|
310
408
|
turnId: prepared.turnId,
|
|
311
409
|
responseText: fullResponse,
|
|
312
410
|
debugHistory: prepared.observability.sequenceDebugSink.getHistory(),
|
|
411
|
+
checkpointId,
|
|
313
412
|
});
|
|
314
413
|
return {
|
|
315
414
|
text: fullResponse,
|
|
@@ -361,4 +460,46 @@ export class RunTurnUseCase {
|
|
|
361
460
|
return agentResponse;
|
|
362
461
|
});
|
|
363
462
|
}
|
|
463
|
+
handleEditTurn(input) {
|
|
464
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
465
|
+
var _a, _b;
|
|
466
|
+
yield input.emit({
|
|
467
|
+
type: "turn-started",
|
|
468
|
+
messageId: randomUUID(),
|
|
469
|
+
});
|
|
470
|
+
const agentResponse = yield this.runAndPersistAgentResponse({
|
|
471
|
+
prompt: input.prompt,
|
|
472
|
+
sessionId: input.sessionId,
|
|
473
|
+
modeName: input.modeName,
|
|
474
|
+
userTimeZone: input.userTimeZone,
|
|
475
|
+
currentPage: input.currentPage,
|
|
476
|
+
chatSurface: input.chatSurface,
|
|
477
|
+
adminPublicOrigin: input.adminPublicOrigin,
|
|
478
|
+
editTurnId: input.turnId,
|
|
479
|
+
abortSignal: input.abortSignal,
|
|
480
|
+
adminUser: input.adminUser,
|
|
481
|
+
emit: input.emit,
|
|
482
|
+
failureLogMessage: (_a = input.failureLogMessage) !== null && _a !== void 0 ? _a : "Agent message edit failed",
|
|
483
|
+
abortLogMessage: (_b = input.abortLogMessage) !== null && _b !== void 0 ? _b : "Agent message edit aborted",
|
|
484
|
+
});
|
|
485
|
+
if (agentResponse.failed) {
|
|
486
|
+
yield input.emit({
|
|
487
|
+
type: "error",
|
|
488
|
+
error: agentResponse.text,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
else if (!agentResponse.aborted) {
|
|
492
|
+
yield input.emit({
|
|
493
|
+
type: "response",
|
|
494
|
+
text: agentResponse.text,
|
|
495
|
+
sessionId: input.sessionId,
|
|
496
|
+
turnId: agentResponse.turnId,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
yield input.emit({
|
|
500
|
+
type: "finish",
|
|
501
|
+
});
|
|
502
|
+
return agentResponse;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
364
505
|
}
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
>
|
|
60
60
|
<div
|
|
61
61
|
v-if="isModeMenuOpen"
|
|
62
|
-
class="absolute bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
|
|
62
|
+
class="absolute z-20 bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
|
|
63
63
|
>
|
|
64
64
|
<button
|
|
65
65
|
v-for="mode in agentStore.availableModes"
|
|
@@ -75,29 +75,52 @@
|
|
|
75
75
|
</Transition>
|
|
76
76
|
</div>
|
|
77
77
|
<MicrophoneButton
|
|
78
|
-
v-if="props.meta.hasAudioAdapter"
|
|
78
|
+
v-if="props.meta.hasAudioAdapter"
|
|
79
|
+
v-show="!agentStore.isEditingMessage"
|
|
79
80
|
/>
|
|
80
81
|
<template v-if="!agentStore.isAudioChatMode">
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
</
|
|
82
|
+
<template v-if="agentStore.isEditingMessage">
|
|
83
|
+
<div class="flex items-center gap-3">
|
|
84
|
+
<Button
|
|
85
|
+
variant="secondary"
|
|
86
|
+
class="!p-0 h-7 w-7 mr-1 bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800"
|
|
87
|
+
title="Cancel edit"
|
|
88
|
+
@click="cancelEdit"
|
|
89
|
+
>
|
|
90
|
+
<IconCloseOutline class="w-5 h-5" />
|
|
91
|
+
</Button>
|
|
92
|
+
<Button
|
|
93
|
+
class="!p-0 h-7 w-7"
|
|
94
|
+
title="Send edited message"
|
|
95
|
+
@click="sendMessage"
|
|
96
|
+
:disabled="!agentStore.trimmedUserMessage"
|
|
97
|
+
>
|
|
98
|
+
<IconCheckOutline class="w-6 h-6 text-white" />
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
<template v-else>
|
|
103
|
+
<Button
|
|
104
|
+
v-if="!agentStore.isResponseInProgress"
|
|
105
|
+
class=" !p-0 h-7 w-7 transition-opacity duration-200"
|
|
106
|
+
@click="sendMessage"
|
|
107
|
+
:disabled="!agentStore.trimmedUserMessage"
|
|
108
|
+
>
|
|
109
|
+
<IconArrowUpOutline
|
|
110
|
+
class="w-6 h-6
|
|
111
|
+
text-white"
|
|
112
|
+
/>
|
|
113
|
+
</Button>
|
|
114
|
+
<Button
|
|
115
|
+
v-else
|
|
116
|
+
class="right-4 bottom-2 !p-0 h-7 w-7"
|
|
117
|
+
@click="stopCurrentRequest"
|
|
118
|
+
>
|
|
119
|
+
<div
|
|
120
|
+
class="w-2.5 h-2.5 bg-white rounded-sm"
|
|
121
|
+
/>
|
|
122
|
+
</Button>
|
|
123
|
+
</template>
|
|
101
124
|
</template>
|
|
102
125
|
</div>
|
|
103
126
|
</div>
|
|
@@ -105,7 +128,7 @@
|
|
|
105
128
|
</template>
|
|
106
129
|
|
|
107
130
|
<script setup lang="ts">
|
|
108
|
-
import { IconArrowUpOutline, IconAngleDownOutline } from '@iconify-prerendered/vue-flowbite';
|
|
131
|
+
import { IconArrowUpOutline, IconAngleDownOutline, IconCloseOutline, IconCheckOutline } from '@iconify-prerendered/vue-flowbite';
|
|
109
132
|
import { useTemplateRef, onMounted, ref, onUnmounted, watch, computed } from 'vue';
|
|
110
133
|
import { onClickOutside } from '@vueuse/core'
|
|
111
134
|
import { useAgentStore } from './composables/useAgentStore';
|
|
@@ -126,6 +149,7 @@ const props = defineProps<{
|
|
|
126
149
|
defaultModeName: string | null;
|
|
127
150
|
stickByDefault: boolean;
|
|
128
151
|
hasAudioAdapter: boolean;
|
|
152
|
+
editingEnabled: boolean;
|
|
129
153
|
}
|
|
130
154
|
adminUser: any
|
|
131
155
|
conversationAreaRef: any
|
|
@@ -150,6 +174,7 @@ onClickOutside(modeMenu as any, () => { isModeMenuOpen.value = false; });
|
|
|
150
174
|
onMounted(async () => {
|
|
151
175
|
agentStore.setAvailableModes(props.meta.modes, props.meta.defaultModeName);
|
|
152
176
|
agentStore.setCurrentGenerationModeFromLocalStorage();
|
|
177
|
+
agentStore.setEditingEnabled(props.meta.editingEnabled);
|
|
153
178
|
agentStore.regisrerTextInput(textInput.value);
|
|
154
179
|
textInput.value?.focus();
|
|
155
180
|
autoResize();
|
|
@@ -187,6 +212,14 @@ function selectMode(modeName: string) {
|
|
|
187
212
|
async function sendMessage() {
|
|
188
213
|
if (agentStore.isAudioChatMode) return;
|
|
189
214
|
isModeMenuOpen.value = false;
|
|
215
|
+
// While editing a message, the same send action commits the edit (regenerating
|
|
216
|
+
// from that turn) instead of starting/steering a turn.
|
|
217
|
+
if (agentStore.isEditingMessage) {
|
|
218
|
+
await agentStore.submitEditMessage();
|
|
219
|
+
autoResize();
|
|
220
|
+
props.conversationAreaRef?.handleSendMessage();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
190
223
|
// Routes to a steer (folded into the running turn) while generating, or a normal
|
|
191
224
|
// new-turn send when idle.
|
|
192
225
|
await agentStore.submitUserMessage();
|
|
@@ -194,6 +227,11 @@ async function sendMessage() {
|
|
|
194
227
|
props.conversationAreaRef?.handleSendMessage();
|
|
195
228
|
}
|
|
196
229
|
|
|
230
|
+
function cancelEdit() {
|
|
231
|
+
agentStore.cancelEditMessage();
|
|
232
|
+
autoResize();
|
|
233
|
+
}
|
|
234
|
+
|
|
197
235
|
function stopCurrentRequest() {
|
|
198
236
|
agentStore.abortCurrentChatRequestAndAddSystemMessage();
|
|
199
237
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DefaultChatTransport } from 'ai';
|
|
2
|
-
import { shallowRef, type Ref } from 'vue';
|
|
2
|
+
import { ref, shallowRef, watch, type Ref } from 'vue';
|
|
3
3
|
import { Chat } from '../../chat';
|
|
4
4
|
import { getCurrentPageContext } from './pageContext';
|
|
5
5
|
// const { DefaultChatTransport } = await import('ai');
|
|
@@ -25,6 +25,7 @@ export function createAgentChatManager({
|
|
|
25
25
|
}: CreateAgentChatManagerOptions) {
|
|
26
26
|
const chats = new Map<string, Chat<any>>();
|
|
27
27
|
const currentChat = shallowRef<Chat<any> | null>();
|
|
28
|
+
const editingTurnId = ref<string | null>(null);
|
|
28
29
|
const agentApiBase = `${(import.meta as AgentImportMeta).env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/agent`;
|
|
29
30
|
|
|
30
31
|
function replaceLastMessage(message: any) {
|
|
@@ -89,6 +90,26 @@ export function createAgentChatManager({
|
|
|
89
90
|
replaceLastMessage(assistantMessage);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// Stamp the just-finished turn's id onto its prompt message so it becomes editable
|
|
94
|
+
// without a session reload. The prompt is the last user message that isn't a
|
|
95
|
+
// mid-turn steer.
|
|
96
|
+
function tagCurrentTurnPrompt(turnId: string) {
|
|
97
|
+
const messages = currentChat.value?.messages;
|
|
98
|
+
if (!messages) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
102
|
+
const message = messages[i];
|
|
103
|
+
if (message.role === 'user' && !message.metadata?.steer) {
|
|
104
|
+
messages.splice(i, 1, {
|
|
105
|
+
...message,
|
|
106
|
+
metadata: { ...(message.metadata ?? {}), turnId },
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
function handleRealtimeChatData(dataPart: any) {
|
|
93
114
|
if (dataPart?.type === 'data-open-page' && typeof dataPart.data?.targetPath === 'string') {
|
|
94
115
|
onOpenPage(dataPart.data.targetPath);
|
|
@@ -97,6 +118,16 @@ export function createAgentChatManager({
|
|
|
97
118
|
|
|
98
119
|
if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
|
|
99
120
|
onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `data-turn-persisted` arrives at turn start, `data-response` at turn end — either
|
|
125
|
+
// way, stamp the prompt with its turn id so it can be edited.
|
|
126
|
+
if (
|
|
127
|
+
(dataPart?.type === 'data-turn-persisted' || dataPart?.type === 'data-response')
|
|
128
|
+
&& typeof dataPart.data?.turnId === 'string'
|
|
129
|
+
) {
|
|
130
|
+
tagCurrentTurnPrompt(dataPart.data.turnId);
|
|
100
131
|
}
|
|
101
132
|
}
|
|
102
133
|
|
|
@@ -128,6 +159,14 @@ export function createAgentChatManager({
|
|
|
128
159
|
|
|
129
160
|
if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
|
|
130
161
|
onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
(dataPart?.type === 'data-turn-persisted' || dataPart?.type === 'data-response')
|
|
167
|
+
&& typeof dataPart.data?.turnId === 'string'
|
|
168
|
+
) {
|
|
169
|
+
tagCurrentTurnPrompt(dataPart.data.turnId);
|
|
131
170
|
}
|
|
132
171
|
}
|
|
133
172
|
|
|
@@ -178,7 +217,11 @@ export function createAgentChatManager({
|
|
|
178
217
|
credentials: 'include',
|
|
179
218
|
prepareSendMessagesRequest({ messages }: any) {
|
|
180
219
|
const message = lastMessage.value;
|
|
181
|
-
const
|
|
220
|
+
const headers = {
|
|
221
|
+
Accept: 'text/event-stream',
|
|
222
|
+
'x-vercel-ai-ui-message-stream': 'v1',
|
|
223
|
+
};
|
|
224
|
+
const body: Record<string, any> = {
|
|
182
225
|
message,
|
|
183
226
|
sessionId,
|
|
184
227
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -186,12 +229,20 @@ export function createAgentChatManager({
|
|
|
186
229
|
currentPage: getCurrentPageContext(),
|
|
187
230
|
};
|
|
188
231
|
|
|
232
|
+
// Editing an existing message branches from its turn: hit /edit, which
|
|
233
|
+
// truncates every turn after `turnId` and regenerates from that point.
|
|
234
|
+
if (editingTurnId.value) {
|
|
235
|
+
body.turnId = editingTurnId.value;
|
|
236
|
+
return {
|
|
237
|
+
api: `${agentApiBase}/edit`,
|
|
238
|
+
headers,
|
|
239
|
+
body,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
189
243
|
return {
|
|
190
|
-
headers
|
|
191
|
-
|
|
192
|
-
'x-vercel-ai-ui-message-stream': 'v1',
|
|
193
|
-
},
|
|
194
|
-
body
|
|
244
|
+
headers,
|
|
245
|
+
body,
|
|
195
246
|
};
|
|
196
247
|
}
|
|
197
248
|
}),
|
|
@@ -210,6 +261,69 @@ export function createAgentChatManager({
|
|
|
210
261
|
currentChat.value?.stop();
|
|
211
262
|
}
|
|
212
263
|
|
|
264
|
+
function isChatBusy() {
|
|
265
|
+
const status = (currentChat.value as any)?.status;
|
|
266
|
+
return status === 'streaming' || status === 'submitted';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function stopActiveTurnAndWaitForIdle() {
|
|
270
|
+
const chat = currentChat.value;
|
|
271
|
+
if (!chat || !isChatBusy()) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await chat.stop();
|
|
275
|
+
if (!isChatBusy()) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await new Promise<void>((resolve) => {
|
|
279
|
+
let settled = false;
|
|
280
|
+
let stopWatch = () => {};
|
|
281
|
+
const finish = () => {
|
|
282
|
+
if (settled) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
settled = true;
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
stopWatch();
|
|
288
|
+
resolve();
|
|
289
|
+
};
|
|
290
|
+
// Safety valve so a stuck status can never hang the edit indefinitely.
|
|
291
|
+
const timer = setTimeout(finish, 3000);
|
|
292
|
+
stopWatch = watch(
|
|
293
|
+
() => (currentChat.value as any)?.status,
|
|
294
|
+
() => {
|
|
295
|
+
if (!isChatBusy()) {
|
|
296
|
+
finish();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Replace the target user message in place and regenerate from it. The SDK's
|
|
304
|
+
// `sendMessage({ messageId })` truncates every message after the replaced one
|
|
305
|
+
// (mirroring the server-side truncate), and `editingTurnId` routes the request
|
|
306
|
+
// to /edit for the matching turn. If a turn is still generating it is stopped
|
|
307
|
+
// first so the edit supersedes it.
|
|
308
|
+
async function sendEditMessage({ messageId, turnId, text }: { messageId: string; turnId: string; text: string; }) {
|
|
309
|
+
const chat = currentChat.value;
|
|
310
|
+
if (!chat) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const currentMessage = chat.messages.find((m: any) => m.id === messageId);
|
|
314
|
+
if (!currentMessage || currentMessage.parts[0].text.trim() === text.trim()) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await stopActiveTurnAndWaitForIdle();
|
|
318
|
+
editingTurnId.value = turnId;
|
|
319
|
+
lastMessage.value = text;
|
|
320
|
+
try {
|
|
321
|
+
await chat.sendMessage({ text, messageId, metadata: { turnId } });
|
|
322
|
+
} finally {
|
|
323
|
+
editingTurnId.value = null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
213
327
|
async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
|
|
214
328
|
const response = await fetch(`${agentApiBase}/approval`, {
|
|
215
329
|
method: 'POST',
|
|
@@ -237,5 +351,6 @@ export function createAgentChatManager({
|
|
|
237
351
|
setCurrentChat,
|
|
238
352
|
abortCurrentChatRequest,
|
|
239
353
|
submitToolApproval,
|
|
354
|
+
sendEditMessage,
|
|
240
355
|
};
|
|
241
356
|
}
|
|
@@ -52,32 +52,35 @@ export function createAgentSessionManager({
|
|
|
52
52
|
if (currentSession.value) {
|
|
53
53
|
currentSession.value.messages = currentChat.value?.messages.map((m: any) => {
|
|
54
54
|
const text = m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join('');
|
|
55
|
+
const turnId = m.metadata?.turnId;
|
|
55
56
|
if (m.role === 'user' && m.metadata?.steer) {
|
|
56
|
-
return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}
|
|
57
|
+
return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}`, turnId };
|
|
57
58
|
}
|
|
58
|
-
return { role: m.role, text };
|
|
59
|
+
return { role: m.role, text, turnId };
|
|
59
60
|
}) || [];
|
|
60
61
|
sessions.value[currentSession.value.sessionId] = currentSession.value;
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
function mapStoredMessage(m: any): any[] {
|
|
66
|
+
const turnId = m.turnId;
|
|
65
67
|
if (m.role === 'user' && typeof m.text === 'string' && m.text.includes(STEER_PERSIST_PREFIX)) {
|
|
66
68
|
const [prompt, ...steers] = m.text.split(STEER_PERSIST_PREFIX);
|
|
67
69
|
const mapped: any[] = [];
|
|
68
70
|
if (prompt) {
|
|
69
|
-
mapped.push({ role: 'user', parts: [{ type: 'text', text: prompt, state: 'done' }] });
|
|
71
|
+
mapped.push({ id: crypto.randomUUID(), role: 'user', metadata: { turnId }, parts: [{ type: 'text', text: prompt, state: 'done' }] });
|
|
70
72
|
}
|
|
71
73
|
for (const steer of steers) {
|
|
72
74
|
mapped.push({
|
|
75
|
+
id: crypto.randomUUID(),
|
|
73
76
|
role: 'user',
|
|
74
|
-
metadata: { steer: true },
|
|
77
|
+
metadata: { turnId, steer: true },
|
|
75
78
|
parts: [{ type: 'text', text: steer, state: 'done' }],
|
|
76
79
|
});
|
|
77
80
|
}
|
|
78
81
|
return mapped;
|
|
79
82
|
}
|
|
80
|
-
return [{ role: m.role, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
|
|
83
|
+
return [{ id: crypto.randomUUID(), role: m.role, metadata: { turnId }, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
async function fetchSession(sessionId: string) {
|
|
@@ -97,7 +97,6 @@ export function createAgentSteerQueue({
|
|
|
97
97
|
state: part?.state === 'streaming' ? 'done' : part?.state,
|
|
98
98
|
}));
|
|
99
99
|
messages[lastIndex] = { ...streamingMessage, parts: frozenParts };
|
|
100
|
-
console.log('Frozen parts:', frozenParts);
|
|
101
100
|
// 2) Redirect the SDK's in-flight reasoning/text parts into a fresh continuation
|
|
102
101
|
// message so subsequent deltas (resolved by id reference) land after the steer.
|
|
103
102
|
const continuation: any = { id: crypto.randomUUID(), role: 'assistant', parts: [] };
|
|
@@ -175,11 +174,16 @@ export function createAgentSteerQueue({
|
|
|
175
174
|
void sendMessage(next.text);
|
|
176
175
|
}
|
|
177
176
|
|
|
177
|
+
function clear() {
|
|
178
|
+
queue.value = [];
|
|
179
|
+
}
|
|
180
|
+
|
|
178
181
|
return {
|
|
179
182
|
queue,
|
|
180
183
|
enqueue,
|
|
181
184
|
removeQueuedMessage,
|
|
182
185
|
steerQueuedMessage,
|
|
183
186
|
flushNext,
|
|
187
|
+
clear,
|
|
184
188
|
};
|
|
185
189
|
}
|