@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web (browser / Next.js) host adapters for {@link useAgentInvoke}.
|
|
3
|
+
*
|
|
4
|
+
* Studio builds its bundle via {@link createWebAdapters}. The implementations
|
|
5
|
+
* touch `window.localStorage`, `crypto`, and `fetch` only inside their
|
|
6
|
+
* functions — never at module load — so this file is import-safe under SSR
|
|
7
|
+
* (the functions guard on `typeof window`).
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentInvokeAdapters, InvokeRequest, ThreadIdStore } from "./types";
|
|
10
|
+
/**
|
|
11
|
+
* Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
|
|
12
|
+
* SSE stream opens). Carries the pod's structured `{ code, message }` when
|
|
13
|
+
* present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
|
|
14
|
+
* generation limit…") the chat UI should surface — falling back to the bare
|
|
15
|
+
* status for non-JSON failures.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AgentResponseError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly code?: string | undefined;
|
|
20
|
+
constructor(message: string, status: number, code?: string | undefined);
|
|
21
|
+
}
|
|
22
|
+
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
23
|
+
export declare const localStorageThreadStore: ThreadIdStore;
|
|
24
|
+
/** Crypto-strong client-message id, with a non-crypto fallback. */
|
|
25
|
+
export declare function webGenerateId(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Web SSE transport. When `accessToken` is present the pod identifies the
|
|
28
|
+
* caller by their verified Cognito access token (the same identity the
|
|
29
|
+
* history read plane uses, so persisted threads round-trip on reload).
|
|
30
|
+
* Otherwise it falls back to `credentials: "include"`, which round-trips the
|
|
31
|
+
* HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
|
|
32
|
+
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
33
|
+
*/
|
|
34
|
+
export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null): AsyncGenerator<string>;
|
|
35
|
+
export interface CreateWebAdaptersOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Public read-plane base (ending in `/v1`) for transcript history. When
|
|
38
|
+
* omitted, no history adapter is installed and reloads start empty.
|
|
39
|
+
*/
|
|
40
|
+
apiBaseUrl?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the caller's Cognito access token (fresh), or `null` when signed
|
|
43
|
+
* out. When a token is present the chat transport AND the history read
|
|
44
|
+
* authenticate as that user, so a reload restores the transcript. Without
|
|
45
|
+
* a token the transport falls back to the guest cookie and history is
|
|
46
|
+
* skipped — the read plane can't identify a cookie-only browser caller
|
|
47
|
+
* (it reads the `x-guuey-guest` header or a Bearer, not the HttpOnly
|
|
48
|
+
* guest cookie), so there is no identity to replay.
|
|
49
|
+
*/
|
|
50
|
+
getAccessToken?: () => Promise<string | null>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
|
|
54
|
+
* access-token resolver (and the read-plane base) to authenticate the chat
|
|
55
|
+
* transport and enable transcript restore on reload; omit them for an
|
|
56
|
+
* anonymous, history-less bundle.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createWebAdapters(opts?: CreateWebAdaptersOptions): AgentInvokeAdapters;
|
|
59
|
+
//# sourceMappingURL=web-adapters.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EACV,mBAAmB,EACnB,aAAa,EAEb,aAAa,EACd,MAAM,SAAS,CAAC;AAGjB;;;;;;GAMG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAGzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;gBAFtB,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;CAKzB;AAED,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED;;;;;;;GAOG;AACH,wBAAuB,oBAAoB,CACzC,GAAG,EAAE,aAAa,EAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAC1B,cAAc,CAAC,MAAM,CAAC,CAyCxB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA+BrB"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { fetchThreadHistory } from "./history";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
|
|
4
|
+
* SSE stream opens). Carries the pod's structured `{ code, message }` when
|
|
5
|
+
* present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
|
|
6
|
+
* generation limit…") the chat UI should surface — falling back to the bare
|
|
7
|
+
* status for non-JSON failures.
|
|
8
|
+
*/
|
|
9
|
+
export class AgentResponseError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
constructor(message, status, code) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "AgentResponseError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
20
|
+
export const localStorageThreadStore = {
|
|
21
|
+
load(key) {
|
|
22
|
+
if (typeof window === "undefined")
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
return window.localStorage.getItem(key);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
save(key, threadId) {
|
|
32
|
+
if (typeof window === "undefined")
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
window.localStorage.setItem(key, threadId);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* private mode / blocked storage — threadId stays in-memory only */
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/** Crypto-strong client-message id, with a non-crypto fallback. */
|
|
43
|
+
export function webGenerateId() {
|
|
44
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
45
|
+
return crypto.randomUUID();
|
|
46
|
+
}
|
|
47
|
+
return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Web SSE transport. When `accessToken` is present the pod identifies the
|
|
51
|
+
* caller by their verified Cognito access token (the same identity the
|
|
52
|
+
* history read plane uses, so persisted threads round-trip on reload).
|
|
53
|
+
* Otherwise it falls back to `credentials: "include"`, which round-trips the
|
|
54
|
+
* HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
|
|
55
|
+
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
56
|
+
*/
|
|
57
|
+
export async function* fetchStreamTransport(req, accessToken) {
|
|
58
|
+
const headers = {
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
Accept: "text/event-stream",
|
|
61
|
+
};
|
|
62
|
+
const init = {
|
|
63
|
+
method: "POST",
|
|
64
|
+
signal: req.signal,
|
|
65
|
+
headers,
|
|
66
|
+
body: JSON.stringify(req.body),
|
|
67
|
+
};
|
|
68
|
+
if (accessToken) {
|
|
69
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
init.credentials = "include";
|
|
73
|
+
}
|
|
74
|
+
const resp = await fetch(req.url, init);
|
|
75
|
+
if (!resp.ok || !resp.body) {
|
|
76
|
+
// Surface a structured pod error ({ code, message }) when present — e.g. a
|
|
77
|
+
// QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
|
|
78
|
+
// back to the bare status for non-JSON failures.
|
|
79
|
+
const body = await resp.json().catch(() => null);
|
|
80
|
+
let message = `agent responded ${resp.status}`;
|
|
81
|
+
let code;
|
|
82
|
+
if (body !== null && typeof body === "object") {
|
|
83
|
+
if ("message" in body && typeof body.message === "string" && body.message) {
|
|
84
|
+
message = body.message;
|
|
85
|
+
}
|
|
86
|
+
if ("code" in body && typeof body.code === "string") {
|
|
87
|
+
code = body.code;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw new AgentResponseError(message, resp.status, code);
|
|
91
|
+
}
|
|
92
|
+
const reader = resp.body.getReader();
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { value, done } = await reader.read();
|
|
96
|
+
if (done)
|
|
97
|
+
break;
|
|
98
|
+
yield decoder.decode(value, { stream: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
|
|
103
|
+
* access-token resolver (and the read-plane base) to authenticate the chat
|
|
104
|
+
* transport and enable transcript restore on reload; omit them for an
|
|
105
|
+
* anonymous, history-less bundle.
|
|
106
|
+
*/
|
|
107
|
+
export function createWebAdapters(opts = {}) {
|
|
108
|
+
const { apiBaseUrl, getAccessToken } = opts;
|
|
109
|
+
const transport = async function* (req) {
|
|
110
|
+
const token = getAccessToken ? await getAccessToken() : null;
|
|
111
|
+
yield* fetchStreamTransport(req, token);
|
|
112
|
+
};
|
|
113
|
+
const adapters = {
|
|
114
|
+
storage: localStorageThreadStore,
|
|
115
|
+
generateId: webGenerateId,
|
|
116
|
+
transport,
|
|
117
|
+
};
|
|
118
|
+
if (apiBaseUrl && getAccessToken) {
|
|
119
|
+
adapters.history = {
|
|
120
|
+
load: async (threadId) => {
|
|
121
|
+
const token = await getAccessToken();
|
|
122
|
+
// No readable identity → leave the chat empty (skip) rather than
|
|
123
|
+
// `gone`, which would clear the persisted threadId.
|
|
124
|
+
if (!token)
|
|
125
|
+
return { messages: [] };
|
|
126
|
+
return fetchThreadHistory({
|
|
127
|
+
baseUrl: apiBaseUrl,
|
|
128
|
+
threadId,
|
|
129
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return adapters;
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guuey/agent-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src",
|
|
12
|
+
"!src/**/*.test.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"react-native": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./react": {
|
|
24
|
+
"react-native": "./src/react.ts",
|
|
25
|
+
"types": "./dist/react.d.ts",
|
|
26
|
+
"import": "./dist/react.js",
|
|
27
|
+
"default": "./dist/react.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@silverprotocol/core": "0.3.2"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@testing-library/dom": "^10.0.0",
|
|
38
|
+
"@testing-library/react": "^16.3.2",
|
|
39
|
+
"@types/react": "^19.0.0",
|
|
40
|
+
"jsdom": "^25.0.1",
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"react-dom": "^19.0.0",
|
|
43
|
+
"typescript": "^5.0.0",
|
|
44
|
+
"vitest": "^3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"guuey",
|
|
51
|
+
"agent",
|
|
52
|
+
"sse",
|
|
53
|
+
"chat",
|
|
54
|
+
"react",
|
|
55
|
+
"client"
|
|
56
|
+
],
|
|
57
|
+
"homepage": "https://guuey.com",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/loqu-co/guuey/issues"
|
|
60
|
+
},
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/withguuey/guuey-sdks.git",
|
|
64
|
+
"directory": "packages/agent-client"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.build.json",
|
|
68
|
+
"dev": "tsc --watch",
|
|
69
|
+
"typecheck": "tsc --noEmit",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/block-ui.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure block-walk / resource-narrowing helpers for a block-preserving agent
|
|
3
|
+
* transcript — no React, no DOM, so the narrowing logic stays unit-testable in
|
|
4
|
+
* isolation (this package's vitest runs a `node` environment) and can be shared
|
|
5
|
+
* by every host renderer (Studio's `AgentBlocks`, Portal-web's agent chat).
|
|
6
|
+
*
|
|
7
|
+
* The pod's AgJSON wire carries generative-UI payloads on `tool.done` events,
|
|
8
|
+
* which the reducer folds onto `tool-result` blocks. Two channels reach us:
|
|
9
|
+
*
|
|
10
|
+
* 1. **`uiData`** — the MCP-Apps *surface* channel. The pod's Claude facet
|
|
11
|
+
* routes a tool result's `structuredContent` here when the server stamped
|
|
12
|
+
* `_meta.ui`. Any resource here is intended as UI.
|
|
13
|
+
* 2. **`provider-raw` content blocks** — an MCP embedded `resource` content
|
|
14
|
+
* part does NOT survive as a first-class `resource` AgBlock in the Claude
|
|
15
|
+
* facet; it degrades to `{ type:'provider-raw', vendor, raw:<part> }`. So a
|
|
16
|
+
* `ui://` resource can be hiding inside `provider-raw.raw` and must be
|
|
17
|
+
* scanned for defensively.
|
|
18
|
+
*
|
|
19
|
+
* The resource-narrowing (opaque `JsonValue` → typed payload) mirrors the
|
|
20
|
+
* proven `create-agentic-app` web template — structural validation, never a cast.
|
|
21
|
+
*/
|
|
22
|
+
import type { AgBlock, AgMessage, JsonValue } from "@silverprotocol/core";
|
|
23
|
+
import type { HistoryCard } from "./types";
|
|
24
|
+
|
|
25
|
+
/** A narrowed MCP embedded UI resource (the `_meta.ui.resource` shape). */
|
|
26
|
+
export interface McpUiResourcePayload {
|
|
27
|
+
uri: string;
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
blob?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Narrow an opaque `JsonValue` to a plain (non-array) JSON object. */
|
|
34
|
+
export function isJsonObject(v: JsonValue | undefined): v is { [key: string]: JsonValue } {
|
|
35
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A JSON object → an MCP UI resource, if it has a `uri` plus renderable
|
|
40
|
+
* payload (`text` or base64 `blob`). Returns `undefined` for anything else.
|
|
41
|
+
*/
|
|
42
|
+
export function asResourcePayload(v: JsonValue | undefined): McpUiResourcePayload | undefined {
|
|
43
|
+
if (!isJsonObject(v)) return undefined;
|
|
44
|
+
if (typeof v.uri !== "string") return undefined;
|
|
45
|
+
if (typeof v.text !== "string" && typeof v.blob !== "string") return undefined;
|
|
46
|
+
return {
|
|
47
|
+
uri: v.uri,
|
|
48
|
+
...(typeof v.mimeType === "string" ? { mimeType: v.mimeType } : {}),
|
|
49
|
+
...(typeof v.text === "string" ? { text: v.text } : {}),
|
|
50
|
+
...(typeof v.blob === "string" ? { blob: v.blob } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Does a `tool-result` block's `uiData` carry an MCP embedded UI resource?
|
|
56
|
+
* Accepts the resource inlined directly, or wrapped as `{ resource: {...} }`
|
|
57
|
+
* (the shape an MCP `resource` content part carries). No `ui://` scheme gate
|
|
58
|
+
* here on purpose: `uiData` is the explicit *surface* channel (the server
|
|
59
|
+
* stamped `_meta.ui`), so any resource on it is meant to render.
|
|
60
|
+
*/
|
|
61
|
+
export function asUiResource(uiData: JsonValue | undefined): McpUiResourcePayload | undefined {
|
|
62
|
+
if (!isJsonObject(uiData)) return undefined;
|
|
63
|
+
const direct = asResourcePayload(uiData);
|
|
64
|
+
if (direct) return direct;
|
|
65
|
+
return asResourcePayload(uiData.resource);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scan a `provider-raw` block's `raw` (the vendor tool_result content part)
|
|
70
|
+
* for a *generative-UI* resource. Unlike {@link asUiResource}, this path IS
|
|
71
|
+
* gated on the `ui://` scheme: `provider-raw` degradation is a lossy catch-all,
|
|
72
|
+
* so a plain file/text resource riding it is NOT a UI to mount — only the
|
|
73
|
+
* mcp-ui `ui://` convention is.
|
|
74
|
+
*/
|
|
75
|
+
export function scanProviderRawForUiResource(
|
|
76
|
+
raw: JsonValue | undefined,
|
|
77
|
+
): McpUiResourcePayload | undefined {
|
|
78
|
+
if (!isJsonObject(raw)) return undefined;
|
|
79
|
+
const candidate =
|
|
80
|
+
raw.resource !== undefined ? asResourcePayload(raw.resource) : asResourcePayload(raw);
|
|
81
|
+
if (!candidate) return undefined;
|
|
82
|
+
return candidate.uri.startsWith("ui://") ? candidate : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract a mountable UI resource from an opaque AgBlock-shaped `JsonValue`
|
|
87
|
+
* (used for persisted card snapshot parts, which arrive untyped). Dispatches
|
|
88
|
+
* by `block.type`:
|
|
89
|
+
* - `tool-result` → its `uiData` surface channel
|
|
90
|
+
* - `provider-raw` → a `ui://` resource hiding in `raw`
|
|
91
|
+
* - `resource` → a first-class embedded resource (gated on `ui://`)
|
|
92
|
+
* Everything else → `undefined`.
|
|
93
|
+
*/
|
|
94
|
+
export function blockUiResource(block: JsonValue): McpUiResourcePayload | undefined {
|
|
95
|
+
if (!isJsonObject(block)) return undefined;
|
|
96
|
+
switch (block.type) {
|
|
97
|
+
case "tool-result":
|
|
98
|
+
return asUiResource(block.uiData);
|
|
99
|
+
case "provider-raw":
|
|
100
|
+
return scanProviderRawForUiResource(block.raw);
|
|
101
|
+
case "resource": {
|
|
102
|
+
const r = asResourcePayload(block.resource);
|
|
103
|
+
return r && r.uri.startsWith("ui://") ? r : undefined;
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A live `tool-result` AgBlock → its mountable UI resource, checking BOTH
|
|
112
|
+
* channels the Claude facet uses:
|
|
113
|
+
* 1. the `uiData` surface channel (server stamped `_meta.ui`), and
|
|
114
|
+
* 2. an embedded `ui://` resource that degraded into a `provider-raw`
|
|
115
|
+
* content part inside the tool result (MCP `resource` parts do NOT survive
|
|
116
|
+
* as first-class `resource` AgBlocks here).
|
|
117
|
+
* First-class `resource` content parts are intentionally not scanned in this
|
|
118
|
+
* typed live path (the Claude facet never emits them); the untyped card path
|
|
119
|
+
* ({@link blockUiResource}) covers them for other facets' persisted snapshots.
|
|
120
|
+
*/
|
|
121
|
+
export function toolResultUiResource(
|
|
122
|
+
block: Extract<AgBlock, { type: "tool-result" }>,
|
|
123
|
+
): McpUiResourcePayload | undefined {
|
|
124
|
+
const fromUiData = asUiResource(block.uiData);
|
|
125
|
+
if (fromUiData) return fromUiData;
|
|
126
|
+
for (const part of block.content) {
|
|
127
|
+
if (part.type === "provider-raw") {
|
|
128
|
+
const found = scanProviderRawForUiResource(part.raw);
|
|
129
|
+
if (found) return found;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A persisted `HistoryCard`'s `cardSnapshot` → a mountable UI resource. The
|
|
137
|
+
* snapshot is the verbatim `AgArtifact` the pod stored (`{ parts: AgBlock[] }`),
|
|
138
|
+
* so walk its `parts` for the first block that yields a resource; fall back to
|
|
139
|
+
* treating the snapshot root itself as a block.
|
|
140
|
+
*
|
|
141
|
+
* NOTE (`no-ggui-tools`): a ggui-rendered card carries NO inline HTML resource —
|
|
142
|
+
* its UI rides `_meta.ggui.bootstrap` and mounts via `@ggui-ai/react`'s
|
|
143
|
+
* `McpAppIframe`. That branch is OUT OF SCOPE for v1 (deferred-pending-capture).
|
|
144
|
+
* So a real ggui card resolves to `undefined` here and renders as the host's
|
|
145
|
+
* coherent placeholder, not a broken mount.
|
|
146
|
+
*/
|
|
147
|
+
export function cardUiResource(cardSnapshot: JsonValue): McpUiResourcePayload | undefined {
|
|
148
|
+
if (!isJsonObject(cardSnapshot)) return undefined;
|
|
149
|
+
const parts = cardSnapshot.parts;
|
|
150
|
+
if (Array.isArray(parts)) {
|
|
151
|
+
for (const part of parts) {
|
|
152
|
+
const found = blockUiResource(part);
|
|
153
|
+
if (found) return found;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return blockUiResource(cardSnapshot);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The resource's HTML: inline `text` wins; else base64-decode `blob`. `atob`
|
|
161
|
+
* alone yields a Latin-1 string (mojibake on multibyte UTF-8), so decode via
|
|
162
|
+
* bytes + `TextDecoder`. Invalid base64 → `undefined` (no renderable payload).
|
|
163
|
+
*/
|
|
164
|
+
export function resourceHtml(resource: McpUiResourcePayload): string | undefined {
|
|
165
|
+
if (resource.text !== undefined) return resource.text;
|
|
166
|
+
if (resource.blob !== undefined) {
|
|
167
|
+
try {
|
|
168
|
+
return new TextDecoder().decode(Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0)));
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The tool name for a `tool-result` block, read off its paired `tool-call`
|
|
178
|
+
* block in the same message (the reducer keeps both in one message's content).
|
|
179
|
+
* Falls back to `"tool"` when the pair is missing.
|
|
180
|
+
*/
|
|
181
|
+
export function toolNameFor(message: AgMessage, toolCallId: string): string {
|
|
182
|
+
for (const b of message.content) {
|
|
183
|
+
if (b.type === "tool-call" && b.toolCallId === toolCallId) return b.name;
|
|
184
|
+
}
|
|
185
|
+
return "tool";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Persisted cards, ascending by transcript `seq` (stable; input untouched).
|
|
190
|
+
* These are PRIOR-turn cards — they always precede the live fold, so a
|
|
191
|
+
* block-preserving renderer surfaces them first (e.g. under an "Earlier in
|
|
192
|
+
* this conversation" divider).
|
|
193
|
+
*/
|
|
194
|
+
export function sortHistoryCards(cards: readonly HistoryCard[]): HistoryCard[] {
|
|
195
|
+
return [...cards].sort((a, b) => a.seq - b.seq);
|
|
196
|
+
}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame → AgEvent[] ingestion for the opt-in block-preserving transcript.
|
|
3
|
+
*
|
|
4
|
+
* The wire carries ONE `message` frame per SSE event, but the *payload* shape
|
|
5
|
+
* differs by producer:
|
|
6
|
+
*
|
|
7
|
+
* - the deployed pod (`nocode-runtime`) emits a SINGLE AgEvent JSON OBJECT per
|
|
8
|
+
* frame (`sendEvent(res, 'message', e)`);
|
|
9
|
+
* - the CLI dev server (`guuey dev --serve`) batches an AgEvent[] ARRAY per
|
|
10
|
+
* frame (`sendEvent(res, 'message', batch)`).
|
|
11
|
+
*
|
|
12
|
+
* `ingestMessageFrame` is tolerant of BOTH shapes (object OR array). Validation
|
|
13
|
+
* is delegated to `@silverprotocol/core`'s `ingestAgEvents`, the library's own
|
|
14
|
+
* parse-known-else-skip consumer validator: any element that is not a valid
|
|
15
|
+
* AgJSON event — including bypass-mode SDKMessage shapes (`type: "assistant"` /
|
|
16
|
+
* `type: "result"`, which are NOT AgEvent `type` literals) — is dropped rather
|
|
17
|
+
* than crashing the stream or forcing a type lie. A frame that is not even a
|
|
18
|
+
* JSON value (`undefined`, a function, `NaN`, …) yields `[]`.
|
|
19
|
+
*
|
|
20
|
+
* The `unknown` → `JsonValue` narrowing is done at runtime via the exported
|
|
21
|
+
* `JsonValue` schema's `safeParse` (the same posture the Claude facet uses for
|
|
22
|
+
* provider-raw parts) — no assertion, no `as`.
|
|
23
|
+
*/
|
|
24
|
+
import { ingestAgEvents, JsonValue, type AgEvent } from "@silverprotocol/core";
|
|
25
|
+
|
|
26
|
+
/** Ingest one `message` SSE payload (object OR array) into validated AgEvents. */
|
|
27
|
+
export function ingestMessageFrame(raw: unknown): AgEvent[] {
|
|
28
|
+
const parsed = JsonValue.safeParse(raw);
|
|
29
|
+
if (!parsed.success) return [];
|
|
30
|
+
const value = parsed.data;
|
|
31
|
+
return ingestAgEvents(Array.isArray(value) ? value : [value]);
|
|
32
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transcript-history reader for the base-platform chat client.
|
|
3
|
+
*
|
|
4
|
+
* Reads a thread's persisted transcript from the public read plane
|
|
5
|
+
* (`GET {baseUrl}/threads/{id}/messages`, paginated by `nextToken`) so a
|
|
6
|
+
* reload can repaint history before any SSE traffic starts. Host-agnostic:
|
|
7
|
+
* the caller supplies the base URL and a `requestInit` carrying whatever
|
|
8
|
+
* identity that host can present (a `Authorization: Bearer` header on web /
|
|
9
|
+
* RN when signed in, an `x-guuey-guest` header for RN guests, cookies via
|
|
10
|
+
* `credentials: "include"`). Consumed by {@link createWebAdapters}; Portal
|
|
11
|
+
* has its own copy today and can migrate onto this later.
|
|
12
|
+
*/
|
|
13
|
+
import type { JsonValue } from "@silverprotocol/core";
|
|
14
|
+
import type { AgentMessage, HistoryCard, HistoryLoadResult } from "./types";
|
|
15
|
+
|
|
16
|
+
/** One row of `GET /v1/threads/:id/messages`. */
|
|
17
|
+
export interface ThreadHistoryRow {
|
|
18
|
+
seq: number;
|
|
19
|
+
at: string;
|
|
20
|
+
kind: string;
|
|
21
|
+
authorRole: string;
|
|
22
|
+
text: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* The verbatim persisted `AgArtifact` on `kind === "card"` rows; `null` or
|
|
25
|
+
* absent on text/event rows (the read plane omits it there). Forwarded
|
|
26
|
+
* opaquely — never re-parsed into AgEvents.
|
|
27
|
+
*/
|
|
28
|
+
cardSnapshot?: JsonValue | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ThreadMessagesResponse {
|
|
32
|
+
rows: ThreadHistoryRow[];
|
|
33
|
+
nextToken: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rows requested per history page. */
|
|
37
|
+
const HISTORY_PAGE_LIMIT = 100;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hard bound on `nextToken` pagination: 10 pages × 100 rows = 1000 messages.
|
|
41
|
+
* The server pages in ASCENDING seq order and the newest turns arrive on the
|
|
42
|
+
* LAST pages, so we follow `nextToken` to completion within this cap rather
|
|
43
|
+
* than stopping at page 1 (which would drop exactly the turns a resuming user
|
|
44
|
+
* cares about). A >1000-message thread truncates its tail — accepted here;
|
|
45
|
+
* a server-side `sort=desc`/`from`-seq param would be the fix if it matters.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_HISTORY_PAGES = 10;
|
|
48
|
+
|
|
49
|
+
/** Project raw rows to chat turns: text rows only, author → role. */
|
|
50
|
+
export function threadHistoryRowsToMessages(rows: ThreadHistoryRow[]): AgentMessage[] {
|
|
51
|
+
const messages: AgentMessage[] = [];
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
if (row.kind !== "text" || row.text == null) continue;
|
|
54
|
+
messages.push({ role: row.authorRole === "user" ? "user" : "assistant", text: row.text });
|
|
55
|
+
}
|
|
56
|
+
return messages;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Project raw rows to persisted generative-UI cards: `kind === "card"` rows
|
|
61
|
+
* that actually carry a snapshot, tagged with their transcript position. The
|
|
62
|
+
* additive counterpart to {@link threadHistoryRowsToMessages} — a
|
|
63
|
+
* block-preserving consumer merges both by `seq`.
|
|
64
|
+
*/
|
|
65
|
+
export function threadHistoryRowsToCards(rows: ThreadHistoryRow[]): HistoryCard[] {
|
|
66
|
+
const cards: HistoryCard[] = [];
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (row.kind !== "card" || row.cardSnapshot == null) continue;
|
|
69
|
+
cards.push({ seq: row.seq, at: row.at, cardSnapshot: row.cardSnapshot });
|
|
70
|
+
}
|
|
71
|
+
return cards;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ThreadHistoryFetchOptions {
|
|
75
|
+
/** Public read-plane base, already ending in `/v1`. */
|
|
76
|
+
baseUrl: string;
|
|
77
|
+
threadId: string;
|
|
78
|
+
/** Per-request init merged into each page fetch (headers, credentials). */
|
|
79
|
+
requestInit?: RequestInit;
|
|
80
|
+
/**
|
|
81
|
+
* Opt-in: ALSO project `kind === "card"` rows into `result.cards` (see
|
|
82
|
+
* {@link HistoryLoadResult}). Off by default so text-only consumers get the
|
|
83
|
+
* byte-identical `{ messages }` shape. On, a block-preserving renderer gets
|
|
84
|
+
* the persisted cards to interleave by `seq`.
|
|
85
|
+
*/
|
|
86
|
+
includeCards?: boolean;
|
|
87
|
+
/** Injection seam for tests; defaults to the global `fetch`. */
|
|
88
|
+
fetchImpl?: typeof fetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch a thread's transcript across all pages. Returns `{ gone: true }` on
|
|
93
|
+
* 403/404 (a stale local threadId the caller no longer owns / that no longer
|
|
94
|
+
* exists) so the hook can drop the persisted id; throws on any other non-OK
|
|
95
|
+
* status so `useAgentInvoke`'s best-effort caller can swallow it and leave
|
|
96
|
+
* the chat empty.
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchThreadHistory({
|
|
99
|
+
baseUrl,
|
|
100
|
+
threadId,
|
|
101
|
+
requestInit,
|
|
102
|
+
includeCards = false,
|
|
103
|
+
fetchImpl = fetch,
|
|
104
|
+
}: ThreadHistoryFetchOptions): Promise<HistoryLoadResult> {
|
|
105
|
+
const routeUrl = `${baseUrl}/threads/${encodeURIComponent(threadId)}/messages`;
|
|
106
|
+
const rows: ThreadHistoryRow[] = [];
|
|
107
|
+
let nextToken: string | null = null;
|
|
108
|
+
|
|
109
|
+
for (let page = 0; page < MAX_HISTORY_PAGES; page++) {
|
|
110
|
+
const url =
|
|
111
|
+
`${routeUrl}?limit=${HISTORY_PAGE_LIMIT}` +
|
|
112
|
+
(nextToken ? `&nextToken=${encodeURIComponent(nextToken)}` : "");
|
|
113
|
+
const res = await fetchImpl(url, requestInit);
|
|
114
|
+
if (res.status === 403 || res.status === 404) return { gone: true };
|
|
115
|
+
if (!res.ok) throw new Error(`history load failed: ${res.status}`);
|
|
116
|
+
const body: ThreadMessagesResponse = await res.json();
|
|
117
|
+
rows.push(...body.rows);
|
|
118
|
+
nextToken = body.nextToken;
|
|
119
|
+
if (!nextToken) break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
messages: threadHistoryRowsToMessages(rows),
|
|
124
|
+
...(includeCards ? { cards: threadHistoryRowsToCards(rows) } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export {
|
|
2
|
+
parseSseEvents,
|
|
3
|
+
extractAssistantText,
|
|
4
|
+
reduceAssistantText,
|
|
5
|
+
stringField,
|
|
6
|
+
type ParsedSseEvent,
|
|
7
|
+
} from "./sse";
|
|
8
|
+
export {
|
|
9
|
+
createWebAdapters,
|
|
10
|
+
localStorageThreadStore,
|
|
11
|
+
webGenerateId,
|
|
12
|
+
fetchStreamTransport,
|
|
13
|
+
AgentResponseError,
|
|
14
|
+
type CreateWebAdaptersOptions,
|
|
15
|
+
} from "./web-adapters";
|
|
16
|
+
export {
|
|
17
|
+
fetchThreadHistory,
|
|
18
|
+
threadHistoryRowsToMessages,
|
|
19
|
+
threadHistoryRowsToCards,
|
|
20
|
+
type ThreadHistoryRow,
|
|
21
|
+
type ThreadHistoryFetchOptions,
|
|
22
|
+
} from "./history";
|
|
23
|
+
export { ingestMessageFrame } from "./blocks";
|
|
24
|
+
// Pure block-walk / resource-narrowing helpers for a block-preserving renderer
|
|
25
|
+
// (shared by Studio's `AgentBlocks` and Portal-web's agent chat). React-free.
|
|
26
|
+
export {
|
|
27
|
+
asResourcePayload,
|
|
28
|
+
asUiResource,
|
|
29
|
+
blockUiResource,
|
|
30
|
+
cardUiResource,
|
|
31
|
+
isJsonObject,
|
|
32
|
+
resourceHtml,
|
|
33
|
+
scanProviderRawForUiResource,
|
|
34
|
+
sortHistoryCards,
|
|
35
|
+
toolNameFor,
|
|
36
|
+
toolResultUiResource,
|
|
37
|
+
type McpUiResourcePayload,
|
|
38
|
+
} from "./block-ui";
|
|
39
|
+
// Re-export the AgJSON types the block-preserving transcript surfaces, so
|
|
40
|
+
// consumers can name `reduceResult` / block types without a direct
|
|
41
|
+
// `@silverprotocol/core` import.
|
|
42
|
+
export type { AgEvent, AgReduceResult, AgMessage, AgBlock } from "@silverprotocol/core";
|
|
43
|
+
export type {
|
|
44
|
+
AgentMessage,
|
|
45
|
+
HistoryCard,
|
|
46
|
+
ThreadIdStore,
|
|
47
|
+
GenerateId,
|
|
48
|
+
InvokeRequest,
|
|
49
|
+
InvokeTransport,
|
|
50
|
+
AgentInvokeAdapters,
|
|
51
|
+
AgentInvokeHistoryAdapter,
|
|
52
|
+
HistoryLoadResult,
|
|
53
|
+
UseAgentInvokeOptions,
|
|
54
|
+
UseAgentInvokeReturn,
|
|
55
|
+
} from "./types";
|