@djangocfg/widget-assistant 0.1.1 → 0.2.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/README.md CHANGED
@@ -29,6 +29,27 @@ It knows nothing about one site (`src/boundary.test.ts` enforces it), so what a
29
29
  - **Floating button.** Pops in, shows a message-style hint after a moment (`hint` prop; text defaults to the bundled one), then a ring every so often. No close control; the host decides how often to show it.
30
30
  - **Help.** A quiet link under the composer opens the list of keys (`ComposerHelp`).
31
31
 
32
+ ## The wire (0.2)
33
+
34
+ One vocabulary, `data: {json with type}\n\n`, decoded only in `wire/frames.ts`:
35
+
36
+ | Frame | Fields |
37
+ |---|---|
38
+ | `run` | `version`, `thread_id`, `message_id`, `client_id`, `seq` |
39
+ | `status` | `phase`: `thinking` \| `searching` \| `answering` |
40
+ | `text_delta` | `delta` |
41
+ | `ui_payload` | `kind`, `data`: the host draws it through `dataRenderers[kind]`; list the kinds in `payloadKinds` and any other is dropped and reported to `onUnknownPayload` |
42
+ | `done` | `tokens_in`, `tokens_out`, `cost_usd`, `finish_reason`, `replayed?` |
43
+ | `error` | `code` (`unavailable` \| `timeout` \| `failed`), `message`, `retryable` |
44
+
45
+ An unknown `type` is ignored. `wire/fixtures/events.json` holds one of each frame and is committed on the server side too: drift fails a test on either side. A public chat must not forward `tool_call` / `tool_result`.
46
+
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
+
32
49
  ## Tests
33
50
 
34
51
  `pnpm --filter @djangocfg/widget-assistant check test`: the wire, stream and boundary tests, and `tsc --noEmit`.
52
+
53
+ ## Migrating from 0.1 (a host that spoke the film frames)
54
+
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 goes in 0.3. The dictionaries lose `statusReading` and `statusChoosing` and gain `statusAnswering`; the bundled `lockedBody` and `hint` no longer talk about films.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-assistant",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",
@@ -44,19 +44,19 @@
44
44
  "test": "vitest run"
45
45
  },
46
46
  "dependencies": {
47
- "@djangocfg/widget-chat": "^0.1.14",
47
+ "@djangocfg/widget-chat": "^0.1.16",
48
48
  "zustand": "^5.0.14"
49
49
  },
