@djangocfg/widget-assistant 0.0.0-stage → 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/package.json +72 -4
  4. package/src/AssistantChat.tsx +115 -0
  5. package/src/Host.tsx +57 -0
  6. package/src/composer/AssistantComposer.tsx +19 -0
  7. package/src/composer/ComposerBehaviour.tsx +84 -0
  8. package/src/composer/ComposerHelp.tsx +46 -0
  9. package/src/composer/keys.ts +2 -0
  10. package/src/composer/useCaretFocus.ts +49 -0
  11. package/src/composer/useComposerNodes.ts +14 -0
  12. package/src/composer/useFocusArea.ts +29 -0
  13. package/src/composer/useGracefulStop.ts +63 -0
  14. package/src/composer/useHeightFloor.ts +27 -0
  15. package/src/composer/useRecording.ts +18 -0
  16. package/src/composer/useSentPrompts.ts +20 -0
  17. package/src/composer/wholeTranscript.ts +106 -0
  18. package/src/i18n/index.ts +36 -0
  19. package/src/i18n/locales/ar.ts +34 -0
  20. package/src/i18n/locales/da.ts +34 -0
  21. package/src/i18n/locales/de.ts +34 -0
  22. package/src/i18n/locales/en.ts +34 -0
  23. package/src/i18n/locales/es.ts +34 -0
  24. package/src/i18n/locales/fr.ts +34 -0
  25. package/src/i18n/locales/it.ts +34 -0
  26. package/src/i18n/locales/ja.ts +34 -0
  27. package/src/i18n/locales/ko.ts +34 -0
  28. package/src/i18n/locales/nl.ts +34 -0
  29. package/src/i18n/locales/no.ts +34 -0
  30. package/src/i18n/locales/pl.ts +34 -0
  31. package/src/i18n/locales/pt-BR.ts +34 -0
  32. package/src/i18n/locales/ru.ts +34 -0
  33. package/src/i18n/locales/sv.ts +34 -0
  34. package/src/i18n/locales/tr.ts +34 -0
  35. package/src/i18n/locales/zh.ts +34 -0
  36. package/src/i18n/sessionCopy.ts +30 -0
  37. package/src/i18n/types.ts +42 -0
  38. package/src/index.ts +17 -0
  39. package/src/messages/MessageFooter.tsx +25 -0
  40. package/src/messages/NoticeLine.tsx +32 -0
  41. package/src/shell/AssistantFab.tsx +112 -0
  42. package/src/shell/AssistantFrame.tsx +103 -0
  43. package/src/shell/AssistantLocked.tsx +44 -0
  44. package/src/shell/ChatDock.tsx +62 -0
  45. package/src/shell/panelState.ts +74 -0
  46. package/src/stream/adapter.ts +144 -0
  47. package/src/stream/fromStored.ts +23 -0
  48. package/src/stream/reconcile.ts +45 -0
  49. package/src/stream/scriptedTransport.ts +28 -0
  50. package/src/stream/useSession.ts +136 -0
  51. package/src/styles/composer.css +14 -0
  52. package/src/styles/fab.css +53 -0
  53. package/src/styles/index.css +6 -0
  54. package/src/styles/layout.css +15 -0
  55. package/src/styles/widget.css +7 -0
  56. package/src/wire/frames.ts +53 -0
  57. package/src/wire/parseSSE.ts +76 -0
  58. package/src/wire/stored.ts +24 -0
  59. package/src/wire/transport.ts +94 -0
