@djangocfg/widget-assistant 0.2.9 → 0.3.7
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 +31 -1
- package/package.json +7 -7
- package/src/AssistantChat.tsx +18 -6
- package/src/composer/ComposerBehaviour.tsx +17 -0
- package/src/composer/ComposerHelp.tsx +4 -1
- package/src/composer/earcons.ts +49 -0
- package/src/composer/handoff.ts +26 -0
- package/src/i18n/index.ts +3 -0
- package/src/i18n/locales/ar.ts +3 -3
- package/src/i18n/locales/da.ts +3 -3
- package/src/i18n/locales/de.ts +3 -3
- package/src/i18n/locales/en.ts +3 -3
- package/src/i18n/locales/es.ts +3 -3
- package/src/i18n/locales/fr.ts +3 -3
- package/src/i18n/locales/it.ts +3 -3
- package/src/i18n/locales/ja.ts +3 -3
- package/src/i18n/locales/ko.ts +3 -3
- package/src/i18n/locales/nl.ts +3 -3
- package/src/i18n/locales/no.ts +3 -3
- package/src/i18n/locales/pl.ts +3 -3
- package/src/i18n/locales/pt-BR.ts +3 -3
- package/src/i18n/locales/ru.ts +3 -3
- package/src/i18n/locales/sv.ts +3 -3
- package/src/i18n/locales/tr.ts +3 -3
- package/src/i18n/locales/zh.ts +3 -3
- package/src/i18n/types.ts +4 -4
- package/src/index.ts +7 -4
- package/src/sessions/SessionsMenu.tsx +82 -0
- package/src/shell/AssistantFrame.tsx +11 -12
- package/src/shell/AssistantLocked.tsx +12 -4
- package/src/shell/ChatDock.tsx +16 -27
- package/src/stream/adapter.ts +8 -1
- package/src/stream/sessions.ts +58 -0
- package/src/stream/useSession.ts +111 -34
- package/src/wire/stored.ts +9 -0
- package/src/wire/transport.ts +13 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, History, Plus } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
Tooltip,
|
|
13
|
+
TooltipContent,
|
|
14
|
+
TooltipProvider,
|
|
15
|
+
TooltipTrigger,
|
|
16
|
+
} from '@djangocfg/ui-core/components';
|
|
17
|
+
|
|
18
|
+
import { useAssistantCopy, useAssistantLocale } from '../i18n';
|
|
19
|
+
import type { Session } from '../stream/useSession';
|
|
20
|
+
import { relativeTime } from '../stream/sessions';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
session: Pick<Session, 'thread' | 'sessions' | 'busy' | 'startNew' | 'switchTo' | 'refreshSessions'>;
|
|
24
|
+
/** The language tag for "5 minutes ago". Absent: the assistant's active language. */
|
|
25
|
+
locale?: string;
|
|
26
|
+
/** Hide the list and keep only "New chat" (a host with no list endpoint, such as an anonymous one). */
|
|
27
|
+
newOnly?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The chat's header controls (put them in the frame's or the dock's `actions`, next to Close; each has a tooltip): "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
|
|
32
|
+
* Both wait while an answer streams: Stop ends it first. Words come from the assistant's own dictionaries.
|
|
33
|
+
*/
|
|
34
|
+
export function SessionsMenu({ session, locale, newOnly = false }: Props) {
|
|
35
|
+
const copy = useAssistantCopy(locale);
|
|
36
|
+
const active = useAssistantLocale(locale);
|
|
37
|
+
const current = session.thread.kind === 'ready' ? session.thread.session : null;
|
|
38
|
+
return (
|
|
39
|
+
<TooltipProvider delayDuration={300}>
|
|
40
|
+
<div className="flex items-center gap-1" data-testid="sessions-menu">
|
|
41
|
+
<Tooltip>
|
|
42
|
+
<TooltipTrigger asChild>
|
|
43
|
+
<Button variant="ghost" size="icon" aria-label={copy.newChat} disabled={session.busy} onClick={() => void session.startNew()}>
|
|
44
|
+
<Plus className="size-4" aria-hidden="true" />
|
|
45
|
+
</Button>
|
|
46
|
+
</TooltipTrigger>
|
|
47
|
+
<TooltipContent>{copy.newChat}</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
{newOnly ? null : (
|
|
50
|
+
<DropdownMenu onOpenChange={(open) => open && session.refreshSessions()}>
|
|
51
|
+
<Tooltip>
|
|
52
|
+
<TooltipTrigger asChild>
|
|
53
|
+
<DropdownMenuTrigger asChild>
|
|
54
|
+
<Button variant="ghost" size="icon" aria-label={copy.sessions}>
|
|
55
|
+
<History className="size-4" aria-hidden="true" />
|
|
56
|
+
</Button>
|
|
57
|
+
</DropdownMenuTrigger>
|
|
58
|
+
</TooltipTrigger>
|
|
59
|
+
<TooltipContent>{copy.sessions}</TooltipContent>
|
|
60
|
+
</Tooltip>
|
|
61
|
+
<DropdownMenuContent align="end" sideOffset={4} className="max-h-96 w-72 overflow-y-auto">
|
|
62
|
+
<DropdownMenuLabel>{copy.sessions}</DropdownMenuLabel>
|
|
63
|
+
{session.sessions === null ? null : session.sessions.length === 0 ? (
|
|
64
|
+
<p className="px-2 py-1.5 text-xs text-muted-foreground">{copy.sessionsEmpty}</p>
|
|
65
|
+
) : (
|
|
66
|
+
session.sessions.map((item) => (
|
|
67
|
+
<DropdownMenuItem key={item.id} disabled={session.busy} onSelect={() => void session.switchTo(item.id)} className="items-start gap-2">
|
|
68
|
+
<Check className={`mt-0.5 size-4 shrink-0 ${item.id === current ? '' : 'invisible'}`} aria-hidden="true" />
|
|
69
|
+
<span className="min-w-0 flex-1">
|
|
70
|
+
<span className="block truncate">{item.title || copy.newChat}</span>
|
|
71
|
+
<span className="block text-xs text-muted-foreground">{relativeTime(item.last_message_at, active)}</span>
|
|
72
|
+
</span>
|
|
73
|
+
</DropdownMenuItem>
|
|
74
|
+
))
|
|
75
|
+
)}
|
|
76
|
+
</DropdownMenuContent>
|
|
77
|
+
</DropdownMenu>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</TooltipProvider>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { Button } from '@djangocfg/ui-core/components';
|
|
5
|
+
import { ChatPanelHeader, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
|
|
8
6
|
|
|
9
7
|
import { AssistantSlot } from '../Host';
|
|
10
8
|
import { useAssistantCopy } from '../i18n';
|
|
@@ -28,6 +26,8 @@ interface Props {
|
|
|
28
26
|
onWidthChange: (width: number) => void;
|
|
29
27
|
onClose: () => void;
|
|
30
28
|
labels: FrameLabels;
|
|
29
|
+
/** Header controls beside the close button (for example `SessionsMenu`): one header, a thin divider keeps them apart from Close. */
|
|
30
|
+
actions?: ReactNode;
|
|
31
31
|
/** What the column shows instead of the chat (the locked view, a notice). Absent: the chat itself. */
|
|
32
32
|
content?: ReactNode;
|
|
33
33
|
/** The page. */
|
|
@@ -47,7 +47,7 @@ const RESTORE_GRACE_MS = 800;
|
|
|
47
47
|
* reflows), with a short fade. `--assistant-w` is a registered length (`layout.css`), so fixed host elements follow the same curve. The column stays mounted until the
|
|
48
48
|
* close finishes. Resizing by hand and the first paint after a page load are not animated.
|
|
49
49
|
*/
|
|
50
|
-
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, children }: Props) {
|
|
50
|
+
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions, children }: Props) {
|
|
51
51
|
const born = useRef(0);
|
|
52
52
|
const [mounted, setMounted] = useState(false);
|
|
53
53
|
const [shown, setShown] = useState(false);
|
|
@@ -90,12 +90,12 @@ export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth
|
|
|
90
90
|
return (
|
|
91
91
|
<div className="assistant-frame flex" data-moving={moving ? 'true' : 'false'} style={style}>
|
|
92
92
|
<div className="min-w-0 flex-1">{children}</div>
|
|
93
|
-
{mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }} /> : null}
|
|
93
|
+
{mounted ? <Column {...{ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }} /> : null}
|
|
94
94
|
</div>
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
|
|
98
|
+
function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, actions }: Omit<Props, 'docked' | 'children'> & { shown: boolean }) {
|
|
99
99
|
const copy = useAssistantCopy();
|
|
100
100
|
// The handle sits on the column's left edge: the pointer's distance from the window's right edge is the new width.
|
|
101
101
|
const drag = useCallback(
|
|
@@ -140,12 +140,11 @@ function Column({ width, shown, minWidth, maxWidth, defaultWidth, onWidthChange,
|
|
|
140
140
|
onDoubleClick={() => onWidthChange(defaultWidth)}
|
|
141
141
|
className="absolute inset-y-0 -left-1 z-10 w-2 cursor-col-resize touch-none outline-none transition-colors hover:bg-primary/30 focus-visible:bg-primary/40"
|
|
142
142
|
/>
|
|
143
|
-
<
|
|
144
|
-
<h2 className=
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
</header>
|
|
143
|
+
<ChatPanelHeader
|
|
144
|
+
heading={<h2 className={PANEL_TITLE_CLASS}>{labels.title}</h2>}
|
|
145
|
+
actions={actions}
|
|
146
|
+
close={<PanelCloseButton label={labels.close ?? copy.close} onClick={onClose} />}
|
|
147
|
+
/>
|
|
149
148
|
{content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
|
|
150
149
|
</aside>
|
|
151
150
|
</div>
|
|
@@ -16,13 +16,15 @@ interface Props {
|
|
|
16
16
|
composerLabel?: string;
|
|
17
17
|
/** Replaces the lock. */
|
|
18
18
|
icon?: ReactNode;
|
|
19
|
+
/** Makes the composer a button for the way out (sign in): touching the field is the same ask as the action. */
|
|
20
|
+
onComposerClick?: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* The chat as it looks when the viewer cannot write: a centred icon, one short reason, the action, and a composer that is visibly switched off.
|
|
23
25
|
* The root is a flex item (`flex-1`), not `h-full`: percentage heights do not resolve inside a flex column, and the message collapsed against the header.
|
|
24
26
|
*/
|
|
25
|
-
export function AssistantLocked({ title, body, action, composerLabel, icon }: Props) {
|
|
27
|
+
export function AssistantLocked({ title, body, action, composerLabel, icon, onComposerClick }: Props) {
|
|
26
28
|
const copy = useAssistantCopy();
|
|
27
29
|
return (
|
|
28
30
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
@@ -35,9 +37,15 @@ export function AssistantLocked({ title, body, action, composerLabel, icon }: Pr
|
|
|
35
37
|
{action ? <div className="mt-2">{action}</div> : null}
|
|
36
38
|
</div>
|
|
37
39
|
<div className="px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
|
|
40
|
+
{onComposerClick ? (
|
|
41
|
+
<button type="button" onClick={onComposerClick} className="flex w-full items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-start text-sm text-muted-foreground">
|
|
42
|
+
{composerLabel ?? copy.lockedComposer}
|
|
43
|
+
</button>
|
|
44
|
+
) : (
|
|
45
|
+
<div aria-disabled="true" className="flex select-none items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-sm text-muted-foreground opacity-70">
|
|
46
|
+
{composerLabel ?? copy.lockedComposer}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
41
49
|
</div>
|
|
42
50
|
</div>
|
|
43
51
|
);
|
package/src/shell/ChatDock.tsx
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { Button, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, SidePanel } from '@djangocfg/ui-core/components';
|
|
5
|
+
import { SidePanel } from '@djangocfg/ui-core/components';
|
|
8
6
|
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
7
|
+
import { ChatPanelHeader, ChatSheet, PANEL_TITLE_CLASS, PanelCloseButton } from '@djangocfg/widget-chat';
|
|
9
8
|
|
|
10
9
|
import { useAssistantCopy } from '../i18n';
|
|
11
10
|
|
|
@@ -19,42 +18,32 @@ interface Props {
|
|
|
19
18
|
contextLine?: string;
|
|
20
19
|
/** Accessible name of the close button on phones. Absent: the bundled word. */
|
|
21
20
|
closeLabel?: string;
|
|
21
|
+
/** Header controls before the close button (for example `SessionsMenu`). */
|
|
22
|
+
actions?: ReactNode;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable),
|
|
25
|
-
export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel }: Props) {
|
|
26
|
-
const copy = useAssistantCopy();
|
|
25
|
+
/** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable), the package's full-screen `ChatSheet` on phones (it owns the keyboard, layer and pinning problems). */
|
|
26
|
+
export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel, actions }: Props) {
|
|
27
27
|
const isMobile = useIsMobile();
|
|
28
|
+
const copy = useAssistantCopy();
|
|
28
29
|
|
|
29
30
|
if (isMobile) {
|
|
30
31
|
return (
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<div>
|
|
35
|
-
<DrawerTitle>{title}</DrawerTitle>
|
|
36
|
-
{contextLine ? <DrawerDescription>{contextLine}</DrawerDescription> : null}
|
|
37
|
-
</div>
|
|
38
|
-
<DrawerClose asChild>
|
|
39
|
-
<Button variant="ghost" size="icon" aria-label={closeLabel ?? copy.close}><X className="size-5" aria-hidden="true" /></Button>
|
|
40
|
-
</DrawerClose>
|
|
41
|
-
</DrawerHeader>
|
|
42
|
-
{/* The drawer wraps its children in a scroller that is as tall as its content (it only stretches with snap points), so `flex-1` cannot fill the sheet:
|
|
43
|
-
the body gets the screen height minus the handle, the header and the bottom padding. */}
|
|
44
|
-
<div className="flex flex-col" style={{ height: 'calc(100dvh - 7.5rem)' }}>{chat}</div>
|
|
45
|
-
</DrawerContent>
|
|
46
|
-
</Drawer>
|
|
32
|
+
<ChatSheet open={open} onOpenChange={onOpenChange} title={title} description={contextLine} closeLabel={closeLabel ?? copy.close} actions={actions}>
|
|
33
|
+
{chat}
|
|
34
|
+
</ChatSheet>
|
|
47
35
|
);
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
return (
|
|
51
39
|
<SidePanel open={open} onOpenChange={onOpenChange}>
|
|
52
40
|
<SidePanel.Content width="min(440px, 90vw)">
|
|
53
|
-
<
|
|
54
|
-
<SidePanel.Title>{title}</SidePanel.Title>
|
|
55
|
-
{contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> :
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
<ChatPanelHeader
|
|
42
|
+
heading={<SidePanel.Title className={PANEL_TITLE_CLASS}>{title}</SidePanel.Title>}
|
|
43
|
+
description={contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : undefined}
|
|
44
|
+
actions={actions}
|
|
45
|
+
close={<PanelCloseButton label={closeLabel ?? copy.close} onClick={() => onOpenChange(false)} />}
|
|
46
|
+
/>
|
|
58
47
|
<SidePanel.Body>{chat}</SidePanel.Body>
|
|
59
48
|
</SidePanel.Content>
|
|
60
49
|
</SidePanel>
|
package/src/stream/adapter.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { ChatHttpError, ChatNetworkError, type ChatTransport } from '../wire/tra
|
|
|
6
6
|
|
|
7
7
|
export interface AdapterDeps {
|
|
8
8
|
transport: ChatTransport;
|
|
9
|
+
/** The session this message goes to: the current one, or a new one created now (the first send of a user who has none). A rejection ends the turn as a failed message. Absent: no session (an anonymous host). */
|
|
10
|
+
threadFor?: () => Promise<string>;
|
|
9
11
|
/** Scope of this turn, read at send time (the page the user is on). */
|
|
10
12
|
getContext: () => ChatContext | undefined;
|
|
11
13
|
/** Ephemeral progress for the live label above the composer; `null` when the run ends. Never enters the transcript. */
|
|
@@ -17,6 +19,8 @@ export interface AdapterDeps {
|
|
|
17
19
|
onUnknownPayload?: (kind: string) => void;
|
|
18
20
|
/** The stream ended without a terminal frame: the host reconciles from the server. */
|
|
19
21
|
onInterrupted?: () => void;
|
|
22
|
+
/** A turn is in flight (true at the send, false when its stream ends): the host holds session switching meanwhile. */
|
|
23
|
+
onActive?: (active: boolean) => void;
|
|
20
24
|
/** A network-level failure, for the connectivity monitor. */
|
|
21
25
|
onNetworkFailure?: () => void;
|
|
22
26
|
/** One plain sentence per outcome (the catalogue). */
|
|
@@ -56,6 +60,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
|
|
|
56
60
|
let pending = '';
|
|
57
61
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
58
62
|
let terminal = false;
|
|
63
|
+
deps.onActive?.(true);
|
|
59
64
|
|
|
60
65
|
const open = (id: string) => {
|
|
61
66
|
messageId = id;
|
|
@@ -114,7 +119,8 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
|
|
|
114
119
|
};
|
|
115
120
|
|
|
116
121
|
try {
|
|
117
|
-
|
|
122
|
+
const threadId = deps.threadFor ? await deps.threadFor() : undefined;
|
|
123
|
+
for await (const frame of deps.transport.open({ threadId, text, clientId, context: deps.getContext(), signal })) {
|
|
118
124
|
handle(frame);
|
|
119
125
|
if (terminal) break;
|
|
120
126
|
}
|
|
@@ -142,6 +148,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
|
|
|
142
148
|
} finally {
|
|
143
149
|
if (timer) clearTimeout(timer);
|
|
144
150
|
deps.onStatus(null);
|
|
151
|
+
deps.onActive?.(false);
|
|
145
152
|
controller.close();
|
|
146
153
|
}
|
|
147
154
|
},
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { StoredConversation, StoredThread } from '../wire/stored';
|
|
2
|
+
import { ThreadNotFoundError } from '../wire/transport';
|
|
3
|
+
|
|
4
|
+
/** The session endpoints (`/apix/chat/v1/threads`), supplied by the app. `load` throws `ThreadNotFoundError` for a session that is not the viewer's or is gone. */
|
|
5
|
+
export interface ThreadApi {
|
|
6
|
+
/** The viewer's sessions with messages, newest activity first. */
|
|
7
|
+
list: (signal: AbortSignal) => Promise<StoredThread[]>;
|
|
8
|
+
/** A new, empty session (`POST threads`). */
|
|
9
|
+
create: () => Promise<StoredThread>;
|
|
10
|
+
/** One session with its last messages. */
|
|
11
|
+
load: (id: string, signal: AbortSignal) => Promise<StoredConversation>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Where the last used session id is kept (the host picks the key). Reads and writes must never throw. */
|
|
15
|
+
export interface SessionStorage {
|
|
16
|
+
read: () => string | null;
|
|
17
|
+
write: (id: string | null) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NO_SESSION: StoredConversation = { id: null, messages: [] };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The session to show on page load: the remembered one, else the newest, else none (a session is created on the first send, never on load).
|
|
24
|
+
* A remembered id that is gone (another account on this browser, a purged session) is forgotten and the newest takes over.
|
|
25
|
+
*/
|
|
26
|
+
export async function restoreSession({ api, storage, signal }: { api: ThreadApi; storage?: SessionStorage; signal: AbortSignal }): Promise<StoredConversation> {
|
|
27
|
+
const remembered = storage?.read() ?? null;
|
|
28
|
+
if (remembered) {
|
|
29
|
+
try {
|
|
30
|
+
return await api.load(remembered, signal);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (!(error instanceof ThreadNotFoundError)) throw error;
|
|
33
|
+
storage?.write(null);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const [newest] = await api.list(signal);
|
|
37
|
+
return newest ? api.load(newest.id, signal) : NO_SESSION;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STEPS: ReadonlyArray<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
41
|
+
['year', 31_536_000],
|
|
42
|
+
['month', 2_592_000],
|
|
43
|
+
['week', 604_800],
|
|
44
|
+
['day', 86_400],
|
|
45
|
+
['hour', 3_600],
|
|
46
|
+
['minute', 60],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** "5 minutes ago" in the viewer's language; under a minute it is `now` (the unit `numeric: 'auto'` words as "now"). */
|
|
50
|
+
export function relativeTime(iso: string, locale: string, now: number = Date.now()): string {
|
|
51
|
+
const seconds = Math.round((Date.parse(iso) - now) / 1000);
|
|
52
|
+
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
53
|
+
const abs = Math.abs(seconds);
|
|
54
|
+
for (const [unit, size] of STEPS) {
|
|
55
|
+
if (abs >= size) return formatter.format(Math.round(seconds / size), unit);
|
|
56
|
+
}
|
|
57
|
+
return formatter.format(0, 'second');
|
|
58
|
+
}
|
package/src/stream/useSession.ts
CHANGED
|
@@ -8,17 +8,12 @@ import { useAssistantCopy } from '../i18n';
|
|
|
8
8
|
import { sessionCopyFor } from '../i18n/sessionCopy';
|
|
9
9
|
|
|
10
10
|
import type { ChatContext, Phase } from '../wire/frames';
|
|
11
|
-
import type { StoredConversation } from '../wire/stored';
|
|
12
|
-
import type
|
|
11
|
+
import type { StoredConversation, StoredThread } from '../wire/stored';
|
|
12
|
+
import { ThreadNotFoundError, type ChatHttpError, type ChatTransport } from '../wire/transport';
|
|
13
13
|
import { createStreamAdapter, type AdapterDeps } from './adapter';
|
|
14
14
|
import { fromStoredThread } from './fromStored';
|
|
15
15
|
import { reconcile } from './reconcile';
|
|
16
|
-
|
|
17
|
-
/** The conversation endpoints, supplied by the app (signed-in hosts). */
|
|
18
|
-
export interface ThreadApi {
|
|
19
|
-
load: (signal: AbortSignal) => Promise<StoredConversation>;
|
|
20
|
-
clear: () => Promise<void>;
|
|
21
|
-
}
|
|
16
|
+
import { NO_SESSION, restoreSession, type SessionStorage, type ThreadApi } from './sessions';
|
|
22
17
|
|
|
23
18
|
export interface SessionCopy {
|
|
24
19
|
messages: AdapterDeps['messages'];
|
|
@@ -30,19 +25,22 @@ export interface SessionCopy {
|
|
|
30
25
|
|
|
31
26
|
export interface SessionDeps {
|
|
32
27
|
transport: ChatTransport;
|
|
33
|
-
/** Absent: an anonymous session. Nothing is fetched, the room starts empty, and a lost stream cannot be reconciled (the notice offers Retry). */
|
|
28
|
+
/** Absent: an anonymous session. Nothing is fetched or listed, the room starts empty, "new chat" empties it, and a lost stream cannot be reconciled (the notice offers Retry). */
|
|
34
29
|
api?: ThreadApi;
|
|
30
|
+
/** Remembers the last used session so a reload returns to it. Absent: the newest session is shown. */
|
|
31
|
+
storage?: SessionStorage;
|
|
35
32
|
getContext: () => ChatContext | undefined;
|
|
36
|
-
/** Absent: the bundled words for the active language. */
|
|
37
|
-
copy?: SessionCopy;
|
|
38
33
|
/** The `kind`s the host draws; any other live payload is dropped and reported. */
|
|
39
34
|
payloadKinds?: readonly string[];
|
|
40
35
|
onUnknownPayload?: (kind: string) => void;
|
|
36
|
+
/** Absent: the bundled words for the active language. */
|
|
37
|
+
copy?: SessionCopy;
|
|
41
38
|
onRejected?: (error: ChatHttpError) => void;
|
|
42
39
|
onNetworkFailure?: () => void;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
/** `id` is the transcript's identity (the session plus a revision: a new one reseeds the widget); `session` is the session itself, `null` before the first send. */
|
|
43
|
+
export type ThreadState = { kind: 'loading' } | { kind: 'error' } | { kind: 'ready'; id: string; session: string | null; history: ChatMessage[] };
|
|
46
44
|
|
|
47
45
|
/** `reconnecting`: the stream was lost and the server is being asked; `stalled`: the answer is still running after the budget (offer Retry). */
|
|
48
46
|
export type SessionNotice = 'reconnecting' | 'stalled';
|
|
@@ -53,14 +51,21 @@ export interface Session {
|
|
|
53
51
|
/** Live progress label while a run streams; null otherwise. */
|
|
54
52
|
statusLabel: string | null;
|
|
55
53
|
notice: SessionNotice | null;
|
|
56
|
-
/** Load the
|
|
54
|
+
/** Load the session again (after a load error or a stalled run). */
|
|
57
55
|
reload: () => void;
|
|
58
|
-
/**
|
|
59
|
-
|
|
56
|
+
/** The viewer's sessions, newest activity first; `null` until `refreshSessions` has answered once. */
|
|
57
|
+
sessions: StoredThread[] | null;
|
|
58
|
+
refreshSessions: () => void;
|
|
59
|
+
/** A turn is in flight: starting or switching a session waits for it (Stop ends it). */
|
|
60
|
+
busy: boolean;
|
|
61
|
+
/** "New chat": a new empty session, shown at once. A no-op while the current one is empty. Resolves `false` when it failed (nothing changed). */
|
|
62
|
+
startNew: () => Promise<boolean>;
|
|
63
|
+
/** Show another session. Resolves `false` when it failed (the current one stays). */
|
|
64
|
+
switchTo: (id: string) => Promise<boolean>;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
|
-
*
|
|
68
|
+
* The signed-in assistant's sessions: the shown session from the server (no client copy), the adapter for new messages, the live status label, and the reconcile
|
|
64
69
|
* that runs when a stream is lost. A new `history` identity (`id` carries a revision) reseeds the widget only between runs.
|
|
65
70
|
*/
|
|
66
71
|
export function useSession(given: SessionDeps): Session {
|
|
@@ -71,27 +76,53 @@ export function useSession(given: SessionDeps): Session {
|
|
|
71
76
|
const [thread, setThread] = useState<ThreadState>({ kind: 'loading' });
|
|
72
77
|
const [phase, setPhase] = useState<Phase | null>(null);
|
|
73
78
|
const [notice, setNotice] = useState<SessionNotice | null>(null);
|
|
79
|
+
const [sessions, setSessions] = useState<StoredThread[] | null>(null);
|
|
80
|
+
const [busy, setBusy] = useState(false);
|
|
74
81
|
const revision = useRef(0);
|
|
75
82
|
const live = useRef<AbortController | null>(null);
|
|
83
|
+
/** The shown session's id; `null` before the first send. Written with the storage, in one place. */
|
|
84
|
+
const current = useRef<string | null>(null);
|
|
76
85
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const remember = useCallback((id: string | null) => {
|
|
87
|
+
current.current = id;
|
|
88
|
+
ref.current.storage?.write(id);
|
|
80
89
|
}, []);
|
|
81
90
|
|
|
91
|
+
const seed = useCallback(
|
|
92
|
+
(conversation: StoredConversation) => {
|
|
93
|
+
revision.current += 1;
|
|
94
|
+
remember(conversation.id);
|
|
95
|
+
setThread({
|
|
96
|
+
kind: 'ready',
|
|
97
|
+
id: `${conversation.id ?? 'new'}:${revision.current}`,
|
|
98
|
+
session: conversation.id,
|
|
99
|
+
history: fromStoredThread(conversation.messages, ref.current.copy.failedText),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
[remember],
|
|
103
|
+
);
|
|
104
|
+
|
|
82
105
|
const load = useCallback(() => {
|
|
83
106
|
live.current?.abort();
|
|
84
107
|
const controller = new AbortController();
|
|
85
108
|
live.current = controller;
|
|
86
|
-
setNotice(null);
|
|
87
|
-
const api = ref.current.api;
|
|
88
|
-
if (!api) return seed({ id: null, messages: [] });
|
|
89
109
|
setThread({ kind: 'loading' });
|
|
90
|
-
|
|
110
|
+
setNotice(null);
|
|
111
|
+
const { api, storage } = ref.current;
|
|
112
|
+
if (!api) return seed(NO_SESSION);
|
|
113
|
+
const restore = () => restoreSession({ api, storage, signal: controller.signal });
|
|
114
|
+
const read = current.current
|
|
115
|
+
? api.load(current.current, controller.signal).catch((error: unknown) => {
|
|
116
|
+
if (!(error instanceof ThreadNotFoundError)) throw error;
|
|
117
|
+
remember(null); // the shown session is gone: fall back to the newest
|
|
118
|
+
return restore();
|
|
119
|
+
})
|
|
120
|
+
: restore();
|
|
121
|
+
read.then(
|
|
91
122
|
(conversation) => !controller.signal.aborted && seed(conversation),
|
|
92
123
|
() => !controller.signal.aborted && setThread({ kind: 'error' }),
|
|
93
124
|
);
|
|
94
|
-
}, [seed]);
|
|
125
|
+
}, [seed, remember]);
|
|
95
126
|
|
|
96
127
|
useEffect(() => {
|
|
97
128
|
load();
|
|
@@ -102,11 +133,25 @@ export function useSession(given: SessionDeps): Session {
|
|
|
102
133
|
() =>
|
|
103
134
|
createStreamAdapter({
|
|
104
135
|
transport: deps.transport,
|
|
136
|
+
// The first send of a viewer with no session creates one; the shown transcript is not reseeded (that would drop the live message).
|
|
137
|
+
threadFor: ref.current.api
|
|
138
|
+
? async () => {
|
|
139
|
+
if (current.current) return current.current;
|
|
140
|
+
const created = await ref.current.api!.create();
|
|
141
|
+
remember(created.id);
|
|
142
|
+
return created.id;
|
|
143
|
+
}
|
|
144
|
+
: undefined,
|
|
105
145
|
getContext: () => ref.current.getContext(),
|
|
106
146
|
onStatus: setPhase,
|
|
107
147
|
payloadKinds: deps.payloadKinds,
|
|
108
148
|
onUnknownPayload: (kind) => ref.current.onUnknownPayload?.(kind),
|
|
109
|
-
|
|
149
|
+
onActive: setBusy,
|
|
150
|
+
onRejected: (error) => {
|
|
151
|
+
// The session vanished (purged, or another account's): forget it, the next send starts a fresh one.
|
|
152
|
+
if (error.code === 'not_found') remember(null);
|
|
153
|
+
ref.current.onRejected?.(error);
|
|
154
|
+
},
|
|
110
155
|
onNetworkFailure: () => ref.current.onNetworkFailure?.(),
|
|
111
156
|
messages: {
|
|
112
157
|
rejected: (error) => ref.current.copy.messages.rejected(error),
|
|
@@ -116,12 +161,12 @@ export function useSession(given: SessionDeps): Session {
|
|
|
116
161
|
},
|
|
117
162
|
},
|
|
118
163
|
onInterrupted: () => {
|
|
119
|
-
setNotice('reconnecting');
|
|
120
164
|
const api = ref.current.api;
|
|
121
165
|
if (!api) return setNotice('stalled');
|
|
166
|
+
setNotice('reconnecting');
|
|
122
167
|
const controller = new AbortController();
|
|
123
168
|
live.current = controller;
|
|
124
|
-
reconcile({ fetchConversation: api.load, signal: controller.signal }).then(
|
|
169
|
+
reconcile({ fetchConversation: (signal) => api.load(current.current ?? '', signal), signal: controller.signal }).then(
|
|
125
170
|
(result) => {
|
|
126
171
|
if (controller.signal.aborted) return;
|
|
127
172
|
if (result.outcome === 'timeout') return setNotice('stalled');
|
|
@@ -133,14 +178,46 @@ export function useSession(given: SessionDeps): Session {
|
|
|
133
178
|
},
|
|
134
179
|
}),
|
|
135
180
|
// The transport is one per mounted chat; everything else is read through `ref`.
|
|
136
|
-
|
|
181
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
182
|
+
[deps.transport, deps.payloadKinds, Boolean(deps.api), seed, remember],
|
|
137
183
|
);
|
|
138
184
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
185
|
+
const refreshSessions = useCallback(() => {
|
|
186
|
+
ref.current.api?.list(new AbortController().signal).then(setSessions, () => undefined);
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const show = useCallback(
|
|
190
|
+
async (read: (signal: AbortSignal) => Promise<StoredConversation>): Promise<boolean> => {
|
|
191
|
+
live.current?.abort();
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
live.current = controller;
|
|
194
|
+
try {
|
|
195
|
+
const conversation = await read(controller.signal);
|
|
196
|
+
if (controller.signal.aborted) return false;
|
|
197
|
+
setNotice(null);
|
|
198
|
+
seed(conversation);
|
|
199
|
+
return true;
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[seed],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const startNew = useCallback(async () => {
|
|
208
|
+
if ((ref.current.api && current.current === null) || (thread.kind === 'ready' && thread.history.length === 0)) return true; // already a blank page
|
|
209
|
+
const api = ref.current.api;
|
|
210
|
+
if (!api) return show(async () => NO_SESSION);
|
|
211
|
+
return show(async () => {
|
|
212
|
+
const created = await api.create();
|
|
213
|
+
return { id: created.id, messages: [] };
|
|
214
|
+
});
|
|
215
|
+
}, [show, thread]);
|
|
216
|
+
|
|
217
|
+
const switchTo = useCallback(
|
|
218
|
+
async (id: string) => (id === current.current || !ref.current.api ? true : show((signal) => ref.current.api!.load(id, signal))),
|
|
219
|
+
[show],
|
|
220
|
+
);
|
|
144
221
|
|
|
145
|
-
return { thread, adapter, statusLabel: phase ? ref.current.copy.status[phase] : null, notice, reload: load,
|
|
222
|
+
return { thread, adapter, statusLabel: phase ? ref.current.copy.status[phase] : null, notice, reload: load, sessions, refreshSessions, busy, startNew, switchTo };
|
|
146
223
|
}
|
package/src/wire/stored.ts
CHANGED
|
@@ -18,7 +18,16 @@ export interface StoredMessage {
|
|
|
18
18
|
created_at: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** One session with its last messages; `id: null` is the state before the first send (no session exists yet, or the host is anonymous). */
|
|
21
22
|
export interface StoredConversation {
|
|
22
23
|
id: string | null;
|
|
23
24
|
messages: StoredMessage[];
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/** A session in the list (`GET threads/`): `title` is the first user message, empty until its first answer finishes. */
|
|
28
|
+
export interface StoredThread {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
last_message_at: string;
|
|
32
|
+
message_count: number;
|
|
33
|
+
}
|