@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 +3 -1
- package/package.json +7 -7
- package/src/stream/adapter.ts +40 -5
- package/src/wire/fixtures/events.json +2 -0
- package/src/wire/frames.ts +7 -0
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
|
|
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.
|
|
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.
|
|
47
|
+
"@djangocfg/widget-chat": "^0.1.27",
|
|
48
48
|
"zustand": "^5.0.16"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@djangocfg/i18n": "^2.1.
|
|
52
|
-
"@djangocfg/ui-core": "^2.1.
|
|
51
|
+
"@djangocfg/i18n": "^2.1.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.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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",
|
package/src/stream/adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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" },
|
package/src/wire/frames.ts
CHANGED
|
@@ -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':
|