@@ -0,0 +1,76 @@
1
+ /** The stream ended because nothing arrived for the idle limit, or the connection broke mid-stream. The stored message is the truth: reconcile. */
2
+ export class StreamLostError extends Error {
3
+ readonly reason: 'idle' | 'network';
4
+
5
+ constructor(reason: 'idle' | 'network') {
6
+ super(`stream lost (${reason})`);
7
+ this.reason = reason;
8
+ }
9
+ }
10
+
11
+ /** One block (lines up to a blank line) to its JSON payload: `:` comment lines are skipped, `data:` lines are joined, malformed JSON is dropped. */
12
+ function payloadOf(block: string): unknown {
13
+ const data: string[] = [];
14
+ for (const line of block.split('\n')) {
15
+ if (line.startsWith(':')) continue;
16
+ if (line.startsWith('data:')) data.push(line.slice(line.charAt(5) === ' ' ? 6 : 5));
17
+ }
18
+ if (data.length === 0) return undefined;
19
+ try {
20
+ return JSON.parse(data.join('\n'));
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ const withIdle = <T,>(read: Promise<T>, idleMs: number): Promise<T> =>
27
+ new Promise<T>((resolve, reject) => {
28
+ const timer = setTimeout(() => reject(new StreamLostError('idle')), idleMs);
29
+ read.then(
30
+ (value) => {
31
+ clearTimeout(timer);
32
+ resolve(value);
33
+ },
34
+ (error: unknown) => {
35
+ clearTimeout(timer);
36
+ reject(error);
37
+ },
38
+ );
39
+ });
40
+
41
+ /**
42
+ * JSON payloads of a Server-Sent Events body, in order. Blocks end at a blank line (`\n\n`, `\r\n\r\n`); a trailing partial block is flushed at the end;
43
+ * a multi-byte character split across chunks is decoded whole. Throws `StreamLostError` on the idle limit or a read failure; an abort by the caller ends quietly.
44
+ */
45
+ export async function* parseSSE(body: ReadableStream<Uint8Array>, options: { idleMs: number; signal?: AbortSignal }): AsyncGenerator<unknown> {
46
+ const reader = body.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = '';
49
+ try {
50
+ for (;;) {
51
+ let chunk: ReadableStreamReadResult<Uint8Array>;
52
+ try {
53
+ chunk = await withIdle(reader.read(), options.idleMs);
54
+ } catch (error) {
55
+ if (options.signal?.aborted) return;
56
+ throw error instanceof StreamLostError ? error : new StreamLostError('network');
57
+ }
58
+ if (chunk.done) break;
59
+ buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n?/g, '\n');
60
+ let cut = buffer.indexOf('\n\n');
61
+ while (cut !== -1) {
62
+ const payload = payloadOf(buffer.slice(0, cut));
63
+ buffer = buffer.slice(cut + 2);
64
+ if (payload !== undefined) yield payload;
65
+ cut = buffer.indexOf('\n\n');
66
+ }
67
+ }
68
+ buffer += decoder.decode().replace(/\r\n?/g, '\n');
69
+ if (buffer.trim()) {
70
+ const payload = payloadOf(buffer);
71
+ if (payload !== undefined) yield payload;
72
+ }
73
+ } finally {
74
+ reader.cancel().catch(() => undefined);
75
+ }
76
+ }
@@ -0,0 +1,24 @@
1
+ /** A stored message as `GET /apix/chat/v1/conversation` returns it (CONTRACT.md `Message`). Replace with the generated type once `make gen-ts` carries group `chat`. */
2
+ export type StoredStatus = 'done' | 'running' | 'error' | 'cancelled';
3
+
4
+ export interface StoredWidget {
5
+ kind: string;
6
+ ids: string[];
7
+ caption: string | null;
8
+ }
9
+
10
+ export interface StoredMessage {
11
+ id: string;
12
+ seq: number;
13
+ role: 'user' | 'assistant';
14
+ text: string;
15
+ widgets: StoredWidget[];
16
+ status: StoredStatus;
17
+ error_code: string | null;
18
+ created_at: string;
19
+ }
20
+
21
+ export interface StoredConversation {
22
+ id: string | null;
23
+ messages: StoredMessage[];
24
+ }
@@ -0,0 +1,94 @@
1
+ import { decodeFrame, IDLE_MS, type ChatContext, type Frame } from './frames.ts';
2
+ import { parseSSE, StreamLostError } from './parseSSE.ts';
3
+
4
+ /** Problem codes the API answers before a stream starts (CONTRACT.md "Auth and errors"). */
5
+ export type RejectCode = 'unauthorized' | 'busy' | 'invalid' | 'rate_limited' | 'unavailable' | 'failed';
6
+
7
+ /** The request was refused before any frame: nothing ran, so a retry with the same `client_id` is safe. */
8
+ export class ChatHttpError extends Error {
9
+ readonly status: number;
10
+ readonly code: RejectCode;
11
+ /** Seconds from `Retry-After`, for 429. */
12
+ readonly retryAfter?: number;
13
+
14
+ constructor(status: number, code: RejectCode, retryAfter?: number) {
15
+ super(`chat ${status} ${code}`);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.retryAfter = retryAfter;
19
+ }
20
+ }
21
+
22
+ /** No answer at all (offline, DNS, reset before the response): nothing is known to have run. */
23
+ export class ChatNetworkError extends Error {
24
+ constructor() {
25
+ super('chat network error');
26
+ }
27
+ }
28
+
29
+ export interface SendInput {
30
+ text: string;
31
+ clientId: string;
32
+ context?: ChatContext;
33
+ signal: AbortSignal;
34
+ }
35
+
36
+ /** Opens one run and yields its frames. The adapter owns the meaning; tests and stories supply a scripted one. */
37
+ export interface ChatTransport {
38
+ open(input: SendInput): AsyncGenerator<Frame>;
39
+ }
40
+
41
+ export interface TransportConfig {
42
+ /** API origin, no trailing slash. The stream is called directly, never through a Next rewrite. */
43
+ baseUrl: string;
44
+ /** The bearer token, read per request; the header only, never the query string. */
45
+ getToken: () => string | null | undefined;
46
+ idleMs?: number;
47
+ fetch?: typeof fetch;
48
+ }
49
+
50
+ const CODES: readonly string[] = ['unauthorized', 'busy', 'invalid', 'rate_limited', 'unavailable'];
51
+
52
+ function rejectCode(status: number, body: unknown): RejectCode {
53
+ const code = typeof body === 'object' && body !== null ? (body as { code?: unknown }).code : undefined;
54
+ if (typeof code === 'string' && CODES.includes(code)) return code as RejectCode;
55
+ if (status === 401) return 'unauthorized';
56
+ if (status === 409) return 'busy';
57
+ if (status === 422) return 'invalid';
58
+ if (status === 429) return 'rate_limited';
59
+ if (status === 503) return 'unavailable';
60
+ return 'failed';
61
+ }
62
+
63
+ export function createTransport(config: TransportConfig): ChatTransport {
64
+ const call = config.fetch ?? fetch;
65
+ return {
66
+ async *open({ text, clientId, context, signal }) {
67
+ const token = config.getToken();
68
+ let response: Response;
69
+ try {
70
+ response = await call(`${config.baseUrl}/apix/chat/v1/stream`, {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
73
+ body: JSON.stringify({ text, client_id: clientId, ...(context ? { context } : {}) }),
74
+ signal,
75
+ });
76
+ } catch {
77
+ if (signal.aborted) return;
78
+ throw new ChatNetworkError();
79
+ }
80
+ if (!response.ok) {
81
+ const body: unknown = await response.json().catch(() => null);
82
+ const wait = Number(response.headers.get('retry-after'));
83
+ throw new ChatHttpError(response.status, rejectCode(response.status, body), Number.isFinite(wait) && wait > 0 ? Math.ceil(wait) : undefined);
84
+ }
85
+ if (!response.body || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) throw new ChatHttpError(response.status, 'failed');
86
+ for await (const raw of parseSSE(response.body, { idleMs: config.idleMs ?? IDLE_MS, signal })) {
87
+ const frame = decodeFrame(raw);
88
+ if (frame) yield frame;
89
+ }
90
+ },
91
+ };
92
+ }
93
+
94
+ export { StreamLostError };