@djangocfg/widget-assistant 0.3.9 → 0.3.11

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.9",
3
+ "version": "0.3.11",
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.25",
47
+ "@djangocfg/widget-chat": "^0.1.27",
48
48
  "zustand": "^5.0.16"
49
49
  },
50
50
  "peerDependencies": {
51
- "@djangocfg/i18n": "^2.1.624",
52
- "@djangocfg/ui-core": "^2.1.624",
51
+ "@djangocfg/i18n": "^2.1.626",
52
+ "@djangocfg/ui-core": "^2.1.626",
53
53
  "lucide-react": "^1.68.0",
54
54
  "react": "^19.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@djangocfg/i18n": "^2.1.624",
58
- "@djangocfg/typescript-config": "^2.1.624",
59
- "@djangocfg/ui-core": "^2.1.624",
57
+ "@djangocfg/i18n": "^2.1.626",
58
+ "@djangocfg/typescript-config": "^2.1.626",
59
+ "@djangocfg/ui-core": "^2.1.626",
60
60
  "@storybook/react-vite": "^10.6.5",
61
61
  "@types/node": "^25.9.5",
62
62
  "@types/react": "19.3.0",
@@ -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':