@guuey/chat 0.8.0 → 0.8.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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/native/components.d.ts.map +1 -1
- package/dist/native/components.js +4 -2
- package/dist/oauth.d.ts +87 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +121 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +2 -0
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +6 -2
- package/dist/react/guuey-chat.d.ts +14 -0
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +21 -3
- package/dist/react/oauth-return.d.ts +61 -0
- package/dist/react/oauth-return.d.ts.map +1 -0
- package/dist/react/oauth-return.js +103 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -0
- package/dist/strings.d.ts +10 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +4 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/corpus/README.md +7 -1
- package/src/corpus/__snapshots__/corpus.test.ts.snap +84 -0
- package/src/corpus/fixtures.ts +77 -0
- package/src/index.ts +11 -0
- package/src/native/components.tsx +14 -4
- package/src/oauth.ts +157 -0
- package/src/plan.ts +2 -0
- package/src/react/components.tsx +16 -4
- package/src/react/guuey-chat.tsx +49 -1
- package/src/react/oauth-return.ts +129 -0
- package/src/react.tsx +9 -0
- package/src/strings.ts +14 -0
- package/src/types.ts +8 -0
- package/styles.css +26 -0
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -71,6 +71,7 @@ import type { ThemeMode } from "./theme-css.js";
|
|
|
71
71
|
import { Transcript, type TranscriptWindowing } from "./transcript.js";
|
|
72
72
|
import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
|
|
73
73
|
import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
|
|
74
|
+
import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* The imperative seam (guuey#210): programmatic send/prefill/focus for
|
|
@@ -177,6 +178,20 @@ export interface GuueyChatProps {
|
|
|
177
178
|
* transport). Fired after the transcript record moves.
|
|
178
179
|
*/
|
|
179
180
|
onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
|
|
181
|
+
/**
|
|
182
|
+
* The OAuth "authorize this server" arm (guuey#178): where the broker
|
|
183
|
+
* sends the user back after the provider dance. Defaults to this page's
|
|
184
|
+
* own location (stale return params stripped). Pass a value when the
|
|
185
|
+
* chat lives at a URL that is not where the user should land.
|
|
186
|
+
*/
|
|
187
|
+
oauthReturnTo?: string;
|
|
188
|
+
/**
|
|
189
|
+
* How the authorize link is opened. Defaults to `openOAuthAuthorize`
|
|
190
|
+
* (in-place navigation; a new tab when framed). The composite ALSO shows
|
|
191
|
+
* the return notice itself (`useOAuthReturn`) — a host that overrides
|
|
192
|
+
* `open` and lands elsewhere renders its own.
|
|
193
|
+
*/
|
|
194
|
+
onOAuthAuthorize?: (href: string, ask: AgPausedAsk) => void;
|
|
180
195
|
/** R11 action slot (sign-in / retry affordances). */
|
|
181
196
|
onErrorAction?: (item: ErrorItem) => void;
|
|
182
197
|
/**
|
|
@@ -226,6 +241,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
226
241
|
viewProps,
|
|
227
242
|
onPromptAction,
|
|
228
243
|
onHitlAnswer,
|
|
244
|
+
oauthReturnTo,
|
|
245
|
+
onOAuthAuthorize,
|
|
229
246
|
onErrorAction,
|
|
230
247
|
onReady,
|
|
231
248
|
onThread,
|
|
@@ -388,9 +405,27 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
388
405
|
[invoke],
|
|
389
406
|
);
|
|
390
407
|
|
|
408
|
+
// guuey#178: the broker's return notice (`?connected=` / `?error=`), read
|
|
409
|
+
// + stripped off the address bar once on mount.
|
|
410
|
+
const oauthReturn = useOAuthReturn();
|
|
411
|
+
|
|
391
412
|
const handlePromptAction = useCallback(
|
|
392
413
|
(item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
|
|
393
414
|
if (item.promptKind === "hitl") {
|
|
415
|
+
// The OAuth arm: the answer is the redirect (no pod door, no
|
|
416
|
+
// `onHitlAnswer`); the ledger records the pick and the link opens.
|
|
417
|
+
if (
|
|
418
|
+
oauthPromptAction({
|
|
419
|
+
item,
|
|
420
|
+
action,
|
|
421
|
+
answerHitlPrompt,
|
|
422
|
+
...(oauthReturnTo !== undefined ? { returnTo: oauthReturnTo } : {}),
|
|
423
|
+
...(onOAuthAuthorize !== undefined ? { open: onOAuthAuthorize } : {}),
|
|
424
|
+
})
|
|
425
|
+
) {
|
|
426
|
+
onPromptAction?.(item, action);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
394
429
|
const answer = answerHitlPrompt(
|
|
395
430
|
item.ask,
|
|
396
431
|
typeof action === "object" ? action : action === "accept" ? "accept" : action,
|
|
@@ -404,7 +439,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
404
439
|
if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
|
|
405
440
|
onPromptAction?.(item, action);
|
|
406
441
|
},
|
|
407
|
-
[resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
|
|
442
|
+
[resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction, oauthReturnTo, onOAuthAuthorize],
|
|
408
443
|
);
|
|
409
444
|
|
|
410
445
|
const strings = policy.strings;
|
|
@@ -430,6 +465,19 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
430
465
|
onViewDiagnosis={onViewDiagnosis}
|
|
431
466
|
{...(viewProps !== undefined ? { viewProps } : {})}
|
|
432
467
|
/>
|
|
468
|
+
{oauthReturn.notice !== null && (
|
|
469
|
+
<p
|
|
470
|
+
role="status"
|
|
471
|
+
className={`guuey-chat-oauth-notice guuey-chat-oauth-${oauthReturn.notice.kind}`}
|
|
472
|
+
>
|
|
473
|
+
{oauthReturn.notice.kind === "connected"
|
|
474
|
+
? strings.oauthConnected(oauthReturn.notice.serverName)
|
|
475
|
+
: strings.oauthFailed(oauthReturn.notice.reason)}
|
|
476
|
+
<button type="button" className="guuey-chat-oauth-dismiss" onClick={oauthReturn.dismiss}>
|
|
477
|
+
{strings.promptDismissed}
|
|
478
|
+
</button>
|
|
479
|
+
</p>
|
|
480
|
+
)}
|
|
433
481
|
<form
|
|
434
482
|
className="guuey-chat-composer"
|
|
435
483
|
onSubmit={(e) => {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web half of the OAuth "authorize this server" arm (guuey#178 Slice 4)
|
|
3
|
+
* — the DOM-touching pieces the pure `../oauth.js` helpers deliberately
|
|
4
|
+
* leave out. Every web surface (the `<GuueyChat>` composite, the widget, the
|
|
5
|
+
* studio agent page) shares these three so the redirect + return behave the
|
|
6
|
+
* same everywhere:
|
|
7
|
+
*
|
|
8
|
+
* - {@link oauthReturnToHere} — the surface's own location, with a stale
|
|
9
|
+
* `?connected=`/`?error=` from a PREVIOUS dance stripped, ready to be
|
|
10
|
+
* the next `returnTo`.
|
|
11
|
+
* - {@link openOAuthAuthorize} — how the link is opened: a top-level page
|
|
12
|
+
* navigates in place (the broker 302s straight back to `returnTo`); a
|
|
13
|
+
* FRAMED page (the widget inside a customer origin) opens a new tab,
|
|
14
|
+
* because identity providers refuse to render inside a third-party
|
|
15
|
+
* frame (`X-Frame-Options` / `frame-ancestors`).
|
|
16
|
+
* - {@link useOAuthReturn} — on mount, read the broker's return params off
|
|
17
|
+
* the address bar, REPLACE the URL without them (a reload never re-shows
|
|
18
|
+
* the notice), and hand the surface a one-shot notice to render.
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useState } from "react";
|
|
21
|
+
import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
|
|
22
|
+
import type { HitlPromptAction } from "../hitl.js";
|
|
23
|
+
import { oauthAuthorizeHref, parseOAuthReturn, stripOAuthReturn, type OAuthReturn } from "../oauth.js";
|
|
24
|
+
import type { HitlPromptItem } from "../types.js";
|
|
25
|
+
|
|
26
|
+
/** The surface's own location as the next `returnTo` (stale return params stripped). */
|
|
27
|
+
export function oauthReturnToHere(): string {
|
|
28
|
+
return stripOAuthReturn(window.location.href);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The slice of `window` the opener touches (injectable — jsdom's `location.assign` cannot be spied). */
|
|
32
|
+
export interface OAuthWindow {
|
|
33
|
+
self: object;
|
|
34
|
+
top: object | null;
|
|
35
|
+
open: (url: string, target: string, features: string) => unknown;
|
|
36
|
+
location: { assign: (url: string) => void };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether this document is rendered inside another origin's frame. */
|
|
40
|
+
function isFramed(win: OAuthWindow): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return win.top !== win.self;
|
|
43
|
+
} catch {
|
|
44
|
+
// A cross-origin `window.top` read throws in some browsers — that IS framed.
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Open the authorize link. In place when top-level (the dance ends back on
|
|
51
|
+
* this page); a new tab when framed (the IdP will not render in a frame —
|
|
52
|
+
* `returnTo` then lands the SAME page top-level in that tab, where
|
|
53
|
+
* {@link useOAuthReturn} shows the notice; the embedded chat picks the
|
|
54
|
+
* connection up on its next turn).
|
|
55
|
+
*/
|
|
56
|
+
export function openOAuthAuthorize(href: string, win: OAuthWindow = window): void {
|
|
57
|
+
if (isFramed(win)) {
|
|
58
|
+
win.open(href, "_blank", "noopener,noreferrer");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
win.location.assign(href);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface UseOAuthReturnResult {
|
|
65
|
+
/** The broker's answer found on the address bar at mount, until dismissed. */
|
|
66
|
+
notice: OAuthReturn | null;
|
|
67
|
+
dismiss: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read `?connected=<serverName>` / `?error=<reason>` off the current
|
|
72
|
+
* location ONCE (on mount), strip them from the address bar via
|
|
73
|
+
* `history.replaceState`, and expose the result as a dismissible notice.
|
|
74
|
+
* SSR-safe: nothing runs until the effect.
|
|
75
|
+
*/
|
|
76
|
+
export function useOAuthReturn(): UseOAuthReturnResult {
|
|
77
|
+
const [notice, setNotice] = useState<OAuthReturn | null>(null);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (typeof window === "undefined") return;
|
|
80
|
+
const href = window.location.href;
|
|
81
|
+
const found = parseOAuthReturn(href);
|
|
82
|
+
if (found === null) return;
|
|
83
|
+
const clean = stripOAuthReturn(href);
|
|
84
|
+
if (clean !== href) window.history.replaceState(window.history.state, "", clean);
|
|
85
|
+
setNotice(found);
|
|
86
|
+
}, []);
|
|
87
|
+
const dismiss = useCallback(() => setNotice(null), []);
|
|
88
|
+
return { notice, dismiss };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OAuthPromptActionArgs {
|
|
92
|
+
item: HitlPromptItem;
|
|
93
|
+
action: HitlPromptAction;
|
|
94
|
+
/** The kit's ledger (`useTranscriptInputs().answerHitlPrompt`) — records the pick locally; nothing is delivered. */
|
|
95
|
+
answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
|
|
96
|
+
/** Where the broker should send the user back; defaults to {@link oauthReturnToHere}. */
|
|
97
|
+
returnTo?: string;
|
|
98
|
+
/** How to open the link; defaults to {@link openOAuthAuthorize}. */
|
|
99
|
+
open?: (href: string, ask: AgPausedAsk) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The OAuth arm of a prompt action, shared by every web surface. Returns
|
|
104
|
+
* `false` (nothing done) when the item is not an OAuth ask, so a host's
|
|
105
|
+
* #207 hitl branch (build the answer, POST it to the pod door) runs as
|
|
106
|
+
* before. For an OAuth ask:
|
|
107
|
+
*
|
|
108
|
+
* - a mode pick (or a plain accept on a mode-less ask) records the pick in
|
|
109
|
+
* the ledger — the card shows "Connecting — <mode>" — and OPENS
|
|
110
|
+
* `authorizationUrl&mode=<id>&returnTo=<here>`; NOTHING is posted to the
|
|
111
|
+
* pod (there is no answer door — the answer is the redirect);
|
|
112
|
+
* - "Not now" / decline / dismiss all record `cancelled` (still pending,
|
|
113
|
+
* re-askable): nothing is written anywhere and the pod asks again next
|
|
114
|
+
* turn.
|
|
115
|
+
*/
|
|
116
|
+
export function oauthPromptAction(args: OAuthPromptActionArgs): boolean {
|
|
117
|
+
const { item, action, answerHitlPrompt } = args;
|
|
118
|
+
if (item.oauth === null) return false;
|
|
119
|
+
if (typeof action === "object" || action === "accept") {
|
|
120
|
+
const grantModeId = typeof action === "object" ? action.grantModeId : null;
|
|
121
|
+
answerHitlPrompt(item.ask, action);
|
|
122
|
+
const href = oauthAuthorizeHref(item.ask, grantModeId, args.returnTo ?? oauthReturnToHere());
|
|
123
|
+
if (args.open !== undefined) args.open(href, item.ask);
|
|
124
|
+
else openOAuthAuthorize(href);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
answerHitlPrompt(item.ask, "dismiss");
|
|
128
|
+
return true;
|
|
129
|
+
}
|
package/src/react.tsx
CHANGED
|
@@ -49,5 +49,14 @@ export {
|
|
|
49
49
|
type UseTranscriptInputsResult,
|
|
50
50
|
} from "./react/use-transcript.js";
|
|
51
51
|
export { GuueyChat, type GuueyChatProps, type GuueyChatHandle } from "./react/guuey-chat.js";
|
|
52
|
+
export {
|
|
53
|
+
oauthPromptAction,
|
|
54
|
+
oauthReturnToHere,
|
|
55
|
+
openOAuthAuthorize,
|
|
56
|
+
useOAuthReturn,
|
|
57
|
+
type OAuthPromptActionArgs,
|
|
58
|
+
type OAuthWindow,
|
|
59
|
+
type UseOAuthReturnResult,
|
|
60
|
+
} from "./react/oauth-return.js";
|
|
52
61
|
export { Markdown } from "./react/markdown.js";
|
|
53
62
|
export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
|
package/src/strings.ts
CHANGED
|
@@ -96,6 +96,16 @@ export interface ChatStrings {
|
|
|
96
96
|
/** The answered record line, e.g. `Allowed — Always`. */
|
|
97
97
|
promptAnsweredWith: (modeLabel: string) => string;
|
|
98
98
|
promptDeclinedRecord: string;
|
|
99
|
+
/**
|
|
100
|
+
* The OAuth arm (guuey#178): the dismiss action ("Not now" — nothing is
|
|
101
|
+
* written, the ask re-emits next turn), the answered record (the user was
|
|
102
|
+
* sent to the provider), and the return notices the surface shows after
|
|
103
|
+
* the broker 302s back with `?connected=<serverName>` / `?error=<reason>`.
|
|
104
|
+
*/
|
|
105
|
+
promptNotNow: string;
|
|
106
|
+
promptOAuthSent: (modeLabel: string) => string;
|
|
107
|
+
oauthConnected: (serverName: string) => string;
|
|
108
|
+
oauthFailed: (reason: string) => string;
|
|
99
109
|
|
|
100
110
|
/** R16 — the notice row's label (provenance shows only under debug). */
|
|
101
111
|
noticeLabel: string;
|
|
@@ -171,6 +181,10 @@ export const defaultChatStrings: ChatStrings = {
|
|
|
171
181
|
promptDismissed: "Dismissed",
|
|
172
182
|
promptAnsweredWith: (modeLabel) => `Allowed — ${modeLabel}`,
|
|
173
183
|
promptDeclinedRecord: "Not allowed",
|
|
184
|
+
promptNotNow: "Not now",
|
|
185
|
+
promptOAuthSent: (modeLabel) => `Connecting — ${modeLabel}`,
|
|
186
|
+
oauthConnected: (serverName) => `Connected ${serverName}. The agent can use it from your next message.`,
|
|
187
|
+
oauthFailed: (reason) => `Couldn't connect: ${reason}`,
|
|
174
188
|
|
|
175
189
|
noticeLabel: "Note",
|
|
176
190
|
|
package/src/types.ts
CHANGED
|
@@ -355,6 +355,14 @@ export interface HitlPromptItem extends BaseItem {
|
|
|
355
355
|
askKind: AgPausedAsk["kind"];
|
|
356
356
|
/** Declared accept variants; empty = a plain accept/decline ask. */
|
|
357
357
|
grantModes: readonly AgGrantMode[];
|
|
358
|
+
/**
|
|
359
|
+
* The OAuth arm (guuey#178): set when the ask is `kind:"auth"` with
|
|
360
|
+
* `authConfig.scheme:"oauth2"` + an `authorizationUrl`. There is no
|
|
361
|
+
* answer door for this ask — a mode pick OPENS `authorizationUrl` with
|
|
362
|
+
* `&mode=` + `&returnTo=` appended (`oauthAuthorizeHref`); "Not now" is a
|
|
363
|
+
* plain dismissal. `null` for every other ask.
|
|
364
|
+
*/
|
|
365
|
+
oauth: { authorizationUrl: string; scopes: readonly string[] } | null;
|
|
358
366
|
state: "pending" | "resolved" | "declined" | "cancelled";
|
|
359
367
|
/** Echo of the chosen mode id (identity, never displayed as meaning). */
|
|
360
368
|
chosenModeId: string | null;
|
package/styles.css
CHANGED
|
@@ -349,6 +349,32 @@
|
|
|
349
349
|
margin: 0;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
/* The OAuth "authorize this server" return notice (guuey#178) — shown by
|
|
353
|
+
<GuueyChat> after the broker sends the user back with ?connected= / ?error=. */
|
|
354
|
+
.guuey-chat-oauth-notice {
|
|
355
|
+
display: flex;
|
|
356
|
+
align-items: center;
|
|
357
|
+
justify-content: space-between;
|
|
358
|
+
gap: 8px;
|
|
359
|
+
margin: 0;
|
|
360
|
+
padding: 8px 12px;
|
|
361
|
+
font-size: 0.85em;
|
|
362
|
+
color: var(--guuey-chat-ink);
|
|
363
|
+
background: color-mix(in srgb, var(--guuey-chat-accent) 10%, var(--guuey-chat-surface));
|
|
364
|
+
border-top: 1px solid var(--guuey-chat-canvas-muted);
|
|
365
|
+
}
|
|
366
|
+
.guuey-chat-oauth-error {
|
|
367
|
+
background: color-mix(in srgb, var(--guuey-chat-error) 7%, var(--guuey-chat-surface));
|
|
368
|
+
}
|
|
369
|
+
.guuey-chat-oauth-dismiss {
|
|
370
|
+
font: inherit;
|
|
371
|
+
cursor: pointer;
|
|
372
|
+
border: 0;
|
|
373
|
+
background: transparent;
|
|
374
|
+
color: var(--guuey-chat-ink-muted);
|
|
375
|
+
text-decoration: underline;
|
|
376
|
+
}
|
|
377
|
+
|
|
352
378
|
/* ── R11 errors ── */
|
|
353
379
|
.guuey-chat-error {
|
|
354
380
|
border: 1px solid color-mix(in srgb, var(--guuey-chat-error) 45%, transparent);
|