50
50
  "peerDependencies": {
51
- "@djangocfg/i18n": "^2.1.585",
52
- "@djangocfg/ui-core": "^2.1.585",
51
+ "@djangocfg/i18n": "^2.1.610",
52
+ "@djangocfg/ui-core": "^2.1.610",
53
53
  "lucide-react": "^0.545.0",
54
54
  "react": "^19.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@djangocfg/i18n": "^2.1.585",
58
- "@djangocfg/typescript-config": "^2.1.585",
59
- "@djangocfg/ui-core": "^2.1.585",
57
+ "@djangocfg/i18n": "^2.1.610",
58
+ "@djangocfg/typescript-config": "^2.1.610",
59
+ "@djangocfg/ui-core": "^2.1.610",
60
60
  "@storybook/react-vite": "^10.5.0",
61
61
  "@types/node": "^25.9.5",
62
62
  "@types/react": "19.2.15",
@@ -35,7 +35,7 @@ interface Props {
35
35
  suggestions: string[];
36
36
  /** The room on screen: `id` changes when the thread is reloaded (reconcile, clear), which reseeds the transcript. */
37
37
  conversation?: { id?: string; history: readonly ChatMessage[] };
38
- /** The host's drawing of structured parts, by `kind` (for example `film_cards`). Unknown kinds fall through to the widget. */
38
+ /** The host's drawing of structured parts, by `kind` (for example `citations`). Unknown kinds fall through to the widget. */
39
39
  dataRenderers?: Readonly<Record<string, DataPartRenderer>>;
40
40
  /** Dictation language and its picker (the microphone menu). Absent: the browser's default engine with the widget's default language. */
41
41
  speech?: SpeechLanguage;
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { DataPartRenderer } from '../AssistantChat';
4
+
5
+ /**
6
+ * A plain knowledge-base host, not a film site: the answer carries a `citations` payload and the host draws it as a numbered list. It is the reference the
7
+ * tests mount, and the shape a new product copies: the payload kinds it draws, and a renderer per kind.
8
+ */
9
+ export interface Citation {
10
+ n: number;
11
+ title: string;
12
+ heading_path?: string[];
13
+ url?: string;
14
+ }
15
+
16
+ const isCitations = (data: unknown): data is { items: Citation[] } =>
17
+ typeof data === 'object' && data !== null && Array.isArray((data as { items?: unknown }).items);
18
+
19
+ export const citationsRenderer: DataPartRenderer = (data): ReactNode | undefined => {
20
+ if (!isCitations(data) || data.items.length === 0) return undefined;
21
+ return (
22
+ <ol className="mt-2 space-y-1 text-sm text-muted-foreground">
23
+ {data.items.map((item) => (
24
+ <li key={item.n}>
25
+ [{item.n}] {item.url ? <a href={item.url}>{item.title}</a> : item.title}
26
+ {item.heading_path && item.heading_path.length > 1 ? ` — ${item.heading_path.slice(1).join(' › ')}` : ''}
27
+ </li>
28
+ ))}
29
+ </ol>
30
+ );
31
+ };
32
+
33
+ /** What the host draws, by `kind`. Pass it as `dataRenderers`, and its keys as `payloadKinds`. */
34
+ export const KB_RENDERERS: Readonly<Record<string, DataPartRenderer>> = { citations: citationsRenderer };
35
+ export const KB_PAYLOAD_KINDS: readonly string[] = Object.keys(KB_RENDERERS);
package/src/i18n/index.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { useMemo } from 'react';
2
-
3
- import { useLocaleOptional } from '@djangocfg/i18n';
1
+ import { createCopy, type Locale } from '@djangocfg/i18n/copy';
4
2
 
5
3
  import { ar } from './locales/ar';
6
4
  import { da } from './locales/da';
@@ -23,14 +21,16 @@ import type { AssistantCopy } from './types';
23
21
 
24
22
  export type { AssistantCopy } from './types';
25
23
 
26
- /** Every bundled dictionary, keyed by the language code the app reports; the same seventeen languages as the framework widget. */
27
- export const ASSISTANT_DICTIONARIES: Readonly<Record<string, AssistantCopy>> = { ar, da, de, en, es, fr, it, ja, ko, nl, no, pl, 'pt-BR': ptBR, ru, sv, tr, zh };
24
+ /** Every bundled dictionary, typed complete over the shared `Locale` union. */
25
+ export const ASSISTANT_DICTIONARIES: Readonly<Record<Locale, AssistantCopy>> = {
26
+ en, ru, ko, ja, de, fr, zh, it, es, nl, ar, tr, pl, sv, no, da,
27
+ 'pt-BR': ptBR,
28
+ };
29
+
30
+ const kit = createCopy(ASSISTANT_DICTIONARIES);
28
31
 
29
32
  /**
30
- * The assistant's own words for the active language (English for one not yet translated). The host passes no strings: the language comes from the framework's
31
- * locale provider, the one the widget's built-in composer copy already follows. With no provider (a story, a test) it is English.
33
+ * The assistant's own words for the active language: the `locale` prop, else the
34
+ * `@djangocfg/i18n` provider, else English. The host passes no strings.
32
35
  */
33
- export function useAssistantCopy(): AssistantCopy {
34
- const locale = useLocaleOptional();
35
- return useMemo(() => (locale ? ASSISTANT_DICTIONARIES[locale] ?? en : en), [locale]);
36
- }
36
+ export const useAssistantCopy = kit.useCopy;
@@ -4,14 +4,13 @@ export const ar: AssistantCopy = {
4
4
  close: 'إغلاق',
5
5
  resize: 'تغيير عرض المساعد',
6
6
  lockedTitle: 'سجّل الدخول للدردشة',
7
- lockedBody: 'يختار المساعد الأفلام لك ويتذكر المحادثة. سجّل الدخول للبدء.',
7
+ lockedBody: 'يجيب المساعد من مستنداتك ويتذكر المحادثة. سجّل الدخول للبدء.',
8
8
  lockedComposer: 'سجّل الدخول لتسأل',
9
9
  signIn: 'تسجيل الدخول',
10
- hint: 'نتحدث عن الأفلام مع الذكاء الاصطناعي؟',
10
+ hint: 'اسأل المساعد؟',
11
11
  statusThinking: 'أفكر…',
12
- statusSearching: 'أبحث في الكتالوج…',
13
- statusReading: 'أقرأ عن الأفلام…',
14
- statusChoosing: 'أختار الأفضل…',
12
+ statusSearching: 'أبحث…',
13
+ statusAnswering: 'أكتب الإجابة…',
15
14
  errAuth: 'سجّل الدخول لتسأل المساعد.',
16
15
  errBusy: 'أنهِ أولاً في التبويب الآخر.',
17
16
  errInvalid: 'الرسالة طويلة جدًا. اختصرها وأعد المحاولة.',
@@ -4,14 +4,13 @@ export const da: AssistantCopy = {
4
4
  close: 'Luk',
5
5
  resize: 'Skift assistentens bredde',
6
6
  lockedTitle: 'Log ind for at chatte',
7
- lockedBody: 'Assistenten vælger film til dig og husker samtalen. Log ind for at komme i gang.',
7
+ lockedBody: 'Assistenten svarer ud fra dine dokumenter og husker samtalen. Log ind for at starte.',
8
8
  lockedComposer: 'Log ind for at spørge',
9
9
  signIn: 'Log ind',
10
- hint: 'Skal vi snakke film med AI?',
10
+ hint: 'Spørg assistenten?',
11
11
  statusThinking: 'Tænker…',
12
- statusSearching: 'Søger i kataloget…',
13
- statusReading: 'Læser om filmene…',
14
- statusChoosing: 'Vælger de bedste…',
12
+ statusSearching: 'Søger…',
13
+ statusAnswering: 'Skriver svaret…',
15
14
  errAuth: 'Log ind for at spørge assistenten.',
16
15
  errBusy: 'Afslut først i den anden fane.',
17
16
  errInvalid: 'Beskeden er for lang. Forkort den og prøv igen.',
@@ -4,14 +4,13 @@ export const de: AssistantCopy = {
4
4
  close: 'Schließen',
5
5
  resize: 'Breite des Assistenten ändern',
6
6
  lockedTitle: 'Anmelden, um zu chatten',
7
- lockedBody: 'Der Assistent wählt Filme für dich aus und merkt sich das Gespräch. Melde dich an, um zu starten.',
7
+ lockedBody: 'Der Assistent antwortet aus deinen Dokumenten und merkt sich das Gespräch. Melde dich an, um zu starten.',
8
8
  lockedComposer: 'Anmelden, um zu fragen',
9
9
  signIn: 'Anmelden',
10
- hint: 'Über Filme mit KI sprechen?',
10
+ hint: 'Den Assistenten fragen?',
11
11
  statusThinking: 'Ich denke nach…',
12
- statusSearching: 'Durchsuche den Katalog…',
13
- statusReading: 'Lese über die Filme…',
14
- statusChoosing: 'Wähle die besten aus…',
12
+ statusSearching: 'Suche…',
13
+ statusAnswering: 'Schreibe die Antwort…',
15
14
  errAuth: 'Melde dich an, um den Assistenten zu fragen.',
16
15
  errBusy: 'Beende zuerst die Antwort im anderen Tab.',
17
16
  errInvalid: 'Die Nachricht ist zu lang. Kürze sie und versuche es erneut.',
@@ -4,14 +4,13 @@ export const en: AssistantCopy = {
4
4
  close: 'Close',
5
5
  resize: 'Resize the assistant',
6
6
  lockedTitle: 'Sign in to chat',
7
- lockedBody: 'The assistant picks films for you and remembers the conversation. Sign in to start.',
7
+ lockedBody: 'The assistant answers from your documents and remembers the conversation. Sign in to start.',
8
8
  lockedComposer: 'Sign in to ask anything',
9
9
  signIn: 'Sign in',
10
- hint: 'Talk films with AI?',
10
+ hint: 'Ask the assistant?',
11
11
  statusThinking: 'Thinking…',
12
- statusSearching: 'Searching the catalog…',
13
- statusReading: 'Reading about the films…',
14
- statusChoosing: 'Choosing the best ones…',
12
+ statusSearching: 'Searching…',
13
+ statusAnswering: 'Writing the answer…',
15
14
  errAuth: 'Sign in to ask the assistant.',
16
15
  errBusy: 'Finish in the other tab first.',
17
16
  errInvalid: 'That message is too long. Shorten it and try again.',
@@ -4,14 +4,13 @@ export const es: AssistantCopy = {
4
4
  close: 'Cerrar',
5
5
  resize: 'Cambiar el ancho del asistente',
6
6
  lockedTitle: 'Inicia sesión para chatear',
7
- lockedBody: 'El asistente elige películas para ti y recuerda la conversación. Inicia sesión para empezar.',
7
+ lockedBody: 'El asistente responde con tus documentos y recuerda la conversación. Inicia sesión para empezar.',
8
8
  lockedComposer: 'Inicia sesión para preguntar',
9
9
  signIn: 'Iniciar sesión',
10
- hint: '¿Hablamos de películas con la IA?',
10
+ hint: '¿Preguntar al asistente?',
11
11
  statusThinking: 'Pensando…',
12
- statusSearching: 'Buscando en el catálogo…',
13
- statusReading: 'Leyendo sobre las películas…',
14
- statusChoosing: 'Eligiendo las mejores…',
12
+ statusSearching: 'Buscando…',
13
+ statusAnswering: 'Escribiendo la respuesta…',
15
14
  errAuth: 'Inicia sesión para preguntar al asistente.',
16
15
  errBusy: 'Termina primero en la otra pestaña.',
17
16
  errInvalid: 'El mensaje es demasiado largo. Acórtalo e inténtalo de nuevo.',
@@ -4,14 +4,13 @@ export const fr: AssistantCopy = {
4
4
  close: 'Fermer',
5
5
  resize: 'Redimensionner l’assistant',
6
6
  lockedTitle: 'Connectez-vous pour discuter',
7
- lockedBody: 'L’assistant choisit des films pour vous et se souvient de la conversation. Connectez-vous pour commencer.',
7
+ lockedBody: 'L’assistant répond à partir de vos documents et se souvient de la conversation. Connectez-vous pour commencer.',
8
8
  lockedComposer: 'Connectez-vous pour demander',
9
9
  signIn: 'Se connecter',
10
- hint: 'On parle films avec l’IA ?',
10
+ hint: 'Demander à l’assistant ?',
11
11
  statusThinking: 'Je réfléchis…',
12
- statusSearching: 'Recherche dans le catalogue…',
13
- statusReading: 'Lecture sur les films…',
14
- statusChoosing: 'Je choisis les meilleurs…',
12
+ statusSearching: 'Recherche…',
13
+ statusAnswering: 'Rédaction de la réponse…',
15
14
  errAuth: 'Connectez-vous pour interroger l\'assistant.',
16
15
  errBusy: 'Terminez d\'abord dans l\'autre onglet.',
17
16
  errInvalid: 'Ce message est trop long. Raccourcissez-le et réessayez.',
@@ -4,14 +4,13 @@ export const it: AssistantCopy = {
4
4
  close: 'Chiudi',
5
5
  resize: 'Ridimensiona l’assistente',
6
6
  lockedTitle: 'Accedi per chattare',
7
- lockedBody: 'L’assistente sceglie i film per te e ricorda la conversazione. Accedi per iniziare.',
7
+ lockedBody: 'L’assistente risponde dai tuoi documenti e ricorda la conversazione. Accedi per iniziare.',
8
8
  lockedComposer: 'Accedi per chiedere',
9
9
  signIn: 'Accedi',
10
- hint: 'Parliamo di film con l’IA?',
10
+ hint: 'Chiedere all’assistente?',
11
11
  statusThinking: 'Sto pensando…',
12
- statusSearching: 'Cerco nel catalogo…',
13
- statusReading: 'Leggo i film…',
14
- statusChoosing: 'Scelgo i migliori…',
12
+ statusSearching: 'Cerco…',
13
+ statusAnswering: 'Scrivo la risposta…',
15
14
  errAuth: 'Accedi per chiedere all\'assistente.',
16
15
  errBusy: 'Prima termina nell\'altra scheda.',
17
16
  errInvalid: 'Il messaggio è troppo lungo. Accorcialo e riprova.',
@@ -4,14 +4,13 @@ export const ja: AssistantCopy = {
4
4
  close: '閉じる',
5
5
  resize: 'アシスタントの幅を変更',
6
6
  lockedTitle: 'チャットするにはサインイン',
7
- lockedBody: 'アシスタントがあなたに合う映画を選び、会話を覚えています。サインインして始めましょう。',
7
+ lockedBody: 'アシスタントはあなたの資料から答え、会話を覚えています。ログインして始めましょう。',
8
8
  lockedComposer: '質問するにはサインイン',
9
9
  signIn: 'サインイン',
10
- hint: 'AIと映画の話をしませんか?',
10
+ hint: 'アシスタントに聞いてみる?',
11
11
  statusThinking: '考え中…',
12
- statusSearching: 'カタログを検索中…',
13
- statusReading: '作品について調べ中…',
14
- statusChoosing: 'おすすめを選んでいます…',
12
+ statusSearching: '検索中…',
13
+ statusAnswering: '回答を作成中…',
15
14
  errAuth: 'アシスタントに聞くにはログインしてください。',
16
15
  errBusy: '先に別のタブでの回答をお待ちください。',
17
16
  errInvalid: 'メッセージが長すぎます。短くして再試行してください。',
@@ -4,14 +4,13 @@ export const ko: AssistantCopy = {
4
4
  close: '닫기',
5
5
  resize: '어시스턴트 너비 조절',
6
6
  lockedTitle: '채팅하려면 로그인하세요',
7
- lockedBody: '어시스턴트가 영화를 골라 주고 대화를 기억합니다. 시작하려면 로그인하세요.',
7
+ lockedBody: '어시스턴트가 문서를 바탕으로 답하고 대화를 기억합니다. 로그인하여 시작하세요.',
8
8
  lockedComposer: '질문하려면 로그인하세요',
9
9
  signIn: '로그인',
10
- hint: 'AI와 영화 이야기 나눠볼까요?',
10
+ hint: '어시스턴트에게 물어볼까요?',
11
11
  statusThinking: '생각 중…',
12
- statusSearching: '카탈로그에서 찾는 중…',
13
- statusReading: '영화 정보를 읽는 중…',
14
- statusChoosing: '가장 좋은 작품을 고르는 중…',
12
+ statusSearching: '검색 중…',
13
+ statusAnswering: '답변 작성 중…',
15
14
  errAuth: '어시스턴트에게 물으려면 로그인하세요.',
16
15
  errBusy: '다른 탭에서 먼저 끝내 주세요.',
17
16
  errInvalid: '메시지가 너무 깁니다. 줄여서 다시 시도하세요.',
@@ -4,14 +4,13 @@ export const nl: AssistantCopy = {
4
4
  close: 'Sluiten',
5
5
  resize: 'Breedte van de assistent aanpassen',
6
6
  lockedTitle: 'Log in om te chatten',
7
- lockedBody: 'De assistent kiest films voor je en onthoudt het gesprek. Log in om te beginnen.',
7
+ lockedBody: 'De assistent antwoordt op basis van je documenten en onthoudt het gesprek. Log in om te beginnen.',
8
8
  lockedComposer: 'Log in om te vragen',
9
9
  signIn: 'Inloggen',
10
- hint: 'Praten over films met AI?',
10
+ hint: 'De assistent iets vragen?',
11
11
  statusThinking: 'Even nadenken…',
12
- statusSearching: 'Zoeken in de catalogus…',
13
- statusReading: 'Lezen over de films…',
14
- statusChoosing: 'De beste kiezen…',
12
+ statusSearching: 'Zoeken…',
13
+ statusAnswering: 'Antwoord schrijven…',
15
14
  errAuth: 'Log in om de assistent te vragen.',
16
15
  errBusy: 'Rond eerst af in het andere tabblad.',
17
16
  errInvalid: 'Dat bericht is te lang. Maak het korter en probeer opnieuw.',
@@ -4,14 +4,13 @@ export const no: AssistantCopy = {
4
4
  close: 'Lukk',
5
5
  resize: 'Endre bredden på assistenten',
6
6
  lockedTitle: 'Logg inn for å chatte',
7
- lockedBody: 'Assistenten velger filmer for deg og husker samtalen. Logg inn for å komme i gang.',
7
+ lockedBody: 'Assistenten svarer ut fra dokumentene dine og husker samtalen. Logg inn for å starte.',
8
8
  lockedComposer: 'Logg inn for å spørre',
9
9
  signIn: 'Logg inn',
10
- hint: 'Skal vi snakke film med AI?',
10
+ hint: 'Spørre assistenten?',
11
11
  statusThinking: 'Tenker…',
12
- statusSearching: 'Søker i katalogen…',
13
- statusReading: 'Leser om filmene…',
14
- statusChoosing: 'Velger de beste…',
12
+ statusSearching: 'Søker…',
13
+ statusAnswering: 'Skriver svaret…',
15
14
  errAuth: 'Logg inn for å spørre assistenten.',
16
15
  errBusy: 'Fullfør først i den andre fanen.',
17
16
  errInvalid: 'Meldingen er for lang. Kort den ned og prøv igjen.',
@@ -4,14 +4,13 @@ export const pl: AssistantCopy = {
4
4
  close: 'Zamknij',
5
5
  resize: 'Zmień szerokość asystenta',
6
6
  lockedTitle: 'Zaloguj się, aby czatować',
7
- lockedBody: 'Asystent wybiera filmy dla Ciebie i pamięta rozmowę. Zaloguj się, aby zacząć.',
7
+ lockedBody: 'Asystent odpowiada na podstawie Twoich dokumentów i pamięta rozmowę. Zaloguj się, aby zacząć.',
8
8
  lockedComposer: 'Zaloguj się, aby zapytać',
9
9
  signIn: 'Zaloguj się',
10
- hint: 'Pogadamy o filmach z AI?',
10
+ hint: 'Zapytać asystenta?',
11
11
  statusThinking: 'Myślę…',
12
- statusSearching: 'Przeszukuję katalog…',
13
- statusReading: 'Czytam o filmach…',
14
- statusChoosing: 'Wybieram najlepsze…',
12
+ statusSearching: 'Szukam…',
13
+ statusAnswering: 'Piszę odpowiedź…',
15
14
  errAuth: 'Zaloguj się, aby zapytać asystenta.',
16
15
  errBusy: 'Najpierw dokończ w drugiej karcie.',
17
16
  errInvalid: 'Wiadomość jest za długa. Skróć ją i spróbuj ponownie.',
@@ -4,14 +4,13 @@ export const ptBR: AssistantCopy = {
4
4
  close: 'Fechar',
5
5
  resize: 'Redimensionar o assistente',
6
6
  lockedTitle: 'Entre para conversar',
7
- lockedBody: 'O assistente escolhe filmes para você e lembra da conversa. Entre para começar.',
7
+ lockedBody: 'O assistente responde com base nos seus documentos e lembra da conversa. Entre para começar.',
8
8
  lockedComposer: 'Entre para perguntar',
9
9
  signIn: 'Entrar',
10
- hint: 'Vamos falar de filmes com a IA?',
10
+ hint: 'Perguntar ao assistente?',
11
11
  statusThinking: 'Pensando…',
12
- statusSearching: 'Buscando no catálogo…',
13
- statusReading: 'Lendo sobre os filmes…',
14
- statusChoosing: 'Escolhendo os melhores…',
12
+ statusSearching: 'Buscando…',
13
+ statusAnswering: 'Escrevendo a resposta…',
15
14
  errAuth: 'Entre para perguntar ao assistente.',
16
15
  errBusy: 'Termine primeiro na outra aba.',
17
16
  errInvalid: 'A mensagem é longa demais. Encurte e tente de novo.',
@@ -4,14 +4,13 @@ export const ru: AssistantCopy = {
4
4
  close: 'Закрыть',
5
5
  resize: 'Изменить ширину ассистента',
6
6
  lockedTitle: 'Войдите, чтобы пообщаться',
7
- lockedBody: 'Ассистент подбирает фильмы и помнит разговор. Войдите, чтобы начать.',
7
+ lockedBody: 'Ассистент отвечает по вашим документам и помнит разговор. Войдите, чтобы начать.',
8
8
  lockedComposer: 'Войдите, чтобы задать вопрос',
9
9
  signIn: 'Войти',
10
- hint: 'Обсудим фильм с ИИ?',
10
+ hint: 'Спросить ассистента?',
11
11
  statusThinking: 'Думаю…',
12
- statusSearching: 'Ищу в каталоге…',
13
- statusReading: 'Читаю про фильмы…',
14
- statusChoosing: 'Выбираю лучшее…',
12
+ statusSearching: 'Ищу…',
13
+ statusAnswering: 'Пишу ответ…',
15
14
  errAuth: 'Войдите, чтобы спросить ассистента.',
16
15
  errBusy: 'Сначала завершите ответ в другой вкладке.',
17
16
  errInvalid: 'Сообщение слишком длинное. Сократите и повторите.',
@@ -4,14 +4,13 @@ export const sv: AssistantCopy = {
4
4
  close: 'Stäng',
5
5
  resize: 'Ändra assistentens bredd',
6
6
  lockedTitle: 'Logga in för att chatta',
7
- lockedBody: 'Assistenten väljer filmer åt dig och minns samtalet. Logga in för att börja.',
7
+ lockedBody: 'Assistenten svarar utifrån dina dokument och minns samtalet. Logga in för att börja.',
8
8
  lockedComposer: 'Logga in för att fråga',
9
9
  signIn: 'Logga in',
10
- hint: 'Ska vi prata film med AI?',
10
+ hint: 'Fråga assistenten?',
11
11
  statusThinking: 'Funderar…',
12
- statusSearching: 'Söker i katalogen…',
13
- statusReading: 'Läser om filmerna…',
14
- statusChoosing: 'Väljer de bästa…',
12
+ statusSearching: 'Söker…',
13
+ statusAnswering: 'Skriver svaret…',
15
14
  errAuth: 'Logga in för att fråga assistenten.',
16
15
  errBusy: 'Avsluta först i den andra fliken.',
17
16
  errInvalid: 'Meddelandet är för långt. Korta ner det och försök igen.',
@@ -4,14 +4,13 @@ export const tr: AssistantCopy = {
4
4
  close: 'Kapat',
5
5
  resize: 'Asistanın genişliğini değiştir',
6
6
  lockedTitle: 'Sohbet için giriş yapın',
7
- lockedBody: 'Asistan sizin için film seçer ve konuşmayı hatırlar. Başlamak için giriş yapın.',
7
+ lockedBody: 'Asistan belgelerinizden yanıt verir ve konuşmayı hatırlar. Başlamak için giriş yapın.',
8
8
  lockedComposer: 'Sormak için giriş yapın',
9
9
  signIn: 'Giriş yap',
10
- hint: 'Yapay zekayla filmleri konuşalım mı?',
10
+ hint: 'Asistana soralım mı?',
11
11
  statusThinking: 'Düşünüyorum…',
12
- statusSearching: 'Katalogda arıyorum…',
13
- statusReading: 'Filmleri okuyorum…',
14
- statusChoosing: 'En iyilerini seçiyorum…',
12
+ statusSearching: 'Arıyorum…',
13
+ statusAnswering: 'Yanıtı yazıyorum…',
15
14
  errAuth: 'Asistana sormak için giriş yapın.',
16
15
  errBusy: 'Önce diğer sekmede bitirin.',
17
16
  errInvalid: 'Mesaj çok uzun. Kısaltıp tekrar deneyin.',
@@ -4,14 +4,13 @@ export const zh: AssistantCopy = {
4
4
  close: '关闭',
5
5
  resize: '调整助手宽度',
6
6
  lockedTitle: '登录后即可聊天',
7
- lockedBody: '助手会为你挑选电影并记住对话。登录后开始使用。',
7
+ lockedBody: '助手会根据你的文档回答,并记住对话。登录后开始。',
8
8
  lockedComposer: '登录后提问',
9
9
  signIn: '登录',
10
- hint: '和 AI 聊聊电影?',
10
+ hint: '问问助手?',
11
11
  statusThinking: '思考中…',
12
- statusSearching: '正在搜索片库…',
13
- statusReading: '正在查阅影片资料…',
14
- statusChoosing: '正在挑选最佳影片…',
12
+ statusSearching: '正在搜索…',
13
+ statusAnswering: '正在撰写回答…',
15
14
  errAuth: '请登录后向助手提问。',
16
15
  errBusy: '请先在另一个标签页完成。',
17
16
  errInvalid: '消息太长,请缩短后重试。',
@@ -24,7 +24,7 @@ function rejectedText(copy: AssistantCopy, error: ChatHttpError): string {
24
24
  export function sessionCopyFor(copy: AssistantCopy): SessionCopy {
25
25
  return {
26
26
  messages: { rejected: (error) => rejectedText(copy, error), streamError: () => copy.failed, failed: copy.failed },
27
- status: { thinking: copy.statusThinking, searching: copy.statusSearching, reading: copy.statusReading, choosing: copy.statusChoosing },
27
+ status: { thinking: copy.statusThinking, searching: copy.statusSearching, answering: copy.statusAnswering },
28
28
  failedText: copy.failed,
29
29
  };
30
30
  }
package/src/i18n/types.ts CHANGED
@@ -13,8 +13,7 @@ export interface AssistantCopy {
13
13
  /** What the assistant is doing while an answer streams. */
14
14
  statusThinking: string;
15
15
  statusSearching: string;
16
- statusReading: string;
17
- statusChoosing: string;
16
+ statusAnswering: string;
18
17
  /** One plain sentence per refusal or failure. */
19
18
  errAuth: string;
20
19
  errBusy: string;
package/src/index.ts CHANGED
@@ -13,5 +13,7 @@ export { reconcile, type Reconciled } from './stream/reconcile';
13
13
  export { fromStored, fromStoredThread } from './stream/fromStored';
14
14
  export { createTransport, ChatHttpError, ChatNetworkError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
15
15
  export { StreamLostError } from './wire/parseSSE';
16
+ export { decodeFrame, WIRE_VERSION } from './wire/frames';
16
17
  export type { ChatContext, Frame, Phase, StreamErrorCode } from './wire/frames';
17
- export type { StoredConversation, StoredMessage, StoredStatus, StoredWidget } from './wire/stored';
18
+ export type { StoredConversation, StoredMessage, StoredPart, StoredStatus } from './wire/stored';
19
+ export { citationsRenderer, KB_RENDERERS, KB_PAYLOAD_KINDS, type Citation } from './examples/KbAssistant';
@@ -12,6 +12,9 @@ export interface AdapterDeps {
12
12
  onStatus: (phase: Phase | null) => void;
13
13
  /** A refusal before any frame (401, 409, 422, 429, 503): the host reacts (invitation, countdown, hide the entry point). */
14
14
  onRejected?: (error: ChatHttpError) => void;
15
+ /** The `kind`s the host draws. A live `ui_payload` of another kind is dropped and reported to `onUnknownPayload`. Absent: every kind is forwarded. */
16
+ payloadKinds?: readonly string[];
17
+ onUnknownPayload?: (kind: string) => void;
15
18
  /** The stream ended without a terminal frame: the host reconciles from the server. */
16
19
  onInterrupted?: () => void;
17
20
  /** A network-level failure, for the connectivity monitor. */
@@ -74,7 +77,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
74
77
  switch (frame.type) {
75
78
  case 'run':
76
79
  unsent = null;
77
- open(frame.assistant_message_id);
80
+ open(frame.message_id);
78
81
  break;
79
82
  case 'status':
80
83
  deps.onStatus(frame.phase);
@@ -91,6 +94,10 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
91
94
  break;
92
95
  case 'ui_payload':
93
96
  if (!messageId) break;
97
+ if (deps.payloadKinds && !deps.payloadKinds.includes(frame.kind)) {
98
+ deps.onUnknownPayload?.(frame.kind);
99
+ break;
100
+ }
94
101
  flush();
95
102
  textPart = null;
96
103
  controller.enqueue({ type: 'data', part: { type: 'data', id: `${messageId}:w${parts++}`, kind: frame.kind, data: frame.data } });
@@ -2,19 +2,15 @@ import type { ChatMessage, MessagePart } from '@djangocfg/widget-chat';
2
2
 
3
3
  import type { StoredMessage } from '../wire/stored.ts';
4
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
5
  /**
9
6
  * 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).
7
+ * (a running message is resolved by `reconcile`, which reseeds the thread when the server finishes it). Parts are kept whatever their `kind`: the host's
8
+ * renderers draw the kinds they know, as for a live answer.
11
9
  */
12
10
  export function fromStored(message: StoredMessage, failedText: string): ChatMessage {
13
11
  const parts: MessagePart[] = [];
14
12
  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
- });
13
+ message.parts.forEach((part, index) => parts.push({ id: `${message.id}:w${index}`, type: 'data', kind: part.kind, data: part.data }));
18
14
  const status: ChatMessage['status'] =
19
15
  message.status === 'done' ? { state: 'complete' } : message.status === 'error' ? { state: 'failed', message: failedText } : { state: 'interrupted' };
20
16
  return { id: message.id, role: message.role, parts, status, createdAt: Date.parse(message.created_at) || 0 };
@@ -14,7 +14,7 @@ import { createStreamAdapter, type AdapterDeps } from './adapter.ts';
14
14
  import { fromStoredThread } from './fromStored.ts';
15
15
  import { reconcile } from './reconcile.ts';
16
16
 
17
- /** The conversation endpoints (`GET`/`DELETE /apix/chat/v1/conversation`), supplied by the app. */
17
+ /** The conversation endpoints, supplied by the app (signed-in hosts). */
18
18
  export interface ThreadApi {
19
19
  load: (signal: AbortSignal) => Promise<StoredConversation>;
20
20
  clear: () => Promise<void>;
@@ -30,10 +30,14 @@ export interface SessionCopy {
30
30
 
31
31
  export interface SessionDeps {
32
32
  transport: ChatTransport;
33
- api: ThreadApi;
33
+ /** Absent: an anonymous session. Nothing is fetched, the room starts empty, and a lost stream cannot be reconciled (the notice offers Retry). */
34
+ api?: ThreadApi;
34
35
  getContext: () => ChatContext | undefined;
35
36
  /** Absent: the bundled words for the active language. */
36
37
  copy?: SessionCopy;
38
+ /** The `kind`s the host draws; any other live payload is dropped and reported. */
39
+ payloadKinds?: readonly string[];
40
+ onUnknownPayload?: (kind: string) => void;
37
41
  onRejected?: (error: ChatHttpError) => void;
38
42
  onNetworkFailure?: () => void;
39
43
  }
@@ -79,9 +83,11 @@ export function useSession(given: SessionDeps): Session {
79
83
  live.current?.abort();
80
84
  const controller = new AbortController();
81
85
  live.current = controller;
82
- setThread({ kind: 'loading' });
83
86
  setNotice(null);
84
- ref.current.api.load(controller.signal).then(
87
+ const api = ref.current.api;
88
+ if (!api) return seed({ id: null, messages: [] });
89
+ setThread({ kind: 'loading' });
90
+ api.load(controller.signal).then(
85
91
  (conversation) => !controller.signal.aborted && seed(conversation),
86
92
  () => !controller.signal.aborted && setThread({ kind: 'error' }),
87
93
  );
@@ -98,6 +104,8 @@ export function useSession(given: SessionDeps): Session {
98
104
  transport: deps.transport,
99
105
  getContext: () => ref.current.getContext(),
100
106
  onStatus: setPhase,
107
+ payloadKinds: deps.payloadKinds,
108
+ onUnknownPayload: (kind) => ref.current.onUnknownPayload?.(kind),
101
109
  onRejected: (error) => ref.current.onRejected?.(error),
102
110
  onNetworkFailure: () => ref.current.onNetworkFailure?.(),
103
111
  messages: {
@@ -109,9 +117,11 @@ export function useSession(given: SessionDeps): Session {
109
117
  },
110
118
  onInterrupted: () => {
111
119
  setNotice('reconnecting');
120
+ const api = ref.current.api;
121
+ if (!api) return setNotice('stalled');
112
122
  const controller = new AbortController();
113
123
  live.current = controller;
114
- reconcile({ fetchConversation: ref.current.api.load, signal: controller.signal }).then(
124
+ reconcile({ fetchConversation: api.load, signal: controller.signal }).then(
115
125
  (result) => {
116
126
  if (controller.signal.aborted) return;
117
127
  if (result.outcome === 'timeout') return setNotice('stalled');
@@ -123,11 +133,11 @@ export function useSession(given: SessionDeps): Session {
123
133
  },
124
134
  }),
125
135
  // The transport is one per mounted chat; everything else is read through `ref`.
126
- [deps.transport, seed],
136
+ [deps.transport, deps.payloadKinds, seed],
127
137
  );
128
138
 
129
139
  const clear = useCallback(async () => {
130
- await ref.current.api.clear();
140
+ await ref.current.api?.clear();
131
141
  live.current?.abort();
132
142
  seed({ id: null, messages: [] });
133
143
  }, [seed]);
@@ -0,0 +1,18 @@
1
+ {
2
+ "_note": "One of each frame the server speaks. The Python side (django_cfg agent bus events) commits the same file in its own tests; a drift fails a test on either side.",
3
+ "frames": [
4
+ { "type": "run", "version": 1, "thread_id": "5f0c8a52-7f0e-4c43-9a43-0e3b5a0d2f11", "message_id": "9b1e2c7a-4a0f-4d52-8d0d-3f1f6c9a7e22", "client_id": "c0a8e1d2-8f2e-4b7e-a1c4-6f5d8c1b2a33", "seq": 2 },
5
+ { "type": "status", "phase": "thinking" },
6
+ { "type": "status", "phase": "searching" },
7
+ { "type": "status", "phase": "answering" },
8
+ { "type": "text_delta", "delta": "Within 14 days " },
9
+ { "type": "ui_payload", "kind": "citations", "data": { "items": [{ "n": 1, "title": "Refund policy", "heading_path": ["Refund policy", "Eligibility"], "url": "" }] } },
10
+ { "type": "done", "tokens_in": 812, "tokens_out": 64, "cost_usd": 0.000412, "finish_reason": "stop" },
11
+ { "type": "done", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0, "finish_reason": "stop", "replayed": true },
12
+ { "type": "error", "code": "timeout", "message": "That took too long. Try again.", "retryable": true }
13
+ ],
14
+ "ignored": [
15
+ { "type": "tool_call", "tool": "kb_search", "args": {} },
16
+ { "type": "future_frame", "x": 1 }
17
+ ]
18
+ }
@@ -1,53 +1,64 @@
1
- /** The assistant SSE stream (frames: text deltas, tool and status events, structured parts, errors). The only file that knows the frame shapes. */
1
+ /**
2
+ * The assistant SSE vocabulary, one version of it: `run`, `status`, `text_delta`, `ui_payload`, `done`, `error`, each as `data: {json with type}\n\n`.
3
+ * The only file that knows the frame shapes. A host's structured parts travel as `ui_payload` `{kind, data}` and are drawn by the host's renderer for that kind.
4
+ * An unknown `type` is ignored and a malformed frame is dropped: the contract is forward compatible.
5
+ */
2
6
 
3
- export type Phase = 'thinking' | 'searching' | 'reading' | 'choosing';
7
+ export type Phase = 'thinking' | 'searching' | 'answering';
4
8
  export type StreamErrorCode = 'unavailable' | 'timeout' | 'failed';
5
9
 
10
+ /** The version `run` carries; a decoder that does not know a version still reads the fields it knows. */
11
+ export const WIRE_VERSION = 1;
12
+
6
13
  export type Frame =
7
- | { type: 'run'; run_id: string; user_message_id: string; assistant_message_id: string }
14
+ | { type: 'run'; version: number; thread_id: string; message_id: string; client_id: string | null; seq: number | null }
8
15
  | { type: 'status'; phase: Phase }
9
16
  | { 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 };
17
+ | { type: 'ui_payload'; kind: string; data: unknown }
18
+ | { type: 'done'; finish_reason: string | null; tokens_in: number | null; tokens_out: number | null; cost_usd: number | null; replayed: boolean }
19
+ | { type: 'error'; code: StreamErrorCode; message: string; retryable: boolean };
13
20
 
14
- const PHASES: readonly string[] = ['thinking', 'searching', 'reading', 'choosing'];
21
+ const PHASES: readonly string[] = ['thinking', 'searching', 'answering'];
15
22
  const ERROR_CODES: readonly string[] = ['unavailable', 'timeout', 'failed'];
16
23
 
17
24
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
18
25
  const str = (value: unknown): value is string => typeof value === 'string';
26
+ const num = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null);
19
27
 
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. */
28
+ /** A known, well-formed frame, or null. */
21
29
  export function decodeFrame(raw: unknown): Frame | null {
22
30
  if (!isRecord(raw)) return null;
23
31
  switch (raw.type) {
24
32
  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 }
33
+ return str(raw.thread_id) && str(raw.message_id)
34
+ ? { type: 'run', version: num(raw.version) ?? WIRE_VERSION, thread_id: raw.thread_id, message_id: raw.message_id, client_id: str(raw.client_id) ? raw.client_id : null, seq: num(raw.seq) }
27
35
  : null;
28
36
  case 'status':
29
37
  return str(raw.phase) && PHASES.includes(raw.phase) ? { type: 'status', phase: raw.phase as Phase } : null;
30
38
  case 'text_delta':
31
39
  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
- }
40
+ case 'ui_payload':
41
+ return str(raw.kind) && raw.kind !== '' && 'data' in raw ? { type: 'ui_payload', kind: raw.kind, data: raw.data } : null;
38
42
  case 'done':
39
- return str(raw.assistant_message_id) ? { type: 'done', assistant_message_id: raw.assistant_message_id } : null;
43
+ return {
44
+ type: 'done',
45
+ finish_reason: str(raw.finish_reason) ? raw.finish_reason : null,
46
+ tokens_in: num(raw.tokens_in),
47
+ tokens_out: num(raw.tokens_out),
48
+ cost_usd: num(raw.cost_usd),
49
+ replayed: raw.replayed === true,
50
+ };
40
51
  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;
52
+ return str(raw.code) && ERROR_CODES.includes(raw.code) && str(raw.message)
53
+ ? { type: 'error', code: raw.code as StreamErrorCode, message: raw.message, retryable: raw.retryable !== false }
54
+ : null;
42
55
  default:
43
56
  return null;
44
57
  }
45
58
  }
46
59
 
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 };
60
+ /** What the host says about the next turn (applies to that turn only): the page on screen, a selection, anything its server reads. The package never looks inside. */
61
+ export type ChatContext = Record<string, unknown>;
51
62
 
52
63
  /** The idle limit above the server's 15 s ping; a silent stream past it is treated as lost. */
53
64
  export const IDLE_MS = 45_000;
@@ -1,10 +1,10 @@
1
- /** A stored message as `GET /apix/chat/v1/conversation` returns it (CONTRACT.md `Message`). Replace with the generated type once `make gen-ts` carries group `chat`. */
1
+ /** A stored message as the host's history endpoint returns it. The shape is the host's server's; this is the part the widget reads. */
2
2
  export type StoredStatus = 'done' | 'running' | 'error' | 'cancelled';
3
3
 
4
- export interface StoredWidget {
4
+ /** A structured part the answer carried, by the same `kind` a live `ui_payload` has. */
5
+ export interface StoredPart {
5
6
  kind: string;
6
- ids: string[];
7
- caption: string | null;
7
+ data: unknown;
8
8
  }
9
9
 
10
10
  export interface StoredMessage {
@@ -12,7 +12,7 @@ export interface StoredMessage {
12
12
  seq: number;
13
13
  role: 'user' | 'assistant';
14
14
  text: string;
15
- widgets: StoredWidget[];
15
+ parts: StoredPart[];
16
16
  status: StoredStatus;
17
17
  error_code: string | null;
18
18
  created_at: string;
@@ -39,38 +39,63 @@ export interface ChatTransport {
39
39
  }
40
40
 
41
41
  export interface TransportConfig {
42
- /** API origin, no trailing slash. The stream is called directly, never through a Next rewrite. */
42
+ /** API origin, no trailing slash; prefixes a relative `streamUrl`. The stream is called directly, never through a Next rewrite. */
43
43
  baseUrl: string;
44
- /** The bearer token, read per request; the header only, never the query string. */
45
- getToken: () => string | null | undefined;
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` (deprecated, removed in 0.3). */
45
+ streamUrl?: string | ((input: SendInput) => string);
46
+ /** The request body. Absent: `{text, client_id, context?}`. */
47
+ buildBody?: (input: SendInput) => unknown;
48
+ /** The bearer token, read per request; the header only, never the query string. Signed-in hosts. */
49
+ getToken?: () => string | null | undefined;
50
+ /** Anonymous hosts: an opaque token the host stores, sent as `x-visitor-token`. No cookie is assumed, no history is fetched. */
51
+ visitorToken?: () => string | null | undefined;
52
+ /** More headers per request (a CSRF token). */
53
+ headers?: () => Record<string, string>;
46
54
  idleMs?: number;
55
+ /** Replaces `fetch` for the stream: add auth, a CSRF token, credentials. */
56
+ streamFetch?: typeof fetch;
57
+ /** @deprecated Use `streamFetch`. */
47
58
  fetch?: typeof fetch;
48
59
  }
49
60
 
50
61
  const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable'];
62
+ /** A server may name a bad request its own way; these read as `invalid`. */
63
+ const INVALID: readonly string[] = ['validation_error'];
51
64
 
52
65
  function rejectCode(status: number, body: unknown): RejectCode {
53
66
  const code = typeof body === 'object' && body !== null ? (body as { code?: unknown }).code : undefined;
54
67
  if (typeof code === 'string' && CODES.includes(code)) return code as RejectCode;
68
+ if (typeof code === 'string' && INVALID.includes(code)) return 'invalid';
55
69
  if (status === 401) return 'unauthorized';
56
70
  if (status === 409) return 'busy';
57
- if (status === 422) return 'invalid';
71
+ if (status === 400 || status === 422) return 'invalid';
58
72
  if (status === 429) return 'rate_limited';
59
73
  if (status === 503) return 'unavailable';
60
74
  return 'failed';
61
75
  }
62
76
 
77
+ const LEGACY_STREAM = '/apix/chat/v1/stream';
78
+
63
79
  export function createTransport(config: TransportConfig): ChatTransport {
64
- const call = config.fetch ?? fetch;
80
+ const call = config.streamFetch ?? config.fetch ?? fetch;
65
81
  return {
66
- async *open({ text, clientId, context, signal }) {
67
- const token = config.getToken();
82
+ async *open(input) {
83
+ const { text, clientId, context, signal } = input;
84
+ const token = config.getToken?.();
85
+ const visitor = config.visitorToken?.();
86
+ const target = typeof config.streamUrl === 'function' ? config.streamUrl(input) : (config.streamUrl ?? LEGACY_STREAM);
87
+ const url = /^https?:\/\//.test(target) ? target : `${config.baseUrl}${target}`;
68
88
  let response: Response;
69
89
  try {
70
- response = await call(`${config.baseUrl}/apix/chat/v1/stream`, {
90
+ response = await call(url, {
71
91
  method: 'POST',
72
- headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
73
- body: JSON.stringify({ text, client_id: clientId, ...(context ? { context } : {}) }),
92
+ headers: {
93
+ 'content-type': 'application/json',
94
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
95
+ ...(visitor ? { 'x-visitor-token': visitor } : {}),
96
+ ...config.headers?.(),
97
+ },
98
+ body: JSON.stringify(config.buildBody ? config.buildBody(input) : { text, client_id: clientId, ...(context ? { context } : {}) }),
74
99
  signal,
75
100
  });
76
101
  } catch {