@djangocfg/widget-assistant 0.3.10 → 0.3.12

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
@@ -42,6 +42,8 @@ One vocabulary, `data: {json with type}\n\n`, decoded only in `wire/frames.ts`:
42
42
  | `run` | `version`, `thread_id`, `message_id`, `client_id`, `seq` |
43
43
  | `status` | `phase`: `thinking` \| `searching` \| `answering` |
44
44
  | `text_delta` | `delta` |
45
+ | `reasoning_delta` | `delta` (optional layer: the model's thinking, drawn by the widget's own Reasoning block) |
46
+ | `reasoning_end` | `elapsed_ms?`, `tokens?` (optional: seals the thought; otherwise the next text, payload or `done` does) |
45
47
  | `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` |
46
48
  | `done` | `tokens_in`, `tokens_out`, `cost_usd`, `finish_reason`, `replayed?` |
47
49
  | `error` | `code` (`unavailable` \| `timeout` \| `failed`), `message`, `retryable` |
@@ -64,7 +66,7 @@ A session is one thread on the server. `useSession({ transport, api, storage, ge
64
66
 
65
67
  ## Voice (0.3.3)
66
68
 
67
- The microphone menu always offers a dictation language: a visitor may speak any language, whatever the host UI says. With no `speech` prop the assistant uses `useSpeechLanguage` from `@djangocfg/widget-chat/store` (the last pick, kept on this device; else the page language if the browser also lists it; else the browser's; else English). A host `speech={{ value, onChange }}` still overrides it.
69
+ The microphone menu always offers a dictation language: a visitor may speak any language, whatever the host UI says. With no `speech` prop the assistant owns it through `useSpeechLanguage` from `@djangocfg/widget-chat/store` (the last pick, kept on this device under `cfg.chat.speech_language`, storage errors swallowed; else the page language if the browser also lists it; else the browser's; else English) and feeds `createWholeTranscriptSpeech`. A host `speech={{ value, onChange }}` still overrides it (controlled mode). The picker lists the base widget's Web Speech catalogue (61 languages, with dialects, searchable), which covers all 17 bundled locales (`zh` is Mandarin, `no` is Bokmål); it has no free "other BCP-47" field. Touch devices hide the picker and the microphone (they have their own keyboard dictation). A host that shows no picker runs an older release: upgrade to 0.3.3 or later.
68
70
 
69
71
  Message sounds (sent, answer starts, answer done, error) come from `widget-chat` and are on by default; `messageSounds={false}` silences them, `messageSounds={{ volume, urls }}` tunes them.
70
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/widget-assistant",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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.26",
47
+ "@djangocfg/widget-chat": "^0.1.28",
48
48
  "zustand": "^5.0.16"
49
49
  },
50
50
  "peerDependencies": {
51
- "@djangocfg/i18n": "^2.1.625",
52
- "@djangocfg/ui-core": "^2.1.625",
51
+ "@djangocfg/i18n": "^2.1.627",
52
+ "@djangocfg/ui-core": "^2.1.627",
53
53
  "lucide-react": "^1.68.0",
54
54
  "react": "^19.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@djangocfg/i18n": "^2.1.625",
58
- "@djangocfg/typescript-config": "^2.1.625",
59
- "@djangocfg/ui-core": "^2.1.625",
57
+ "@djangocfg/i18n": "^2.1.627",
58
+ "@djangocfg/typescript-config": "^2.1.627",
59
+ "@djangocfg/ui-core": "^2.1.627",
60
60
  "@storybook/react-vite": "^10.6.5",
61
61
  "@types/node": "^25.9.5",
62
62
  "@types/react": "19.3.0",
package/src/index.ts CHANGED
@@ -11,6 +11,20 @@ export { AssistantHostProvider, AssistantPortal, AssistantSlot, AssistantHeaderA
11
11
  export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type ThreadState } from './stream/useSession';
12
12
  export { relativeTime, restoreSession, type SessionStorage, type ThreadApi } from './stream/sessions';
13
13
  export { SessionsMenu } from './sessions/SessionsMenu';
14
+ export { createSessionStorage, useRememberedSession } from './sessions/storage';
15
+ export {
16
+ createChatTransport,
17
+ storedMessageOf,
18
+ storedThreadOf,
19
+ type ChatCallResult,
20
+ type ChatConnection,
21
+ type ChatMessageShape,
22
+ type ChatThreadCalls,
23
+ type ChatThreadDetailShape,
24
+ type ChatThreadListItemShape,
25
+ type ChatThreadShape,
26
+ type ChatTransportConfig,
27
+ } from './sessions/kit';
14
28
  export { createStreamAdapter, type AdapterDeps } from './stream/adapter';
15
29
  export { reconcile, type Reconciled } from './stream/reconcile';
16
30
  export { fromStored, fromStoredThread } from './stream/fromStored';
@@ -0,0 +1,119 @@
1
+ import type { StoredConversation, StoredMessage, StoredStatus, StoredThread } from '../wire/stored';
2
+ import { createTransport, ThreadNotFoundError, type ChatTransport, type SendInput, type TransportConfig } from '../wire/transport';
3
+ import type { ThreadApi } from '../stream/sessions';
4
+
5
+ /**
6
+ * The chat API kit's thread shapes (django-cfg `django_llm.agent.chat.api`), as the generated client names them (`ChatThread`, `ChatThreadListItem`,
7
+ * `ChatThreadDetail`, `ChatMessage`). Structural and read-only: a generated type is assignable (its enums are string enums), and a field the contract
8
+ * changes shows up at the host's call site as a type error. Only the fields the widget reads are listed.
9
+ */
10
+ export interface ChatMessageShape {
11
+ id: string;
12
+ seq: number;
13
+ role: string;
14
+ text: string;
15
+ status: string;
16
+ /** `''` when there is none. */
17
+ error_code: string | null;
18
+ parts: ReadonlyArray<{ kind: string; data?: unknown }>;
19
+ created_at: string;
20
+ }
21
+ export interface ChatThreadShape {
22
+ id: string;
23
+ title: string;
24
+ updated_at: string;
25
+ }
26
+ export interface ChatThreadListItemShape extends ChatThreadShape {
27
+ message_count: number;
28
+ last_message_at: string | null;
29
+ }
30
+ export interface ChatThreadDetailShape extends ChatThreadShape {
31
+ messages: readonly ChatMessageShape[];
32
+ }
33
+
34
+ /** What a generated hey-api call resolves to (`{data, error, response}`); `response.status` carries a 404. */
35
+ export interface ChatCallResult<T> {
36
+ data?: T;
37
+ error?: unknown;
38
+ response?: { status: number };
39
+ }
40
+
41
+ /** The three plain calls of the kit's thread routes, as the host maps them from its generated client (`Chat.chatThreadsList`, ...). The stream is not among them: no schema describes it. */
42
+ export interface ChatThreadCalls {
43
+ list: (options: { signal?: AbortSignal }) => Promise<ChatCallResult<readonly ChatThreadListItemShape[]>>;
44
+ create: (options: { body?: { kind?: string } }) => Promise<ChatCallResult<ChatThreadShape>>;
45
+ retrieve: (options: { path: { thread_id: string }; signal?: AbortSignal }) => Promise<ChatCallResult<ChatThreadDetailShape>>;
46
+ }
47
+
48
+ const STATUSES: readonly StoredStatus[] = ['done', 'running', 'error', 'cancelled'];
49
+
50
+ /** The kit's message as the widget's stored message. An unknown status reads as `error` (never a spinner without a stream). */
51
+ export function storedMessageOf(message: ChatMessageShape): StoredMessage {
52
+ return {
53
+ id: message.id,
54
+ seq: message.seq,
55
+ role: message.role === 'user' ? 'user' : 'assistant',
56
+ text: message.text,
57
+ parts: message.parts.map((part) => ({ kind: part.kind, data: part.data })),
58
+ status: (STATUSES as readonly string[]).includes(message.status) ? (message.status as StoredStatus) : 'error',
59
+ error_code: message.error_code ? message.error_code : null,
60
+ created_at: message.created_at,
61
+ };
62
+ }
63
+
64
+ /** A listed row, or a freshly created thread (`POST` answers without the counters). */
65
+ export function storedThreadOf(row: ChatThreadShape & Partial<Pick<ChatThreadListItemShape, 'message_count' | 'last_message_at'>>): StoredThread {
66
+ return { id: row.id, title: row.title, last_message_at: row.last_message_at ?? row.updated_at, message_count: row.message_count ?? 0 };
67
+ }
68
+
69
+ export interface ChatTransportConfig
70
+ extends Pick<TransportConfig, 'getToken' | 'headers' | 'idleMs' | 'streamFetch'> {
71
+ /** The thread calls of the generated client. */
72
+ client: ChatThreadCalls;
73
+ /** API origin, no trailing slash (the stream is called directly, never through a rewrite). */
74
+ baseUrl: string;
75
+ /** Where the kit is mounted, e.g. `/apix/assistant` (no trailing slash). The stream goes to `<mount>/threads/<id>/messages/`. */
76
+ mount: string;
77
+ /** `false` when the kit was mounted with `trailing_slash=False`. Default `true`. */
78
+ trailingSlash?: boolean;
79
+ /** The chat kind "New chat" asks for; omit where the server named a default kind. */
80
+ kind?: string;
81
+ }
82
+
83
+ export interface ChatConnection {
84
+ /** Hand to `useSession({ transport })`. */
85
+ transport: ChatTransport;
86
+ /** Hand to `useSession({ api })`. */
87
+ api: ThreadApi;
88
+ }
89
+
90
+ /**
91
+ * Everything a host connects to the kit, from the generated client's three thread calls: the SSE transport (`POST <mount>/threads/<id>/messages`, the frames of
92
+ * `wire-contract.md`) and the `ThreadApi` for list / new chat / load (a 404 is `ThreadNotFoundError`, an error body or an absent one is a plain `Error`).
93
+ * The host adds renderers, words, auth (`getToken` / `streamFetch`) and the mount point; nothing else.
94
+ */
95
+ export function createChatTransport(config: ChatTransportConfig): ChatConnection {
96
+ const { client, baseUrl, mount, kind, trailingSlash = true } = config;
97
+ const tail = trailingSlash ? '/' : '';
98
+ const streamUrl = ({ threadId }: SendInput) => `${mount}/threads/${encodeURIComponent(threadId ?? '')}/messages${tail}`;
99
+ const transport = createTransport({ baseUrl, streamUrl, getToken: config.getToken, headers: config.headers, idleMs: config.idleMs, streamFetch: config.streamFetch });
100
+ const api: ThreadApi = {
101
+ async list(signal) {
102
+ const res = await client.list({ signal });
103
+ if (res.error || !res.data) throw new Error('chat threads');
104
+ return res.data.map(storedThreadOf);
105
+ },
106
+ async create() {
107
+ const res = await client.create(kind ? { body: { kind } } : {});
108
+ if (res.error || !res.data) throw new Error('chat create');
109
+ return storedThreadOf(res.data);
110
+ },
111
+ async load(id, signal): Promise<StoredConversation> {
112
+ const res = await client.retrieve({ path: { thread_id: id }, signal });
113
+ if (res.response?.status === 404) throw new ThreadNotFoundError();
114
+ if (res.error || !res.data) throw new Error('chat thread');
115
+ return { id: res.data.id, messages: res.data.messages.map(storedMessageOf) };
116
+ },
117
+ };
118
+ return { transport, api };
119
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import type { SessionStorage } from '../stream/sessions';
6
+
7
+ /** The last used session id in a Storage (default `localStorage`). Never throws: remembering is a convenience (private mode, a full quota, SSR). */
8
+ export function createSessionStorage(key: string, backing?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>): SessionStorage {
9
+ const store = () => backing ?? (typeof localStorage === 'undefined' ? null : localStorage);
10
+ return {
11
+ read: () => {
12
+ try {
13
+ return store()?.getItem(key) ?? null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ },
18
+ write: (id) => {
19
+ try {
20
+ if (id) store()?.setItem(key, id);
21
+ else store()?.removeItem(key);
22
+ } catch {
23
+ // see above
24
+ }
25
+ },
26
+ };
27
+ }
28
+
29
+ /** A stable `SessionStorage` for `useSession({ storage })`: the key should be namespaced to the app (and to the account if accounts share a browser). */
30
+ export function useRememberedSession(key: string): SessionStorage {
31
+ return useMemo(() => createSessionStorage(key), [key]);
32
+ }
@@ -37,7 +37,7 @@ export interface AdapterDeps {
37
37
  const defaultId = () => globalThis.crypto.randomUUID();
38
38
 
39
39
  /**
40
- * The wire to the widget. Maps one run's frames to chunks (`run` opens the assistant message, `text_delta` grows its text, `ui_payload` adds a card part after the text
40
+ * The wire to the widget. Maps one run's frames to chunks (`run` opens the assistant message, `text_delta` grows its text, the optional `reasoning_delta` / `reasoning_end` frames drive the widget's own Reasoning part, `ui_payload` adds a card part after the text
41
41
  * it follows, `done` finishes, `error` fails keeping the partial text); a stream that ends without a terminal frame is `interrupted`, and the host reconciles from the server.
42
42
  * Everything a run needs travels inside its own ordered chunk stream: no shared buffer between runs.
43
43
  */
@@ -56,8 +56,12 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
56
56
  async start(controller) {
57
57
  let messageId = '';
58
58
  let textPart: string | null = null;
59
+ /** The open reasoning part (optional `reasoning_delta` frames); sealed by `reasoning_end` or by the next part. */
60
+ let thoughtPart: string | null = null;
59
61
  let parts = 0;
60
62
  let pending = '';
63
+ /** The part `pending` belongs to, with the chunk that carries it. */
64
+ let pendingKind: 'text-delta' | 'reasoning-delta' = 'text-delta';
61
65
  let timer: ReturnType<typeof setTimeout> | null = null;
62
66
  let terminal = false;
63
67
  deps.onActive?.(true);
@@ -69,10 +73,25 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
69
73
  const flush = () => {
70
74
  if (timer) clearTimeout(timer);
71
75
  timer = null;
72
- if (pending && textPart) controller.enqueue({ type: 'text-delta', partId: textPart, delta: pending });
76
+ const target = pendingKind === 'text-delta' ? textPart : thoughtPart;
77
+ if (pending && target) controller.enqueue({ type: pendingKind, partId: target, delta: pending });
73
78
  pending = '';
74
79
  };
80
+ const queue = (kind: 'text-delta' | 'reasoning-delta', delta: string) => {
81
+ if (pending && pendingKind !== kind) flush();
82
+ pendingKind = kind;
83
+ pending += delta;
84
+ if (coalesceMs === 0) flush();
85
+ else timer ??= setTimeout(flush, coalesceMs);
86
+ };
87
+ const endThought = (elapsedMs?: number, tokens?: number) => {
88
+ if (!thoughtPart) return;
89
+ flush();
90
+ controller.enqueue({ type: 'reasoning-end', partId: thoughtPart, ...(elapsedMs === undefined ? {} : { elapsedMs }), ...(tokens === undefined ? {} : { tokens }) });
91
+ thoughtPart = null;
92
+ };
75
93
  const fail = (message: string) => {
94
+ endThought();
76
95
  flush();
77
96
  if (!messageId) open(`local-${clientId}`);
78
97
  controller.enqueue({ type: 'error', message });
@@ -89,13 +108,25 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
89
108
  break;
90
109
  case 'text_delta':
91
110
  if (!messageId) break;
111
+ endThought();
92
112
  if (!textPart) {
93
113
  textPart = `${messageId}:t${parts++}`;
94
114
  controller.enqueue({ type: 'text-start', partId: textPart });
95
115
  }
96
- pending += frame.delta;
97
- if (coalesceMs === 0) flush();
98
- else timer ??= setTimeout(flush, coalesceMs);
116
+ queue('text-delta', frame.delta);
117
+ break;
118
+ case 'reasoning_delta':
119
+ if (!messageId) break;
120
+ if (!thoughtPart) {
121
+ flush();
122
+ textPart = null;
123
+ thoughtPart = `${messageId}:r${parts++}`;
124
+ controller.enqueue({ type: 'reasoning-start', partId: thoughtPart });
125
+ }
126
+ queue('reasoning-delta', frame.delta);
127
+ break;
128
+ case 'reasoning_end':
129
+ endThought(frame.elapsed_ms ?? undefined, frame.tokens ?? undefined);
99
130
  break;
100
131
  case 'ui_payload':
101
132
  if (!messageId) break;
@@ -103,11 +134,13 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
103
134
  deps.onUnknownPayload?.(frame.kind);
104
135
  break;
105
136
  }
137
+ endThought();
106
138
  flush();
107
139
  textPart = null;
108
140
  controller.enqueue({ type: 'data', part: { type: 'data', id: `${messageId}:w${parts++}`, kind: frame.kind, data: frame.data } });
109
141
  break;
110
142
  case 'done':
143
+ endThought();
111
144
  flush();
112
145
  controller.enqueue({ type: 'finish' });
113
146
  terminal = true;
@@ -125,6 +158,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
125
158
  if (terminal) break;
126
159
  }
127
160
  if (!terminal) {
161
+ endThought();
128
162
  flush();
129
163
  if (!signal.aborted) {
130
164
  controller.enqueue({ type: 'interrupted' });
@@ -139,6 +173,7 @@ export function createStreamAdapter(deps: AdapterDeps): ChatAdapter {
139
173
  deps.onNetworkFailure?.();
140
174
  fail(deps.messages.failed);
141
175
  } else if (error instanceof StreamLostError) {
176
+ endThought();
142
177
  flush();
143
178
  controller.enqueue({ type: 'interrupted' });
144
179
  deps.onInterrupted?.();
@@ -5,6 +5,8 @@
5
5
  { "type": "status", "phase": "thinking" },
6
6
  { "type": "status", "phase": "searching" },
7
7
  { "type": "status", "phase": "answering" },
8
+ { "type": "reasoning_delta", "delta": "Checking the refund policy. " },
9
+ { "type": "reasoning_end", "elapsed_ms": 1200, "tokens": 42 },
8
10
  { "type": "text_delta", "delta": "Within 14 days " },
9
11
  { "type": "ui_payload", "kind": "citations", "data": { "items": [{ "n": 1, "title": "Refund policy", "heading_path": ["Refund policy", "Eligibility"], "url": "" }] } },
10
12
  { "type": "done", "tokens_in": 812, "tokens_out": 64, "cost_usd": 0.000412, "finish_reason": "stop" },
@@ -1,6 +1,7 @@
1
1
  /**
2
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
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
+ * `reasoning_delta` / `reasoning_end` are an optional layer: a host that never emits them is unaffected.
4
5
  * An unknown `type` is ignored and a malformed frame is dropped: the contract is forward compatible.
5
6
  */
6
7
 
@@ -14,6 +15,8 @@ export type Frame =
14
15
  | { type: 'run'; version: number; thread_id: string; message_id: string; client_id: string | null; seq: number | null }
15
16
  | { type: 'status'; phase: Phase }
16
17
  | { type: 'text_delta'; delta: string }
18
+ | { type: 'reasoning_delta'; delta: string }
19
+ | { type: 'reasoning_end'; elapsed_ms: number | null; tokens: number | null }
17
20
  | { type: 'ui_payload'; kind: string; data: unknown }
18
21
  | { type: 'done'; finish_reason: string | null; tokens_in: number | null; tokens_out: number | null; cost_usd: number | null; replayed: boolean }
19
22
  | { type: 'error'; code: StreamErrorCode; message: string; retryable: boolean };
@@ -37,6 +40,10 @@ export function decodeFrame(raw: unknown): Frame | null {
37
40
  return str(raw.phase) && PHASES.includes(raw.phase) ? { type: 'status', phase: raw.phase as Phase } : null;
38
41
  case 'text_delta':
39
42
  return str(raw.delta) ? { type: 'text_delta', delta: raw.delta } : null;
43
+ case 'reasoning_delta':
44
+ return str(raw.delta) ? { type: 'reasoning_delta', delta: raw.delta } : null;
45
+ case 'reasoning_end':
46
+ return { type: 'reasoning_end', elapsed_ms: num(raw.elapsed_ms), tokens: num(raw.tokens) };
40
47
  case 'ui_payload':
41
48
  return str(raw.kind) && raw.kind !== '' && 'data' in raw ? { type: 'ui_payload', kind: raw.kind, data: raw.data } : null;
42
49
  case 'done':