@djangocfg/widget-assistant 0.2.9 → 0.3.2
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 +17 -1
- package/package.json +6 -6
- package/src/AssistantChat.tsx +5 -3
- 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 +6 -4
- package/src/sessions/SessionsMenu.tsx +59 -0
- 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
package/README.md
CHANGED
|
@@ -46,10 +46,26 @@ An unknown `type` is ignored. `wire/fixtures/events.json` holds one of each fram
|
|
|
46
46
|
|
|
47
47
|
`createTransport` takes `streamUrl` (a path, or a function of the send, for a thread id in the path), `buildBody`, `headers`, `streamFetch`, `getToken` (signed in) or `visitorToken` (anonymous, sent as `x-visitor-token`). Omit `api` from `useSession` for an anonymous host: nothing is fetched and the room starts empty. `context` is a plain record the host defines; the package never reads it. `examples/KbAssistant.tsx` is a knowledge-base host with a `citations` payload, the reference the tests mount.
|
|
48
48
|
|
|
49
|
+
## Sessions (0.3)
|
|
50
|
+
|
|
51
|
+
A session is one thread on the server. `useSession({ transport, api, storage, getContext })` shows one at a time and gives the host `startNew()`, `switchTo(id)`, `sessions` / `refreshSessions()` and `busy` (a turn is streaming: starting or switching waits, Stop ends it).
|
|
52
|
+
|
|
53
|
+
* `api: ThreadApi` is three calls the host maps to its endpoints: `list(signal)` (newest activity first), `create()` (`POST threads/`, always a new row) and `load(id, signal)` (throws `ThreadNotFoundError` on a 404: a stranger's or purged session).
|
|
54
|
+
* On load the remembered session (`storage: { read, write }`, the host picks the key; reads and writes must not throw) is shown, else the newest, else a blank page. **A session is created on the first send, never on load**; the transcript is not reseeded then, so the live message survives.
|
|
55
|
+
* `createTransport({ streamUrl: ({ threadId }) => … })` posts to the session in the path; `SendInput.threadId` is what `threadFor` returned.
|
|
56
|
+
* `<SessionsMenu session={session} />` is the header control: "New chat" and the list of earlier chats (title, "5 minutes ago", a check on the current one). Words come from the bundled dictionaries (`newChat`, `sessions`, `sessionsEmpty`).
|
|
57
|
+
* `<AssistantChat draftRoom={session.thread.session ?? 'new'} />` keeps each session's unsent draft under its own id.
|
|
58
|
+
* There is no delete and no `clear()`: "clear" is "new chat". Retention is the server's job.
|
|
59
|
+
* Without `api` (anonymous host) nothing is listed; `startNew()` just empties the room.
|
|
60
|
+
|
|
49
61
|
## Tests
|
|
50
62
|
|
|
51
63
|
`pnpm --filter @djangocfg/widget-assistant check test`: the wire, stream and boundary tests, and `tsc --noEmit`.
|
|
52
64
|
|
|
53
65
|
## Migrating from 0.1 (a host that spoke the film frames)
|
|
54
66
|
|
|
55
|
-
0.2 breaks the wire. In the host's server: `run` carries `thread_id` and `message_id` (not `run_id`, `user_message_id`, `assistant_message_id`); `status.phase` is `thinking|searching|answering` (`reading` and `choosing` are gone); `done` carries no message id; a card payload is `ui_payload {kind: 'film_cards', data}` as before, but the package no longer validates it, so register it: `dataRenderers={{ film_cards: … }}` and `payloadKinds={['film_cards']}`. A stored message has `parts: [{kind, data}]` in place of `widgets: [{kind, ids, caption}]`. `ChatContext` is a plain record (the old `title` and `browse` kinds are the host's to define). `createTransport` still opens `/apix/chat/v1/stream` when no `streamUrl` is given; that default
|
|
67
|
+
0.2 breaks the wire. In the host's server: `run` carries `thread_id` and `message_id` (not `run_id`, `user_message_id`, `assistant_message_id`); `status.phase` is `thinking|searching|answering` (`reading` and `choosing` are gone); `done` carries no message id; a card payload is `ui_payload {kind: 'film_cards', data}` as before, but the package no longer validates it, so register it: `dataRenderers={{ film_cards: … }}` and `payloadKinds={['film_cards']}`. A stored message has `parts: [{kind, data}]` in place of `widgets: [{kind, ids, caption}]`. `ChatContext` is a plain record (the old `title` and `browse` kinds are the host's to define). `createTransport` still opens `/apix/chat/v1/stream` when no `streamUrl` is given; that default stays for anonymous hosts only; a signed-in host with sessions sets `streamUrl`. The dictionaries lose `statusReading` and `statusChoosing` and gain `statusAnswering`; the bundled `lockedBody` and `hint` no longer talk about films.
|
|
68
|
+
|
|
69
|
+
## Migrating from 0.2 (one thread, `clear()`)
|
|
70
|
+
|
|
71
|
+
0.3 replaces the single-thread `ThreadApi { load, clear }` with `ThreadApi { list, create, load(id, signal) }` (`useSession` takes an optional `storage`), removes `Session.clear` (use `startNew`), swaps the dictionary keys `clear`, `clearConfirm`, `clearYes` for `newChat`, `sessions`, `sessionsEmpty`, and adds `ThreadNotFoundError`, the `not_found` reject code, `SendInput.threadId`, `AdapterDeps.threadFor` / `onActive`, `AssistantChat.draftRoom`, `SessionsMenu`, `useAssistantLocale` and `relativeTime`. A host that kept a hand-written sessions bar can delete it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-assistant",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A site assistant on top of @djangocfg/widget-chat: the SSE wire and session layer, a persistent host, docked column, floating button and sheet, dictation, Help and 17 built-in locales",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chat",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"zustand": "^5.0.16"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@djangocfg/i18n": "^2.1.
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
51
|
+
"@djangocfg/i18n": "^2.1.621",
|
|
52
|
+
"@djangocfg/ui-core": "^2.1.621",
|
|
53
53
|
"lucide-react": "^1.68.0",
|
|
54
54
|
"react": "^19.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@djangocfg/i18n": "^2.1.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
57
|
+
"@djangocfg/i18n": "^2.1.621",
|
|
58
|
+
"@djangocfg/typescript-config": "^2.1.621",
|
|
59
|
+
"@djangocfg/ui-core": "^2.1.621",
|
|
60
60
|
"@storybook/react-vite": "^10.6.5",
|
|
61
61
|
"@types/node": "^25.9.5",
|
|
62
62
|
"@types/react": "19.3.0",
|
package/src/AssistantChat.tsx
CHANGED
|
@@ -33,8 +33,10 @@ interface Props {
|
|
|
33
33
|
adapter: ChatAdapter;
|
|
34
34
|
/** Example prompts shown in an empty chat. */
|
|
35
35
|
suggestions: string[];
|
|
36
|
-
/** The room on screen: `id` changes when the thread is reloaded (reconcile,
|
|
36
|
+
/** The room on screen: `id` changes when the thread is reloaded (reconcile, a new or another session), which reseeds the transcript. */
|
|
37
37
|
conversation?: { id?: string; history: readonly ChatMessage[] };
|
|
38
|
+
/** What the unsent draft is kept under: the session's own id, so each session keeps its draft across reseeds. Absent: the conversation `id`. */
|
|
39
|
+
draftRoom?: string;
|
|
38
40
|
/** The host's drawing of structured parts, by `kind` (for example `citations`). Unknown kinds fall through to the widget. */
|
|
39
41
|
dataRenderers?: Readonly<Record<string, DataPartRenderer>>;
|
|
40
42
|
/** Dictation language and its picker (the microphone menu). Absent: the browser's default engine with the widget's default language. */
|
|
@@ -57,7 +59,7 @@ const NO_RENDERERS: Readonly<Record<string, DataPartRenderer>> = {};
|
|
|
57
59
|
* recall of sent prompts with the arrow keys, an unsent draft that survives a reload). The widget's packaged layout shows only a bare composer, so the layout
|
|
58
60
|
* is composed here from its own primitives: every behaviour is the widget's, nothing is re-implemented.
|
|
59
61
|
*/
|
|
60
|
-
export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
|
|
62
|
+
export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM, draftRoom, dataRenderers = NO_RENDERERS, speech, notice, onRetry, focusWhen, refocus, className }: Props) {
|
|
61
63
|
const renderers = useMemo(
|
|
62
64
|
() => ({
|
|
63
65
|
text: ({ part }: PartRenderProps) => (part.type === 'text' ? <Response>{part.text}</Response> : null),
|
|
@@ -106,7 +108,7 @@ export function AssistantChat({ adapter, suggestions, conversation = EMPTY_ROOM,
|
|
|
106
108
|
children: (
|
|
107
109
|
<>
|
|
108
110
|
<ChatTranscript footer={(message) => <MessageFooter message={message} />} />
|
|
109
|
-
<AssistantComposer speech={speech} draftRoom={conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
|
|
111
|
+
<AssistantComposer speech={speech} draftRoom={draftRoom ?? conversation.id ?? 'assistant'} focusWhen={focusWhen} refocus={refocus} />
|
|
110
112
|
</>
|
|
111
113
|
),
|
|
112
114
|
}}
|
package/src/i18n/index.ts
CHANGED
|
@@ -34,3 +34,6 @@ const kit = createCopy(ASSISTANT_DICTIONARIES);
|
|
|
34
34
|
* `@djangocfg/i18n` provider, else English. The host passes no strings.
|
|
35
35
|
*/
|
|
36
36
|
export const useAssistantCopy = kit.useCopy;
|
|
37
|
+
|
|
38
|
+
/** The bundled locale the assistant resolved to (feed it to `Intl`). */
|
|
39
|
+
export const useAssistantLocale = kit.useLocale;
|
package/src/i18n/locales/ar.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const ar: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'إعادة الاتصال…',
|
|
21
21
|
stalled: 'ما زالت الإجابة قيد الإعداد.',
|
|
22
22
|
retry: 'إعادة المحاولة',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'محادثة جديدة',
|
|
24
|
+
sessions: 'المحادثات السابقة',
|
|
25
|
+
sessionsEmpty: 'لا توجد محادثات سابقة بعد',
|
|
26
26
|
loadError: 'تعذّر تحميل المحادثة.',
|
|
27
27
|
helpLabel: 'مساعدة',
|
|
28
28
|
helpDictate: 'بدء الإملاء أو إيقافه',
|
package/src/i18n/locales/da.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const da: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Forbinder igen…',
|
|
21
21
|
stalled: 'Svaret er stadig undervejs.',
|
|
22
22
|
retry: 'Prøv igen',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Ny chat',
|
|
24
|
+
sessions: 'Tidligere chats',
|
|
25
|
+
sessionsEmpty: 'Ingen tidligere chats endnu',
|
|
26
26
|
loadError: 'Samtalen kunne ikke indlæses.',
|
|
27
27
|
helpLabel: 'Hjælp',
|
|
28
28
|
helpDictate: 'Start eller stop diktering',
|
package/src/i18n/locales/de.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const de: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Verbinde neu…',
|
|
21
21
|
stalled: 'Die Antwort wird noch erstellt.',
|
|
22
22
|
retry: 'Erneut versuchen',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Neuer Chat',
|
|
24
|
+
sessions: 'Frühere Chats',
|
|
25
|
+
sessionsEmpty: 'Noch keine früheren Chats',
|
|
26
26
|
loadError: 'Die Unterhaltung konnte nicht geladen werden.',
|
|
27
27
|
helpLabel: 'Hilfe',
|
|
28
28
|
helpDictate: 'Diktat starten oder beenden',
|
package/src/i18n/locales/en.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const en: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Reconnecting…',
|
|
21
21
|
stalled: 'Still working on the answer.',
|
|
22
22
|
retry: 'Retry',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'New chat',
|
|
24
|
+
sessions: 'Earlier chats',
|
|
25
|
+
sessionsEmpty: 'No earlier chats yet',
|
|
26
26
|
loadError: 'Could not load the conversation.',
|
|
27
27
|
helpLabel: 'Help',
|
|
28
28
|
helpDictate: 'Start or stop dictation',
|
package/src/i18n/locales/es.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const es: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Reconectando…',
|
|
21
21
|
stalled: 'La respuesta sigue en curso.',
|
|
22
22
|
retry: 'Reintentar',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Nuevo chat',
|
|
24
|
+
sessions: 'Chats anteriores',
|
|
25
|
+
sessionsEmpty: 'Aún no hay chats anteriores',
|
|
26
26
|
loadError: 'No se pudo cargar la conversación.',
|
|
27
27
|
helpLabel: 'Ayuda',
|
|
28
28
|
helpDictate: 'Iniciar o detener el dictado',
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const fr: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Reconnexion…',
|
|
21
21
|
stalled: 'La réponse est encore en cours.',
|
|
22
22
|
retry: 'Réessayer',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Nouvelle discussion',
|
|
24
|
+
sessions: 'Discussions précédentes',
|
|
25
|
+
sessionsEmpty: 'Aucune discussion précédente',
|
|
26
26
|
loadError: 'Impossible de charger la conversation.',
|
|
27
27
|
helpLabel: 'Aide',
|
|
28
28
|
helpDictate: 'Démarrer ou arrêter la dictée',
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const it: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Riconnessione…',
|
|
21
21
|
stalled: 'La risposta è ancora in corso.',
|
|
22
22
|
retry: 'Riprova',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Nuova chat',
|
|
24
|
+
sessions: 'Chat precedenti',
|
|
25
|
+
sessionsEmpty: 'Ancora nessuna chat precedente',
|
|
26
26
|
loadError: 'Impossibile caricare la conversazione.',
|
|
27
27
|
helpLabel: 'Aiuto',
|
|
28
28
|
helpDictate: 'Avvia o ferma la dettatura',
|
package/src/i18n/locales/ja.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const ja: AssistantCopy = {
|
|
|
20
20
|
reconnecting: '再接続中…',
|
|
21
21
|
stalled: 'まだ回答を作成中です。',
|
|
22
22
|
retry: '再試行',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: '新しいチャット',
|
|
24
|
+
sessions: '過去のチャット',
|
|
25
|
+
sessionsEmpty: '過去のチャットはまだありません',
|
|
26
26
|
loadError: '会話を読み込めませんでした。',
|
|
27
27
|
helpLabel: 'ヘルプ',
|
|
28
28
|
helpDictate: '音声入力の開始/停止',
|
package/src/i18n/locales/ko.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const ko: AssistantCopy = {
|
|
|
20
20
|
reconnecting: '다시 연결하는 중…',
|
|
21
21
|
stalled: '아직 답변을 준비 중입니다.',
|
|
22
22
|
retry: '다시 시도',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: '새 채팅',
|
|
24
|
+
sessions: '이전 채팅',
|
|
25
|
+
sessionsEmpty: '이전 채팅이 아직 없습니다',
|
|
26
26
|
loadError: '대화를 불러오지 못했습니다.',
|
|
27
27
|
helpLabel: '도움말',
|
|
28
28
|
helpDictate: '음성 입력 시작/중지',
|
package/src/i18n/locales/nl.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const nl: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Opnieuw verbinden…',
|
|
21
21
|
stalled: 'Het antwoord wordt nog gemaakt.',
|
|
22
22
|
retry: 'Opnieuw proberen',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Nieuwe chat',
|
|
24
|
+
sessions: 'Eerdere chats',
|
|
25
|
+
sessionsEmpty: 'Nog geen eerdere chats',
|
|
26
26
|
loadError: 'Het gesprek kon niet worden geladen.',
|
|
27
27
|
helpLabel: 'Help',
|
|
28
28
|
helpDictate: 'Dicteren starten of stoppen',
|
package/src/i18n/locales/no.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const no: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Kobler til igjen…',
|
|
21
21
|
stalled: 'Svaret lages fortsatt.',
|
|
22
22
|
retry: 'Prøv igjen',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Ny chat',
|
|
24
|
+
sessions: 'Tidligere chatter',
|
|
25
|
+
sessionsEmpty: 'Ingen tidligere chatter ennå',
|
|
26
26
|
loadError: 'Samtalen kunne ikke lastes.',
|
|
27
27
|
helpLabel: 'Hjelp',
|
|
28
28
|
helpDictate: 'Start eller stopp diktering',
|
package/src/i18n/locales/pl.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const pl: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Łączę ponownie…',
|
|
21
21
|
stalled: 'Odpowiedź wciąż powstaje.',
|
|
22
22
|
retry: 'Spróbuj ponownie',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Nowy czat',
|
|
24
|
+
sessions: 'Wcześniejsze czaty',
|
|
25
|
+
sessionsEmpty: 'Brak wcześniejszych czatów',
|
|
26
26
|
loadError: 'Nie udało się wczytać rozmowy.',
|
|
27
27
|
helpLabel: 'Pomoc',
|
|
28
28
|
helpDictate: 'Włącz lub zatrzymaj dyktowanie',
|
|
@@ -20,9 +20,9 @@ export const ptBR: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Reconectando…',
|
|
21
21
|
stalled: 'A resposta ainda está sendo preparada.',
|
|
22
22
|
retry: 'Tentar de novo',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Novo chat',
|
|
24
|
+
sessions: 'Chats anteriores',
|
|
25
|
+
sessionsEmpty: 'Ainda não há chats anteriores',
|
|
26
26
|
loadError: 'Não foi possível carregar a conversa.',
|
|
27
27
|
helpLabel: 'Ajuda',
|
|
28
28
|
helpDictate: 'Iniciar ou parar o ditado',
|
package/src/i18n/locales/ru.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const ru: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Подключаемся снова…',
|
|
21
21
|
stalled: 'Ответ ещё готовится.',
|
|
22
22
|
retry: 'Повторить',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Новый чат',
|
|
24
|
+
sessions: 'Прошлые чаты',
|
|
25
|
+
sessionsEmpty: 'Прошлых чатов пока нет',
|
|
26
26
|
loadError: 'Не удалось загрузить разговор.',
|
|
27
27
|
helpLabel: 'Справка',
|
|
28
28
|
helpDictate: 'Начать или остановить диктовку',
|
package/src/i18n/locales/sv.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const sv: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Återansluter…',
|
|
21
21
|
stalled: 'Svaret är fortfarande på väg.',
|
|
22
22
|
retry: 'Försök igen',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Ny chatt',
|
|
24
|
+
sessions: 'Tidigare chattar',
|
|
25
|
+
sessionsEmpty: 'Inga tidigare chattar ännu',
|
|
26
26
|
loadError: 'Konversationen kunde inte laddas.',
|
|
27
27
|
helpLabel: 'Hjälp',
|
|
28
28
|
helpDictate: 'Starta eller stoppa diktering',
|
package/src/i18n/locales/tr.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const tr: AssistantCopy = {
|
|
|
20
20
|
reconnecting: 'Yeniden bağlanıyor…',
|
|
21
21
|
stalled: 'Yanıt hâlâ hazırlanıyor.',
|
|
22
22
|
retry: 'Tekrar dene',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: 'Yeni sohbet',
|
|
24
|
+
sessions: 'Önceki sohbetler',
|
|
25
|
+
sessionsEmpty: 'Henüz önceki sohbet yok',
|
|
26
26
|
loadError: 'Konuşma yüklenemedi.',
|
|
27
27
|
helpLabel: 'Yardım',
|
|
28
28
|
helpDictate: 'Dikteyi başlat veya durdur',
|
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -20,9 +20,9 @@ export const zh: AssistantCopy = {
|
|
|
20
20
|
reconnecting: '正在重新连接…',
|
|
21
21
|
stalled: '仍在生成回答。',
|
|
22
22
|
retry: '重试',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
newChat: '新对话',
|
|
24
|
+
sessions: '以前的对话',
|
|
25
|
+
sessionsEmpty: '还没有以前的对话',
|
|
26
26
|
loadError: '无法加载对话。',
|
|
27
27
|
helpLabel: '帮助',
|
|
28
28
|
helpDictate: '开始或停止听写',
|
package/src/i18n/types.ts
CHANGED
|
@@ -26,10 +26,10 @@ export interface AssistantCopy {
|
|
|
26
26
|
reconnecting: string;
|
|
27
27
|
stalled: string;
|
|
28
28
|
retry: string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
/** Sessions: start a new chat, the list of earlier ones, and its empty line. */
|
|
30
|
+
newChat: string;
|
|
31
|
+
sessions: string;
|
|
32
|
+
sessionsEmpty: string;
|
|
33
33
|
loadError: string;
|
|
34
34
|
/** The "Help" link under the composer and its list of keys. */
|
|
35
35
|
helpLabel: string;
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
export { AssistantChat, type DataPartRenderer } from './AssistantChat';
|
|
2
2
|
export { scriptedTransport, type ScriptStep } from './stream/scriptedTransport';
|
|
3
|
-
export { useAssistantCopy, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
|
|
3
|
+
export { useAssistantCopy, useAssistantLocale, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
|
|
4
4
|
export { ChatDock } from './shell/ChatDock';
|
|
5
5
|
export { AssistantFrame, type FrameLabels } from './shell/AssistantFrame';
|
|
6
6
|
export { AssistantFab, type FabHint } from './shell/AssistantFab';
|
|
7
7
|
export { AssistantLocked } from './shell/AssistantLocked';
|
|
8
8
|
export { createAssistantUi, useMediaQuery, type UiOptions, type UiState, type UiStorage } from './shell/panelState';
|
|
9
9
|
export { AssistantHostProvider, AssistantPortal, AssistantSlot } from './Host';
|
|
10
|
-
export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type
|
|
10
|
+
export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type ThreadState } from './stream/useSession';
|
|
11
|
+
export { relativeTime, restoreSession, type SessionStorage, type ThreadApi } from './stream/sessions';
|
|
12
|
+
export { SessionsMenu } from './sessions/SessionsMenu';
|
|
11
13
|
export { createStreamAdapter, type AdapterDeps } from './stream/adapter';
|
|
12
14
|
export { reconcile, type Reconciled } from './stream/reconcile';
|
|
13
15
|
export { fromStored, fromStoredThread } from './stream/fromStored';
|
|
14
|
-
export { createTransport, ChatHttpError, ChatNetworkError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
|
|
16
|
+
export { createTransport, ChatHttpError, ChatNetworkError, ThreadNotFoundError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
|
|
15
17
|
export { StreamLostError } from './wire/parseSSE';
|
|
16
18
|
export { decodeFrame, WIRE_VERSION } from './wire/frames';
|
|
17
19
|
export type { ChatContext, Frame, Phase, StreamErrorCode } from './wire/frames';
|
|
18
|
-
export type { StoredConversation, StoredMessage, StoredPart, StoredStatus } from './wire/stored';
|
|
20
|
+
export type { StoredConversation, StoredMessage, StoredPart, StoredStatus, StoredThread } from './wire/stored';
|
|
19
21
|
export { citationsRenderer, KB_RENDERERS, KB_PAYLOAD_KINDS, type Citation } from './examples/KbAssistant';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, History, Plus } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
import { useAssistantCopy, useAssistantLocale } from '../i18n';
|
|
8
|
+
import type { Session } from '../stream/useSession';
|
|
9
|
+
import { relativeTime } from '../stream/sessions';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
session: Pick<Session, 'thread' | 'sessions' | 'busy' | 'startNew' | 'switchTo' | 'refreshSessions'>;
|
|
13
|
+
/** The language tag for "5 minutes ago". Absent: the assistant's active language. */
|
|
14
|
+
locale?: string;
|
|
15
|
+
/** Hide the list and keep only "New chat" (a host with no list endpoint, such as an anonymous one). */
|
|
16
|
+
newOnly?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The chat's header controls: "New chat" (a new session; the old ones stay) and the list of earlier sessions, newest first, to switch back.
|
|
21
|
+
* Both wait while an answer streams: Stop ends it first. Words come from the assistant's own dictionaries.
|
|
22
|
+
*/
|
|
23
|
+
export function SessionsMenu({ session, locale, newOnly = false }: Props) {
|
|
24
|
+
const copy = useAssistantCopy(locale);
|
|
25
|
+
const active = useAssistantLocale(locale);
|
|
26
|
+
const current = session.thread.kind === 'ready' ? session.thread.session : null;
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-center" data-testid="sessions-menu">
|
|
29
|
+
<Button variant="ghost" size="icon" aria-label={copy.newChat} title={copy.newChat} disabled={session.busy} onClick={() => void session.startNew()}>
|
|
30
|
+
<Plus className="size-5" aria-hidden="true" />
|
|
31
|
+
</Button>
|
|
32
|
+
{newOnly ? null : (
|
|
33
|
+
<DropdownMenu onOpenChange={(open) => open && session.refreshSessions()}>
|
|
34
|
+
<DropdownMenuTrigger asChild>
|
|
35
|
+
<Button variant="ghost" size="icon" aria-label={copy.sessions} title={copy.sessions}>
|
|
36
|
+
<History className="size-5" aria-hidden="true" />
|
|
37
|
+
</Button>
|
|
38
|
+
</DropdownMenuTrigger>
|
|
39
|
+
<DropdownMenuContent align="end" sideOffset={4} className="max-h-96 w-72 overflow-y-auto">
|
|
40
|
+
<DropdownMenuLabel>{copy.sessions}</DropdownMenuLabel>
|
|
41
|
+
{session.sessions === null ? null : session.sessions.length === 0 ? (
|
|
42
|
+
<p className="px-2 py-1.5 text-xs text-muted-foreground">{copy.sessionsEmpty}</p>
|
|
43
|
+
) : (
|
|
44
|
+
session.sessions.map((item) => (
|
|
45
|
+
<DropdownMenuItem key={item.id} disabled={session.busy} onSelect={() => void session.switchTo(item.id)} className="items-start gap-2">
|
|
46
|
+
<Check className={`mt-0.5 size-4 shrink-0 ${item.id === current ? '' : 'invisible'}`} aria-hidden="true" />
|
|
47
|
+
<span className="min-w-0 flex-1">
|
|
48
|
+
<span className="block truncate">{item.title || copy.newChat}</span>
|
|
49
|
+
<span className="block text-xs text-muted-foreground">{relativeTime(item.last_message_at, active)}</span>
|
|
50
|
+
</span>
|
|
51
|
+
</DropdownMenuItem>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</DropdownMenuContent>
|
|
55
|
+
</DropdownMenu>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
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
|
+
}
|
package/src/wire/transport.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { decodeFrame, IDLE_MS, type ChatContext, type Frame } from './frames';
|
|
|
2
2
|
import { parseSSE, StreamLostError } from './parseSSE';
|
|
3
3
|
|
|
4
4
|
/** Problem codes the API answers before a stream starts (CONTRACT.md "Auth and errors"). */
|
|
5
|
-
export type RejectCode = 'unauthorized' | 'busy' | 'invalid' | 'rate_limited' | 'unavailable' | 'failed';
|
|
5
|
+
export type RejectCode = 'unauthorized' | 'busy' | 'invalid' | 'rate_limited' | 'unavailable' | 'not_found' | 'failed';
|
|
6
6
|
|
|
7
7
|
/** The request was refused before any frame: nothing ran, so a retry with the same `client_id` is safe. */
|
|
8
8
|
export class ChatHttpError extends Error {
|
|
@@ -19,6 +19,13 @@ export class ChatHttpError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The session is not there (never was, or it is someone else's: the API answers both with a 404). The host forgets the remembered id. */
|
|
23
|
+
export class ThreadNotFoundError extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super('chat thread not found');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/** No answer at all (offline, DNS, reset before the response): nothing is known to have run. */
|
|
23
30
|
export class ChatNetworkError extends Error {
|
|
24
31
|
constructor() {
|
|
@@ -27,6 +34,8 @@ export class ChatNetworkError extends Error {
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export interface SendInput {
|
|
37
|
+
/** The session this message goes to (set by the session layer; read by `streamUrl`). Absent for an anonymous host. */
|
|
38
|
+
threadId?: string;
|
|
30
39
|
text: string;
|
|
31
40
|
clientId: string;
|
|
32
41
|
context?: ChatContext;
|
|
@@ -41,7 +50,7 @@ export interface ChatTransport {
|
|
|
41
50
|
export interface TransportConfig {
|
|
42
51
|
/** API origin, no trailing slash; prefixes a relative `streamUrl`. The stream is called directly, never through a Next rewrite. */
|
|
43
52
|
baseUrl: string;
|
|
44
|
-
/** Where one run is opened: a path or URL, or a function read per request (a thread id in the path). Absent: the legacy `/apix/chat/v1/stream` (
|
|
53
|
+
/** Where one run is opened: a path or URL, or a function read per request (a thread id in the path). Absent: the legacy `/apix/chat/v1/stream` (anonymous hosts only; a signed-in host with sessions always sets this). */
|
|
45
54
|
streamUrl?: string | ((input: SendInput) => string);
|
|
46
55
|
/** The request body. Absent: `{text, client_id, context?}`. */
|
|
47
56
|
buildBody?: (input: SendInput) => unknown;
|
|
@@ -58,7 +67,7 @@ export interface TransportConfig {
|
|
|
58
67
|
fetch?: typeof fetch;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable'];
|
|
70
|
+
const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable', 'not_found'];
|
|
62
71
|
/** A server may name a bad request its own way; these read as `invalid`. */
|
|
63
72
|
const INVALID: readonly string[] = ['validation_error'];
|
|
64
73
|
|
|
@@ -67,6 +76,7 @@ function rejectCode(status: number, body: unknown): RejectCode {
|
|
|
67
76
|
if (typeof code === 'string' && CODES.includes(code)) return code as RejectCode;
|
|
68
77
|
if (typeof code === 'string' && INVALID.includes(code)) return 'invalid';
|
|
69
78
|
if (status === 401) return 'unauthorized';
|
|
79
|
+
if (status === 404) return 'not_found';
|
|
70
80
|
if (status === 409) return 'busy';
|
|
71
81
|
if (status === 400 || status === 422) return 'invalid';
|
|
72
82
|
if (status === 429) return 'rate_limited';
|