@guuey/agent-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/block-ui.d.ts +112 -0
- package/dist/block-ui.d.ts.map +1 -0
- package/dist/block-ui.js +165 -0
- package/dist/blocks.d.ts +27 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +32 -0
- package/dist/history.d.ts +62 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +66 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/react.d.ts +11 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +9 -0
- package/dist/sse.d.ts +43 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +107 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/useAgentInvoke.d.ts +20 -0
- package/dist/useAgentInvoke.d.ts.map +1 -0
- package/dist/useAgentInvoke.js +282 -0
- package/dist/web-adapters.d.ts +59 -0
- package/dist/web-adapters.d.ts.map +1 -0
- package/dist/web-adapters.js +135 -0
- package/package.json +73 -0
- package/src/block-ui.ts +196 -0
- package/src/blocks.ts +32 -0
- package/src/history.ts +126 -0
- package/src/index.ts +55 -0
- package/src/react.ts +13 -0
- package/src/sse.ts +115 -0
- package/src/types.ts +153 -0
- package/src/useAgentInvoke.ts +303 -0
- package/src/web-adapters.ts +174 -0
package/src/react.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React entry point (`@guuey/agent-client/react`).
|
|
3
|
+
*
|
|
4
|
+
* The `useAgentInvoke` hook is the only React-coupled surface — the root
|
|
5
|
+
* subpath (`@guuey/agent-client`) stays React-free (pure SSE helpers, the
|
|
6
|
+
* history reader, and the web adapters). Consumers that only need those never
|
|
7
|
+
* import React at all; consumers that render chat import the hook from here.
|
|
8
|
+
*/
|
|
9
|
+
export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke";
|
|
10
|
+
// The block-preserving transcript surfaces `AgReduceResult`; re-export it (and
|
|
11
|
+
// `AgEvent`) here so `./react` consumers can type `reduceResult` without a
|
|
12
|
+
// direct `@silverprotocol/core` import.
|
|
13
|
+
export type { AgEvent, AgReduceResult } from "@silverprotocol/core";
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure SSE helpers for the base-platform invoke contract. Zero React / DOM /
|
|
3
|
+
* platform dependencies — unit-tested in isolation (`sse.test.ts`) and shared
|
|
4
|
+
* verbatim across web (Studio) and React-Native (Portal).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ParsedSseEvent {
|
|
8
|
+
event: string;
|
|
9
|
+
data: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse complete `event:`/`data:` frames out of an SSE buffer. Returns the
|
|
14
|
+
* parsed events plus the unparsed remainder (a partial trailing frame). Frames
|
|
15
|
+
* are separated by a blank line; `data:` JSON is parsed best-effort.
|
|
16
|
+
*/
|
|
17
|
+
export function parseSseEvents(buffer: string): { events: ParsedSseEvent[]; rest: string } {
|
|
18
|
+
const events: ParsedSseEvent[] = [];
|
|
19
|
+
const parts = buffer.split("\n\n");
|
|
20
|
+
const rest = parts.pop() ?? "";
|
|
21
|
+
for (const block of parts) {
|
|
22
|
+
let event = "message";
|
|
23
|
+
const dataLines: string[] = [];
|
|
24
|
+
for (const line of block.split("\n")) {
|
|
25
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
26
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
|
27
|
+
}
|
|
28
|
+
if (dataLines.length === 0) continue;
|
|
29
|
+
let data: unknown = dataLines.join("\n");
|
|
30
|
+
try {
|
|
31
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
32
|
+
} catch {
|
|
33
|
+
/* leave as raw string */
|
|
34
|
+
}
|
|
35
|
+
events.push({ event, data });
|
|
36
|
+
}
|
|
37
|
+
return { events, rest };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isResultMessage(data: unknown): boolean {
|
|
41
|
+
return (
|
|
42
|
+
typeof data === "object" &&
|
|
43
|
+
data !== null &&
|
|
44
|
+
(data as { type?: unknown }).type === "result"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fold one `message` SSE payload into the running assistant text. `assistant`
|
|
50
|
+
* messages APPEND (streaming). The success `result` message carries the SAME
|
|
51
|
+
* final text, so it REPLACES rather than appends — otherwise the answer renders
|
|
52
|
+
* twice. The result is also the fallback for result-only turns.
|
|
53
|
+
*/
|
|
54
|
+
export function reduceAssistantText(current: string, data: unknown): string {
|
|
55
|
+
if (isResultMessage(data)) {
|
|
56
|
+
const result = extractAssistantText(data);
|
|
57
|
+
return result || current;
|
|
58
|
+
}
|
|
59
|
+
return current + extractAssistantText(data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Best-effort assistant text from one `message` payload — BOTH protocols:
|
|
64
|
+
*
|
|
65
|
+
* - **silver (the pod default)**: AgJSON events — `text.delta` carries the
|
|
66
|
+
* streamed text in `.delta`; every other event family (turn/message/tool
|
|
67
|
+
* lifecycle) contributes "". AgJSON never resends the final text, so
|
|
68
|
+
* deltas are append-only (no result-replacement leg).
|
|
69
|
+
* - **bypass**: SDKMessage shapes — text blocks off an `assistant` message;
|
|
70
|
+
* the success `result` string as fallback/replacement.
|
|
71
|
+
*
|
|
72
|
+
* The silver arm was MISSING while silver became the pod default — live
|
|
73
|
+
* portal/studio chat rendered EMPTY assistant messages (0.3.2 coverage
|
|
74
|
+
* audit G14, confirmed by reducing a live wire capture to "").
|
|
75
|
+
*/
|
|
76
|
+
export function extractAssistantText(data: unknown): string {
|
|
77
|
+
if (typeof data !== "object" || data === null) return "";
|
|
78
|
+
const type = (data as { type?: unknown }).type;
|
|
79
|
+
if (type === "text.delta") {
|
|
80
|
+
const delta = (data as { delta?: unknown }).delta;
|
|
81
|
+
return typeof delta === "string" ? delta : "";
|
|
82
|
+
}
|
|
83
|
+
if (type === "assistant") {
|
|
84
|
+
const message = (data as { message?: unknown }).message;
|
|
85
|
+
if (typeof message === "object" && message !== null && "content" in message) {
|
|
86
|
+
const content = (message as { content?: unknown }).content;
|
|
87
|
+
if (Array.isArray(content)) {
|
|
88
|
+
return content
|
|
89
|
+
.filter(
|
|
90
|
+
(b): b is { type: "text"; text: string } =>
|
|
91
|
+
typeof b === "object" &&
|
|
92
|
+
b !== null &&
|
|
93
|
+
(b as { type?: unknown }).type === "text" &&
|
|
94
|
+
typeof (b as { text?: unknown }).text === "string",
|
|
95
|
+
)
|
|
96
|
+
.map((b) => b.text)
|
|
97
|
+
.join("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
if (type === "result") {
|
|
103
|
+
const subtype = (data as { subtype?: unknown }).subtype;
|
|
104
|
+
const result = (data as { result?: unknown }).result;
|
|
105
|
+
if (subtype === "success" && typeof result === "string") return result;
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a string field off an SSE `data` object, or undefined. */
|
|
111
|
+
export function stringField(data: unknown, key: string): string | undefined {
|
|
112
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
113
|
+
const v = (data as Record<string, unknown>)[key];
|
|
114
|
+
return typeof v === "string" ? v : undefined;
|
|
115
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the base-platform chat client.
|
|
3
|
+
*
|
|
4
|
+
* The hook's behaviour is platform-agnostic; its three host couplings —
|
|
5
|
+
* thread-id storage, client-message-id generation, and the network transport
|
|
6
|
+
* (which also carries anonymous identity) — are INJECTED by the consumer via
|
|
7
|
+
* {@link AgentInvokeAdapters}. Web (Studio) passes localStorage / crypto /
|
|
8
|
+
* credentialed-cookie fetch; React-Native (Portal) passes AsyncStorage /
|
|
9
|
+
* getRandomValues / header-identity SSE fetch. This mirrors the ggui
|
|
10
|
+
* `MessageStorageAdapter` injection pattern.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgReduceResult, JsonValue } from "@silverprotocol/core";
|
|
14
|
+
|
|
15
|
+
/** A flat chat turn as rendered by the consumer UI. */
|
|
16
|
+
export interface AgentMessage {
|
|
17
|
+
role: "user" | "assistant";
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A persisted generative-UI card rehydrated from thread history — the verbatim
|
|
23
|
+
* `AgArtifact` snapshot the pod stored on a `kind: "card"` row, tagged with its
|
|
24
|
+
* transcript position. A block-preserving renderer interleaves these with
|
|
25
|
+
* {@link AgentMessage}s (and the live {@link UseAgentInvokeReturn.reduceResult}
|
|
26
|
+
* fold) by ascending `seq`. `cardSnapshot` is forwarded opaquely from the read
|
|
27
|
+
* plane — the SDK does not re-parse it into AgEvents.
|
|
28
|
+
*/
|
|
29
|
+
export interface HistoryCard {
|
|
30
|
+
seq: number;
|
|
31
|
+
at: string;
|
|
32
|
+
cardSnapshot: JsonValue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Persists the durable `threadId` per app so a reload continues the same
|
|
37
|
+
* conversation. `load` may be sync (localStorage) or async (AsyncStorage).
|
|
38
|
+
*/
|
|
39
|
+
export interface ThreadIdStore {
|
|
40
|
+
load(key: string): string | null | Promise<string | null>;
|
|
41
|
+
save(key: string, threadId: string): void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Generates a fresh client-message id (idempotency key for B1 dedup). */
|
|
45
|
+
export type GenerateId = () => string;
|
|
46
|
+
|
|
47
|
+
/** One invoke request handed to the transport. */
|
|
48
|
+
export interface InvokeRequest {
|
|
49
|
+
/** Fully-resolved POST target (already normalised to end in `/agent/invoke`). */
|
|
50
|
+
url: string;
|
|
51
|
+
/** JSON request body: `{ input, threadId?, clientMessageId }`. */
|
|
52
|
+
body: unknown;
|
|
53
|
+
/** Aborts the in-flight stream. */
|
|
54
|
+
signal: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Opens an invoke request and yields decoded UTF-8 text chunks of the SSE
|
|
59
|
+
* stream (the hook accumulates + parses frames itself). MUST throw on a
|
|
60
|
+
* non-OK response or network failure. Owns headers + identity entirely, so
|
|
61
|
+
* the hook never sees cookies or bearer tokens.
|
|
62
|
+
*/
|
|
63
|
+
export type InvokeTransport = (req: InvokeRequest) => AsyncIterable<string>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The persisted transcript for a thread, or a signal that it no longer exists.
|
|
67
|
+
*
|
|
68
|
+
* `cards` is populated ONLY when the loader opts in (see
|
|
69
|
+
* `fetchThreadHistory`'s `includeCards`); the default text-only mapping omits
|
|
70
|
+
* it so existing text-only consumers (portal-native) are unaffected. When
|
|
71
|
+
* present it carries the thread's persisted {@link HistoryCard}s for a
|
|
72
|
+
* block-preserving renderer to interleave by `seq`.
|
|
73
|
+
*/
|
|
74
|
+
export type HistoryLoadResult =
|
|
75
|
+
| { messages: AgentMessage[]; cards?: HistoryCard[] }
|
|
76
|
+
| { gone: true };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Optional seam for rehydrating a chat transcript from a server-side read
|
|
80
|
+
* after the persisted `threadId` hydrates (see `useAgentInvoke`'s mount
|
|
81
|
+
* effect). Best-effort: a rejected `load` is logged and skipped, never
|
|
82
|
+
* blocks chat.
|
|
83
|
+
*/
|
|
84
|
+
export interface AgentInvokeHistoryAdapter {
|
|
85
|
+
/** Fetch the persisted transcript for a thread. `gone` = 403/404 (stale local id). */
|
|
86
|
+
load(threadId: string): Promise<HistoryLoadResult>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The host couplings the hook needs, injected by the consumer. */
|
|
90
|
+
export interface AgentInvokeAdapters {
|
|
91
|
+
storage: ThreadIdStore;
|
|
92
|
+
generateId: GenerateId;
|
|
93
|
+
transport: InvokeTransport;
|
|
94
|
+
/** Optional: rehydrate the transcript for a hydrated threadId. See {@link AgentInvokeHistoryAdapter}. */
|
|
95
|
+
history?: AgentInvokeHistoryAdapter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface UseAgentInvokeOptions {
|
|
99
|
+
/** Pod base URL (with or without a trailing `/agent/invoke`). Chat is disabled when null. */
|
|
100
|
+
endpointUrl: string | null;
|
|
101
|
+
/** Owning app id — namespaces the persisted threadId. */
|
|
102
|
+
appId?: string;
|
|
103
|
+
/** Platform host couplings (storage / crypto / transport). */
|
|
104
|
+
adapters: AgentInvokeAdapters;
|
|
105
|
+
/**
|
|
106
|
+
* Opt-in: ALSO fold the full AgJSON (silver-mode) stream into a
|
|
107
|
+
* block-preserving transcript exposed as {@link UseAgentInvokeReturn.reduceResult},
|
|
108
|
+
* alongside the always-on flat text surface. Off by default; when off the
|
|
109
|
+
* reducer is never constructed and the text behaviour is byte-identical.
|
|
110
|
+
*/
|
|
111
|
+
preserveBlocks?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UseAgentInvokeReturn {
|
|
115
|
+
messages: AgentMessage[];
|
|
116
|
+
send: (input: string) => Promise<void>;
|
|
117
|
+
isStreaming: boolean;
|
|
118
|
+
error: string | null;
|
|
119
|
+
threadId: string | null;
|
|
120
|
+
/** Abort the in-flight turn (the stream stops; partial text is kept). */
|
|
121
|
+
abort: () => void;
|
|
122
|
+
reset: () => void;
|
|
123
|
+
/**
|
|
124
|
+
* The folded AgJSON transcript, or `null`.
|
|
125
|
+
*
|
|
126
|
+
* Contract — **null-until-first-valid-AgEvent** (the documented choice for
|
|
127
|
+
* the ambiguous "which protocol?" case; see {@link UseAgentInvokeOptions.preserveBlocks}):
|
|
128
|
+
*
|
|
129
|
+
* - `null` whenever `preserveBlocks` is off (the reducer is never built);
|
|
130
|
+
* - when `preserveBlocks` is on, `null` UNTIL the per-conversation `Reducer`
|
|
131
|
+
* has folded at least one VALID AgEvent, then the reducer's live
|
|
132
|
+
* `result()` snapshot (a fresh object on each fold, so it re-renders).
|
|
133
|
+
*
|
|
134
|
+
* The hook cannot know a priori whether the pod is in silver or bypass mode.
|
|
135
|
+
* In **bypass mode** the `message` frames are SDKMessage shapes that never
|
|
136
|
+
* validate as AgEvents, so nothing folds and `reduceResult` stays `null` for
|
|
137
|
+
* the whole conversation — the reducer only makes sense for silver AgJSON
|
|
138
|
+
* frames. `reset()` returns it to `null`. History rehydrate does NOT populate
|
|
139
|
+
* it — persisted cards surface via {@link historyCards} instead (Task 4), so
|
|
140
|
+
* a snapshot never has to be lied back into the live reducer.
|
|
141
|
+
*/
|
|
142
|
+
reduceResult: AgReduceResult | null;
|
|
143
|
+
/**
|
|
144
|
+
* Persisted generative-UI cards rehydrated from thread history, ascending by
|
|
145
|
+
* `seq`. Empty (`[]`) unless the injected history adapter opted into cards
|
|
146
|
+
* (`fetchThreadHistory({ includeCards: true })`) AND the rehydrate seeded a
|
|
147
|
+
* transcript. Independent of {@link reduceResult}: `reduceResult` is the
|
|
148
|
+
* LIVE turn's fold, `historyCards` is the persisted PRIOR turns' cards — a
|
|
149
|
+
* block-preserving renderer interleaves both (and {@link messages}) by `seq`.
|
|
150
|
+
* `reset()` clears it back to `[]`.
|
|
151
|
+
*/
|
|
152
|
+
historyCards: HistoryCard[];
|
|
153
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAgentInvoke — the base-platform chat client.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the nocode-runtime pod's Bedrock-style SSE contract (NOT the parked
|
|
5
|
+
* ggui generative-UI protocol that `@ggui-ai/react`'s useInvoke targets):
|
|
6
|
+
*
|
|
7
|
+
* POST {endpointUrl}/agent/invoke
|
|
8
|
+
* body: { input, threadId?, clientMessageId }
|
|
9
|
+
* ← SSE:
|
|
10
|
+
* event: session { sessionId, userId, threadId? }
|
|
11
|
+
* event: message <SDKMessage JSON> (assistant turns + result)
|
|
12
|
+
* event: done { stopReason, threadId?, userSeq?, agentSeq? }
|
|
13
|
+
* event: error { code, message }
|
|
14
|
+
*
|
|
15
|
+
* History persistence (B1) is server-side: the pod resolves a durable Thread
|
|
16
|
+
* from the `threadId` we replay and persists each turn. The threadId is kept
|
|
17
|
+
* in consumer-provided storage (per app) so a reload continues the same
|
|
18
|
+
* conversation.
|
|
19
|
+
*
|
|
20
|
+
* Platform-agnostic: storage, id generation, and the network transport (which
|
|
21
|
+
* also carries anonymous identity) are injected via `opts.adapters`. See
|
|
22
|
+
* `./web-adapters` for the web (Studio) bundle; Portal supplies RN adapters.
|
|
23
|
+
*/
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
|
+
import { Reducer, type AgReduceResult } from "@silverprotocol/core";
|
|
26
|
+
import { parseSseEvents, reduceAssistantText, stringField } from "./sse";
|
|
27
|
+
import { ingestMessageFrame } from "./blocks";
|
|
28
|
+
import type {
|
|
29
|
+
AgentInvokeAdapters,
|
|
30
|
+
AgentMessage,
|
|
31
|
+
HistoryCard,
|
|
32
|
+
HistoryLoadResult,
|
|
33
|
+
UseAgentInvokeOptions,
|
|
34
|
+
UseAgentInvokeReturn,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
function threadStorageKey(appId: string | undefined): string {
|
|
38
|
+
return `guuey:thread:${appId ?? "default"}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The decision `applyHistoryResult` reaches for a loaded transcript. */
|
|
42
|
+
export type HistoryApplication =
|
|
43
|
+
| { kind: "seed"; messages: AgentMessage[] }
|
|
44
|
+
| { kind: "skip" }
|
|
45
|
+
| { kind: "clear" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pure decision seam for post-hydration history application (see
|
|
49
|
+
* `AgentInvokeHistoryAdapter` in `./types`). `gone` always clears the
|
|
50
|
+
* persisted thread. Otherwise a non-empty transcript seeds the chat UNLESS
|
|
51
|
+
* the chat has already been touched (`currentMessages.length > 0`) — a
|
|
52
|
+
* mid-flight send always beats late-arriving history.
|
|
53
|
+
*/
|
|
54
|
+
export function applyHistoryResult(
|
|
55
|
+
result: HistoryLoadResult,
|
|
56
|
+
currentMessages: AgentMessage[],
|
|
57
|
+
): HistoryApplication {
|
|
58
|
+
if ("gone" in result) return { kind: "clear" };
|
|
59
|
+
if (currentMessages.length > 0 || result.messages.length === 0) return { kind: "skip" };
|
|
60
|
+
return { kind: "seed", messages: result.messages };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeReturn {
|
|
64
|
+
const { endpointUrl, appId } = opts;
|
|
65
|
+
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
|
66
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
const [threadId, setThreadId] = useState<string | null>(null);
|
|
69
|
+
// Opt-in block-preserving transcript. `reduceResult` follows the
|
|
70
|
+
// null-until-first-valid-AgEvent contract documented on the return type: it
|
|
71
|
+
// starts null and only becomes non-null once the per-conversation reducer
|
|
72
|
+
// folds a valid AgEvent (so it stays null forever in bypass mode).
|
|
73
|
+
const [reduceResult, setReduceResult] = useState<AgReduceResult | null>(null);
|
|
74
|
+
// Persisted generative-UI cards rehydrated from history (see return-type
|
|
75
|
+
// contract). Independent of the live `reduceResult` fold — populated only
|
|
76
|
+
// when a card-carrying history load seeds the transcript.
|
|
77
|
+
const [historyCards, setHistoryCards] = useState<HistoryCard[]>([]);
|
|
78
|
+
|
|
79
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
80
|
+
// Mirror the latest threadId + adapters into refs so `send` reads fresh
|
|
81
|
+
// values without depending on them (keeps the callback identity stable and
|
|
82
|
+
// sidesteps the async-hydration race).
|
|
83
|
+
const threadIdRef = useRef<string | null>(null);
|
|
84
|
+
const adaptersRef = useRef<AgentInvokeAdapters>(opts.adapters);
|
|
85
|
+
adaptersRef.current = opts.adapters;
|
|
86
|
+
// The per-conversation AgJSON reducer (only built when `preserveBlocks`).
|
|
87
|
+
// Lazily (re)created on the first valid AgEvent after a fresh start / reset,
|
|
88
|
+
// so an off run never constructs one and a bypass run never allocates.
|
|
89
|
+
const reducerRef = useRef<Reducer | null>(null);
|
|
90
|
+
const preserveBlocksRef = useRef<boolean>(opts.preserveBlocks ?? false);
|
|
91
|
+
preserveBlocksRef.current = opts.preserveBlocks ?? false;
|
|
92
|
+
// The in-flight threadId hydration for the current appId. `send` awaits it
|
|
93
|
+
// so a fast first send replays the persisted thread instead of minting a
|
|
94
|
+
// new (orphan) one — critical on async stores (AsyncStorage).
|
|
95
|
+
const hydrationRef = useRef<Promise<void> | null>(null);
|
|
96
|
+
|
|
97
|
+
// Hydrate the persisted threadId on mount / app change. Switching apps
|
|
98
|
+
// starts a FRESH session: reset everything first so one agent's thread +
|
|
99
|
+
// transcript never leaks into another (the hook is shared + appId-keyed,
|
|
100
|
+
// and a consumer may swap appId in place without remounting). Tolerates a
|
|
101
|
+
// sync (localStorage) or async (AsyncStorage) store.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
abortRef.current?.abort();
|
|
104
|
+
threadIdRef.current = null;
|
|
105
|
+
setThreadId(null);
|
|
106
|
+
setMessages([]);
|
|
107
|
+
setError(null);
|
|
108
|
+
setIsStreaming(false);
|
|
109
|
+
// Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
|
|
110
|
+
// the next valid AgEvent. Persisted cards are re-seeded below from history.
|
|
111
|
+
reducerRef.current = null;
|
|
112
|
+
setReduceResult(null);
|
|
113
|
+
setHistoryCards([]);
|
|
114
|
+
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
const key = threadStorageKey(appId);
|
|
117
|
+
const hydration = Promise.resolve(adaptersRef.current.storage.load(key))
|
|
118
|
+
.then((id) => {
|
|
119
|
+
if (!cancelled && id && !threadIdRef.current) {
|
|
120
|
+
threadIdRef.current = id;
|
|
121
|
+
setThreadId(id);
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {
|
|
125
|
+
// Storage unavailable (private mode / keychain error) — no persisted
|
|
126
|
+
// thread; the session simply starts fresh.
|
|
127
|
+
});
|
|
128
|
+
// `hydrationRef` (which `send` awaits) resolves at threadId-load time —
|
|
129
|
+
// history rehydration below is a SEPARATE, un-awaited continuation so a
|
|
130
|
+
// slow history endpoint never gates the user's first send.
|
|
131
|
+
hydrationRef.current = hydration;
|
|
132
|
+
|
|
133
|
+
void hydration.then(async () => {
|
|
134
|
+
// Best-effort transcript rehydration: only runs when a persisted
|
|
135
|
+
// threadId was actually found and a history adapter was supplied.
|
|
136
|
+
// Never throws — a failed/missing history load simply leaves the
|
|
137
|
+
// chat empty and the user starts fresh.
|
|
138
|
+
const tid = threadIdRef.current;
|
|
139
|
+
const history = adaptersRef.current.history;
|
|
140
|
+
if (cancelled || !tid || !history) return;
|
|
141
|
+
let result: HistoryLoadResult;
|
|
142
|
+
try {
|
|
143
|
+
result = await history.load(tid);
|
|
144
|
+
} catch {
|
|
145
|
+
return; // best-effort: offline / transient — chat continues without history
|
|
146
|
+
}
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
if ("gone" in result) {
|
|
149
|
+
// Ordering intent: only clear if no concurrent send() has since
|
|
150
|
+
// established a fresh threadId (a session event mutates
|
|
151
|
+
// `threadIdRef` + saves it). Clearing/overwriting storage here after
|
|
152
|
+
// that would clobber a freshly-valid id with '' on async stores.
|
|
153
|
+
if (threadIdRef.current !== tid) return;
|
|
154
|
+
threadIdRef.current = null;
|
|
155
|
+
setThreadId(null);
|
|
156
|
+
void adaptersRef.current.storage.save(threadStorageKey(appId), "");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Single decision authority: `applyHistoryResult` runs INSIDE the
|
|
160
|
+
// functional update against the live `prev`, so a mid-flight send()'s
|
|
161
|
+
// optimistic messages always beat late-arriving history.
|
|
162
|
+
setMessages((prev) => {
|
|
163
|
+
const application = applyHistoryResult(result, prev);
|
|
164
|
+
return application.kind === "seed" ? application.messages : prev;
|
|
165
|
+
});
|
|
166
|
+
// Surface any persisted cards the loader opted to include (independent of
|
|
167
|
+
// the text seed decision — cards are their own render lane, never
|
|
168
|
+
// optimistically added by send(), so there is nothing to clobber). Empty
|
|
169
|
+
// when the adapter is text-only (no `cards` key on the result).
|
|
170
|
+
if ("cards" in result && result.cards && result.cards.length > 0) {
|
|
171
|
+
setHistoryCards(result.cards);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled = true;
|
|
176
|
+
};
|
|
177
|
+
}, [appId]);
|
|
178
|
+
|
|
179
|
+
const abort = useCallback(() => {
|
|
180
|
+
abortRef.current?.abort();
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const reset = useCallback(() => {
|
|
184
|
+
abortRef.current?.abort();
|
|
185
|
+
// Start a genuinely fresh conversation: forget the durable thread so the
|
|
186
|
+
// next send() mints a new one (not append to the old), clear the
|
|
187
|
+
// persisted key so a reload doesn't rehydrate the old transcript, and
|
|
188
|
+
// wipe the visible state.
|
|
189
|
+
threadIdRef.current = null;
|
|
190
|
+
setThreadId(null);
|
|
191
|
+
void adaptersRef.current.storage.save(threadStorageKey(appId), "");
|
|
192
|
+
setMessages([]);
|
|
193
|
+
setError(null);
|
|
194
|
+
setIsStreaming(false);
|
|
195
|
+
// Re-create the reducer for the new conversation (rebuilt lazily on the
|
|
196
|
+
// next valid AgEvent) and clear the exposed fold + any rehydrated cards.
|
|
197
|
+
reducerRef.current = null;
|
|
198
|
+
setReduceResult(null);
|
|
199
|
+
setHistoryCards([]);
|
|
200
|
+
}, [appId]);
|
|
201
|
+
|
|
202
|
+
const send = useCallback(
|
|
203
|
+
async (input: string) => {
|
|
204
|
+
if (!endpointUrl || !input.trim() || isStreaming) return;
|
|
205
|
+
setError(null);
|
|
206
|
+
setIsStreaming(true);
|
|
207
|
+
setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
|
|
208
|
+
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
abortRef.current = controller;
|
|
211
|
+
const adapters = adaptersRef.current;
|
|
212
|
+
// Wait for the persisted threadId to load before deciding whether to
|
|
213
|
+
// replay it — otherwise a fast first send mints a new orphan thread and
|
|
214
|
+
// clobbers the stored id. `hydrationRef` never rejects (it self-catches).
|
|
215
|
+
if (hydrationRef.current) {
|
|
216
|
+
await hydrationRef.current;
|
|
217
|
+
}
|
|
218
|
+
if (controller.signal.aborted) {
|
|
219
|
+
setIsStreaming(false);
|
|
220
|
+
abortRef.current = null;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let assistantText = "";
|
|
224
|
+
const renderAssistant = (text: string) => {
|
|
225
|
+
assistantText = text;
|
|
226
|
+
setMessages((prev) => {
|
|
227
|
+
const next = prev.slice();
|
|
228
|
+
// The trailing entry is the assistant bubble we just pushed.
|
|
229
|
+
next[next.length - 1] = { role: "assistant", text };
|
|
230
|
+
return next;
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// The endpointUrl may be a pod base (`https://host`) or the full
|
|
236
|
+
// invoke URL the deploy-controller records (`https://host/agent/invoke`).
|
|
237
|
+
// Normalize to exactly one `/agent/invoke`.
|
|
238
|
+
const base = endpointUrl.replace(/\/+$/, "");
|
|
239
|
+
const invokeUrl = base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
|
|
240
|
+
const body = {
|
|
241
|
+
input,
|
|
242
|
+
...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
|
|
243
|
+
clientMessageId: adapters.generateId(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let buffer = "";
|
|
247
|
+
for await (const chunk of adapters.transport({ url: invokeUrl, body, signal: controller.signal })) {
|
|
248
|
+
buffer += chunk;
|
|
249
|
+
const { events, rest } = parseSseEvents(buffer);
|
|
250
|
+
buffer = rest;
|
|
251
|
+
for (const ev of events) {
|
|
252
|
+
if (ev.event === "session") {
|
|
253
|
+
const tid = stringField(ev.data, "threadId");
|
|
254
|
+
if (tid) {
|
|
255
|
+
threadIdRef.current = tid;
|
|
256
|
+
setThreadId(tid);
|
|
257
|
+
void adapters.storage.save(threadStorageKey(appId), tid);
|
|
258
|
+
}
|
|
259
|
+
} else if (ev.event === "message") {
|
|
260
|
+
renderAssistant(reduceAssistantText(assistantText, ev.data));
|
|
261
|
+
// Additively fold the SAME frame into the AgJSON reducer when
|
|
262
|
+
// opted in. The text surface above is untouched; only VALID
|
|
263
|
+
// AgEvents advance the reducer (bypass frames ingest to [] and
|
|
264
|
+
// leave `reduceResult` null — see the return-type contract).
|
|
265
|
+
if (preserveBlocksRef.current) {
|
|
266
|
+
const agEvents = ingestMessageFrame(ev.data);
|
|
267
|
+
if (agEvents.length > 0) {
|
|
268
|
+
if (!reducerRef.current) reducerRef.current = new Reducer();
|
|
269
|
+
for (const agEvent of agEvents) reducerRef.current.push(agEvent);
|
|
270
|
+
setReduceResult(reducerRef.current.result());
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else if (ev.event === "error") {
|
|
274
|
+
setError(stringField(ev.data, "message") ?? "agent error");
|
|
275
|
+
}
|
|
276
|
+
// `done` needs no handling — the stream closes after it.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (e) {
|
|
280
|
+
if (!controller.signal.aborted) {
|
|
281
|
+
setError(e instanceof Error ? e.message : "failed to reach agent");
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
setIsStreaming(false);
|
|
285
|
+
abortRef.current = null;
|
|
286
|
+
// A turn aborted before any assistant text streamed leaves an empty
|
|
287
|
+
// placeholder bubble — drop it so a stopped turn doesn't linger as a
|
|
288
|
+
// blank assistant message.
|
|
289
|
+
if (controller.signal.aborted && assistantText === "") {
|
|
290
|
+
setMessages((prev) => {
|
|
291
|
+
const last = prev[prev.length - 1];
|
|
292
|
+
return last && last.role === "assistant" && last.text === ""
|
|
293
|
+
? prev.slice(0, -1)
|
|
294
|
+
: prev;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
[endpointUrl, appId, isStreaming],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return { messages, send, isStreaming, error, threadId, abort, reset, reduceResult, historyCards };
|
|
303
|
+
}
|