@djangocfg/widget-assistant 0.3.11 → 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/package.json +7 -7
- package/src/index.ts +14 -0
- package/src/sessions/kit.ts +119 -0
- package/src/sessions/storage.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/widget-assistant",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
47
|
+
"@djangocfg/widget-chat": "^0.1.28",
|
|
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.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.
|
|
58
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
59
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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
|
+
}
|