@guuey/chat 0.16.8 → 0.16.10
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/README.md +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +28 -7
- package/dist/react/components.d.ts +1 -1
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/guuey-chat.d.ts +69 -1
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +116 -6
- package/dist/react/markdown.d.ts +0 -26
- package/dist/react/markdown.d.ts.map +1 -1
- package/dist/react/markdown.js +115 -2
- package/dist/react/theme-css.d.ts +8 -0
- package/dist/react/theme-css.d.ts.map +1 -1
- package/dist/react/theme-css.js +24 -13
- package/dist/react/transcript.d.ts +8 -0
- package/dist/react/transcript.d.ts.map +1 -1
- package/dist/react/transcript.js +2 -2
- package/dist/strings.d.ts +19 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +6 -0
- package/dist/theme.d.ts +28 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +67 -2
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/plan.ts +27 -8
- package/src/react/components.tsx +1 -0
- package/src/react/guuey-chat.tsx +237 -2
- package/src/react/markdown.tsx +104 -2
- package/src/react/theme-css.ts +24 -13
- package/src/react/transcript.tsx +10 -1
- package/src/strings.ts +30 -0
- package/src/theme.ts +71 -2
- package/src/types.ts +14 -1
- package/styles.css +239 -100
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
createUiActionRelay,
|
|
61
61
|
createUiResourceReader,
|
|
62
62
|
createWebAdapters,
|
|
63
|
+
deleteThread,
|
|
63
64
|
type AgentInvokeAdapters,
|
|
64
65
|
} from "@guuey/agent-client";
|
|
65
66
|
import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
|
|
@@ -93,7 +94,10 @@ import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
|
|
|
93
94
|
export function viewPropsWithThemeAnnounce(
|
|
94
95
|
viewProps: TranscriptItemContext["viewProps"],
|
|
95
96
|
mode: ThemeMode,
|
|
96
|
-
defaults: Pick<
|
|
97
|
+
defaults: Pick<
|
|
98
|
+
ViewSlotProps,
|
|
99
|
+
"onCallTool" | "onUpdateModelContext" | "onUserMessage" | "onOpenLink"
|
|
100
|
+
> = {},
|
|
97
101
|
): TranscriptItemContext["viewProps"] {
|
|
98
102
|
const themed = (base: ViewSlotProps | undefined): ViewSlotProps => ({
|
|
99
103
|
// Kit-default host wires (guuey#335): the ACTION RELAY (Confirm inside
|
|
@@ -166,6 +170,28 @@ export interface GuueyChatHandle {
|
|
|
166
170
|
* to the transcript).
|
|
167
171
|
*/
|
|
168
172
|
viewSlotProps(): ViewSlotProps;
|
|
173
|
+
/**
|
|
174
|
+
* Forget this conversation (guuey#526 — the public-computer story).
|
|
175
|
+
*
|
|
176
|
+
* Local half, unconditional and immediate: aborts any in-flight turn
|
|
177
|
+
* FIRST (a completing turn can re-persist its own rows), drops the
|
|
178
|
+
* durable thread pointer AND its persisted storage key, wipes the
|
|
179
|
+
* visible transcript, clears the typed draft and any pending link ask —
|
|
180
|
+
* the next send mints a fresh thread.
|
|
181
|
+
*
|
|
182
|
+
* Server half, best-effort: when the surface has a platform door
|
|
183
|
+
* (`apiBaseUrl`) and a minted thread, fires
|
|
184
|
+
* `DELETE /v1/threads/:threadId` with the SAME identity the thread was
|
|
185
|
+
* created under (captured before any rotation) — real, child-first
|
|
186
|
+
* server-side erasure. A denied or failed delete still clears the
|
|
187
|
+
* device (the contract's unlinkability fallback) and surfaces nothing
|
|
188
|
+
* louder than the `thread-delete` debug event.
|
|
189
|
+
*
|
|
190
|
+
* Afterwards `onGuestSecretRotate` fires (when wired), so the identity
|
|
191
|
+
* owner mints a fresh guest secret — even an orphaned server row is
|
|
192
|
+
* unreachable from a rotated identity.
|
|
193
|
+
*/
|
|
194
|
+
clearConversation(): void;
|
|
169
195
|
}
|
|
170
196
|
|
|
171
197
|
export interface GuueyChatProps {
|
|
@@ -214,6 +240,19 @@ export interface GuueyChatProps {
|
|
|
214
240
|
strings?: Partial<ChatStrings>;
|
|
215
241
|
theme?: GuueyChatTheme;
|
|
216
242
|
mode?: ThemeMode;
|
|
243
|
+
/**
|
|
244
|
+
* Message-surface presentation (guuey#521). `"card"` (default) keeps
|
|
245
|
+
* assistant text on its own card; `"bare"` sits the transcript directly
|
|
246
|
+
* on the host's background so the embed reads native to the page.
|
|
247
|
+
*/
|
|
248
|
+
surface?: "card" | "bare";
|
|
249
|
+
/**
|
|
250
|
+
* Render the built-in composer (default `true`). `false` hides it for
|
|
251
|
+
* hosts that drive the conversation through the imperative handle
|
|
252
|
+
* (guuey#210 `ask`/`compose`) or their own input — pairs with the
|
|
253
|
+
* handle instead of forcing a CSS hide (guuey#521).
|
|
254
|
+
*/
|
|
255
|
+
composer?: boolean;
|
|
217
256
|
/** DOM windowing (§3.2). `false` renders everything. */
|
|
218
257
|
window?: TranscriptWindowing | false;
|
|
219
258
|
/**
|
|
@@ -222,6 +261,39 @@ export interface GuueyChatProps {
|
|
|
222
261
|
* pod door) when omitted; an explicit reader always wins.
|
|
223
262
|
*/
|
|
224
263
|
reader?: UiResourceReader;
|
|
264
|
+
/**
|
|
265
|
+
* The forget-this-device affordance (guuey#526 — the public-computer
|
|
266
|
+
* story): a quiet "Clear conversation" control with a two-tap confirm,
|
|
267
|
+
* wired to {@link GuueyChatHandle.clearConversation}. Default: ON when
|
|
268
|
+
* the surface runs GUEST identity (`getGuestSecret` present — the
|
|
269
|
+
* founder's seamless principle; a signed-in surface has account-level
|
|
270
|
+
* recourse), OFF otherwise; pass a boolean to override either way.
|
|
271
|
+
*/
|
|
272
|
+
clearAffordance?: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* Guest-secret rotation hook (guuey#526): called at the END of every
|
|
275
|
+
* {@link GuueyChatHandle.clearConversation} — after the server delete
|
|
276
|
+
* has been dispatched with the OLD identity — so the owner of the
|
|
277
|
+
* secret's storage (the kit never stores it; `getGuestSecret` is a
|
|
278
|
+
* getter) mints a fresh one. Rotation is what makes even an orphaned
|
|
279
|
+
* server row unreachable: the new identity is a stranger to it. Guest
|
|
280
|
+
* KV state is (user,mcp)-scoped and is orphaned by rotation, by design.
|
|
281
|
+
*/
|
|
282
|
+
onGuestSecretRotate?: () => void;
|
|
283
|
+
/**
|
|
284
|
+
* Suggestion chips (guuey#533 — declared content, the one-truth design):
|
|
285
|
+
* rendered on the EMPTY transcript only (they retire the moment a
|
|
286
|
+
* message exists — live send or restored history both count), one tap
|
|
287
|
+
* SENDS the chip text verbatim through exactly the Send button's gate.
|
|
288
|
+
* Default-ON when content exists; `false` opts the surface out. The kit
|
|
289
|
+
* mirrors the card projection's caps render-side (4 shown, ≤80 chars
|
|
290
|
+
* each, blank-rejected) — a direct consumer bypassing the platform's
|
|
291
|
+
* emit gate gets the same bounds. Content is the host's (the widget
|
|
292
|
+
* passes the card's `suggestions`; a direct consumer passes its own);
|
|
293
|
+
* the kit never invents chips — an honest empty state beats invented
|
|
294
|
+
* questions in the builder's voice.
|
|
295
|
+
*/
|
|
296
|
+
suggestions?: readonly string[] | false;
|
|
225
297
|
/** The debug sink (spec §5) — fires only under the debug policy. */
|
|
226
298
|
onDebugEvent?: (event: ChatDebugEvent) => void;
|
|
227
299
|
/** R6 pass-through (relay hook, sandbox page/flags, host context…). */
|
|
@@ -323,6 +395,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
323
395
|
strings: stringOverrides,
|
|
324
396
|
theme = DEFAULT_CHAT_THEME,
|
|
325
397
|
mode = "light",
|
|
398
|
+
surface = "card",
|
|
399
|
+
composer = true,
|
|
326
400
|
window: windowing,
|
|
327
401
|
reader,
|
|
328
402
|
onDebugEvent,
|
|
@@ -335,6 +409,9 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
335
409
|
oauthReturnTo,
|
|
336
410
|
onOAuthAuthorize,
|
|
337
411
|
onErrorAction,
|
|
412
|
+
clearAffordance,
|
|
413
|
+
onGuestSecretRotate,
|
|
414
|
+
suggestions,
|
|
338
415
|
onActivity,
|
|
339
416
|
onReady,
|
|
340
417
|
onThread,
|
|
@@ -352,8 +429,14 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
352
429
|
// host back → setState → re-render → "Maximum update depth exceeded".
|
|
353
430
|
const getAccessTokenRef = useRef(getAccessToken);
|
|
354
431
|
getAccessTokenRef.current = getAccessToken;
|
|
432
|
+
// The handle is created once ([] deps) but must read the LIVE base —
|
|
433
|
+
// same ref discipline as every other handle-read value in this file.
|
|
434
|
+
const apiBaseUrlRef = useRef(apiBaseUrl);
|
|
435
|
+
apiBaseUrlRef.current = apiBaseUrl;
|
|
355
436
|
const getGuestSecretRef = useRef(getGuestSecret);
|
|
356
437
|
getGuestSecretRef.current = getGuestSecret;
|
|
438
|
+
const onGuestSecretRotateRef = useRef(onGuestSecretRotate);
|
|
439
|
+
onGuestSecretRotateRef.current = onGuestSecretRotate;
|
|
357
440
|
const hasAccessToken = getAccessToken !== undefined;
|
|
358
441
|
const hasGuestSecret = getGuestSecret !== undefined;
|
|
359
442
|
|
|
@@ -621,14 +704,64 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
621
704
|
void live.invoke.send(next).catch(() => {});
|
|
622
705
|
}, [busy]);
|
|
623
706
|
|
|
707
|
+
// guuey#522: the kit default for `ui/open-link` NEVER navigates
|
|
708
|
+
// sight-unseen — the ask surfaces as a disclosure affordance (host +
|
|
709
|
+
// full URL + Open/Dismiss) and the OPEN is the human's own anchor
|
|
710
|
+
// click, so the browser's activation gate and the review posture agree
|
|
711
|
+
// by construction. ONE pending ask at a time, newest replaces (a
|
|
712
|
+
// card spamming asks can never grow an unbounded stack).
|
|
713
|
+
const [pendingLink, setPendingLink] = useState<string | null>(null);
|
|
714
|
+
// guuey#526: the two-tap confirm window for the clear affordance (an
|
|
715
|
+
// accidental tap must not nuke a conversation; a second tap within the
|
|
716
|
+
// window does — the state auto-expires).
|
|
717
|
+
const [clearArmed, setClearArmed] = useState(false);
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
if (!clearArmed) return;
|
|
720
|
+
const timer = setTimeout(() => setClearArmed(false), 4000);
|
|
721
|
+
return () => clearTimeout(timer);
|
|
722
|
+
}, [clearArmed]);
|
|
723
|
+
const showClearAffordance = clearAffordance ?? getGuestSecret !== undefined;
|
|
724
|
+
// guuey#533: the render-side mirror of the card projection's emit caps
|
|
725
|
+
// (4 shown / <=80 chars / blank-rejected) — same defense posture as the
|
|
726
|
+
// table normalizer: the kit is public npm and cannot assume every host
|
|
727
|
+
// routed content through platform's write gate.
|
|
728
|
+
const chips = useMemo<readonly string[]>(() => {
|
|
729
|
+
if (suggestions === false || suggestions === undefined) return [];
|
|
730
|
+
return suggestions
|
|
731
|
+
.map((c) => c.trim())
|
|
732
|
+
.filter((c) => c.length > 0 && c.length <= 80)
|
|
733
|
+
.slice(0, 4);
|
|
734
|
+
}, [suggestions]);
|
|
735
|
+
const sendSuggestion = useCallback((text: string): void => {
|
|
736
|
+
const live = liveRef.current;
|
|
737
|
+
// Exactly the Send button's gate (the #210 rule): unavailable, busy,
|
|
738
|
+
// or blank -> the tap is a no-op, never a bypass. The typed draft is
|
|
739
|
+
// left untouched (a chip send must not eat a half-typed message).
|
|
740
|
+
if (!live.available || live.busy || text.trim() === "") return;
|
|
741
|
+
void live.invoke.send(text).catch(() => {
|
|
742
|
+
// Same contract as submit: the hook owns failure surfacing.
|
|
743
|
+
});
|
|
744
|
+
}, []);
|
|
745
|
+
const defaultOnOpenLink = useCallback((url: string) => {
|
|
746
|
+
setPendingLink(url);
|
|
747
|
+
}, []);
|
|
748
|
+
|
|
624
749
|
const effectiveViewProps = useMemo<TranscriptItemContext["viewProps"]>(
|
|
625
750
|
() =>
|
|
626
751
|
viewPropsWithThemeAnnounce(viewProps, mode, {
|
|
627
752
|
...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
|
|
628
753
|
onUpdateModelContext: defaultOnUpdateModelContext,
|
|
629
754
|
onUserMessage: defaultOnUserMessage,
|
|
755
|
+
onOpenLink: defaultOnOpenLink,
|
|
630
756
|
}),
|
|
631
|
-
[
|
|
757
|
+
[
|
|
758
|
+
viewProps,
|
|
759
|
+
mode,
|
|
760
|
+
stagedDefaultOnCallTool,
|
|
761
|
+
defaultOnUpdateModelContext,
|
|
762
|
+
defaultOnUserMessage,
|
|
763
|
+
defaultOnOpenLink,
|
|
764
|
+
],
|
|
632
765
|
);
|
|
633
766
|
|
|
634
767
|
// The canvas-host door (guuey#335): a host mounting views DIRECTLY from
|
|
@@ -644,6 +777,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
644
777
|
...(stagedDefaultOnCallTool !== undefined ? { onCallTool: stagedDefaultOnCallTool } : {}),
|
|
645
778
|
onUpdateModelContext: defaultOnUpdateModelContext,
|
|
646
779
|
onUserMessage: defaultOnUserMessage,
|
|
780
|
+
onOpenLink: defaultOnOpenLink,
|
|
647
781
|
hostContext: { theme: mode },
|
|
648
782
|
}
|
|
649
783
|
: (effectiveViewProps ?? {});
|
|
@@ -671,6 +805,48 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
671
805
|
focusComposer: (): void => {
|
|
672
806
|
inputRef.current?.focus();
|
|
673
807
|
},
|
|
808
|
+
clearConversation: (): void => {
|
|
809
|
+
const live = liveRef.current;
|
|
810
|
+
// Contract ordering (guuey#526, platform's frozen comment): abort
|
|
811
|
+
// FIRST — a completing turn can re-persist its own rows after the
|
|
812
|
+
// delete. Capture the id + guest secret BEFORE anything clears or
|
|
813
|
+
// rotates them: the delete must authenticate as the identity that
|
|
814
|
+
// OWNS the thread.
|
|
815
|
+
live.invoke.abort();
|
|
816
|
+
const threadId = threadIdRef.current;
|
|
817
|
+
const getGuest = getGuestSecretRef.current;
|
|
818
|
+
const guestSecret = getGuest !== undefined ? getGuest() : null;
|
|
819
|
+
if (apiBaseUrlRef.current !== undefined && threadId !== null) {
|
|
820
|
+
// Best-effort server erasure — fire-and-forget, never blocks the
|
|
821
|
+
// local clear. 200 and 404 are both "deleted" (idempotent by
|
|
822
|
+
// contract); denied/failed still cleared locally below (the
|
|
823
|
+
// unlinkability fallback) and surface ONLY as a debug line.
|
|
824
|
+
void deleteThread({
|
|
825
|
+
apiBaseUrl: apiBaseUrlRef.current,
|
|
826
|
+
threadId,
|
|
827
|
+
...(getAccessTokenRef.current !== undefined
|
|
828
|
+
? { getAccessToken: getAccessTokenRef.current }
|
|
829
|
+
: {}),
|
|
830
|
+
guestSecret,
|
|
831
|
+
}).then((outcome) => {
|
|
832
|
+
onDebugEventRef.current?.({ type: "thread-delete", threadId, outcome });
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
onDebugEventRef.current?.({ type: "thread-delete", threadId, outcome: "skipped" });
|
|
836
|
+
}
|
|
837
|
+
// The hook's reset owns the durable local half (pointer + storage
|
|
838
|
+
// key, transcript/fold/cards); the kit clears ITS OWN residue —
|
|
839
|
+
// the typed draft, a pending link ask, queued doorbells — so the
|
|
840
|
+
// device holds nothing of the conversation.
|
|
841
|
+
live.invoke.reset();
|
|
842
|
+
setInput("");
|
|
843
|
+
setPendingLink(null);
|
|
844
|
+
pendingDoorbellsRef.current = [];
|
|
845
|
+
// Rotation LAST: the delete above already captured the old
|
|
846
|
+
// identity, so the fresh secret is a stranger to even an orphaned
|
|
847
|
+
// server row.
|
|
848
|
+
onGuestSecretRotateRef.current?.();
|
|
849
|
+
},
|
|
674
850
|
// A getter, not a captured value: the handle is created once, but the
|
|
675
851
|
// thread hydrates after mount and can change — reads go through the
|
|
676
852
|
// same ref the default reader uses, so it is always the live id.
|
|
@@ -777,6 +953,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
777
953
|
strings={strings}
|
|
778
954
|
theme={theme}
|
|
779
955
|
mode={mode}
|
|
956
|
+
surface={surface}
|
|
780
957
|
{...(windowing !== undefined ? { window: windowing } : {})}
|
|
781
958
|
{...(components !== undefined ? { components } : {})}
|
|
782
959
|
onToggle={toggle}
|
|
@@ -789,6 +966,62 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
789
966
|
{...(onViewRef !== undefined ? { onViewRef } : {})}
|
|
790
967
|
viewProps={effectiveViewProps}
|
|
791
968
|
/>
|
|
969
|
+
{chips.length > 0 && inputs.messages.length === 0 && (
|
|
970
|
+
<nav className="guuey-chat-chips-row" aria-label={strings.suggestionsLabel}>
|
|
971
|
+
{chips.map((chip) => (
|
|
972
|
+
<button
|
|
973
|
+
key={chip}
|
|
974
|
+
type="button"
|
|
975
|
+
className="guuey-chat-chip"
|
|
976
|
+
onClick={() => sendSuggestion(chip)}
|
|
977
|
+
>
|
|
978
|
+
{chip}
|
|
979
|
+
</button>
|
|
980
|
+
))}
|
|
981
|
+
</nav>
|
|
982
|
+
)}
|
|
983
|
+
{showClearAffordance && inputs.messages.length > 0 && (
|
|
984
|
+
<div className="guuey-chat-clear-row">
|
|
985
|
+
<button
|
|
986
|
+
type="button"
|
|
987
|
+
className={`guuey-chat-clear${clearArmed ? " guuey-chat-clear-armed" : ""}`}
|
|
988
|
+
onClick={() => {
|
|
989
|
+
if (clearArmed) {
|
|
990
|
+
setClearArmed(false);
|
|
991
|
+
handle.clearConversation();
|
|
992
|
+
} else {
|
|
993
|
+
setClearArmed(true);
|
|
994
|
+
}
|
|
995
|
+
}}
|
|
996
|
+
>
|
|
997
|
+
{clearArmed ? strings.clearConversationConfirm : strings.clearConversationLabel}
|
|
998
|
+
</button>
|
|
999
|
+
</div>
|
|
1000
|
+
)}
|
|
1001
|
+
{pendingLink !== null && (
|
|
1002
|
+
<div role="status" className="guuey-chat-link-ask">
|
|
1003
|
+
<span className="guuey-chat-link-ask-label">
|
|
1004
|
+
{strings.linkAskLabel(new URL(pendingLink).host)}
|
|
1005
|
+
</span>
|
|
1006
|
+
<span className="guuey-chat-link-ask-url">{pendingLink}</span>
|
|
1007
|
+
<a
|
|
1008
|
+
className="guuey-chat-link-ask-open"
|
|
1009
|
+
href={pendingLink}
|
|
1010
|
+
target="_blank"
|
|
1011
|
+
rel="noopener noreferrer"
|
|
1012
|
+
onClick={() => setPendingLink(null)}
|
|
1013
|
+
>
|
|
1014
|
+
{strings.linkOpen}
|
|
1015
|
+
</a>
|
|
1016
|
+
<button
|
|
1017
|
+
type="button"
|
|
1018
|
+
className="guuey-chat-link-ask-dismiss"
|
|
1019
|
+
onClick={() => setPendingLink(null)}
|
|
1020
|
+
>
|
|
1021
|
+
{strings.linkDismiss}
|
|
1022
|
+
</button>
|
|
1023
|
+
</div>
|
|
1024
|
+
)}
|
|
792
1025
|
{oauthReturn.notice !== null && (
|
|
793
1026
|
<p
|
|
794
1027
|
role="status"
|
|
@@ -802,6 +1035,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
802
1035
|
</button>
|
|
803
1036
|
</p>
|
|
804
1037
|
)}
|
|
1038
|
+
{composer && (
|
|
805
1039
|
<form
|
|
806
1040
|
className="guuey-chat-composer"
|
|
807
1041
|
onSubmit={(e) => {
|
|
@@ -843,6 +1077,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
843
1077
|
</button>
|
|
844
1078
|
)}
|
|
845
1079
|
</form>
|
|
1080
|
+
)}
|
|
846
1081
|
</div>
|
|
847
1082
|
);
|
|
848
1083
|
});
|
package/src/react/markdown.tsx
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Streaming-tolerant by upstream design: unclosed emphasis mid-delta
|
|
25
25
|
* renders as formatted-so-far.
|
|
26
26
|
*/
|
|
27
|
+
import { useState } from "react";
|
|
27
28
|
import type { ReactNode } from "react";
|
|
28
29
|
import {
|
|
29
30
|
parseRichText,
|
|
@@ -32,11 +33,75 @@ import {
|
|
|
32
33
|
} from "@silverprotocol/richtext";
|
|
33
34
|
import { normalizeTableRow, type RichTextTableBlock } from "../richtext-table.js";
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Bare-URL autolink (guuey#515): richtext mints `link` nodes only from
|
|
38
|
+
* explicit `[text](url)` markdown, so a plain "https://…" in agent prose
|
|
39
|
+
* parses as a text node and rendered as untappable static text — on a
|
|
40
|
+
* phone that means retyping a URL out of a chat bubble. Linkify at the
|
|
41
|
+
* render boundary, conservatively:
|
|
42
|
+
* - the scheme is part of the match (http/https only), so no
|
|
43
|
+
* `javascript:`/`data:` target can ever qualify — the SAFE_HREF
|
|
44
|
+
* property holds by construction;
|
|
45
|
+
* - built as React elements (no HTML string exists to inject into),
|
|
46
|
+
* same `rel`/`target` contract as explicit links;
|
|
47
|
+
* - trailing punctuation stays prose ("see https://guuey.com." must not
|
|
48
|
+
* link the dot), while a balanced closing paren/bracket stays in the
|
|
49
|
+
* URL (wiki-style paths);
|
|
50
|
+
* - schemeless "www.foo.com" is deliberately NOT linkified — guessing
|
|
51
|
+
* schemes is how autolinkers start lying;
|
|
52
|
+
* - code spans are untouched (they render `node.code`, not this path).
|
|
53
|
+
*/
|
|
54
|
+
const BARE_URL = /https?:\/\/[^\s<>]+/g;
|
|
55
|
+
|
|
56
|
+
function trimTrailingPunctuation(url: string): string {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const last = url[url.length - 1];
|
|
59
|
+
if (last === undefined) return url;
|
|
60
|
+
if (last === ")" || last === "]") {
|
|
61
|
+
const open = last === ")" ? "(" : "[";
|
|
62
|
+
const opens = url.split(open).length - 1;
|
|
63
|
+
const closes = url.split(last).length - 1;
|
|
64
|
+
if (closes > opens) {
|
|
65
|
+
url = url.slice(0, -1);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
if (".,;:!?'\"»›".includes(last)) {
|
|
71
|
+
url = url.slice(0, -1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function linkify(text: string): ReactNode {
|
|
79
|
+
if (!text.includes("http")) return text;
|
|
80
|
+
const out: ReactNode[] = [];
|
|
81
|
+
let consumed = 0;
|
|
82
|
+
for (const match of text.matchAll(BARE_URL)) {
|
|
83
|
+
const start = match.index;
|
|
84
|
+
if (start === undefined) continue;
|
|
85
|
+
const url = trimTrailingPunctuation(match[0]);
|
|
86
|
+
if (url.length === 0) continue;
|
|
87
|
+
if (start > consumed) out.push(text.slice(consumed, start));
|
|
88
|
+
out.push(
|
|
89
|
+
<a key={`url-${start}`} href={url} target="_blank" rel="noopener noreferrer">
|
|
90
|
+
{url}
|
|
91
|
+
</a>,
|
|
92
|
+
);
|
|
93
|
+
consumed = start + url.length;
|
|
94
|
+
}
|
|
95
|
+
if (out.length === 0) return text;
|
|
96
|
+
if (consumed < text.length) out.push(text.slice(consumed));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
35
100
|
function Inline({ nodes }: { nodes: RichTextInline[] }): ReactNode {
|
|
36
101
|
return nodes.map((node, i) => {
|
|
37
102
|
switch (node.type) {
|
|
38
103
|
case "text":
|
|
39
|
-
return <span key={i}>{node.text}</span>;
|
|
104
|
+
return <span key={i}>{linkify(node.text)}</span>;
|
|
40
105
|
case "break":
|
|
41
106
|
return <br key={i} />;
|
|
42
107
|
case "strong":
|
|
@@ -124,7 +189,7 @@ function Block({ block }: { block: RichTextBlock }): ReactNode {
|
|
|
124
189
|
</p>
|
|
125
190
|
);
|
|
126
191
|
case "code-fence":
|
|
127
|
-
return <
|
|
192
|
+
return <CodeFence code={block.code} />;
|
|
128
193
|
case "list": {
|
|
129
194
|
const Tag = block.ordered ? "ol" : "ul";
|
|
130
195
|
return (
|
|
@@ -140,6 +205,43 @@ function Block({ block }: { block: RichTextBlock }): ReactNode {
|
|
|
140
205
|
}
|
|
141
206
|
}
|
|
142
207
|
|
|
208
|
+
/**
|
|
209
|
+
* A fenced code block with its copy affordance (guuey#529 — the
|
|
210
|
+
* seamless-default family: every embedder's visitors get it with zero
|
|
211
|
+
* setup). The button lives in a wrapper DIV so the `<pre>` holds ONLY the
|
|
212
|
+
* code (select-all inside the fence never picks up UI text); the
|
|
213
|
+
* transcript's sibling-margin rules name the wrapper class alongside
|
|
214
|
+
* `pre`. Copy failure is shown truthfully: the label simply stays "Copy"
|
|
215
|
+
* (clipboard access needs a secure context + the iframe's
|
|
216
|
+
* clipboard-write grant — manual selection always still works).
|
|
217
|
+
*/
|
|
218
|
+
function CodeFence({ code }: { code: string }): ReactNode {
|
|
219
|
+
const [copied, setCopied] = useState(false);
|
|
220
|
+
return (
|
|
221
|
+
<div className="guuey-chat-code-wrap">
|
|
222
|
+
<pre className="guuey-chat-code-fence">{code}</pre>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
className="guuey-chat-code-copy"
|
|
226
|
+
aria-label={copied ? "Copied" : "Copy code"}
|
|
227
|
+
onClick={() => {
|
|
228
|
+
const clipboard = navigator.clipboard;
|
|
229
|
+
if (clipboard === undefined) return;
|
|
230
|
+
clipboard.writeText(code).then(
|
|
231
|
+
() => {
|
|
232
|
+
setCopied(true);
|
|
233
|
+
setTimeout(() => setCopied(false), 1600);
|
|
234
|
+
},
|
|
235
|
+
() => setCopied(false),
|
|
236
|
+
);
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{copied ? "Copied" : "Copy"}
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
143
245
|
/** Sanitized markdown → React elements (see the module docblock). */
|
|
144
246
|
export function Markdown({ text }: { text: string }): ReactNode {
|
|
145
247
|
return (
|
package/src/react/theme-css.ts
CHANGED
|
@@ -24,27 +24,38 @@ const DENSITY_GAP: Record<GuueyChatTheme["shape"]["density"], string> = {
|
|
|
24
24
|
* One mode's tokens as inline custom properties for the transcript root.
|
|
25
25
|
* Returned as a plain record so callers can spread it into `style` or emit
|
|
26
26
|
* a stylesheet from it.
|
|
27
|
+
*
|
|
28
|
+
* Stamped under INTERNAL names (`--_guuey-chat-*`), never the documented
|
|
29
|
+
* `--guuey-chat-*` channel (guuey#521): the stylesheet reads every token
|
|
30
|
+
* as `var(--guuey-chat-<t>, var(--_guuey-chat-<t>))`, so a host CSS
|
|
31
|
+
* variable — set on any ancestor — wins per-token over the resolved
|
|
32
|
+
* theme, and the two theming channels COMPOSE instead of the inline
|
|
33
|
+
* stamp silently shadowing the documented one (the trap the landing
|
|
34
|
+
* home-hero embed hit four rounds of).
|
|
27
35
|
*/
|
|
28
36
|
export function themeCssVars(theme: GuueyChatTheme, mode: ThemeMode): Record<string, string> {
|
|
29
37
|
const palette = theme.colors[mode];
|
|
30
38
|
const vars: Record<string, string> = {
|
|
31
|
-
"--
|
|
32
|
-
"--
|
|
33
|
-
"--
|
|
34
|
-
"--
|
|
35
|
-
"--
|
|
36
|
-
"--
|
|
37
|
-
"--
|
|
38
|
-
"--
|
|
39
|
-
"--
|
|
40
|
-
"--
|
|
41
|
-
"--
|
|
39
|
+
"--_guuey-chat-accent": palette.accent,
|
|
40
|
+
"--_guuey-chat-on-accent": palette.onAccent,
|
|
41
|
+
"--_guuey-chat-ink": palette.ink,
|
|
42
|
+
"--_guuey-chat-ink-muted": palette.inkMuted,
|
|
43
|
+
"--_guuey-chat-surface": palette.surface,
|
|
44
|
+
"--_guuey-chat-canvas": palette.canvas,
|
|
45
|
+
"--_guuey-chat-canvas-muted": palette.canvasMuted,
|
|
46
|
+
"--_guuey-chat-error": palette.error,
|
|
47
|
+
"--_guuey-chat-radius": RADIUS_PX[theme.shape.radius],
|
|
48
|
+
"--_guuey-chat-gap": DENSITY_GAP[theme.shape.density],
|
|
49
|
+
"--_guuey-chat-scale": String(theme.typography.scale ?? 1),
|
|
42
50
|
};
|
|
51
|
+
if (palette.link !== undefined) {
|
|
52
|
+
vars["--_guuey-chat-link"] = palette.link;
|
|
53
|
+
}
|
|
43
54
|
if (theme.typography.fontFamily !== undefined) {
|
|
44
|
-
vars["--
|
|
55
|
+
vars["--_guuey-chat-font"] = theme.typography.fontFamily;
|
|
45
56
|
}
|
|
46
57
|
if (theme.typography.monoFontFamily !== undefined) {
|
|
47
|
-
vars["--
|
|
58
|
+
vars["--_guuey-chat-mono-font"] = theme.typography.monoFontFamily;
|
|
48
59
|
}
|
|
49
60
|
return vars;
|
|
50
61
|
}
|
package/src/react/transcript.tsx
CHANGED
|
@@ -63,6 +63,14 @@ export interface TranscriptProps
|
|
|
63
63
|
strings?: ChatStrings;
|
|
64
64
|
theme?: GuueyChatTheme;
|
|
65
65
|
mode?: ThemeMode;
|
|
66
|
+
/**
|
|
67
|
+
* Message-surface presentation (guuey#521). `"card"` (default) renders
|
|
68
|
+
* assistant text on its own surface-tinted, hairline-bordered card;
|
|
69
|
+
* `"bare"` sits the transcript directly on the host's background — the
|
|
70
|
+
* embed disappears into the page it lives on. The user pill stays a
|
|
71
|
+
* bubble in both.
|
|
72
|
+
*/
|
|
73
|
+
surface?: "card" | "bare";
|
|
66
74
|
/** DOM windowing (§3.2). `false` renders everything. Default tail 80. */
|
|
67
75
|
window?: TranscriptWindowing | false;
|
|
68
76
|
className?: string;
|
|
@@ -76,6 +84,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
|
|
|
76
84
|
strings = defaultChatStrings,
|
|
77
85
|
theme = DEFAULT_CHAT_THEME,
|
|
78
86
|
mode = "light",
|
|
87
|
+
surface = "card",
|
|
79
88
|
window: windowing = { tail: 80 },
|
|
80
89
|
className,
|
|
81
90
|
style,
|
|
@@ -169,7 +178,7 @@ export function Transcript(props: TranscriptProps): ReactNode {
|
|
|
169
178
|
|
|
170
179
|
return (
|
|
171
180
|
<div
|
|
172
|
-
className={`guuey-chat${className !== undefined ? ` ${className}` : ""}`}
|
|
181
|
+
className={`guuey-chat${surface === "bare" ? " guuey-chat--bare" : ""}${className !== undefined ? ` ${className}` : ""}`}
|
|
173
182
|
style={rootStyle}
|
|
174
183
|
data-guuey-chat-mode={mode}
|
|
175
184
|
>
|
package/src/strings.ts
CHANGED
|
@@ -128,6 +128,28 @@ export interface ChatStrings {
|
|
|
128
128
|
/** R16 — the notice row's label (provenance shows only under debug). */
|
|
129
129
|
noticeLabel: string;
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The `ui/open-link` disclosure affordance (guuey#522): a card asked to
|
|
133
|
+
* open a URL — the kit shows WHERE before anything navigates, and the
|
|
134
|
+
* human's own click on `linkOpen` is the only door out.
|
|
135
|
+
*/
|
|
136
|
+
linkAskLabel: (host: string) => string;
|
|
137
|
+
linkOpen: string;
|
|
138
|
+
linkDismiss: string;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The forget-this-device affordance (guuey#526): the quiet clear
|
|
142
|
+
* control and its two-tap confirm face.
|
|
143
|
+
*/
|
|
144
|
+
clearConversationLabel: string;
|
|
145
|
+
clearConversationConfirm: string;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The suggestion-chip row's accessible name (guuey#533) — the chips
|
|
149
|
+
* themselves are declared content (the app's own words), never kit copy.
|
|
150
|
+
*/
|
|
151
|
+
suggestionsLabel: string;
|
|
152
|
+
|
|
131
153
|
/** The 3c composer (`<GuueyChat>`). */
|
|
132
154
|
composerPlaceholder: string;
|
|
133
155
|
composerUnavailable: string;
|
|
@@ -233,6 +255,14 @@ export const defaultChatStrings: ChatStrings = {
|
|
|
233
255
|
|
|
234
256
|
noticeLabel: "Note",
|
|
235
257
|
|
|
258
|
+
linkAskLabel: (host) => `This card wants to open ${host}`,
|
|
259
|
+
linkOpen: "Open",
|
|
260
|
+
linkDismiss: "Dismiss",
|
|
261
|
+
|
|
262
|
+
clearConversationLabel: "Clear conversation",
|
|
263
|
+
suggestionsLabel: "Suggestions",
|
|
264
|
+
clearConversationConfirm: "Tap again to clear this device",
|
|
265
|
+
|
|
236
266
|
composerPlaceholder: "Message the agent…",
|
|
237
267
|
composerUnavailable: "Chat is unavailable.",
|
|
238
268
|
composerLabel: "Message",
|