@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 +21 -0
- package/package.json +7 -7
- package/src/AssistantChat.tsx +1 -1
- package/src/examples/KbAssistant.tsx +35 -0
- package/src/i18n/index.ts +11 -11
- package/src/i18n/locales/ar.ts +4 -5
- package/src/i18n/locales/da.ts +4 -5
- package/src/i18n/locales/de.ts +4 -5
- package/src/i18n/locales/en.ts +4 -5
- package/src/i18n/locales/es.ts +4 -5
- package/src/i18n/locales/fr.ts +4 -5
- package/src/i18n/locales/it.ts +4 -5
- package/src/i18n/locales/ja.ts +4 -5
- package/src/i18n/locales/ko.ts +4 -5
- package/src/i18n/locales/nl.ts +4 -5
- package/src/i18n/locales/no.ts +4 -5
- package/src/i18n/locales/pl.ts +4 -5
- package/src/i18n/locales/pt-BR.ts +4 -5
- package/src/i18n/locales/ru.ts +4 -5
- package/src/i18n/locales/sv.ts +4 -5
- package/src/i18n/locales/tr.ts +4 -5
- package/src/i18n/locales/zh.ts +4 -5
- package/src/i18n/sessionCopy.ts +1 -1
- package/src/i18n/types.ts +1 -2
- package/src/index.ts +3 -1
- package/src/stream/adapter.ts +8 -1
- package/src/stream/fromStored.ts +3 -7
- package/src/stream/useSession.ts +17 -7
- package/src/wire/fixtures/events.json +18 -0
- package/src/wire/frames.ts +33 -22
- package/src/wire/stored.ts +5 -5
- package/src/wire/transport.ts +35 -10
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.
|
|
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.
|
|
47
|
+
"@djangocfg/widget-chat": "^0.1.16",
|
|
48
48
|
"zustand": "^5.0.14"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@djangocfg/i18n": "^2.1.
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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",
|
package/src/AssistantChat.tsx
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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,
|
|
27
|
-
export const ASSISTANT_DICTIONARIES: Readonly<Record<
|
|
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
|
|
31
|
-
*
|
|
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
|
|
34
|
-
const locale = useLocaleOptional();
|
|
35
|
-
return useMemo(() => (locale ? ASSISTANT_DICTIONARIES[locale] ?? en : en), [locale]);
|
|
36
|
-
}
|
|
36
|
+
export const useAssistantCopy = kit.useCopy;
|
package/src/i18n/locales/ar.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
statusChoosing: 'أختار الأفضل…',
|
|
12
|
+
statusSearching: 'أبحث…',
|
|
13
|
+
statusAnswering: 'أكتب الإجابة…',
|
|
15
14
|
errAuth: 'سجّل الدخول لتسأل المساعد.',
|
|
16
15
|
errBusy: 'أنهِ أولاً في التبويب الآخر.',
|
|
17
16
|
errInvalid: 'الرسالة طويلة جدًا. اختصرها وأعد المحاولة.',
|
package/src/i18n/locales/da.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Spørg assistenten?',
|
|
11
11
|
statusThinking: 'Tænker…',
|
|
12
|
-
statusSearching: 'Søger
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/de.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Den Assistenten fragen?',
|
|
11
11
|
statusThinking: 'Ich denke nach…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/en.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Ask the assistant?',
|
|
11
11
|
statusThinking: 'Thinking…',
|
|
12
|
-
statusSearching: 'Searching
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/es.ts
CHANGED
|
@@ -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
|
|
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: '¿
|
|
10
|
+
hint: '¿Preguntar al asistente?',
|
|
11
11
|
statusThinking: 'Pensando…',
|
|
12
|
-
statusSearching: 'Buscando
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Demander à l’assistant ?',
|
|
11
11
|
statusThinking: 'Je réfléchis…',
|
|
12
|
-
statusSearching: 'Recherche
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Chiedere all’assistente?',
|
|
11
11
|
statusThinking: 'Sto pensando…',
|
|
12
|
-
statusSearching: 'Cerco
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/ja.ts
CHANGED
|
@@ -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: '
|
|
10
|
+
hint: 'アシスタントに聞いてみる?',
|
|
11
11
|
statusThinking: '考え中…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
14
|
-
statusChoosing: 'おすすめを選んでいます…',
|
|
12
|
+
statusSearching: '検索中…',
|
|
13
|
+
statusAnswering: '回答を作成中…',
|
|
15
14
|
errAuth: 'アシスタントに聞くにはログインしてください。',
|
|
16
15
|
errBusy: '先に別のタブでの回答をお待ちください。',
|
|
17
16
|
errInvalid: 'メッセージが長すぎます。短くして再試行してください。',
|
package/src/i18n/locales/ko.ts
CHANGED
|
@@ -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: '
|
|
10
|
+
hint: '어시스턴트에게 물어볼까요?',
|
|
11
11
|
statusThinking: '생각 중…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
14
|
-
statusChoosing: '가장 좋은 작품을 고르는 중…',
|
|
12
|
+
statusSearching: '검색 중…',
|
|
13
|
+
statusAnswering: '답변 작성 중…',
|
|
15
14
|
errAuth: '어시스턴트에게 물으려면 로그인하세요.',
|
|
16
15
|
errBusy: '다른 탭에서 먼저 끝내 주세요.',
|
|
17
16
|
errInvalid: '메시지가 너무 깁니다. 줄여서 다시 시도하세요.',
|
package/src/i18n/locales/nl.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'De assistent iets vragen?',
|
|
11
11
|
statusThinking: 'Even nadenken…',
|
|
12
|
-
statusSearching: 'Zoeken
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/no.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Spørre assistenten?',
|
|
11
11
|
statusThinking: 'Tenker…',
|
|
12
|
-
statusSearching: 'Søker
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/pl.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Zapytać asystenta?',
|
|
11
11
|
statusThinking: 'Myślę…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
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
|
|
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: '
|
|
10
|
+
hint: 'Perguntar ao assistente?',
|
|
11
11
|
statusThinking: 'Pensando…',
|
|
12
|
-
statusSearching: 'Buscando
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/ru.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
statusChoosing: 'Выбираю лучшее…',
|
|
12
|
+
statusSearching: 'Ищу…',
|
|
13
|
+
statusAnswering: 'Пишу ответ…',
|
|
15
14
|
errAuth: 'Войдите, чтобы спросить ассистента.',
|
|
16
15
|
errBusy: 'Сначала завершите ответ в другой вкладке.',
|
|
17
16
|
errInvalid: 'Сообщение слишком длинное. Сократите и повторите.',
|
package/src/i18n/locales/sv.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Fråga assistenten?',
|
|
11
11
|
statusThinking: 'Funderar…',
|
|
12
|
-
statusSearching: 'Söker
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/tr.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
10
|
+
hint: 'Asistana soralım mı?',
|
|
11
11
|
statusThinking: 'Düşünüyorum…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
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.',
|
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -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: '
|
|
10
|
+
hint: '问问助手?',
|
|
11
11
|
statusThinking: '思考中…',
|
|
12
|
-
statusSearching: '
|
|
13
|
-
|
|
14
|
-
statusChoosing: '正在挑选最佳影片…',
|
|
12
|
+
statusSearching: '正在搜索…',
|
|
13
|
+
statusAnswering: '正在撰写回答…',
|
|
15
14
|
errAuth: '请登录后向助手提问。',
|
|
16
15
|
errBusy: '请先在另一个标签页完成。',
|
|
17
16
|
errInvalid: '消息太长,请缩短后重试。',
|
package/src/i18n/sessionCopy.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
18
|
+
export type { StoredConversation, StoredMessage, StoredPart, StoredStatus } from './wire/stored';
|
|
19
|
+
export { citationsRenderer, KB_RENDERERS, KB_PAYLOAD_KINDS, type Citation } from './examples/KbAssistant';
|
package/src/stream/adapter.ts
CHANGED
|
@@ -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.
|
|
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 } });
|
package/src/stream/fromStored.ts
CHANGED
|
@@ -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.
|
|
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 };
|
package/src/stream/useSession.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/src/wire/frames.ts
CHANGED
|
@@ -1,53 +1,64 @@
|
|
|
1
|
-
/**
|
|
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' | '
|
|
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';
|
|
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:
|
|
11
|
-
| { type: 'done';
|
|
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', '
|
|
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
|
|
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.
|
|
26
|
-
? { type: 'run',
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
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;
|
package/src/wire/stored.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
/** A stored message as
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
+
parts: StoredPart[];
|
|
16
16
|
status: StoredStatus;
|
|
17
17
|
error_code: string | null;
|
|
18
18
|
created_at: string;
|
package/src/wire/transport.ts
CHANGED
|
@@ -39,38 +39,63 @@ export interface ChatTransport {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface TransportConfig {
|
|
42
|
-
/** API origin, no trailing slash
|
|
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
|
-
/**
|
|
45
|
-
|
|
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(
|
|
67
|
-
const
|
|
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(
|
|
90
|
+
response = await call(url, {
|
|
71
91
|
method: 'POST',
|
|
72
|
-
headers: {
|
|
73
|
-
|
|
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 {
|