@guuey/chat 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hitl.d.ts +58 -0
- package/dist/hitl.d.ts.map +1 -0
- package/dist/hitl.js +81 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/native/components.d.ts +18 -2
- package/dist/native/components.d.ts.map +1 -1
- package/dist/native/components.js +49 -0
- package/dist/native/transcript.d.ts +1 -1
- package/dist/native/transcript.d.ts.map +1 -1
- package/dist/native/transcript.js +2 -2
- package/dist/native.d.ts +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +1 -1
- package/dist/plan.d.ts +15 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +213 -15
- package/dist/policy.d.ts +4 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +2 -0
- package/dist/react/components.d.ts +42 -5
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +51 -1
- package/dist/react/guuey-chat.d.ts +92 -7
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +114 -11
- package/dist/react/transcript.d.ts +1 -1
- package/dist/react/transcript.d.ts.map +1 -1
- package/dist/react/transcript.js +2 -2
- package/dist/react/use-transcript.d.ts +21 -2
- package/dist/react/use-transcript.d.ts.map +1 -1
- package/dist/react/use-transcript.js +57 -3
- package/dist/react.d.ts +2 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/strings.d.ts +13 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +8 -0
- package/dist/types.d.ts +146 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/corpus/README.md +17 -1
- package/src/corpus/__snapshots__/corpus.test.ts.snap +348 -0
- package/src/corpus/drive.ts +3 -3
- package/src/corpus/fixtures.ts +249 -1
- package/src/hitl.ts +103 -0
- package/src/index.ts +15 -0
- package/src/native/components.tsx +136 -1
- package/src/native/transcript.tsx +4 -3
- package/src/native.tsx +1 -0
- package/src/plan.ts +235 -22
- package/src/policy.ts +4 -0
- package/src/react/components.tsx +171 -8
- package/src/react/guuey-chat.tsx +222 -15
- package/src/react/transcript.tsx +4 -3
- package/src/react/use-transcript.ts +83 -5
- package/src/react.tsx +3 -1
- package/src/strings.ts +25 -0
- package/src/types.ts +152 -6
- package/styles.css +21 -0
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -31,29 +31,104 @@
|
|
|
31
31
|
* adapter (e.g. `createWebAdapters({ apiBaseUrl, … })`) and a persisted
|
|
32
32
|
* threadId rehydrates on mount — text transcript + persisted cards, which
|
|
33
33
|
* mount through the same R6 path as live views. Nothing here to configure.
|
|
34
|
+
*
|
|
35
|
+
* ## The imperative seam (guuey#210)
|
|
36
|
+
*
|
|
37
|
+
* Suggested-prompt chips and other host-driven sends stay on the
|
|
38
|
+
* batteries-included path via {@link GuueyChatHandle} — a ref handle
|
|
39
|
+
* (`forwardRef`) and/or the `onReady` callback, ONE stable object for the
|
|
40
|
+
* component's whole life. `send` runs through exactly the Send button's
|
|
41
|
+
* gate (never bypasses it; the typed draft is left untouched — a chip send
|
|
42
|
+
* must not eat a half-typed message); `prefill` mirrors the widget's
|
|
43
|
+
* staged-composer semantics (append joins with a space, never clobbers).
|
|
44
|
+
* Web-only for now: the native tier ships `<NativeTranscript>` without a
|
|
45
|
+
* native GuueyChat, so there is no native surface to put a handle on yet.
|
|
34
46
|
*/
|
|
35
|
-
import {
|
|
36
|
-
|
|
47
|
+
import {
|
|
48
|
+
forwardRef,
|
|
49
|
+
useCallback,
|
|
50
|
+
useEffect,
|
|
51
|
+
useImperativeHandle,
|
|
52
|
+
useMemo,
|
|
53
|
+
useRef,
|
|
54
|
+
useState,
|
|
55
|
+
type CSSProperties,
|
|
56
|
+
type ReactNode,
|
|
57
|
+
} from "react";
|
|
58
|
+
import {
|
|
59
|
+
createUiResourceReader,
|
|
60
|
+
createWebAdapters,
|
|
61
|
+
type AgentInvokeAdapters,
|
|
62
|
+
} from "@guuey/agent-client";
|
|
63
|
+
import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
|
|
37
64
|
import { useAgentInvoke } from "@guuey/agent-client/react";
|
|
38
65
|
import type { UiResourceReader } from "@guuey/mcp-apps-host";
|
|
39
66
|
import { calmPolicy, debugPolicy, type TranscriptPolicy } from "../policy.js";
|
|
40
67
|
import { defaultChatStrings, type ChatStrings } from "../strings.js";
|
|
41
68
|
import { DEFAULT_CHAT_THEME, type GuueyChatTheme } from "../theme.js";
|
|
42
|
-
import type { ErrorItem, PromptItem, UserMessageItem } from "../types.js";
|
|
69
|
+
import type { ChatDebugEvent, ErrorItem, PromptItem, UserMessageItem } from "../types.js";
|
|
43
70
|
import type { ThemeMode } from "./theme-css.js";
|
|
44
71
|
import { Transcript, type TranscriptWindowing } from "./transcript.js";
|
|
45
72
|
import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
|
|
46
73
|
import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
|
|
47
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The imperative seam (guuey#210): programmatic send/prefill/focus for
|
|
77
|
+
* hosts that stay on the batteries-included path (suggested-prompt chips
|
|
78
|
+
* being the filing use case). Reach it via `ref` or `onReady` — both
|
|
79
|
+
* deliver the SAME stable object, valid for the component's whole life.
|
|
80
|
+
*/
|
|
81
|
+
export interface GuueyChatHandle {
|
|
82
|
+
/**
|
|
83
|
+
* Send `text` through exactly the Send button's gate: returns `false`
|
|
84
|
+
* and does nothing when chat is unavailable (`endpointUrl === null`), a
|
|
85
|
+
* turn is in flight, or the text is blank — it NEVER bypasses the
|
|
86
|
+
* composer's rules. The typed draft is left untouched (a programmatic
|
|
87
|
+
* send must not eat a half-typed message). Failures surface the same
|
|
88
|
+
* way a composer send's do (the hook's `error` / R0 failed-send).
|
|
89
|
+
*/
|
|
90
|
+
send(text: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Put `text` into the composer draft. `append: true` joins onto a
|
|
93
|
+
* non-empty draft with a single space (the widget's staged-composer
|
|
94
|
+
* semantic — never clobbers); default replaces. Focuses the input
|
|
95
|
+
* unless `focus: false`.
|
|
96
|
+
*/
|
|
97
|
+
prefill(text: string, opts?: { focus?: boolean; append?: boolean }): void;
|
|
98
|
+
/** Focus the composer input. */
|
|
99
|
+
focusComposer(): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
48
102
|
export interface GuueyChatProps {
|
|
49
103
|
/** Pod base URL (with or without `/agent/invoke`). `null` disables chat. */
|
|
50
104
|
endpointUrl: string | null;
|
|
51
105
|
/** Owning app id — namespaces the persisted threadId. */
|
|
52
106
|
appId?: string;
|
|
107
|
+
/**
|
|
108
|
+
* The guuey public API base (`…/v1`). Enables the batteries-included
|
|
109
|
+
* read paths without hand-wiring: when set and no `adapters` are given,
|
|
110
|
+
* the default `createWebAdapters` gains transcript history; when set and
|
|
111
|
+
* no `reader` is given, a `UiResourceReader` is built over the same
|
|
112
|
+
* identity so generative-UI locators resolve (guuey#221 — a guest kit
|
|
113
|
+
* user has no bearer to construct one with). Explicit `adapters` /
|
|
114
|
+
* `reader` always win. Absent → today's behavior (no history, no
|
|
115
|
+
* default reader; locators render as expired, labeled).
|
|
116
|
+
*/
|
|
117
|
+
apiBaseUrl?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Identity for the default adapters + default reader — the same two
|
|
120
|
+
* resolvers `createWebAdapters` takes (bearer wins; guest secret next;
|
|
121
|
+
* neither → cookie identity). Ignored when explicit `adapters` and
|
|
122
|
+
* `reader` are both supplied.
|
|
123
|
+
*/
|
|
124
|
+
getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
125
|
+
getGuestSecret?: () => string | null;
|
|
53
126
|
/**
|
|
54
127
|
* Host couplings (storage / id / transport / history). Default:
|
|
55
|
-
* `createWebAdapters(
|
|
56
|
-
* transport (cookie/guest
|
|
128
|
+
* `createWebAdapters({ apiBaseUrl, getAccessToken, getGuestSecret })` —
|
|
129
|
+
* localStorage thread persistence + the web SSE transport (cookie/guest
|
|
130
|
+
* identity, saturation + cold-start retries), plus history when
|
|
131
|
+
* `apiBaseUrl` is set.
|
|
57
132
|
*/
|
|
58
133
|
adapters?: AgentInvokeAdapters;
|
|
59
134
|
/** Policy preset (spec §5). Default `"calm"`. */
|
|
@@ -68,8 +143,14 @@ export interface GuueyChatProps {
|
|
|
68
143
|
mode?: ThemeMode;
|
|
69
144
|
/** DOM windowing (§3.2). `false` renders everything. */
|
|
70
145
|
window?: TranscriptWindowing | false;
|
|
71
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* R6 locator resolution (history cards + meta-less live renders). See
|
|
148
|
+
* `useTranscript`. Defaults from `apiBaseUrl` (+ `endpointUrl` for the
|
|
149
|
+
* pod door) when omitted; an explicit reader always wins.
|
|
150
|
+
*/
|
|
72
151
|
reader?: UiResourceReader;
|
|
152
|
+
/** The debug sink (spec §5) — fires only under the debug policy. */
|
|
153
|
+
onDebugEvent?: (event: ChatDebugEvent) => void;
|
|
73
154
|
/** R6 pass-through (relay hook, sandbox page/flags, host context…). */
|
|
74
155
|
viewProps?: TranscriptItemContext["viewProps"];
|
|
75
156
|
/**
|
|
@@ -77,9 +158,24 @@ export interface GuueyChatProps {
|
|
|
77
158
|
* The transcript record moves regardless; without a handler the prompt
|
|
78
159
|
* card is record-only.
|
|
79
160
|
*/
|
|
80
|
-
onPromptAction?: (
|
|
161
|
+
onPromptAction?: (
|
|
162
|
+
item: PromptItem,
|
|
163
|
+
action: "accept" | "decline" | "dismiss" | { grantModeId: string },
|
|
164
|
+
) => void;
|
|
165
|
+
/**
|
|
166
|
+
* Receives the VALIDATED wire answer for an AgJSON HITL ask (spec
|
|
167
|
+
* draft.2) — the host owns delivering it (the kit has no answer
|
|
168
|
+
* transport). Fired after the transcript record moves.
|
|
169
|
+
*/
|
|
170
|
+
onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
|
|
81
171
|
/** R11 action slot (sign-in / retry affordances). */
|
|
82
172
|
onErrorAction?: (item: ErrorItem) => void;
|
|
173
|
+
/**
|
|
174
|
+
* Callback route to the {@link GuueyChatHandle} for hosts that prefer
|
|
175
|
+
* wiring over refs. Fires ONCE per component instance, on mount, with
|
|
176
|
+
* the same stable handle the ref receives.
|
|
177
|
+
*/
|
|
178
|
+
onReady?: (handle: GuueyChatHandle) => void;
|
|
83
179
|
className?: string;
|
|
84
180
|
style?: CSSProperties;
|
|
85
181
|
}
|
|
@@ -90,10 +186,16 @@ const PROMPT_STATE = {
|
|
|
90
186
|
dismiss: "dismissed",
|
|
91
187
|
} as const;
|
|
92
188
|
|
|
93
|
-
export
|
|
189
|
+
export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function GuueyChat(
|
|
190
|
+
props: GuueyChatProps,
|
|
191
|
+
ref,
|
|
192
|
+
): ReactNode {
|
|
94
193
|
const {
|
|
95
194
|
endpointUrl,
|
|
96
195
|
appId,
|
|
196
|
+
apiBaseUrl,
|
|
197
|
+
getAccessToken,
|
|
198
|
+
getGuestSecret,
|
|
97
199
|
adapters: adaptersProp,
|
|
98
200
|
preset = "calm",
|
|
99
201
|
policy: policyOverrides,
|
|
@@ -103,16 +205,60 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
|
103
205
|
mode = "light",
|
|
104
206
|
window: windowing,
|
|
105
207
|
reader,
|
|
208
|
+
onDebugEvent,
|
|
106
209
|
viewProps,
|
|
107
210
|
onPromptAction,
|
|
211
|
+
onHitlAnswer,
|
|
108
212
|
onErrorAction,
|
|
213
|
+
onReady,
|
|
109
214
|
className,
|
|
110
215
|
style,
|
|
111
216
|
} = props;
|
|
112
217
|
|
|
113
|
-
const adapters = useMemo(
|
|
218
|
+
const adapters = useMemo(
|
|
219
|
+
() =>
|
|
220
|
+
adaptersProp ??
|
|
221
|
+
createWebAdapters({
|
|
222
|
+
...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}),
|
|
223
|
+
...(getAccessToken !== undefined ? { getAccessToken } : {}),
|
|
224
|
+
...(getGuestSecret !== undefined ? { getGuestSecret } : {}),
|
|
225
|
+
}),
|
|
226
|
+
[adaptersProp, apiBaseUrl, getAccessToken, getGuestSecret],
|
|
227
|
+
);
|
|
114
228
|
const invoke = useAgentInvoke({ endpointUrl, ...(appId !== undefined ? { appId } : {}), adapters, preserveBlocks: true });
|
|
115
229
|
|
|
230
|
+
// Default reader (guuey#221): built over the SAME identity as the
|
|
231
|
+
// transport/history, targeting the pod door (live turns) then the
|
|
232
|
+
// platform door (persisted). The threadId hydrates after mount and can
|
|
233
|
+
// change on reset, so the reader is a stable function that reads the
|
|
234
|
+
// CURRENT thread through a ref — `useTranscript` sees one reader for the
|
|
235
|
+
// component's life and never re-resolves every mount on a thread flip.
|
|
236
|
+
const threadIdRef = useRef<string | null>(invoke.threadId);
|
|
237
|
+
threadIdRef.current = invoke.threadId;
|
|
238
|
+
const defaultReader = useMemo<UiResourceReader | undefined>(() => {
|
|
239
|
+
if (apiBaseUrl === undefined) return undefined;
|
|
240
|
+
return async (resourceUri: string) => {
|
|
241
|
+
const threadId = threadIdRef.current;
|
|
242
|
+
// No thread yet ⇒ nothing persisted to scope a read to; a live-turn
|
|
243
|
+
// locator always arrives with the thread already admitted (the
|
|
244
|
+
// `session` frame precedes tool results).
|
|
245
|
+
if (threadId === null) return undefined;
|
|
246
|
+
// Assembled per read: `createUiResourceReader` is a cheap closure, and
|
|
247
|
+
// the guest secret is re-resolved each call so a rotation takes
|
|
248
|
+
// effect immediately — the same per-request property
|
|
249
|
+
// `createWebAdapters` documents for its own resolvers.
|
|
250
|
+
const read = createUiResourceReader({
|
|
251
|
+
apiBaseUrl,
|
|
252
|
+
threadId,
|
|
253
|
+
endpointUrl,
|
|
254
|
+
...(getAccessToken !== undefined ? { getAccessToken } : {}),
|
|
255
|
+
guestSecret: getGuestSecret ? getGuestSecret() : null,
|
|
256
|
+
});
|
|
257
|
+
return read(resourceUri);
|
|
258
|
+
};
|
|
259
|
+
}, [apiBaseUrl, endpointUrl, getAccessToken, getGuestSecret]);
|
|
260
|
+
const effectiveReader = reader ?? defaultReader;
|
|
261
|
+
|
|
116
262
|
const policy = useMemo(() => {
|
|
117
263
|
const factory = preset === "debug" ? debugPolicy : calmPolicy;
|
|
118
264
|
const strings: ChatStrings = {
|
|
@@ -123,19 +269,68 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
|
123
269
|
return factory({ ...policyOverrides, strings });
|
|
124
270
|
}, [preset, policyOverrides, stringOverrides]);
|
|
125
271
|
|
|
126
|
-
const { inputs, resolvePrompt } = useTranscriptInputs(invoke);
|
|
272
|
+
const { inputs, resolvePrompt, answerHitlPrompt } = useTranscriptInputs(invoke);
|
|
127
273
|
const { plan, toggle, resolvedMounts, onViewPhase } = useTranscript({
|
|
128
274
|
inputs,
|
|
129
275
|
policy,
|
|
130
|
-
...(
|
|
276
|
+
...(effectiveReader !== undefined ? { reader: effectiveReader } : {}),
|
|
277
|
+
...(onDebugEvent !== undefined ? { onDebugEvent } : {}),
|
|
131
278
|
});
|
|
132
279
|
|
|
133
280
|
// ── Composer ─────────────────────────────────────────────────────────
|
|
134
281
|
const [input, setInput] = useState("");
|
|
282
|
+
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
135
283
|
const busy = invoke.status !== "ready";
|
|
136
284
|
const available = endpointUrl !== null;
|
|
137
285
|
const canSend = available && !busy && input.trim() !== "";
|
|
138
286
|
|
|
287
|
+
// ── The imperative seam (guuey#210) ──────────────────────────────────
|
|
288
|
+
// ONE stable handle for the component's whole life (hosts capture it in
|
|
289
|
+
// `onReady` and keep it), reading live truth through a ref so a call
|
|
290
|
+
// always sees the CURRENT gate — never a stale closure's.
|
|
291
|
+
const liveRef = useRef({ available, busy, invoke, onReady });
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
liveRef.current = { available, busy, invoke, onReady };
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const handle = useMemo<GuueyChatHandle>(
|
|
297
|
+
() => ({
|
|
298
|
+
send: (text: string): boolean => {
|
|
299
|
+
const live = liveRef.current;
|
|
300
|
+
const trimmed = text.trim();
|
|
301
|
+
// Exactly the Send button's gate — a handle send never bypasses it.
|
|
302
|
+
if (!live.available || live.busy || trimmed === "") return false;
|
|
303
|
+
void live.invoke.send(trimmed).catch(() => {
|
|
304
|
+
// Same contract as submit: the hook owns failure surfacing.
|
|
305
|
+
});
|
|
306
|
+
return true;
|
|
307
|
+
},
|
|
308
|
+
prefill: (text: string, opts?: { focus?: boolean; append?: boolean }): void => {
|
|
309
|
+
setInput((prev) =>
|
|
310
|
+
// The widget's staged-composer semantic: append joins with a
|
|
311
|
+
// space onto a non-empty draft, never clobbers it.
|
|
312
|
+
opts?.append === true && prev.trim() !== "" ? `${prev.trimEnd()} ${text}` : text,
|
|
313
|
+
);
|
|
314
|
+
if (opts?.focus !== false) inputRef.current?.focus();
|
|
315
|
+
},
|
|
316
|
+
focusComposer: (): void => {
|
|
317
|
+
inputRef.current?.focus();
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
[],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
useImperativeHandle(ref, () => handle, [handle]);
|
|
324
|
+
|
|
325
|
+
// `onReady` fires once per instance, on mount, with the stable handle
|
|
326
|
+
// (guarded ref: StrictMode's remount cycle must not double-fire it).
|
|
327
|
+
const readyFiredRef = useRef(false);
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (readyFiredRef.current) return;
|
|
330
|
+
readyFiredRef.current = true;
|
|
331
|
+
liveRef.current.onReady?.(handle);
|
|
332
|
+
}, [handle]);
|
|
333
|
+
|
|
139
334
|
const submit = useCallback((): void => {
|
|
140
335
|
const text = input.trim();
|
|
141
336
|
if (text === "" || !available || busy) return;
|
|
@@ -155,11 +350,22 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
|
155
350
|
);
|
|
156
351
|
|
|
157
352
|
const handlePromptAction = useCallback(
|
|
158
|
-
(item: PromptItem, action: "accept" | "decline" | "dismiss") => {
|
|
159
|
-
|
|
353
|
+
(item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
|
|
354
|
+
if (item.promptKind === "hitl") {
|
|
355
|
+
const answer = answerHitlPrompt(
|
|
356
|
+
item.ask,
|
|
357
|
+
typeof action === "object" ? action : action === "accept" ? "accept" : action,
|
|
358
|
+
);
|
|
359
|
+
onHitlAnswer?.(answer, item.ask);
|
|
360
|
+
onPromptAction?.(item, action);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Profile prompts only ever receive the string actions (the default
|
|
364
|
+
// card renders no mode buttons for them).
|
|
365
|
+
if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
|
|
160
366
|
onPromptAction?.(item, action);
|
|
161
367
|
},
|
|
162
|
-
[resolvePrompt, onPromptAction],
|
|
368
|
+
[resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
|
|
163
369
|
);
|
|
164
370
|
|
|
165
371
|
const strings = policy.strings;
|
|
@@ -192,6 +398,7 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
|
192
398
|
}}
|
|
193
399
|
>
|
|
194
400
|
<textarea
|
|
401
|
+
ref={inputRef}
|
|
195
402
|
className="guuey-chat-composer-input"
|
|
196
403
|
rows={1}
|
|
197
404
|
value={input}
|
|
@@ -224,4 +431,4 @@ export function GuueyChat(props: GuueyChatProps): ReactNode {
|
|
|
224
431
|
</form>
|
|
225
432
|
</div>
|
|
226
433
|
);
|
|
227
|
-
}
|
|
434
|
+
});
|
package/src/react/transcript.tsx
CHANGED
|
@@ -46,7 +46,7 @@ export interface TranscriptWindowing {
|
|
|
46
46
|
export interface TranscriptProps
|
|
47
47
|
extends Pick<
|
|
48
48
|
TranscriptItemContext,
|
|
49
|
-
"onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "viewProps"
|
|
49
|
+
"onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "onViewRef" | "viewProps"
|
|
50
50
|
> {
|
|
51
51
|
plan: TranscriptPlan;
|
|
52
52
|
/** Per-slot component overrides (spec §3's override column). */
|
|
@@ -77,6 +77,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
|
|
|
77
77
|
onErrorAction,
|
|
78
78
|
resolvedMounts,
|
|
79
79
|
onViewPhase,
|
|
80
|
+
onViewRef,
|
|
80
81
|
viewProps,
|
|
81
82
|
} = props;
|
|
82
83
|
|
|
@@ -85,8 +86,8 @@ export function Transcript(props: TranscriptProps): ReactNode {
|
|
|
85
86
|
[components],
|
|
86
87
|
);
|
|
87
88
|
const ctx: TranscriptItemContext = useMemo(
|
|
88
|
-
() => ({ strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps }),
|
|
89
|
-
[strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, viewProps],
|
|
89
|
+
() => ({ strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef, viewProps }),
|
|
90
|
+
[strings, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef, viewProps],
|
|
90
91
|
);
|
|
91
92
|
|
|
92
93
|
// ── Windowing ────────────────────────────────────────────────────────
|
|
@@ -13,17 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
15
|
import type { UseAgentInvokeReturn } from "@guuey/agent-client";
|
|
16
|
+
import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
|
|
16
17
|
import {
|
|
17
18
|
resolveViewMount,
|
|
18
19
|
type ResolvedViewMount,
|
|
19
20
|
type UiResourceReader,
|
|
20
21
|
type ViewHostPhase,
|
|
21
22
|
} from "@guuey/mcp-apps-host";
|
|
23
|
+
import { buildHitlAnswer, hitlPromptsFromFold, type HitlAnswerRecord, type HitlPromptAction } from "../hitl.js";
|
|
22
24
|
import { planTranscript } from "../plan.js";
|
|
23
25
|
import type { TranscriptPolicy } from "../policy.js";
|
|
24
26
|
import type {
|
|
27
|
+
ChatDebugEvent,
|
|
25
28
|
ItemKey,
|
|
26
|
-
|
|
29
|
+
ProfilePromptInput,
|
|
27
30
|
TranscriptInputs,
|
|
28
31
|
TranscriptMessage,
|
|
29
32
|
TranscriptOverrides,
|
|
@@ -42,6 +45,13 @@ export interface UseTranscriptArgs {
|
|
|
42
45
|
* resolution — labeled, never blank.
|
|
43
46
|
*/
|
|
44
47
|
reader?: UiResourceReader;
|
|
48
|
+
/**
|
|
49
|
+
* The debug sink (spec §5, real API since the #135 refinement wave):
|
|
50
|
+
* receives {@link ChatDebugEvent}s — view-phase transitions, R15
|
|
51
|
+
* unknown-block sightings, the #192 recovered-turn marker. Fires ONLY
|
|
52
|
+
* under the debug policy (`debugDetail`); `calm` ignores it by design.
|
|
53
|
+
*/
|
|
54
|
+
onDebugEvent?: (event: ChatDebugEvent) => void;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export interface UseTranscriptResult {
|
|
@@ -56,13 +66,22 @@ export interface UseTranscriptResult {
|
|
|
56
66
|
resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
export function useTranscript({
|
|
69
|
+
export function useTranscript({
|
|
70
|
+
inputs,
|
|
71
|
+
policy,
|
|
72
|
+
reader,
|
|
73
|
+
onDebugEvent,
|
|
74
|
+
}: UseTranscriptArgs): UseTranscriptResult {
|
|
60
75
|
const [overrides, setOverrides] = useState<TranscriptOverrides>({});
|
|
61
76
|
const [phases, setPhases] = useState<Readonly<Record<string, ViewHostPhase>>>({});
|
|
62
77
|
const [resolvedMounts, setResolvedMounts] = useState<
|
|
63
78
|
ReadonlyMap<ItemKey, ResolvedViewMount | "expired">
|
|
64
79
|
>(new Map());
|
|
65
80
|
|
|
81
|
+
// The debug sink (gated on the debug policy — calm ignores it, spec §5).
|
|
82
|
+
const debugSink = useRef<((event: ChatDebugEvent) => void) | null>(null);
|
|
83
|
+
debugSink.current = policy.debugDetail && onDebugEvent !== undefined ? onDebugEvent : null;
|
|
84
|
+
|
|
66
85
|
const merged = useMemo<TranscriptInputs>(
|
|
67
86
|
() => ({ ...inputs, viewPhases: { ...inputs.viewPhases, ...phases } }),
|
|
68
87
|
[inputs, phases],
|
|
@@ -92,10 +111,37 @@ export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): Us
|
|
|
92
111
|
setOverrides((prev) => ({ ...prev, [key]: { expanded: !current } }));
|
|
93
112
|
}, []);
|
|
94
113
|
|
|
114
|
+
// Mirror of `phases` for the change check OUTSIDE the state updater — a
|
|
115
|
+
// sink call inside an updater would double-fire under StrictMode.
|
|
116
|
+
const phasesRef = useRef<Readonly<Record<string, ViewHostPhase>>>({});
|
|
95
117
|
const onViewPhase = useCallback((key: ItemKey, phase: ViewHostPhase) => {
|
|
118
|
+
if (phasesRef.current[key] !== phase) {
|
|
119
|
+
phasesRef.current = { ...phasesRef.current, [key]: phase };
|
|
120
|
+
debugSink.current?.({ type: "view-phase", key, phase });
|
|
121
|
+
}
|
|
96
122
|
setPhases((prev) => (prev[key] === phase ? prev : { ...prev, [key]: phase }));
|
|
97
123
|
}, []);
|
|
98
124
|
|
|
125
|
+
// Plan-derived debug events, emitted once per sighting (post-render — the
|
|
126
|
+
// plan itself stays pure data).
|
|
127
|
+
const emittedUnknowns = useRef(new Set<ItemKey>());
|
|
128
|
+
const recoveryEmitted = useRef(false);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const sink = debugSink.current;
|
|
131
|
+
if (sink === null) return;
|
|
132
|
+
for (const item of plan.items) {
|
|
133
|
+
if (item.kind !== "unknown" || emittedUnknowns.current.has(item.key)) continue;
|
|
134
|
+
emittedUnknowns.current.add(item.key);
|
|
135
|
+
sink({ type: "unknown-block", key: item.key, typeName: item.typeName, byteSize: item.byteSize });
|
|
136
|
+
}
|
|
137
|
+
if (plan.recovery !== null && !recoveryEmitted.current) {
|
|
138
|
+
recoveryEmitted.current = true;
|
|
139
|
+
sink({ type: "turn-recovered", marker: plan.recovery });
|
|
140
|
+
} else if (plan.recovery === null) {
|
|
141
|
+
recoveryEmitted.current = false;
|
|
142
|
+
}
|
|
143
|
+
}, [plan]);
|
|
144
|
+
|
|
99
145
|
// Locator resolution — one read per locator key, misses become "expired".
|
|
100
146
|
const readerRef = useRef(reader);
|
|
101
147
|
readerRef.current = reader;
|
|
@@ -110,6 +156,10 @@ export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): Us
|
|
|
110
156
|
inFlight.current.add(item.key);
|
|
111
157
|
const settle = (value: ResolvedViewMount | "expired"): void => {
|
|
112
158
|
inFlight.current.delete(item.key);
|
|
159
|
+
// The R13 expired verdict is a phase transition too (debug sink).
|
|
160
|
+
if (value === "expired") {
|
|
161
|
+
debugSink.current?.({ type: "view-phase", key: item.key, phase: "expired" });
|
|
162
|
+
}
|
|
113
163
|
setResolvedMounts((prev) => {
|
|
114
164
|
const next = new Map(prev);
|
|
115
165
|
next.set(item.key, value);
|
|
@@ -141,6 +191,16 @@ export interface UseTranscriptInputsResult {
|
|
|
141
191
|
* without a recorded action reads as `dismissed`.
|
|
142
192
|
*/
|
|
143
193
|
resolvePrompt: (id: string, state: "answered" | "declined" | "dismissed") => void;
|
|
194
|
+
/**
|
|
195
|
+
* Answer an AgJSON HITL ask (spec draft.2): constructs the wire answer,
|
|
196
|
+
* VALIDATES it against the ask's persisted record (`validateHitlAnswer`
|
|
197
|
+
* — required-iff-declared, echo-must-be-declared, requestState byte-echo)
|
|
198
|
+
* BEFORE anything dispatches, records it in the ledger, and returns it
|
|
199
|
+
* for the HOST to deliver — the kit renders and validates; the answer
|
|
200
|
+
* transport is the host's (no client→pod hitl-answer channel exists on
|
|
201
|
+
* the guuey wire today; see the #16 producer flag).
|
|
202
|
+
*/
|
|
203
|
+
answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
|
|
144
204
|
}
|
|
145
205
|
|
|
146
206
|
/** How often the escalation clock ticks while a status needs one. */
|
|
@@ -161,7 +221,7 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
|
|
|
161
221
|
|
|
162
222
|
// R10 ledger: the hook exposes only the LATEST pending ask; the
|
|
163
223
|
// transcript keeps the record of every ask and its resolution.
|
|
164
|
-
const [prompts, setPrompts] = useState<
|
|
224
|
+
const [prompts, setPrompts] = useState<ProfilePromptInput[]>([]);
|
|
165
225
|
const promptSeq = useRef(0);
|
|
166
226
|
useEffect(() => {
|
|
167
227
|
const request = invoke.profileConsentRequest;
|
|
@@ -228,6 +288,23 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
|
|
|
228
288
|
[invoke, prompts],
|
|
229
289
|
);
|
|
230
290
|
|
|
291
|
+
// HITL ledger (spec draft.2): asks come from the FOLD's persisted
|
|
292
|
+
// records; this ledger holds only the host-side answers, keyed by askId.
|
|
293
|
+
// A `cancelled` record keeps the card re-askable (guuey's #16 ruling) —
|
|
294
|
+
// a later action on the same ask simply overwrites it.
|
|
295
|
+
const [hitlAnswers, setHitlAnswers] = useState<Readonly<Record<string, HitlAnswerRecord>>>({});
|
|
296
|
+
const answerHitlPrompt = useCallback((ask: AgPausedAsk, action: HitlPromptAction): AgHitlAnswer => {
|
|
297
|
+
const answer = buildHitlAnswer(ask, action);
|
|
298
|
+
setHitlAnswers((prev) => ({
|
|
299
|
+
...prev,
|
|
300
|
+
[ask.askId]: {
|
|
301
|
+
status: answer.status,
|
|
302
|
+
...(answer.grantModeId !== undefined ? { grantModeId: answer.grantModeId } : {}),
|
|
303
|
+
},
|
|
304
|
+
}));
|
|
305
|
+
return answer;
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
231
308
|
const inputs = useMemo<TranscriptInputs>(() => {
|
|
232
309
|
// Source-ownership split (plan.ts's rules): the trailing assistant
|
|
233
310
|
// entry is the IN-FLIGHT fold (or the abort-kept partial) — it moves to
|
|
@@ -248,7 +325,7 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
|
|
|
248
325
|
statusElapsedMs: elapsedMs,
|
|
249
326
|
activeTool: invoke.activeTool,
|
|
250
327
|
error: invoke.error !== null ? { message: invoke.error, code: invoke.errorCode } : null,
|
|
251
|
-
prompts,
|
|
328
|
+
prompts: [...prompts, ...hitlPromptsFromFold(invoke.reduceResult, hitlAnswers)],
|
|
252
329
|
messages,
|
|
253
330
|
...(invoke.historyCards.length > 0 ? { historyCards: invoke.historyCards } : {}),
|
|
254
331
|
sendStates: invoke.sendStates,
|
|
@@ -268,7 +345,8 @@ export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscript
|
|
|
268
345
|
invoke.adopted,
|
|
269
346
|
elapsedMs,
|
|
270
347
|
prompts,
|
|
348
|
+
hitlAnswers,
|
|
271
349
|
]);
|
|
272
350
|
|
|
273
|
-
return { inputs, resolvePrompt };
|
|
351
|
+
return { inputs, resolvePrompt, answerHitlPrompt };
|
|
274
352
|
}
|
package/src/react.tsx
CHANGED
|
@@ -27,6 +27,7 @@ export {
|
|
|
27
27
|
DefaultToolGroup,
|
|
28
28
|
DefaultDataResult,
|
|
29
29
|
DefaultView,
|
|
30
|
+
DefaultViewRef,
|
|
30
31
|
DefaultMedia,
|
|
31
32
|
DefaultCode,
|
|
32
33
|
DefaultCitations,
|
|
@@ -38,6 +39,7 @@ export {
|
|
|
38
39
|
DefaultStatus,
|
|
39
40
|
type TranscriptComponents,
|
|
40
41
|
type TranscriptItemContext,
|
|
42
|
+
type ViewSlotProps,
|
|
41
43
|
} from "./react/components.js";
|
|
42
44
|
export {
|
|
43
45
|
useTranscript,
|
|
@@ -46,6 +48,6 @@ export {
|
|
|
46
48
|
type UseTranscriptResult,
|
|
47
49
|
type UseTranscriptInputsResult,
|
|
48
50
|
} from "./react/use-transcript.js";
|
|
49
|
-
export { GuueyChat, type GuueyChatProps } from "./react/guuey-chat.js";
|
|
51
|
+
export { GuueyChat, type GuueyChatProps, type GuueyChatHandle } from "./react/guuey-chat.js";
|
|
50
52
|
export { Markdown } from "./react/markdown.js";
|
|
51
53
|
export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
|
package/src/strings.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface ChatStrings {
|
|
|
55
55
|
viewInlineFallback: string;
|
|
56
56
|
viewExpired: string;
|
|
57
57
|
viewSandboxUnavailable: string;
|
|
58
|
+
/** guuey#204: the chip text for a mount promoted to a host stage/canvas. */
|
|
59
|
+
viewPromoted: (title: string) => string;
|
|
60
|
+
/** Chip title when the mount has no producing-call title (history cards). */
|
|
61
|
+
viewRefFallbackTitle: string;
|
|
58
62
|
|
|
59
63
|
/** #192 debug-preset marker (calm never shows it — spec §3, F10). */
|
|
60
64
|
recoveredFromHistory: string;
|
|
@@ -74,6 +78,17 @@ export interface ChatStrings {
|
|
|
74
78
|
copy: string;
|
|
75
79
|
copied: string;
|
|
76
80
|
|
|
81
|
+
/** R10 hitl actions (spec draft.2) — mode buttons use the ASKER's labels. */
|
|
82
|
+
promptAccept: string;
|
|
83
|
+
promptDecline: string;
|
|
84
|
+
promptDismissed: string;
|
|
85
|
+
/** The answered record line, e.g. `Allowed — Always`. */
|
|
86
|
+
promptAnsweredWith: (modeLabel: string) => string;
|
|
87
|
+
promptDeclinedRecord: string;
|
|
88
|
+
|
|
89
|
+
/** R16 — the notice row's label (provenance shows only under debug). */
|
|
90
|
+
noticeLabel: string;
|
|
91
|
+
|
|
77
92
|
/** The 3c composer (`<GuueyChat>`). */
|
|
78
93
|
composerPlaceholder: string;
|
|
79
94
|
composerUnavailable: string;
|
|
@@ -121,6 +136,8 @@ export const defaultChatStrings: ChatStrings = {
|
|
|
121
136
|
viewInlineFallback: "Showing plain content",
|
|
122
137
|
viewExpired: "This view expired",
|
|
123
138
|
viewSandboxUnavailable: "Interactive view unavailable",
|
|
139
|
+
viewPromoted: (title) => `${title} — on canvas`,
|
|
140
|
+
viewRefFallbackTitle: "Card",
|
|
124
141
|
|
|
125
142
|
recoveredFromHistory: "recovered from history",
|
|
126
143
|
|
|
@@ -136,6 +153,14 @@ export const defaultChatStrings: ChatStrings = {
|
|
|
136
153
|
copy: "Copy",
|
|
137
154
|
copied: "Copied",
|
|
138
155
|
|
|
156
|
+
promptAccept: "Allow",
|
|
157
|
+
promptDecline: "Don't allow",
|
|
158
|
+
promptDismissed: "Dismissed",
|
|
159
|
+
promptAnsweredWith: (modeLabel) => `Allowed — ${modeLabel}`,
|
|
160
|
+
promptDeclinedRecord: "Not allowed",
|
|
161
|
+
|
|
162
|
+
noticeLabel: "Note",
|
|
163
|
+
|
|
139
164
|
composerPlaceholder: "Message the agent…",
|
|
140
165
|
composerUnavailable: "Chat is unavailable.",
|
|
141
166
|
composerLabel: "Message",
|