@djangocfg/widget-assistant 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +33 -2
- package/package.json +72 -4
- package/src/AssistantChat.tsx +115 -0
- package/src/Host.tsx +57 -0
- package/src/composer/AssistantComposer.tsx +19 -0
- package/src/composer/ComposerBehaviour.tsx +84 -0
- package/src/composer/ComposerHelp.tsx +46 -0
- package/src/composer/keys.ts +2 -0
- package/src/composer/useCaretFocus.ts +49 -0
- package/src/composer/useComposerNodes.ts +14 -0
- package/src/composer/useFocusArea.ts +29 -0
- package/src/composer/useGracefulStop.ts +63 -0
- package/src/composer/useHeightFloor.ts +27 -0
- package/src/composer/useRecording.ts +18 -0
- package/src/composer/useSentPrompts.ts +20 -0
- package/src/composer/wholeTranscript.ts +106 -0
- package/src/i18n/index.ts +36 -0
- package/src/i18n/locales/ar.ts +34 -0
- package/src/i18n/locales/da.ts +34 -0
- package/src/i18n/locales/de.ts +34 -0
- package/src/i18n/locales/en.ts +34 -0
- package/src/i18n/locales/es.ts +34 -0
- package/src/i18n/locales/fr.ts +34 -0
- package/src/i18n/locales/it.ts +34 -0
- package/src/i18n/locales/ja.ts +34 -0
- package/src/i18n/locales/ko.ts +34 -0
- package/src/i18n/locales/nl.ts +34 -0
- package/src/i18n/locales/no.ts +34 -0
- package/src/i18n/locales/pl.ts +34 -0
- package/src/i18n/locales/pt-BR.ts +34 -0
- package/src/i18n/locales/ru.ts +34 -0
- package/src/i18n/locales/sv.ts +34 -0
- package/src/i18n/locales/tr.ts +34 -0
- package/src/i18n/locales/zh.ts +34 -0
- package/src/i18n/sessionCopy.ts +30 -0
- package/src/i18n/types.ts +42 -0
- package/src/index.ts +17 -0
- package/src/messages/MessageFooter.tsx +25 -0
- package/src/messages/NoticeLine.tsx +32 -0
- package/src/shell/AssistantFab.tsx +112 -0
- package/src/shell/AssistantFrame.tsx +103 -0
- package/src/shell/AssistantLocked.tsx +44 -0
- package/src/shell/ChatDock.tsx +62 -0
- package/src/shell/panelState.ts +74 -0
- package/src/stream/adapter.ts +144 -0
- package/src/stream/fromStored.ts +23 -0
- package/src/stream/reconcile.ts +45 -0
- package/src/stream/scriptedTransport.ts +28 -0
- package/src/stream/useSession.ts +136 -0
- package/src/styles/composer.css +14 -0
- package/src/styles/fab.css +53 -0
- package/src/styles/index.css +6 -0
- package/src/styles/layout.css +15 -0
- package/src/styles/widget.css +7 -0
- package/src/wire/frames.ts +53 -0
- package/src/wire/parseSSE.ts +76 -0
- package/src/wire/stored.ts +24 -0
- package/src/wire/transport.ts +94 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import { create } from 'zustand';
|
|
3
|
+
|
|
4
|
+
/** Where the open flag and the width are remembered. The host decides the keys; reads and writes must never throw. */
|
|
5
|
+
export interface UiStorage {
|
|
6
|
+
read: (field: 'open' | 'width') => string | null;
|
|
7
|
+
write: (field: 'open' | 'width', value: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UiOptions {
|
|
11
|
+
storage?: UiStorage;
|
|
12
|
+
minWidth?: number;
|
|
13
|
+
maxWidth?: number;
|
|
14
|
+
defaultWidth?: number;
|
|
15
|
+
/** Whether the column starts open for a viewer who has never chosen. A choice, once made, is remembered. */
|
|
16
|
+
defaultOpen?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UiState {
|
|
20
|
+
open: boolean;
|
|
21
|
+
width: number;
|
|
22
|
+
setOpen: (open: boolean) => void;
|
|
23
|
+
setWidth: (width: number) => void;
|
|
24
|
+
/** Read the remembered state once (`canOpen` decides whether a remembered "open" applies on this screen: a column does, a full-screen sheet must not reopen by itself), once the browser is up (the server render and the first client render both show it closed). */
|
|
25
|
+
restore: (canOpen?: () => boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Open/closed and column width of the assistant, remembered through `storage`. One store per host app. */
|
|
29
|
+
export function createAssistantUi({ storage, minWidth = 320, maxWidth = 560, defaultWidth = 400, defaultOpen = false }: UiOptions = {}) {
|
|
30
|
+
const clamp = (width: number) => Math.min(maxWidth, Math.max(minWidth, Math.round(width)));
|
|
31
|
+
const useUi = create<UiState>((set) => ({
|
|
32
|
+
open: false,
|
|
33
|
+
width: defaultWidth,
|
|
34
|
+
setOpen: (open) => {
|
|
35
|
+
storage?.write('open', open ? '1' : '0');
|
|
36
|
+
set({ open });
|
|
37
|
+
},
|
|
38
|
+
setWidth: (next) => {
|
|
39
|
+
const width = clamp(next);
|
|
40
|
+
storage?.write('width', String(width));
|
|
41
|
+
set({ width });
|
|
42
|
+
},
|
|
43
|
+
restore: (canOpen) => {
|
|
44
|
+
const width = Number(storage?.read('width'));
|
|
45
|
+
const stored = storage?.read('open');
|
|
46
|
+
const wanted = stored == null ? defaultOpen : stored === '1';
|
|
47
|
+
set({ open: wanted && (canOpen ? canOpen() : true), width: Number.isFinite(width) && width > 0 ? clamp(width) : defaultWidth });
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
return {
|
|
51
|
+
useUi,
|
|
52
|
+
open: () => useUi.getState().setOpen(true),
|
|
53
|
+
close: () => useUi.getState().setOpen(false),
|
|
54
|
+
limits: { minWidth, maxWidth, defaultWidth },
|
|
55
|
+
/** Mount once in the shell. */
|
|
56
|
+
useRestore: (canOpen?: () => boolean) => {
|
|
57
|
+
const restore = useUi((s) => s.restore);
|
|
58
|
+
useEffect(() => restore(canOpen), []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A media query as state: `false` on the server and during hydration, so server and first client render agree. */
|
|
64
|
+
export function useMediaQuery(query: string): boolean {
|
|
65
|
+
return useSyncExternalStore(
|
|
66
|
+
(onChange) => {
|
|
67
|
+
const list = window.matchMedia(query);
|
|
68
|
+
list.addEventListener('change', onChange);
|
|
69
|
+
return () => list.removeEventListener('change', onChange);
|
|
70
|
+
},
|
|
71
|
+
() => window.matchMedia(query).matches,
|
|
72
|
+
() => false,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ChatAdapter, StreamChunk } from '@djangocfg/widget-chat';
|
|
2
|
+
|
|
3
|
+
import type { ChatContext, Frame, Phase, StreamErrorCode } from '../wire/frames.ts';
|
|
4
|
+
import { StreamLostError } from '../wire/parseSSE.ts';
|
|
5
|
+
import { ChatHttpError, ChatNetworkError, type ChatTransport } from '../wire/transport.ts';
|
|
6
|
+
|
|
7
|
+
export interface AdapterDeps {
|
|
8
|
+
transport: ChatTransport;
|
|
9
|
+
/** Scope of this turn, read at send time (the page the user is on). */
|
|
10
|
+
getContext: () => ChatContext | undefined;
|
|
11
|
+
/** Ephemeral progress for the live label above the composer; `null` when the run ends. Never enters the transcript. */
|
|
12
|
+
onStatus: (phase: Phase | null) => void;
|
|
13
|
+
/** A refusal before any frame (401, 409, 422, 429, 503): the host reacts (invitation, countdown, hide the entry point). */
|
|
14
|
+
onRejected?: (error: ChatHttpError) => void;
|
|
15
|
+
/** The stream ended without a terminal frame: the host reconciles from the server. */
|
|
16
|
+
onInterrupted?: () => void;
|
|
17
|
+
/** A network-level failure, for the connectivity monitor. */
|
|
18
|
+
onNetworkFailure?: () => void;
|
|
19
|
+
/** One plain sentence per outcome (the catalogue). */
|
|
20
|
+
messages: {
|
|
21
|
+
rejected: (error: ChatHttpError) => string;
|
|
22
|
+
streamError: (code: StreamErrorCode, serverMessage: string) => string;
|
|
23
|
+
failed: string;
|
|
24
|
+
};
|
|
25
|
+
newId?: () => string;
|
|
26
|
+
/** Token coalescing window; `0` forwards every delta at once (tests). About 30 fps by default. */
|
|
27
|
+
coalesceMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultId = () => globalThis.crypto.randomUUID();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The wire to the widget. Maps one run's frames to chunks (`run` opens the assistant message, `text_delta` grows its text, `ui_payload` adds a card part after the text
|
|
34
|
+
* it follows, `done` finishes, `error` fails keeping the partial text); a stream that ends without a terminal frame is `interrupted`, and the host reconciles from the server.
|
|
35
|
+
* Everything a run needs travels inside its own ordered chunk stream: no shared buffer between runs.
|
|
36
|
+
*/
|
|
37
|
+
export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
|
|
38
|
+
const newId = deps.newId ?? defaultId;
|
|
39
|
+
const coalesceMs = deps.coalesceMs ?? 33;
|
|
40
|
+
/** The last attempt that never reached `run`: resending the same text reuses its `client_id` (nothing ran, so the resend is idempotent). */
|
|
41
|
+
let unsent: { text: string; clientId: string } | null = null;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
async sendMessage({ text, signal }) {
|
|
45
|
+
const clientId = unsent?.text === text ? unsent.clientId : newId();
|
|
46
|
+
unsent = { text, clientId };
|
|
47
|
+
|
|
48
|
+
return new ReadableStream<StreamChunk>({
|
|
49
|
+
async start(controller) {
|
|
50
|
+
let messageId = '';
|
|
51
|
+
let textPart: string | null = null;
|
|
52
|
+
let parts = 0;
|
|
53
|
+
let pending = '';
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
let terminal = false;
|
|
56
|
+
|
|
57
|
+
const open = (id: string) => {
|
|
58
|
+
messageId = id;
|
|
59
|
+
controller.enqueue({ type: 'message-start', messageId: id, createdAt: Date.now() });
|
|
60
|
+
};
|
|
61
|
+
const flush = () => {
|
|
62
|
+
if (timer) clearTimeout(timer);
|
|
63
|
+
timer = null;
|
|
64
|
+
if (pending && textPart) controller.enqueue({ type: 'text-delta', partId: textPart, delta: pending });
|
|
65
|
+
pending = '';
|
|
66
|
+
};
|
|
67
|
+
const fail = (message: string) => {
|
|
68
|
+
flush();
|
|
69
|
+
if (!messageId) open(`local-${clientId}`);
|
|
70
|
+
controller.enqueue({ type: 'error', message });
|
|
71
|
+
terminal = true;
|
|
72
|
+
};
|
|
73
|
+
const handle = (frame: Frame) => {
|
|
74
|
+
switch (frame.type) {
|
|
75
|
+
case 'run':
|
|
76
|
+
unsent = null;
|
|
77
|
+
open(frame.assistant_message_id);
|
|
78
|
+
break;
|
|
79
|
+
case 'status':
|
|
80
|
+
deps.onStatus(frame.phase);
|
|
81
|
+
break;
|
|
82
|
+
case 'text_delta':
|
|
83
|
+
if (!messageId) break;
|
|
84
|
+
if (!textPart) {
|
|
85
|
+
textPart = `${messageId}:t${parts++}`;
|
|
86
|
+
controller.enqueue({ type: 'text-start', partId: textPart });
|
|
87
|
+
}
|
|
88
|
+
pending += frame.delta;
|
|
89
|
+
if (coalesceMs === 0) flush();
|
|
90
|
+
else timer ??= setTimeout(flush, coalesceMs);
|
|
91
|
+
break;
|
|
92
|
+
case 'ui_payload':
|
|
93
|
+
if (!messageId) break;
|
|
94
|
+
flush();
|
|
95
|
+
textPart = null;
|
|
96
|
+
controller.enqueue({ type: 'data', part: { type: 'data', id: `${messageId}:w${parts++}`, kind: frame.kind, data: frame.data } });
|
|
97
|
+
break;
|
|
98
|
+
case 'done':
|
|
99
|
+
flush();
|
|
100
|
+
controller.enqueue({ type: 'finish' });
|
|
101
|
+
terminal = true;
|
|
102
|
+
break;
|
|
103
|
+
case 'error':
|
|
104
|
+
fail(deps.messages.streamError(frame.code, frame.message));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
for await (const frame of deps.transport.open({ text, clientId, context: deps.getContext(), signal })) {
|
|
111
|
+
handle(frame);
|
|
112
|
+
if (terminal) break;
|
|
113
|
+
}
|
|
114
|
+
if (!terminal) {
|
|
115
|
+
flush();
|
|
116
|
+
if (!signal.aborted) {
|
|
117
|
+
controller.enqueue({ type: 'interrupted' });
|
|
118
|
+
deps.onInterrupted?.();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof ChatHttpError) {
|
|
123
|
+
deps.onRejected?.(error);
|
|
124
|
+
fail(deps.messages.rejected(error));
|
|
125
|
+
} else if (error instanceof ChatNetworkError) {
|
|
126
|
+
deps.onNetworkFailure?.();
|
|
127
|
+
fail(deps.messages.failed);
|
|
128
|
+
} else if (error instanceof StreamLostError) {
|
|
129
|
+
flush();
|
|
130
|
+
controller.enqueue({ type: 'interrupted' });
|
|
131
|
+
deps.onInterrupted?.();
|
|
132
|
+
} else {
|
|
133
|
+
fail(deps.messages.failed);
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
if (timer) clearTimeout(timer);
|
|
137
|
+
deps.onStatus(null);
|
|
138
|
+
controller.close();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChatMessage, MessagePart } from '@djangocfg/widget-chat';
|
|
2
|
+
|
|
3
|
+
import type { StoredMessage } from '../wire/stored.ts';
|
|
4
|
+
|
|
5
|
+
/** The card widget kind (the one the host renders); stored widgets of another kind are skipped, as live ones are. */
|
|
6
|
+
const FILM_CARDS = 'film_cards';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stored message as a widget message. A `running` or `cancelled` one reads as interrupted: the thread never shows a spinner without a stream behind it
|
|
10
|
+
* (a running message is resolved by `reconcile`, which reseeds the thread when the server finishes it).
|
|
11
|
+
*/
|
|
12
|
+
export function fromStored(message: StoredMessage, failedText: string): ChatMessage {
|
|
13
|
+
const parts: MessagePart[] = [];
|
|
14
|
+
if (message.text) parts.push({ id: `${message.id}:t`, type: 'text', text: message.text });
|
|
15
|
+
message.widgets.forEach((widget, index) => {
|
|
16
|
+
if (widget.kind === FILM_CARDS && widget.ids.length > 0) parts.push({ id: `${message.id}:w${index}`, type: 'data', kind: FILM_CARDS, data: { ids: widget.ids, caption: widget.caption } });
|
|
17
|
+
});
|
|
18
|
+
const status: ChatMessage['status'] =
|
|
19
|
+
message.status === 'done' ? { state: 'complete' } : message.status === 'error' ? { state: 'failed', message: failedText } : { state: 'interrupted' };
|
|
20
|
+
return { id: message.id, role: message.role, parts, status, createdAt: Date.parse(message.created_at) || 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const fromStoredThread = (messages: readonly StoredMessage[], failedText: string): ChatMessage[] => messages.map((m) => fromStored(m, failedText));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StoredConversation, StoredMessage } from '../wire/stored.ts';
|
|
2
|
+
|
|
3
|
+
/** What the server says about the run the client lost track of. */
|
|
4
|
+
export type Reconciled =
|
|
5
|
+
| { outcome: 'done' | 'error' | 'cancelled'; conversation: StoredConversation }
|
|
6
|
+
/** The server never stored an answer: nothing ran, a resend with the same `client_id` is safe. */
|
|
7
|
+
| { outcome: 'lost'; conversation: StoredConversation }
|
|
8
|
+
/** Still running after the budget: offer Retry, keep looking on the next load. */
|
|
9
|
+
| { outcome: 'timeout'; conversation: StoredConversation };
|
|
10
|
+
|
|
11
|
+
export interface ReconcileDeps {
|
|
12
|
+
fetchConversation: (signal: AbortSignal) => Promise<StoredConversation>;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
pollMs?: number;
|
|
17
|
+
budgetMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const wait = (ms: number, signal: AbortSignal) =>
|
|
21
|
+
new Promise<void>((resolve) => {
|
|
22
|
+
const timer = setTimeout(resolve, ms);
|
|
23
|
+
signal.addEventListener('abort', () => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve();
|
|
26
|
+
}, { once: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const lastAssistant = (messages: StoredMessage[]): StoredMessage | undefined => [...messages].reverse().find((m) => m.role === 'assistant');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Refetch the thread and trust the last assistant message (CONTRACT.md "Abort, loss and reconcile"): `done`, `error` and `cancelled` are final;
|
|
33
|
+
* `running` is polled every 2 s for about 60 s. No replay, no resume: the stored message is the truth. A fetch failure throws; the caller shows Retry.
|
|
34
|
+
*/
|
|
35
|
+
export async function reconcile({ fetchConversation, signal, sleep = wait, now = Date.now, pollMs = 2000, budgetMs = 60_000 }: ReconcileDeps): Promise<Reconciled> {
|
|
36
|
+
const deadline = now() + budgetMs;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const conversation = await fetchConversation(signal);
|
|
39
|
+
const last = lastAssistant(conversation.messages);
|
|
40
|
+
if (!last) return { outcome: 'lost', conversation };
|
|
41
|
+
if (last.status !== 'running') return { outcome: last.status, conversation };
|
|
42
|
+
if (now() + pollMs > deadline || signal.aborted) return { outcome: 'timeout', conversation };
|
|
43
|
+
await sleep(pollMs, signal);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Frame } from '../wire/frames.ts';
|
|
2
|
+
import type { ChatTransport } from '../wire/transport.ts';
|
|
3
|
+
|
|
4
|
+
/** One step of a scripted run: a frame after a pause, or an error thrown as a real transport would (refusal, network, lost stream). */
|
|
5
|
+
export type ScriptStep = { frame: Frame; after?: number } | { throw: Error; after?: number };
|
|
6
|
+
|
|
7
|
+
const pause = (ms: number, signal: AbortSignal) =>
|
|
8
|
+
new Promise<void>((resolve) => {
|
|
9
|
+
const timer = setTimeout(resolve, ms);
|
|
10
|
+
signal.addEventListener('abort', () => {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
resolve();
|
|
13
|
+
}, { once: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** A transport that replays a script: stories and tests drive the real adapter and session with it, so every state is the production code path. */
|
|
17
|
+
export function scriptedTransport(steps: ScriptStep[] | ((text: string) => ScriptStep[])): ChatTransport {
|
|
18
|
+
return {
|
|
19
|
+
async *open({ text, signal }) {
|
|
20
|
+
for (const step of typeof steps === 'function' ? steps(text) : steps) {
|
|
21
|
+
await pause(step.after ?? 40, signal);
|
|
22
|
+
if (signal.aborted) return;
|
|
23
|
+
if ('throw' in step) throw step.throw;
|
|
24
|
+
yield step.frame;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ChatAdapter, ChatMessage } from '@djangocfg/widget-chat';
|
|
6
|
+
|
|
7
|
+
import { useAssistantCopy } from '../i18n';
|
|
8
|
+
import { sessionCopyFor } from '../i18n/sessionCopy';
|
|
9
|
+
|
|
10
|
+
import type { ChatContext, Phase } from '../wire/frames.ts';
|
|
11
|
+
import type { StoredConversation } from '../wire/stored.ts';
|
|
12
|
+
import type { ChatHttpError, ChatTransport } from '../wire/transport.ts';
|
|
13
|
+
import { createStreamAdapter, type AdapterDeps } from './adapter.ts';
|
|
14
|
+
import { fromStoredThread } from './fromStored.ts';
|
|
15
|
+
import { reconcile } from './reconcile.ts';
|
|
16
|
+
|
|
17
|
+
/** The conversation endpoints (`GET`/`DELETE /apix/chat/v1/conversation`), supplied by the app. */
|
|
18
|
+
export interface ThreadApi {
|
|
19
|
+
load: (signal: AbortSignal) => Promise<StoredConversation>;
|
|
20
|
+
clear: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionCopy {
|
|
24
|
+
messages: AdapterDeps['messages'];
|
|
25
|
+
/** Live label per phase ("Searching the catalog..."). */
|
|
26
|
+
status: Record<Phase, string>;
|
|
27
|
+
/** Shown on a message that ended in an error when the thread is reloaded. */
|
|
28
|
+
failedText: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionDeps {
|
|
32
|
+
transport: ChatTransport;
|
|
33
|
+
api: ThreadApi;
|
|
34
|
+
getContext: () => ChatContext | undefined;
|
|
35
|
+
/** Absent: the bundled words for the active language. */
|
|
36
|
+
copy?: SessionCopy;
|
|
37
|
+
onRejected?: (error: ChatHttpError) => void;
|
|
38
|
+
onNetworkFailure?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ThreadState = { kind: 'loading' } | { kind: 'error' } | { kind: 'ready'; id: string; history: ChatMessage[] };
|
|
42
|
+
|
|
43
|
+
/** `reconnecting`: the stream was lost and the server is being asked; `stalled`: the answer is still running after the budget (offer Retry). */
|
|
44
|
+
export type SessionNotice = 'reconnecting' | 'stalled';
|
|
45
|
+
|
|
46
|
+
export interface Session {
|
|
47
|
+
thread: ThreadState;
|
|
48
|
+
adapter: ChatAdapter;
|
|
49
|
+
/** Live progress label while a run streams; null otherwise. */
|
|
50
|
+
statusLabel: string | null;
|
|
51
|
+
notice: SessionNotice | null;
|
|
52
|
+
/** Load the thread again (after a load error or a stalled run). */
|
|
53
|
+
reload: () => void;
|
|
54
|
+
/** Delete the conversation; the next message starts a new one. */
|
|
55
|
+
clear: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One signed-in conversation: the thread from the server (no client copy), the adapter for new messages, the live status label, and the reconcile
|
|
60
|
+
* that runs when a stream is lost. A new `history` identity (`id` carries a revision) reseeds the widget only between runs.
|
|
61
|
+
*/
|
|
62
|
+
export function useSession(given: SessionDeps): Session {
|
|
63
|
+
const bundled = useAssistantCopy();
|
|
64
|
+
const deps = { ...given, copy: given.copy ?? sessionCopyFor(bundled) };
|
|
65
|
+
const ref = useRef(deps);
|
|
66
|
+
ref.current = deps;
|
|
67
|
+
const [thread, setThread] = useState<ThreadState>({ kind: 'loading' });
|
|
68
|
+
const [phase, setPhase] = useState<Phase | null>(null);
|
|
69
|
+
const [notice, setNotice] = useState<SessionNotice | null>(null);
|
|
70
|
+
const revision = useRef(0);
|
|
71
|
+
const live = useRef<AbortController | null>(null);
|
|
72
|
+
|
|
73
|
+
const seed = useCallback((conversation: StoredConversation) => {
|
|
74
|
+
revision.current += 1;
|
|
75
|
+
setThread({ kind: 'ready', id: `${conversation.id ?? 'new'}:${revision.current}`, history: fromStoredThread(conversation.messages, ref.current.copy.failedText) });
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const load = useCallback(() => {
|
|
79
|
+
live.current?.abort();
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
live.current = controller;
|
|
82
|
+
setThread({ kind: 'loading' });
|
|
83
|
+
setNotice(null);
|
|
84
|
+
ref.current.api.load(controller.signal).then(
|
|
85
|
+
(conversation) => !controller.signal.aborted && seed(conversation),
|
|
86
|
+
() => !controller.signal.aborted && setThread({ kind: 'error' }),
|
|
87
|
+
);
|
|
88
|
+
}, [seed]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
load();
|
|
92
|
+
return () => live.current?.abort();
|
|
93
|
+
}, [load]);
|
|
94
|
+
|
|
95
|
+
const adapter = useMemo(
|
|
96
|
+
() =>
|
|
97
|
+
createStreamAdapter({
|
|
98
|
+
transport: deps.transport,
|
|
99
|
+
getContext: () => ref.current.getContext(),
|
|
100
|
+
onStatus: setPhase,
|
|
101
|
+
onRejected: (error) => ref.current.onRejected?.(error),
|
|
102
|
+
onNetworkFailure: () => ref.current.onNetworkFailure?.(),
|
|
103
|
+
messages: {
|
|
104
|
+
rejected: (error) => ref.current.copy.messages.rejected(error),
|
|
105
|
+
streamError: (code, message) => ref.current.copy.messages.streamError(code, message),
|
|
106
|
+
get failed() {
|
|
107
|
+
return ref.current.copy.messages.failed;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
onInterrupted: () => {
|
|
111
|
+
setNotice('reconnecting');
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
live.current = controller;
|
|
114
|
+
reconcile({ fetchConversation: ref.current.api.load, signal: controller.signal }).then(
|
|
115
|
+
(result) => {
|
|
116
|
+
if (controller.signal.aborted) return;
|
|
117
|
+
if (result.outcome === 'timeout') return setNotice('stalled');
|
|
118
|
+
setNotice(null);
|
|
119
|
+
seed(result.conversation);
|
|
120
|
+
},
|
|
121
|
+
() => !controller.signal.aborted && setNotice('stalled'),
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
// The transport is one per mounted chat; everything else is read through `ref`.
|
|
126
|
+
[deps.transport, seed],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const clear = useCallback(async () => {
|
|
130
|
+
await ref.current.api.clear();
|
|
131
|
+
live.current?.abort();
|
|
132
|
+
seed({ id: null, messages: [] });
|
|
133
|
+
}, [seed]);
|
|
134
|
+
|
|
135
|
+
return { thread, adapter, statusLabel: phase ? ref.current.copy.status[phase] : null, notice, reload: load, clear };
|
|
136
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* A larger composer than the widget default, through the widget's own tokens, so the field reads as the main control of the dock. */
|
|
2
|
+
.assistant-chat {
|
|
3
|
+
--chat-composer-inset-y: 0.625rem;
|
|
4
|
+
--chat-composer-inset-x: 1rem;
|
|
5
|
+
--chat-composer-control-h: 2.5rem;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Field text and controls share one centre line: the box aligns items to the end by default, which sat the text a few pixels low. */
|
|
9
|
+
.assistant-chat .composer-box {
|
|
10
|
+
align-items: center;
|
|
11
|
+
}
|
|
12
|
+
.assistant-chat .composer-box textarea {
|
|
13
|
+
padding-block: 0.25rem;
|
|
14
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* The floating button's motion: it pops in on load, its hint bubble grows out of the button, and a ring now and then reminds of it. All of it only for people who allow motion. */
|
|
2
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
3
|
+
.assistant-fab {
|
|
4
|
+
animation: assistant-fab-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
|
5
|
+
}
|
|
6
|
+
.assistant-fab-hint {
|
|
7
|
+
transform-origin: bottom right;
|
|
8
|
+
animation: assistant-fab-hint-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
|
9
|
+
}
|
|
10
|
+
.assistant-fab-hint[data-leaving] {
|
|
11
|
+
animation: assistant-fab-hint-out 0.25s ease-in both;
|
|
12
|
+
}
|
|
13
|
+
.assistant-fab-ring {
|
|
14
|
+
animation: assistant-fab-ring 1.8s ease-out 2 both;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@keyframes assistant-fab-pop {
|
|
19
|
+
from {
|
|
20
|
+
opacity: 0;
|
|
21
|
+
transform: translateY(1.5rem) scale(0.4);
|
|
22
|
+
}
|
|
23
|
+
to {
|
|
24
|
+
opacity: 1;
|
|
25
|
+
transform: none;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
@keyframes assistant-fab-hint-in {
|
|
29
|
+
from {
|
|
30
|
+
opacity: 0;
|
|
31
|
+
transform: translateY(0.5rem) scale(0.6);
|
|
32
|
+
}
|
|
33
|
+
to {
|
|
34
|
+
opacity: 1;
|
|
35
|
+
transform: none;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
@keyframes assistant-fab-hint-out {
|
|
39
|
+
to {
|
|
40
|
+
opacity: 0;
|
|
41
|
+
transform: translateY(0.25rem) scale(0.9);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
@keyframes assistant-fab-ring {
|
|
45
|
+
from {
|
|
46
|
+
opacity: 0.55;
|
|
47
|
+
transform: scale(1);
|
|
48
|
+
}
|
|
49
|
+
to {
|
|
50
|
+
opacity: 0;
|
|
51
|
+
transform: scale(1.9);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The widget's root is a plain block: the host supplies the column layout (`AssistantChat` adds `.assistant-chat`).
|
|
3
|
+
* The widget reserves its inset on the right only (room for a scrollbar) and leaves the left to the host, so one token is mirrored
|
|
4
|
+
* on the left, plus a bottom margin for the composer.
|
|
5
|
+
*/
|
|
6
|
+
.assistant-chat {
|
|
7
|
+
--chat-edge-r: 0.75rem;
|
|
8
|
+
padding-inline-start: 0.75rem;
|
|
9
|
+
padding-block-end: 0.75rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* The transcript takes the free height and scrolls, so the composer sits at the bottom of the dock. */
|
|
13
|
+
.assistant-chat > .chat-surface {
|
|
14
|
+
flex: 1 1 0;
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* The widget's own stylesheet carries the visual rules (composer, transcript, markdown); it must join the Tailwind pipeline by @import, not by a JS import in a component. */
|
|
2
|
+
@import "@djangocfg/widget-chat/styles";
|
|
3
|
+
|
|
4
|
+
/* Tailwind must see the widgets' classes: without these sources it purges them silently (the root node_modules holds them; the package has none of its own). */
|
|
5
|
+
@source "../../../../node_modules/@djangocfg/widget-chat/src/**/*.{ts,tsx}";
|
|
6
|
+
@source "../../../../node_modules/@djangocfg/widget-kit/src/**/*.{ts,tsx}";
|
|
7
|
+
@source "../../../../node_modules/@djangocfg/widget-media/src/**/*.{ts,tsx}";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The assistant SSE stream (frames: text deltas, tool and status events, structured parts, errors). The only file that knows the frame shapes. */
|
|
2
|
+
|
|
3
|
+
export type Phase = 'thinking' | 'searching' | 'reading' | 'choosing';
|
|
4
|
+
export type StreamErrorCode = 'unavailable' | 'timeout' | 'failed';
|
|
5
|
+
|
|
6
|
+
export type Frame =
|
|
7
|
+
| { type: 'run'; run_id: string; user_message_id: string; assistant_message_id: string }
|
|
8
|
+
| { type: 'status'; phase: Phase }
|
|
9
|
+
| { type: 'text_delta'; delta: string }
|
|
10
|
+
| { type: 'ui_payload'; kind: 'film_cards'; data: { ids: string[]; caption: string | null } }
|
|
11
|
+
| { type: 'done'; assistant_message_id: string }
|
|
12
|
+
| { type: 'error'; code: StreamErrorCode; message: string };
|
|
13
|
+
|
|
14
|
+
const PHASES: readonly string[] = ['thinking', 'searching', 'reading', 'choosing'];
|
|
15
|
+
const ERROR_CODES: readonly string[] = ['unavailable', 'timeout', 'failed'];
|
|
16
|
+
|
|
17
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
const str = (value: unknown): value is string => typeof value === 'string';
|
|
19
|
+
|
|
20
|
+
/** A known, well-formed frame, or null: an unknown `type` or widget `kind` is ignored (the contract is forward compatible), a malformed one is dropped. */
|
|
21
|
+
export function decodeFrame(raw: unknown): Frame | null {
|
|
22
|
+
if (!isRecord(raw)) return null;
|
|
23
|
+
switch (raw.type) {
|
|
24
|
+
case 'run':
|
|
25
|
+
return str(raw.run_id) && str(raw.user_message_id) && str(raw.assistant_message_id)
|
|
26
|
+
? { type: 'run', run_id: raw.run_id, user_message_id: raw.user_message_id, assistant_message_id: raw.assistant_message_id }
|
|
27
|
+
: null;
|
|
28
|
+
case 'status':
|
|
29
|
+
return str(raw.phase) && PHASES.includes(raw.phase) ? { type: 'status', phase: raw.phase as Phase } : null;
|
|
30
|
+
case 'text_delta':
|
|
31
|
+
return str(raw.delta) ? { type: 'text_delta', delta: raw.delta } : null;
|
|
32
|
+
case 'ui_payload': {
|
|
33
|
+
if (raw.kind !== 'film_cards' || !isRecord(raw.data) || !Array.isArray(raw.data.ids)) return null;
|
|
34
|
+
const ids = raw.data.ids.filter(str);
|
|
35
|
+
if (ids.length === 0) return null;
|
|
36
|
+
return { type: 'ui_payload', kind: 'film_cards', data: { ids, caption: str(raw.data.caption) ? raw.data.caption : null } };
|
|
37
|
+
}
|
|
38
|
+
case 'done':
|
|
39
|
+
return str(raw.assistant_message_id) ? { type: 'done', assistant_message_id: raw.assistant_message_id } : null;
|
|
40
|
+
case 'error':
|
|
41
|
+
return str(raw.code) && ERROR_CODES.includes(raw.code) && str(raw.message) ? { type: 'error', code: raw.code as StreamErrorCode, message: raw.message } : null;
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn context of the next message (applies to that turn only). */
|
|
48
|
+
export type ChatContext =
|
|
49
|
+
| { kind: 'title'; imdb_id: string }
|
|
50
|
+
| { kind: 'browse'; query: string | null; filters: Record<string, unknown> | null };
|
|
51
|
+
|
|
52
|
+
/** The idle limit above the server's 15 s ping; a silent stream past it is treated as lost. */
|
|
53
|
+
export const IDLE_MS = 45_000;
|