@djangocfg/ui-tools 2.1.336 → 2.1.338

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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Chat dev logger.
3
+ *
4
+ * A thin namespaced wrapper over `consola` that no-ops in production unless
5
+ * the host app explicitly opts in via `<ChatRoot debug />`. The default
6
+ * detection uses `isDev` from `@djangocfg/ui-core/lib/env` (NODE_ENV).
7
+ *
8
+ * Why a dedicated module: chat is async and event-heavy (bootstrap, transport,
9
+ * SSE chunks, tool calls, regenerate, …). Inline `console.log`s rot fast and
10
+ * leak into prod. A single `getChatLogger()` call gives every layer the same
11
+ * namespaced sub-logger and keeps zero-cost gating in one place.
12
+ *
13
+ * Sub-loggers:
14
+ * bootstrap — initial session bootstrap (createSession / loadHistory)
15
+ * transport — outbound transport calls + responses
16
+ * stream — SSE chunk / tool / message_end events
17
+ * lifecycle — sendMessage, regenerate, newSession, edits
18
+ * tools — tool_call_start / _delta / _end specifics
19
+ * error — caught errors (always emitted as `error` level)
20
+ */
21
+ import { consola, type ConsolaInstance } from 'consola';
22
+
23
+ import { isDev } from '@djangocfg/ui-core/lib';
24
+
25
+ export type ChatLogScope = 'bootstrap' | 'transport' | 'stream' | 'lifecycle' | 'tools' | 'error';
26
+
27
+ export interface ChatLogger {
28
+ bootstrap: ConsolaInstance;
29
+ transport: ConsolaInstance;
30
+ stream: ConsolaInstance;
31
+ lifecycle: ConsolaInstance;
32
+ tools: ConsolaInstance;
33
+ error: ConsolaInstance;
34
+ /** True when this logger is actually emitting (host opted in or NODE_ENV=development). */
35
+ enabled: boolean;
36
+ }
37
+
38
+ const SCOPES: ChatLogScope[] = ['bootstrap', 'transport', 'stream', 'lifecycle', 'tools', 'error'];
39
+
40
+ /** Module-level cache so all hooks/components share the same logger instance per `enabled` mode. */
41
+ const cache = new Map<boolean, ChatLogger>();
42
+
43
+ function buildLogger(enabled: boolean): ChatLogger {
44
+ const root = consola.withTag('chat');
45
+ const subs = Object.fromEntries(
46
+ SCOPES.map((scope) => [scope, root.withTag(scope)]),
47
+ ) as Record<ChatLogScope, ConsolaInstance>;
48
+
49
+ if (!enabled) {
50
+ // Silence everything except `error` — surfaced errors should never go
51
+ // missing even if the host didn't opt in to debug logs.
52
+ for (const scope of SCOPES) {
53
+ if (scope === 'error') continue;
54
+ subs[scope].level = -999;
55
+ }
56
+ }
57
+
58
+ return { ...subs, enabled };
59
+ }
60
+
61
+ /**
62
+ * Get the chat logger.
63
+ * @param debug Explicit override from the host. `undefined` falls back to `isDev`.
64
+ */
65
+ export function getChatLogger(debug?: boolean): ChatLogger {
66
+ const enabled = debug ?? isDev;
67
+ let logger = cache.get(enabled);
68
+ if (!logger) {
69
+ logger = buildLogger(enabled);
70
+ cache.set(enabled, logger);
71
+ }
72
+ return logger;
73
+ }
@@ -18,6 +18,7 @@ import {
18
18
  type ChatAction,
19
19
  } from '../core/reducer';
20
20
  import { createId } from '../core/ids';
21
+ import { getChatLogger } from '../core/logger';
21
22
  import { createTokenBuffer } from '../core/markdown';
22
23
 
