cortena-ui 1.5.0 → 1.7.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/CHANGELOG.md +183 -0
- package/README.md +122 -2
- package/dist/agent-chat/bridge.js +8 -4
- package/dist/agent-chat/bridge.js.map +1 -1
- package/dist/agent-chat/session.d.ts +22 -1
- package/dist/agent-chat/session.js +164 -8
- package/dist/agent-chat/session.js.map +1 -1
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +90 -4
- package/dist/agent-chat/store.js +421 -28
- package/dist/agent-chat/store.js.map +1 -1
- package/dist/agent-chat/types.d.ts +101 -0
- package/dist/agent-chat/types.js.map +1 -1
- package/dist/agent-chat.d.ts +6 -5
- package/dist/agent-chat.js +5 -4
- package/dist/components/agent-chat-popup.d.ts +2 -2
- package/dist/components/agent-chat-popup.js.map +1 -1
- package/dist/components/agent-chat.d.ts +191 -15
- package/dist/components/agent-chat.js +300 -32
- package/dist/components/agent-chat.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/toast.d.ts +1 -1
- package/package.json +3 -2
- package/src/agent-chat/bridge.ts +17 -5
- package/src/agent-chat/session.ts +193 -6
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +692 -24
- package/src/agent-chat/types.ts +106 -0
- package/src/components/agent-chat-popup.tsx +3 -3
- package/src/components/agent-chat.tsx +737 -147
- package/src/entries/agent-chat.ts +38 -2
package/dist/agent-chat/store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use client";
|
|
3
3
|
import { extractA2UIBlocks, foldA2UIBlocks } from "./a2ui-block.js";
|
|
4
|
-
import {
|
|
4
|
+
import { confirmationRequiredLabel, isGenericLabel, stepLabel, truncate } from "./step-label.js";
|
|
5
|
+
import { isProvisionalSessionKey, isSessionOfAgent, mintSessionKey, parseHistoryMessages, readStoredSessionKey, readStoredSteps, separateThinking, wouldShrinkHistory, writeStoredSessionKey, writeStoredSteps } from "./session.js";
|
|
5
6
|
import * as React from "react";
|
|
6
7
|
//#region src/agent-chat/store.ts
|
|
7
8
|
/**
|
|
@@ -36,6 +37,8 @@ const EMPTY = {
|
|
|
36
37
|
loadingHistory: false,
|
|
37
38
|
toolOrder: [],
|
|
38
39
|
tools: {},
|
|
40
|
+
steps: [],
|
|
41
|
+
retiredSteps: [],
|
|
39
42
|
approvals: [],
|
|
40
43
|
historyCount: 0,
|
|
41
44
|
sessions: [],
|
|
@@ -63,6 +66,11 @@ function reduce(state, action) {
|
|
|
63
66
|
...state,
|
|
64
67
|
approvals: state.approvals.filter((r) => r.id !== action.id)
|
|
65
68
|
};
|
|
69
|
+
case "approval/denied": return {
|
|
70
|
+
...state,
|
|
71
|
+
approvals: state.approvals.filter((r) => r.id !== action.id),
|
|
72
|
+
steps: endRunningSteps(state.steps, { kind: "denied" })
|
|
73
|
+
};
|
|
66
74
|
case "approval/failed": return state.approvals.some((r) => r.id === action.request.id) ? state : {
|
|
67
75
|
...state,
|
|
68
76
|
approvals: [...state.approvals, {
|
|
@@ -78,7 +86,10 @@ function reduce(state, action) {
|
|
|
78
86
|
case "send": return {
|
|
79
87
|
...state,
|
|
80
88
|
sessionKey: action.sessionKey,
|
|
81
|
-
messages: action.message ? [...state.messages, action.message] : state.messages
|
|
89
|
+
messages: action.message ? [...state.messages, action.message] : action.reanchor ? state.messages.map((m) => m.id === action.reanchor ? {
|
|
90
|
+
...m,
|
|
91
|
+
runId: action.runId
|
|
92
|
+
} : m) : state.messages,
|
|
82
93
|
streaming: true,
|
|
83
94
|
streamText: "",
|
|
84
95
|
streamThinkingText: "",
|
|
@@ -86,9 +97,15 @@ function reduce(state, action) {
|
|
|
86
97
|
runId: action.runId,
|
|
87
98
|
toolOrder: [],
|
|
88
99
|
tools: {},
|
|
100
|
+
steps: [],
|
|
101
|
+
retiredSteps: [],
|
|
89
102
|
approvals: [],
|
|
90
103
|
error: null
|
|
91
104
|
};
|
|
105
|
+
case "message/remove": return {
|
|
106
|
+
...state,
|
|
107
|
+
messages: state.messages.filter((m) => m.id !== action.id)
|
|
108
|
+
};
|
|
92
109
|
case "send/failed": return {
|
|
93
110
|
...state,
|
|
94
111
|
streaming: false,
|
|
@@ -108,13 +125,18 @@ function reduce(state, action) {
|
|
|
108
125
|
streamThinkingText: "",
|
|
109
126
|
streamA2ui: [],
|
|
110
127
|
runId: null,
|
|
111
|
-
approvals: []
|
|
128
|
+
approvals: [],
|
|
129
|
+
steps: endRunningSteps(state.steps, { kind: "stopped" })
|
|
112
130
|
};
|
|
113
131
|
case "history/loading": return {
|
|
114
132
|
...state,
|
|
115
133
|
loadingHistory: true
|
|
116
134
|
};
|
|
117
135
|
case "history/loaded":
|
|
136
|
+
if (action.sessionKey !== state.sessionKey) return {
|
|
137
|
+
...state,
|
|
138
|
+
loadingHistory: false
|
|
139
|
+
};
|
|
118
140
|
if (wouldShrinkHistory({
|
|
119
141
|
currentSessionKey: state.sessionKey,
|
|
120
142
|
loadedSessionKey: action.sessionKey,
|
|
@@ -139,13 +161,18 @@ function reduce(state, action) {
|
|
|
139
161
|
streamThinkingText: "",
|
|
140
162
|
streamA2ui: []
|
|
141
163
|
};
|
|
142
|
-
case "history/older":
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
case "history/older":
|
|
165
|
+
if (action.sessionKey !== state.sessionKey) return {
|
|
166
|
+
...state,
|
|
167
|
+
loadingHistory: false
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
...state,
|
|
171
|
+
messages: [...action.messages, ...state.messages],
|
|
172
|
+
hasMoreHistory: action.hasMore,
|
|
173
|
+
loadingHistory: false,
|
|
174
|
+
historyCount: state.historyCount + action.records
|
|
175
|
+
};
|
|
149
176
|
case "history/failed": return {
|
|
150
177
|
...state,
|
|
151
178
|
loadingHistory: false
|
|
@@ -159,6 +186,10 @@ function reduce(state, action) {
|
|
|
159
186
|
sessions: action.sessions,
|
|
160
187
|
loadingSessions: false
|
|
161
188
|
};
|
|
189
|
+
case "steps/restored": return state.steps.length > 0 ? state : {
|
|
190
|
+
...state,
|
|
191
|
+
steps: action.steps.slice(-50)
|
|
192
|
+
};
|
|
162
193
|
case "error": return {
|
|
163
194
|
...state,
|
|
164
195
|
error: action.message
|
|
@@ -203,7 +234,8 @@ function reduceChat(state, event) {
|
|
|
203
234
|
streaming: false,
|
|
204
235
|
streamText: "",
|
|
205
236
|
streamThinkingText: "",
|
|
206
|
-
streamA2ui: []
|
|
237
|
+
streamA2ui: [],
|
|
238
|
+
steps: endRunningSteps(state.steps, event.interrupted ? { kind: "paused" } : { kind: "finished" })
|
|
207
239
|
};
|
|
208
240
|
if (!text && a2ui.length === 0) {
|
|
209
241
|
const accumulated = state.streamThinkingText;
|
|
@@ -260,6 +292,10 @@ function reduceChat(state, event) {
|
|
|
260
292
|
streamThinkingText: "",
|
|
261
293
|
streamA2ui: [],
|
|
262
294
|
runId: null,
|
|
295
|
+
steps: endRunningSteps(state.steps, {
|
|
296
|
+
kind: "failed",
|
|
297
|
+
message: truncate(event.errorMessage ?? "The run failed.", ERROR_LIMIT)
|
|
298
|
+
}),
|
|
263
299
|
messages: [...state.messages, {
|
|
264
300
|
id: generateId(),
|
|
265
301
|
role: "assistant",
|
|
@@ -333,16 +369,275 @@ function reduceTool(state, event) {
|
|
|
333
369
|
break;
|
|
334
370
|
default: return state;
|
|
335
371
|
}
|
|
372
|
+
const stepped = reduceStep(state, event, id);
|
|
336
373
|
return {
|
|
337
374
|
...state,
|
|
338
375
|
tools: {
|
|
339
376
|
...state.tools,
|
|
340
377
|
[id]: entry
|
|
341
378
|
},
|
|
342
|
-
toolOrder: state.toolOrder.includes(id) ? state.toolOrder : [...state.toolOrder, id]
|
|
379
|
+
toolOrder: state.toolOrder.includes(id) ? state.toolOrder : [...state.toolOrder, id],
|
|
380
|
+
steps: stepped.steps,
|
|
381
|
+
retiredSteps: stepped.retiredSteps
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* `TOOL_CALL_RESULT.metadata`, read defensively.
|
|
386
|
+
*
|
|
387
|
+
* `null` when nothing usable arrived, which a transport that predates the
|
|
388
|
+
* contract will produce. A step with no metadata at all is treated as a
|
|
389
|
+
* success by `tool.complete` and as a failure by `tool.error`, which is the
|
|
390
|
+
* most either event can honestly claim on its own.
|
|
391
|
+
*
|
|
392
|
+
* Note what is NOT here: any reading of `content`. The result text is the
|
|
393
|
+
* model's copy of the reply, in whatever shape the tool returned, and the
|
|
394
|
+
* client's previous attempt to find `ok: false` in it matched nothing real —
|
|
395
|
+
* a Cortena tool result is `{ content: [...], details }`, so the envelope it
|
|
396
|
+
* was looking for was two levels down when it existed at all.
|
|
397
|
+
*/
|
|
398
|
+
function readResultMetadata(raw) {
|
|
399
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
400
|
+
const record = raw;
|
|
401
|
+
const meta = readMeta(record.meta);
|
|
402
|
+
return {
|
|
403
|
+
...typeof record.toolName === "string" ? { toolName: record.toolName } : {},
|
|
404
|
+
isError: record.isError === true,
|
|
405
|
+
...meta ? { meta } : {}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function readMeta(raw) {
|
|
409
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
410
|
+
const record = raw;
|
|
411
|
+
const meta = {
|
|
412
|
+
...typeof record.status === "number" ? { status: record.status } : {},
|
|
413
|
+
...typeof record.code === "string" ? { code: record.code } : {},
|
|
414
|
+
...typeof record.message === "string" ? { message: record.message } : {}
|
|
415
|
+
};
|
|
416
|
+
return Object.keys(meta).length > 0 ? meta : null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Is this the broker asking for a yes rather than reporting a failure?
|
|
420
|
+
*
|
|
421
|
+
* The broker refuses a catalogued write with `409 confirmation_required` until
|
|
422
|
+
* the call carries `confirmed: true`. It is the safety rail working, and the
|
|
423
|
+
* step says so instead of showing the user a red cross for it.
|
|
424
|
+
*
|
|
425
|
+
* Keyed on the CODE, never on the status. `409` is also an ordinary conflict —
|
|
426
|
+
* a task already claimed, a version that moved under a write — and wording one
|
|
427
|
+
* of those as "Needs your confirmation" invites the user to retry something
|
|
428
|
+
* that will never work.
|
|
429
|
+
*/
|
|
430
|
+
function isConfirmationRequired(meta) {
|
|
431
|
+
return meta?.code === "confirmation_required";
|
|
432
|
+
}
|
|
433
|
+
/** Longest failure message put on a step. */
|
|
434
|
+
const ERROR_LIMIT = 200;
|
|
435
|
+
/**
|
|
436
|
+
* The human part of a tool result, for a failure that carried no `meta`.
|
|
437
|
+
*
|
|
438
|
+
* A Cortena tool returns `{ content: [{ type: "text", text }], details }`,
|
|
439
|
+
* JSON-stringified by the mapper, so the text blocks are the message and the
|
|
440
|
+
* rest is machinery. Anything else — a plain string, a shape this does not
|
|
441
|
+
* recognise — is used as it stands. Bounded and sanitised either way.
|
|
442
|
+
*/
|
|
443
|
+
function resultText(output) {
|
|
444
|
+
const direct = typeof output === "string" ? output : "";
|
|
445
|
+
let value = output;
|
|
446
|
+
if (direct) try {
|
|
447
|
+
value = JSON.parse(direct);
|
|
448
|
+
} catch {
|
|
449
|
+
return truncate(direct, ERROR_LIMIT);
|
|
450
|
+
}
|
|
451
|
+
if (typeof value === "object" && value !== null) {
|
|
452
|
+
const content = value.content;
|
|
453
|
+
if (Array.isArray(content)) {
|
|
454
|
+
const text = content.map((part) => typeof part === "object" && part !== null && typeof part.text === "string" ? part.text : "").filter(Boolean).join(" ");
|
|
455
|
+
if (text) return truncate(text, ERROR_LIMIT);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return truncate(direct, ERROR_LIMIT);
|
|
459
|
+
}
|
|
460
|
+
/** What a failed step says: the server's message, or the result text. */
|
|
461
|
+
function errorMessageOf(meta, output) {
|
|
462
|
+
return (meta?.message ? truncate(meta.message, ERROR_LIMIT) : "") || resultText(output) || "The call failed.";
|
|
463
|
+
}
|
|
464
|
+
/** The call's input as an object, or nothing when it never parsed as one. */
|
|
465
|
+
function readArgs(input) {
|
|
466
|
+
return typeof input === "object" && input !== null && !Array.isArray(input) ? input : void 0;
|
|
467
|
+
}
|
|
468
|
+
/** Is this step still open — spinning, or waiting on the user? */
|
|
469
|
+
function isOpenStep(step) {
|
|
470
|
+
return step.status === "running" || step.status === "paused";
|
|
471
|
+
}
|
|
472
|
+
function endRunningSteps(steps, outcome) {
|
|
473
|
+
const closes = (step) => outcome.kind === "paused" ? step.status === "running" : outcome.kind === "denied" ? step.status === "paused" : isOpenStep(step);
|
|
474
|
+
if (!steps.some(closes)) return steps;
|
|
475
|
+
const endedAt = Date.now();
|
|
476
|
+
return steps.map((step) => {
|
|
477
|
+
if (!closes(step)) return step;
|
|
478
|
+
if (outcome.kind === "paused") return {
|
|
479
|
+
...step,
|
|
480
|
+
status: "paused"
|
|
481
|
+
};
|
|
482
|
+
if (outcome.kind === "failed") return {
|
|
483
|
+
...step,
|
|
484
|
+
status: "error",
|
|
485
|
+
endedAt,
|
|
486
|
+
errorMessage: outcome.message
|
|
487
|
+
};
|
|
488
|
+
if (outcome.kind === "stopped" || outcome.kind === "denied") return {
|
|
489
|
+
...step,
|
|
490
|
+
status: "stopped",
|
|
491
|
+
endedAt
|
|
492
|
+
};
|
|
493
|
+
return {
|
|
494
|
+
...step,
|
|
495
|
+
status: "done",
|
|
496
|
+
endedAt
|
|
497
|
+
};
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* A step appended, with the list capped at `STEP_LIMIT`.
|
|
502
|
+
*
|
|
503
|
+
* A turn with more calls than this is a loop, not a plan, and an uncapped list
|
|
504
|
+
* grows without bound in memory and re-renders the whole strip on every event
|
|
505
|
+
* of it. The most recent are kept: they are what the user is waiting on, and
|
|
506
|
+
* the ids of the ones dropped are kept so a late result for one is recognised
|
|
507
|
+
* rather than mistaken for a call nobody announced.
|
|
508
|
+
*/
|
|
509
|
+
function append(steps, retiredSteps, step) {
|
|
510
|
+
const next = [...steps, step];
|
|
511
|
+
if (next.length <= 50) return {
|
|
512
|
+
steps: next,
|
|
513
|
+
retiredSteps
|
|
514
|
+
};
|
|
515
|
+
const dropped = next.slice(0, next.length - 50).map((s) => s.id);
|
|
516
|
+
return {
|
|
517
|
+
steps: next.slice(-50),
|
|
518
|
+
retiredSteps: [...retiredSteps, ...dropped].slice(-RETIRED_LIMIT)
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** Most dropped ids remembered. Enough for stragglers, not a transcript. */
|
|
522
|
+
const RETIRED_LIMIT = 100;
|
|
523
|
+
function replaceStep(steps, index, next) {
|
|
524
|
+
const copy = [...steps];
|
|
525
|
+
copy[index] = next;
|
|
526
|
+
return copy;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* One tool event, folded into the step list.
|
|
530
|
+
*
|
|
531
|
+
* Start and end are paired by the AG-UI `toolCallId`, not by position: calls
|
|
532
|
+
* overlap, and a run that issues two searches at once ended the wrong one when
|
|
533
|
+
* the list was treated as a stack.
|
|
534
|
+
*
|
|
535
|
+
* The label is recomputed when the arguments land, because they land in a
|
|
536
|
+
* SECOND event. `TOOL_CALL_START` carries the name only, so a label fixed at
|
|
537
|
+
* start time is "Working: engram_search" for the whole of the call — the one
|
|
538
|
+
* line the wording map exists to avoid.
|
|
539
|
+
*/
|
|
540
|
+
function reduceStep(state, event, id) {
|
|
541
|
+
const { steps, retiredSteps } = state;
|
|
542
|
+
const keep = {
|
|
543
|
+
steps,
|
|
544
|
+
retiredSteps
|
|
545
|
+
};
|
|
546
|
+
const index = steps.findIndex((step) => step.id === id);
|
|
547
|
+
const existing = index === -1 ? void 0 : steps[index];
|
|
548
|
+
if (!existing && retiredSteps.includes(id)) return keep;
|
|
549
|
+
if (event.event === "tool.start") {
|
|
550
|
+
if (existing && !isOpenStep(existing)) return keep;
|
|
551
|
+
const toolName = event.data.name ?? existing?.toolName ?? "tool";
|
|
552
|
+
const step = {
|
|
553
|
+
id,
|
|
554
|
+
toolName,
|
|
555
|
+
...existing?.args ? { args: existing.args } : {},
|
|
556
|
+
label: stepLabel(toolName, existing?.args, "running"),
|
|
557
|
+
startedAt: event.data.startedAt ?? existing?.startedAt ?? Date.now(),
|
|
558
|
+
status: "running"
|
|
559
|
+
};
|
|
560
|
+
return existing && index !== -1 ? {
|
|
561
|
+
steps: replaceStep(steps, index, step),
|
|
562
|
+
retiredSteps
|
|
563
|
+
} : append(steps, retiredSteps, step);
|
|
564
|
+
}
|
|
565
|
+
const base = existing ?? {
|
|
566
|
+
id,
|
|
567
|
+
toolName: event.data.name ?? "tool",
|
|
568
|
+
label: stepLabel(event.data.name ?? "tool", void 0, "running"),
|
|
569
|
+
startedAt: event.data.startedAt ?? Date.now(),
|
|
570
|
+
status: "running"
|
|
571
|
+
};
|
|
572
|
+
const at = index === -1 ? steps.length : index;
|
|
573
|
+
const put = (next) => index === -1 ? append(steps, retiredSteps, next) : {
|
|
574
|
+
steps: replaceStep(steps, at, next),
|
|
575
|
+
retiredSteps
|
|
576
|
+
};
|
|
577
|
+
switch (event.event) {
|
|
578
|
+
case "tool.args": {
|
|
579
|
+
const args = readArgs(event.data.input);
|
|
580
|
+
if (!args) return keep;
|
|
581
|
+
if (base.label === CONFIRMATION_FALLBACK) return put({
|
|
582
|
+
...base,
|
|
583
|
+
args,
|
|
584
|
+
label: confirmationRequiredLabel(args)
|
|
585
|
+
});
|
|
586
|
+
const open = isOpenStep(base);
|
|
587
|
+
const relabel = open || isGenericLabel(base.toolName, base.label);
|
|
588
|
+
return put({
|
|
589
|
+
...base,
|
|
590
|
+
args,
|
|
591
|
+
label: relabel ? stepLabel(base.toolName, args, open ? "running" : "done") : base.label
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
case "tool.progress": return keep;
|
|
595
|
+
case "tool.complete":
|
|
596
|
+
case "tool.error": return put(endStep(base, event));
|
|
597
|
+
default: return keep;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** The confirmation label with no operation on it; see `tool.args` above. */
|
|
601
|
+
const CONFIRMATION_FALLBACK = confirmationRequiredLabel(void 0);
|
|
602
|
+
/**
|
|
603
|
+
* A start event's step, ended by its result.
|
|
604
|
+
*
|
|
605
|
+
* The whole outcome decision, in one place, against
|
|
606
|
+
* `TOOL_CALL_RESULT.metadata` and nothing else:
|
|
607
|
+
*
|
|
608
|
+
* - `isError` with `meta.code === "confirmation_required"` — not a failure.
|
|
609
|
+
* The broker is asking a question, and the step is relabelled to ask it.
|
|
610
|
+
* - `isError` otherwise — a failure, worded from `meta.message` when there is
|
|
611
|
+
* one and from the result text when there is not.
|
|
612
|
+
* - neither — done.
|
|
613
|
+
*/
|
|
614
|
+
function endStep(base, event) {
|
|
615
|
+
const metadata = readResultMetadata(event.data.metadata);
|
|
616
|
+
const isError = metadata?.isError === true || event.event === "tool.error";
|
|
617
|
+
const meta = metadata?.meta;
|
|
618
|
+
const endedAt = event.data.completedAt ?? Date.now();
|
|
619
|
+
if (isError && isConfirmationRequired(meta)) return {
|
|
620
|
+
...base,
|
|
621
|
+
status: "done",
|
|
622
|
+
endedAt,
|
|
623
|
+
label: confirmationRequiredLabel(base.args)
|
|
624
|
+
};
|
|
625
|
+
if (isError) return {
|
|
626
|
+
...base,
|
|
627
|
+
status: "error",
|
|
628
|
+
endedAt,
|
|
629
|
+
errorMessage: errorMessageOf(meta, event.data.output)
|
|
630
|
+
};
|
|
631
|
+
return {
|
|
632
|
+
...base,
|
|
633
|
+
status: "done",
|
|
634
|
+
endedAt,
|
|
635
|
+
label: stepLabel(base.toolName, base.args, "done")
|
|
343
636
|
};
|
|
344
637
|
}
|
|
345
|
-
|
|
638
|
+
/** How long a burst of step changes is collapsed for before it is written. */
|
|
639
|
+
const STEP_WRITE_DEBOUNCE_MS = 250;
|
|
640
|
+
function useAgentChat({ client, storageScope, ephemeral, thinking, mintSessionKey: mintKey, restoreSession = true, listSessions = true }) {
|
|
346
641
|
const [state, dispatch] = React.useReducer(reduce, EMPTY);
|
|
347
642
|
const stateRef = React.useRef(state);
|
|
348
643
|
stateRef.current = state;
|
|
@@ -372,6 +667,7 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
372
667
|
};
|
|
373
668
|
}, [client]);
|
|
374
669
|
const refreshSessions = React.useCallback(() => {
|
|
670
|
+
if (!listSessions) return;
|
|
375
671
|
dispatch({ kind: "sessions/loading" });
|
|
376
672
|
client.request("sessions.list", { includeDerivedTitles: true }).then((response) => {
|
|
377
673
|
const sessions = (response?.sessions ?? []).filter((s) => isSessionOfAgent(s.key, client.agentId));
|
|
@@ -389,7 +685,7 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
389
685
|
message: errorText(err)
|
|
390
686
|
});
|
|
391
687
|
});
|
|
392
|
-
}, [client]);
|
|
688
|
+
}, [client, listSessions]);
|
|
393
689
|
const loadHistory = React.useCallback((sessionKey) => {
|
|
394
690
|
dispatch({ kind: "history/loading" });
|
|
395
691
|
client.request("chat.history", {
|
|
@@ -410,13 +706,18 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
410
706
|
React.useEffect(() => {
|
|
411
707
|
if (restored.current) return;
|
|
412
708
|
restored.current = true;
|
|
413
|
-
const stored = ephemeral ? null : readStoredSessionKey(storageScope);
|
|
709
|
+
const stored = ephemeral || !restoreSession ? null : readStoredSessionKey(storageScope);
|
|
414
710
|
const key = stored && isSessionOfAgent(stored, client.agentId) ? stored : null;
|
|
415
711
|
if (key) {
|
|
416
712
|
dispatch({
|
|
417
713
|
kind: "session",
|
|
418
714
|
key
|
|
419
715
|
});
|
|
716
|
+
const steps = ephemeral ? [] : readStoredSteps(storageScope, key);
|
|
717
|
+
if (steps.length > 0) dispatch({
|
|
718
|
+
kind: "steps/restored",
|
|
719
|
+
steps
|
|
720
|
+
});
|
|
420
721
|
loadHistory(key);
|
|
421
722
|
}
|
|
422
723
|
refreshSessions();
|
|
@@ -425,8 +726,50 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
425
726
|
ephemeral,
|
|
426
727
|
loadHistory,
|
|
427
728
|
refreshSessions,
|
|
729
|
+
restoreSession,
|
|
428
730
|
storageScope
|
|
429
731
|
]);
|
|
732
|
+
/**
|
|
733
|
+
* Keep the per-window store in step with the strip, on a debounce.
|
|
734
|
+
*
|
|
735
|
+
* Skipped while there is no session key, which is also the first commit —
|
|
736
|
+
* writing then would empty the slot the effect above is about to read.
|
|
737
|
+
*
|
|
738
|
+
* Debounced because a busy run changes `steps` several times per call —
|
|
739
|
+
* start, arguments, result — and each write serialises the whole list and
|
|
740
|
+
* hands it to a SYNCHRONOUS storage API on the main thread. A quarter of a
|
|
741
|
+
* second collapses a call's worth of edits into one write and is far below
|
|
742
|
+
* anything a reload could notice.
|
|
743
|
+
*/
|
|
744
|
+
const pendingSteps = React.useRef(null);
|
|
745
|
+
React.useEffect(() => {
|
|
746
|
+
if (ephemeral || !state.sessionKey) return;
|
|
747
|
+
const sessionKey = state.sessionKey;
|
|
748
|
+
const steps = state.steps;
|
|
749
|
+
pendingSteps.current = {
|
|
750
|
+
sessionKey,
|
|
751
|
+
steps
|
|
752
|
+
};
|
|
753
|
+
const id = setTimeout(() => {
|
|
754
|
+
pendingSteps.current = null;
|
|
755
|
+
writeStoredSteps(storageScope, sessionKey, steps);
|
|
756
|
+
}, STEP_WRITE_DEBOUNCE_MS);
|
|
757
|
+
return () => clearTimeout(id);
|
|
758
|
+
}, [
|
|
759
|
+
ephemeral,
|
|
760
|
+
state.sessionKey,
|
|
761
|
+
state.steps,
|
|
762
|
+
storageScope
|
|
763
|
+
]);
|
|
764
|
+
React.useEffect(() => {
|
|
765
|
+
return () => {
|
|
766
|
+
const pending = pendingSteps.current;
|
|
767
|
+
if (!ephemeral && pending) {
|
|
768
|
+
pendingSteps.current = null;
|
|
769
|
+
writeStoredSteps(storageScope, pending.sessionKey, pending.steps);
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}, [ephemeral, storageScope]);
|
|
430
773
|
const setSession = React.useCallback((key) => {
|
|
431
774
|
dispatch({
|
|
432
775
|
kind: "session",
|
|
@@ -434,24 +777,26 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
434
777
|
});
|
|
435
778
|
if (!ephemeral) writeStoredSessionKey(storageScope, key);
|
|
436
779
|
}, [ephemeral, storageScope]);
|
|
437
|
-
const run = React.useCallback((text, extra = {}) => {
|
|
780
|
+
const run = React.useCallback((text, extra = {}, reanchor) => {
|
|
438
781
|
const current = stateRef.current;
|
|
439
782
|
if (current.streaming) return;
|
|
440
|
-
const sessionKey = current.sessionKey ?? mintSessionKey(client.agentId);
|
|
783
|
+
const sessionKey = current.sessionKey ?? (mintKey ? mintKey() : mintSessionKey(client.agentId));
|
|
441
784
|
if (!current.sessionKey && !ephemeral) writeStoredSessionKey(storageScope, sessionKey);
|
|
442
785
|
const runId = generateId();
|
|
443
|
-
const message = text || !extra.a2uiAction && !extra.mcpAppAction ? {
|
|
786
|
+
const message = !reanchor && (text || !extra.a2uiAction && !extra.mcpAppAction) ? {
|
|
444
787
|
id: runId,
|
|
445
788
|
role: "user",
|
|
446
789
|
text,
|
|
447
790
|
timestamp: Date.now(),
|
|
448
|
-
runId
|
|
791
|
+
runId,
|
|
792
|
+
...extra.attachments?.length ? { attachmentItems: extra.attachments } : {}
|
|
449
793
|
} : null;
|
|
450
794
|
dispatch({
|
|
451
795
|
kind: "send",
|
|
452
796
|
message,
|
|
453
797
|
runId,
|
|
454
|
-
sessionKey
|
|
798
|
+
sessionKey,
|
|
799
|
+
...reanchor ? { reanchor } : {}
|
|
455
800
|
});
|
|
456
801
|
client.send({
|
|
457
802
|
sessionKey,
|
|
@@ -466,16 +811,44 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
466
811
|
}, [
|
|
467
812
|
client,
|
|
468
813
|
ephemeral,
|
|
814
|
+
mintKey,
|
|
469
815
|
refreshSessions,
|
|
470
816
|
storageScope,
|
|
471
817
|
thinking
|
|
472
818
|
]);
|
|
473
|
-
const send = React.useCallback((text, attachments) => {
|
|
474
|
-
run(text,
|
|
819
|
+
const send = React.useCallback((text, attachments, options) => {
|
|
820
|
+
run(text, {
|
|
821
|
+
...attachments?.length ? { attachments } : {},
|
|
822
|
+
...options?.model ? { model: options.model } : {},
|
|
823
|
+
...options?.thinking ? { thinking: options.thinking } : {}
|
|
824
|
+
});
|
|
475
825
|
}, [run]);
|
|
476
826
|
const sendAction = React.useCallback((a2uiAction) => {
|
|
477
827
|
run("", { a2uiAction });
|
|
478
828
|
}, [run]);
|
|
829
|
+
const sendMcpAppAction = React.useCallback((mcpAppAction) => {
|
|
830
|
+
run("", { mcpAppAction });
|
|
831
|
+
}, [run]);
|
|
832
|
+
const regenerate = React.useCallback((messageId) => {
|
|
833
|
+
const { messages, streaming } = stateRef.current;
|
|
834
|
+
if (streaming) return;
|
|
835
|
+
const index = messages.findIndex((m) => m.id === messageId);
|
|
836
|
+
if (index < 1) return;
|
|
837
|
+
let question = null;
|
|
838
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
839
|
+
const candidate = messages[i];
|
|
840
|
+
if (candidate?.role === "user" && !candidate.isToolMessage) {
|
|
841
|
+
question = candidate;
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (question === null) return;
|
|
846
|
+
dispatch({
|
|
847
|
+
kind: "message/remove",
|
|
848
|
+
id: messageId
|
|
849
|
+
});
|
|
850
|
+
run(question.text, {}, question.id);
|
|
851
|
+
}, [run]);
|
|
479
852
|
const abort = React.useCallback(() => {
|
|
480
853
|
const { runId, sessionKey } = stateRef.current;
|
|
481
854
|
dispatch({ kind: "abort" });
|
|
@@ -487,7 +860,10 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
487
860
|
const resolveApproval = React.useCallback((id, decision) => {
|
|
488
861
|
const request = stateRef.current.approvals.find((r) => r.id === id);
|
|
489
862
|
if (!request) return;
|
|
490
|
-
dispatch({
|
|
863
|
+
dispatch(decision === "deny" ? {
|
|
864
|
+
kind: "approval/denied",
|
|
865
|
+
id
|
|
866
|
+
} : {
|
|
491
867
|
kind: "approval/resolved",
|
|
492
868
|
id
|
|
493
869
|
});
|
|
@@ -503,38 +879,55 @@ function useAgentChat({ client, storageScope, ephemeral, thinking }) {
|
|
|
503
879
|
});
|
|
504
880
|
}, [client]);
|
|
505
881
|
const newSession = React.useCallback(() => {
|
|
506
|
-
setSession(mintSessionKey(client.agentId));
|
|
507
|
-
}, [
|
|
882
|
+
setSession(mintKey ? mintKey() : mintSessionKey(client.agentId));
|
|
883
|
+
}, [
|
|
884
|
+
client,
|
|
885
|
+
mintKey,
|
|
886
|
+
setSession
|
|
887
|
+
]);
|
|
888
|
+
const clearSession = React.useCallback(() => {
|
|
889
|
+
dispatch({
|
|
890
|
+
kind: "session",
|
|
891
|
+
key: null
|
|
892
|
+
});
|
|
893
|
+
if (!ephemeral) writeStoredSessionKey(storageScope, null);
|
|
894
|
+
}, [ephemeral, storageScope]);
|
|
508
895
|
const resumeSession = React.useCallback((key) => {
|
|
509
896
|
setSession(key);
|
|
510
897
|
loadHistory(key);
|
|
511
898
|
}, [loadHistory, setSession]);
|
|
512
899
|
const loadOlder = React.useCallback(() => {
|
|
513
900
|
const current = stateRef.current;
|
|
514
|
-
if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) return;
|
|
901
|
+
if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) return false;
|
|
902
|
+
const sessionKey = current.sessionKey;
|
|
515
903
|
dispatch({ kind: "history/loading" });
|
|
516
904
|
client.request("chat.history", {
|
|
517
|
-
sessionKey
|
|
905
|
+
sessionKey,
|
|
518
906
|
limit: 100,
|
|
519
907
|
offset: current.historyCount
|
|
520
908
|
}).then((response) => {
|
|
521
909
|
const records = response?.messages ?? [];
|
|
522
910
|
dispatch({
|
|
523
911
|
kind: "history/older",
|
|
912
|
+
sessionKey,
|
|
524
913
|
messages: parseHistoryMessages(records),
|
|
525
914
|
hasMore: response?.hasMore ?? false,
|
|
526
915
|
records: records.length
|
|
527
916
|
});
|
|
528
917
|
}).catch(() => dispatch({ kind: "history/failed" }));
|
|
918
|
+
return true;
|
|
529
919
|
}, [client]);
|
|
530
920
|
return {
|
|
531
921
|
...state,
|
|
532
922
|
send,
|
|
533
923
|
sendAction,
|
|
924
|
+
sendMcpAppAction,
|
|
534
925
|
abort,
|
|
535
926
|
resolveApproval,
|
|
536
927
|
newSession,
|
|
537
928
|
resumeSession,
|
|
929
|
+
clearSession,
|
|
930
|
+
regenerate,
|
|
538
931
|
loadOlder,
|
|
539
932
|
refreshSessions
|
|
540
933
|
};
|
|
@@ -543,6 +936,6 @@ function errorText(err) {
|
|
|
543
936
|
return err instanceof Error ? err.message : "The agent could not be reached";
|
|
544
937
|
}
|
|
545
938
|
//#endregion
|
|
546
|
-
export { useAgentChat };
|
|
939
|
+
export { readResultMetadata, useAgentChat };
|
|
547
940
|
|
|
548
941
|
//# sourceMappingURL=store.js.map
|