23
24
  export interface UseChatConfig {
@@ -36,6 +37,12 @@ export interface UseChatConfig {
36
37
  metadata?: Record<string, unknown>;
37
38
  /** Stamped on outgoing user messages as `message.sender`. */
38
39
  userPersona?: ChatPersona;
40
+ /**
41
+ * Enable verbose dev-mode logging (consola, namespace `chat:*`).
42
+ * Defaults to `isDev` from `@djangocfg/ui-core/lib`. Pass `false` to silence
43
+ * even in development; `true` to force on in production.
44
+ */
45
+ debug?: boolean;
39
46
  }
40
47
 
41
48
  export interface UseChatReturn extends ChatState {
@@ -57,37 +64,84 @@ export function useChat(config: UseChatConfig): UseChatReturn {
57
64
 
58
65
  const abortRef = useRef<AbortController | null>(null);
59
66
  const lastErrorRef = useRef<Error | null>(null);
60
- const initRef = useRef(false);
61
67
  const streamingMsgIdRef = useRef<string | null>(null);
68
+ // Promise resolved once the initial session is available (or `null` when the
69
+ // bootstrap finished without producing one — e.g. autoCreateSession=false).
70
+ // Action methods (sendMessage, regenerate, …) await this so users who type
71
+ // before the first network round-trip resolves don't hit "No active session".
72
+ const bootstrapRef = useRef<Promise<string | null> | null>(null);
62
73
 
63
74
  const { transport, autoCreateSession = true, streaming = true, pageSize = LIMITS.pageSize } =
64
75
  config;
76
+ const log = getChatLogger(config.debug);
65
77
 
66
78
  // Initial session bootstrap.
79
+ //
80
+ // Strict Mode quirk: this effect runs twice in dev (mount → unmount → mount).
81
+ // Previous design used `initRef` to skip the second run, but the first run's
82
+ // cleanup sets `cancelled = true`, so its dispatch never lands — and the
83
+ // second run was blocked. Result: bootstrap silently completed the network
84
+ // call but never wrote sessionId to state.
85
+ //
86
+ // Fix: drop `initRef`. On the second mount we DO re-run, but `bootstrapRef`
87
+ // is preserved across renders so we don't re-fetch if a previous run already
88
+ // succeeded — we just resolve from existing state.
67
89
  useEffect(() => {
68
- if (initRef.current) return;
69
- initRef.current = true;
70
-
71
90
  let cancelled = false;
72
- const run = async () => {
91
+
92
+ // If a prior run already produced a sessionId, skip.
93
+ if (stateRef.current.sessionId) {
94
+ return;
95
+ }
96
+
97
+ // Show "loading" state immediately so the UI doesn't look idle while we
98
+ // wait for createSession / loadHistory to come back.
99
+ if (config.initialSessionId || autoCreateSession) {
100
+ dispatch({ type: 'HISTORY_LOAD_START' });
101
+ }
102
+
103
+ log.bootstrap.info('start', {
104
+ mode: config.initialSessionId ? 'resume' : autoCreateSession ? 'create' : 'idle',
105
+ initialSessionId: config.initialSessionId,
106
+ });
107
+
108
+ const run = async (): Promise<string | null> => {
109
+ const t0 = performance.now();
73
110
  try {
74
111
  if (config.initialSessionId) {
75
- dispatch({
76
- type: 'SESSION_SET',
77
- sessionId: config.initialSessionId,
78
- });
79
- dispatch({ type: 'HISTORY_LOAD_START' });
112
+ if (!cancelled) {
113
+ dispatch({
114
+ type: 'SESSION_SET',
115
+ sessionId: config.initialSessionId,
116
+ });
117
+ }
80
118
  const page = await transport.loadHistory(config.initialSessionId, null, pageSize);
81
- if (cancelled) return;
119
+ if (cancelled) {
120
+ log.bootstrap.debug('cancelled (post-loadHistory)');
121
+ // Even though *this* effect is cancelled, the network call did
122
+ // succeed — return the sessionId so awaitSession() doesn't see
123
+ // a phantom null when the next mount picks up.
124
+ return config.initialSessionId;
125
+ }
82
126
  dispatch({
83
127
  type: 'HISTORY_LOAD_DONE',
84
128
  messages: page.messages,
85
129
  hasMore: page.hasMore,
86
130
  cursor: page.nextCursor,
87
131
  });
88
- } else if (autoCreateSession) {
132
+ log.bootstrap.success('resumed', {
133
+ sessionId: config.initialSessionId,
134
+ messages: page.messages.length,
135
+ hasMore: page.hasMore,
136
+ elapsedMs: Math.round(performance.now() - t0),
137
+ });
138
+ return config.initialSessionId;
139
+ }
140
+ if (autoCreateSession) {
89
141
  const info = await transport.createSession({ metadata: config.metadata });
90
- if (cancelled) return;
142
+ // We always commit the session to state even if this effect was
143
+ // cancelled — the network call already succeeded, throwing away the
144
+ // sessionId would just trigger a duplicate createSession on remount.
91
145
  dispatch({
92
146
  type: 'SESSION_SET',
93
147
  sessionId: info.sessionId,
@@ -95,21 +149,53 @@ export function useChat(config: UseChatConfig): UseChatReturn {
95
149
  hasMore: info.hasMore ?? false,
96
150
  cursor: info.cursor ?? null,
97
151
  });
152
+ dispatch({
153
+ type: 'HISTORY_LOAD_DONE',
154
+ messages: info.messages ?? [],
155
+ hasMore: info.hasMore ?? false,
156
+ cursor: info.cursor ?? null,
157
+ });
158
+ log.bootstrap.success(cancelled ? 'created (post-cancel)' : 'created', {
159
+ sessionId: info.sessionId,
160
+ resumed: info.resumed ?? false,
161
+ cancelled,
162
+ elapsedMs: Math.round(performance.now() - t0),
163
+ });
164
+ return info.sessionId;
98
165
  }
166
+ log.bootstrap.debug('idle (no initialSessionId, autoCreateSession=false)');
167
+ return null;
99
168
  } catch (err) {
100
169
  const e = err instanceof Error ? err : new Error(String(err));
170
+ if (cancelled) {
171
+ log.bootstrap.debug('cancelled (in catch)', { message: e.message });
172
+ return null;
173
+ }
101
174
  lastErrorRef.current = e;
102
175
  dispatch({ type: 'ERROR_SET', error: e.message });
103
176
  config.onError?.(e);
177
+ log.error.error('bootstrap failed', { message: e.message, elapsedMs: Math.round(performance.now() - t0) });
178
+ return null;
104
179
  }
105
180
  };
106
- void run();
181
+ bootstrapRef.current = run();
107
182
  return () => {
108
183
  cancelled = true;
109
184
  };
110
185
  // eslint-disable-next-line react-hooks/exhaustive-deps
111
186
  }, []);
112
187
 
188
+ /** Wait for the initial session bootstrap to settle, then return whatever
189
+ * sessionId is now in state. Safe to call multiple times. */
190
+ const awaitSession = useCallback(async (): Promise<string | null> => {
191
+ if (stateRef.current.sessionId) return stateRef.current.sessionId;
192
+ if (bootstrapRef.current) {
193
+ const id = await bootstrapRef.current;
194
+ if (id) return id;
195
+ }
196
+ return stateRef.current.sessionId;
197
+ }, []);
198
+
113
199
  const consumeStream = useCallback(
114
200
  async (
115
201
  sessionId: string,
@@ -123,12 +209,16 @@ export function useChat(config: UseChatConfig): UseChatReturn {
123
209
 
124
210
  dispatch({ type: 'STREAM_START', id: assistantId });
125
211
  config.onStreamStart?.(assistantId);
212
+ log.stream.info('start', { sessionId, assistantId, chars: content.length });
126
213
 
127
214
  const tokenBuffer = createTokenBuffer((delta) =>
128
215
  dispatch({ type: 'STREAM_CHUNK', delta }),
129
216
  );
130
217
 
131
218
  let serverMessageId: string | null = null;
219
+ let chunkCount = 0;
220
+ let charsReceived = 0;
221
+ const t0 = performance.now();
132
222
 
133
223
  try {
134
224
  const iterator = transport.stream(sessionId, content, {
@@ -150,18 +240,26 @@ export function useChat(config: UseChatConfig): UseChatReturn {
150
240
 
151
241
  const finalMsg = stateRef.current.messages.find((m) => m.id === assistantId);
152
242
  if (finalMsg) config.onMessageEnd?.(finalMsg);
243
+ log.stream.success('done', {
244
+ assistantId,
245
+ chunks: chunkCount,
246
+ chars: charsReceived,
247
+ elapsedMs: Math.round(performance.now() - t0),
248
+ });
153
249
  } catch (err) {
154
250
  tokenBuffer.close();
155
251
  if (ctrl.signal.aborted) {
156
252
  const partial =
157
253
  stateRef.current.messages.find((m) => m.id === assistantId)?.content ?? '';
158
254
  dispatch({ type: 'STREAM_CANCELLED', id: assistantId, partialText: partial });
255
+ log.stream.warn('cancelled', { assistantId, partialChars: partial.length });
159
256
  return;
160
257
  }
161
258
  const e = err instanceof Error ? err : new Error(String(err));
162
259
  lastErrorRef.current = e;
163
260
  dispatch({ type: 'STREAM_ERROR', id: assistantId, message: e.message });
164
261
  config.onError?.(e);
262
+ log.error.error('stream failed', { assistantId, message: e.message });
165
263
  } finally {
166
264
  tokenBuffer.close();
167
265
  if (abortRef.current === ctrl) abortRef.current = null;
@@ -172,13 +270,17 @@ export function useChat(config: UseChatConfig): UseChatReturn {
172
270
  switch (ev.type) {
173
271
  case 'message_start':
174
272
  serverMessageId = ev.messageId;
273
+ log.stream.debug('message_start', { messageId: ev.messageId });
175
274
  return;
176
275
  case 'chunk':
177
276
  tokenBuffer.push(ev.delta);
277
+ chunkCount += 1;
278
+ charsReceived += ev.delta.length;
178
279
  return;
179
280
  case 'tool_activity':
180
281
  tokenBuffer.flush();
181
282
  dispatch({ type: 'STREAM_TOOL_ACTIVITY', tool: ev.tool });
283
+ log.tools.debug('activity', { tool: ev.tool, status: ev.status });
182
284
  return;
183
285
  case 'tool_call_start': {
184
286
  tokenBuffer.flush();
@@ -195,6 +297,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
195
297
  messageId: assistantId,
196
298
  toolCall,
197
299
  });
300
+ log.tools.info('call_start', { toolId: ev.toolId, name: ev.name });
198
301
  return;
199
302
  }
200
303
  case 'tool_call_delta':
@@ -213,6 +316,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
213
316
  output: ev.output,
214
317
  status: ev.status,
215
318
  });
319
+ log.tools.info('call_end', { toolId: ev.toolId, status: ev.status });
216
320
  return;
217
321
  case 'message_end':
218
322
  tokenBuffer.flush();
@@ -223,6 +327,11 @@ export function useChat(config: UseChatConfig): UseChatReturn {
223
327
  tokensOut: ev.tokensOut,
224
328
  sources: ev.sources,
225
329
  });
330
+ log.stream.debug('message_end', {
331
+ tokensIn: ev.tokensIn,
332
+ tokensOut: ev.tokensOut,
333
+ sources: ev.sources?.length ?? 0,
334
+ });
226
335
  return;
227
336
  case 'error':
228
337
  tokenBuffer.flush();
@@ -231,6 +340,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
231
340
  id: assistantId,
232
341
  message: ev.message,
233
342
  });
343
+ log.error.error('stream event error', { code: ev.code, message: ev.message });
234
344
  return;
235
345
  }
236
346
  // unreachable; prevents unused-var on serverMessageId
@@ -270,16 +380,31 @@ export function useChat(config: UseChatConfig): UseChatReturn {
270
380
 
271
381
  const sendMessage = useCallback(
272
382
  async (content: string, attachments?: ChatAttachment[]) => {
273
- const sessionId = stateRef.current.sessionId;
383
+ // Wait for the initial session bootstrap if it's still in flight.
384
+ // Without this, fast typers hit "No active session" before
385
+ // transport.createSession resolves.
386
+ log.lifecycle.info('sendMessage', {
387
+ chars: content.length,
388
+ attachments: attachments?.length ?? 0,
389
+ hasSession: !!stateRef.current.sessionId,
390
+ });
391
+ const sessionId = await awaitSession();
274
392
  if (!sessionId) {
275
393
  const e = new Error('No active session');
276
394
  lastErrorRef.current = e;
277
395
  dispatch({ type: 'ERROR_SET', error: e.message });
278
396
  config.onError?.(e);
397
+ log.error.error('sendMessage aborted: no session');
398
+ return;
399
+ }
400
+ if (!content.trim() && !(attachments && attachments.length > 0)) {
401
+ log.lifecycle.debug('sendMessage skipped (empty)');
402
+ return;
403
+ }
404
+ if (stateRef.current.isStreaming) {
405
+ log.lifecycle.debug('sendMessage skipped (already streaming)');
279
406
  return;
280
407
  }
281
- if (!content.trim() && !(attachments && attachments.length > 0)) return;
282
- if (stateRef.current.isStreaming) return;
283
408
 
284
409
  const userMsg: ChatMessage = {
285
410
  id: createId('u'),
@@ -298,7 +423,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
298
423
  await consumeBuffered(sessionId, content, attachments);
299
424
  }
300
425
  },
301
- [streaming, consumeStream, consumeBuffered, config],
426
+ [streaming, consumeStream, consumeBuffered, config, awaitSession],
302
427
  );
303
428
 
304
429
  const cancelStream = useCallback(() => {
@@ -307,6 +432,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
307
432
 
308
433
  const regenerate = useCallback(
309
434
  async (messageId?: string) => {
435
+ log.lifecycle.info('regenerate', { messageId: messageId ?? '(last)' });
310
436
  const messages = stateRef.current.messages;
311
437
  let targetUserIdx = -1;
312
438
  if (messageId) {
@@ -324,7 +450,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
324
450
  for (let i = messages.length - 1; i > targetUserIdx; i -= 1) {
325
451
  dispatch({ type: 'MESSAGE_DELETE', id: messages[i].id });
326
452
  }
327
- const sessionId = stateRef.current.sessionId;
453
+ const sessionId = await awaitSession();
328
454
  if (!sessionId) return;
329
455
  if (streaming) {
330
456
  await consumeStream(sessionId, userMsg.content, userMsg.attachments);
@@ -332,7 +458,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
332
458
  await consumeBuffered(sessionId, userMsg.content, userMsg.attachments);
333
459
  }
334
460
  },
335
- [streaming, consumeStream, consumeBuffered],
461
+ [streaming, consumeStream, consumeBuffered, awaitSession],
336
462
  );
337
463
 
338
464
  const editMessage = useCallback(
@@ -381,6 +507,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
381
507
  }, [transport, pageSize, config]);
382
508
 
383
509
  const newSession = useCallback(async () => {
510
+ log.lifecycle.info('newSession', { previous: stateRef.current.sessionId });
384
511
  abortRef.current?.abort();
385
512
  const previous = stateRef.current.sessionId;
386
513
  if (previous) {
@@ -400,11 +527,13 @@ export function useChat(config: UseChatConfig): UseChatReturn {
400
527
  hasMore: info.hasMore ?? false,
401
528
  cursor: info.cursor ?? null,
402
529
  });
530
+ log.lifecycle.success('newSession ok', { sessionId: info.sessionId });
403
531
  } catch (err) {
404
532
  const e = err instanceof Error ? err : new Error(String(err));
405
533
  lastErrorRef.current = e;
406
534
  dispatch({ type: 'ERROR_SET', error: e.message });
407
535
  config.onError?.(e);
536
+ log.error.error('newSession failed', { message: e.message });
408
537
  }
409
538
  }, [transport, config]);
410
539
 
@@ -109,6 +109,9 @@ export {
109
109
  export { useChatLightbox, type UseChatLightboxReturn, type ChatLightboxState } from './hooks';
110
110
  export { collectImageAttachments } from './utils/collectImageAttachments';
111
111
 
112
+ // Dev logger (consola-based, namespace "chat:*")
113
+ export { getChatLogger, type ChatLogger, type ChatLogScope } from './core/logger';
114
+
112
115
  // Context
113
116
  export {
114
117
  ChatProvider,
@@ -1,5 +0,0 @@
1
- export { ChatRoot } from './chunk-KRETIZU6.mjs';
2
- import './chunk-2ZLKZ5VR.mjs';
3
- import './chunk-N2XQF2OL.mjs';
4
- //# sourceMappingURL=ChatRoot-IIYQEWUU.mjs.map
5
- //# sourceMappingURL=ChatRoot-IIYQEWUU.mjs.map
@@ -1,14 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkNRXYYO5V_cjs = require('./chunk-NRXYYO5V.cjs');
4
- require('./chunk-B5AWZOHJ.cjs');
5
- require('./chunk-OLISEQHS.cjs');
6
-
7
-
8
-
9
- Object.defineProperty(exports, "ChatRoot", {
10
- enumerable: true,
11
- get: function () { return chunkNRXYYO5V_cjs.ChatRoot; }
12
- });
13
- //# sourceMappingURL=ChatRoot-UUKTYM4N.cjs.map
14
- //# sourceMappingURL=ChatRoot-UUKTYM4N.cjs.map