@djangocfg/ui-tools 2.1.336 → 2.1.337

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 {
@@ -59,9 +66,15 @@ export function useChat(config: UseChatConfig): UseChatReturn {
59
66
  const lastErrorRef = useRef<Error | null>(null);
60
67
  const initRef = useRef(false);
61
68
  const streamingMsgIdRef = useRef<string | null>(null);
69
+ // Promise resolved once the initial session is available (or `null` when the
70
+ // bootstrap finished without producing one — e.g. autoCreateSession=false).
71
+ // Action methods (sendMessage, regenerate, …) await this so users who type
72
+ // before the first network round-trip resolves don't hit "No active session".
73
+ const bootstrapRef = useRef<Promise<string | null> | null>(null);
62
74
 
63
75
  const { transport, autoCreateSession = true, streaming = true, pageSize = LIMITS.pageSize } =
64
76
  config;
77
+ const log = getChatLogger(config.debug);
65
78
 
66
79
  // Initial session bootstrap.
67
80
  useEffect(() => {
@@ -69,25 +82,50 @@ export function useChat(config: UseChatConfig): UseChatReturn {
69
82
  initRef.current = true;
70
83
 
71
84
  let cancelled = false;
72
- const run = async () => {
85
+ // Show "loading" state immediately so the UI doesn't look idle while we
86
+ // wait for createSession / loadHistory to come back.
87
+ if (config.initialSessionId || autoCreateSession) {
88
+ dispatch({ type: 'HISTORY_LOAD_START' });
89
+ }
90
+
91
+ log.bootstrap.info('start', {
92
+ mode: config.initialSessionId ? 'resume' : autoCreateSession ? 'create' : 'idle',
93
+ initialSessionId: config.initialSessionId,
94
+ });
95
+
96
+ const run = async (): Promise<string | null> => {
97
+ const t0 = performance.now();
73
98
  try {
74
99
  if (config.initialSessionId) {
75
100
  dispatch({
76
101
  type: 'SESSION_SET',
77
102
  sessionId: config.initialSessionId,
78
103
  });
79
- dispatch({ type: 'HISTORY_LOAD_START' });
80
104
  const page = await transport.loadHistory(config.initialSessionId, null, pageSize);
81
- if (cancelled) return;
105
+ if (cancelled) {
106
+ log.bootstrap.debug('cancelled (post-loadHistory)');
107
+ return null;
108
+ }
82
109
  dispatch({
83
110
  type: 'HISTORY_LOAD_DONE',
84
111
  messages: page.messages,
85
112
  hasMore: page.hasMore,
86
113
  cursor: page.nextCursor,
87
114
  });
88
- } else if (autoCreateSession) {
115
+ log.bootstrap.success('resumed', {
116
+ sessionId: config.initialSessionId,
117
+ messages: page.messages.length,
118
+ hasMore: page.hasMore,
119
+ elapsedMs: Math.round(performance.now() - t0),
120
+ });
121
+ return config.initialSessionId;
122
+ }
123
+ if (autoCreateSession) {
89
124
  const info = await transport.createSession({ metadata: config.metadata });
90
- if (cancelled) return;
125
+ if (cancelled) {
126
+ log.bootstrap.debug('cancelled (post-createSession)');
127
+ return null;
128
+ }
91
129
  dispatch({
92
130
  type: 'SESSION_SET',
93
131
  sessionId: info.sessionId,
@@ -95,21 +133,55 @@ export function useChat(config: UseChatConfig): UseChatReturn {
95
133
  hasMore: info.hasMore ?? false,
96
134
  cursor: info.cursor ?? null,
97
135
  });
136
+ // SESSION_SET implicitly clears `error` and leaves isLoading from
137
+ // the earlier HISTORY_LOAD_START set; mark history as done so the
138
+ // composer un-disables.
139
+ dispatch({
140
+ type: 'HISTORY_LOAD_DONE',
141
+ messages: info.messages ?? [],
142
+ hasMore: info.hasMore ?? false,
143
+ cursor: info.cursor ?? null,
144
+ });
145
+ log.bootstrap.success('created', {
146
+ sessionId: info.sessionId,
147
+ resumed: info.resumed ?? false,
148
+ elapsedMs: Math.round(performance.now() - t0),
149
+ });
150
+ return info.sessionId;
98
151
  }
152
+ log.bootstrap.debug('idle (no initialSessionId, autoCreateSession=false)');
153
+ return null;
99
154
  } catch (err) {
155
+ if (cancelled) {
156
+ log.bootstrap.debug('cancelled (in catch)');
157
+ return null;
158
+ }
100
159
  const e = err instanceof Error ? err : new Error(String(err));
101
160
  lastErrorRef.current = e;
102
161
  dispatch({ type: 'ERROR_SET', error: e.message });
103
162
  config.onError?.(e);
163
+ log.error.error('bootstrap failed', { message: e.message, elapsedMs: Math.round(performance.now() - t0) });
164
+ return null;
104
165
  }
105
166
  };
106
- void run();
167
+ bootstrapRef.current = run();
107
168
  return () => {
108
169
  cancelled = true;
109
170
  };
110
171
  // eslint-disable-next-line react-hooks/exhaustive-deps
111
172
  }, []);
112
173
 
174
+ /** Wait for the initial session bootstrap to settle, then return whatever
175
+ * sessionId is now in state. Safe to call multiple times. */
176
+ const awaitSession = useCallback(async (): Promise<string | null> => {
177
+ if (stateRef.current.sessionId) return stateRef.current.sessionId;
178
+ if (bootstrapRef.current) {
179
+ const id = await bootstrapRef.current;
180
+ if (id) return id;
181
+ }
182
+ return stateRef.current.sessionId;
183
+ }, []);
184
+
113
185
  const consumeStream = useCallback(
114
186
  async (
115
187
  sessionId: string,
@@ -123,12 +195,16 @@ export function useChat(config: UseChatConfig): UseChatReturn {
123
195
 
124
196
  dispatch({ type: 'STREAM_START', id: assistantId });
125
197
  config.onStreamStart?.(assistantId);
198
+ log.stream.info('start', { sessionId, assistantId, chars: content.length });
126
199
 
127
200
  const tokenBuffer = createTokenBuffer((delta) =>
128
201
  dispatch({ type: 'STREAM_CHUNK', delta }),
129
202
  );
130
203
 
131
204
  let serverMessageId: string | null = null;
205
+ let chunkCount = 0;
206
+ let charsReceived = 0;
207
+ const t0 = performance.now();
132
208
 
133
209
  try {
134
210
  const iterator = transport.stream(sessionId, content, {
@@ -150,18 +226,26 @@ export function useChat(config: UseChatConfig): UseChatReturn {
150
226
 
151
227
  const finalMsg = stateRef.current.messages.find((m) => m.id === assistantId);
152
228
  if (finalMsg) config.onMessageEnd?.(finalMsg);
229
+ log.stream.success('done', {
230
+ assistantId,
231
+ chunks: chunkCount,
232
+ chars: charsReceived,
233
+ elapsedMs: Math.round(performance.now() - t0),
234
+ });
153
235
  } catch (err) {
154
236
  tokenBuffer.close();
155
237
  if (ctrl.signal.aborted) {
156
238
  const partial =
157
239
  stateRef.current.messages.find((m) => m.id === assistantId)?.content ?? '';
158
240
  dispatch({ type: 'STREAM_CANCELLED', id: assistantId, partialText: partial });
241
+ log.stream.warn('cancelled', { assistantId, partialChars: partial.length });
159
242
  return;
160
243
  }
161
244
  const e = err instanceof Error ? err : new Error(String(err));
162
245
  lastErrorRef.current = e;
163
246
  dispatch({ type: 'STREAM_ERROR', id: assistantId, message: e.message });
164
247
  config.onError?.(e);
248
+ log.error.error('stream failed', { assistantId, message: e.message });
165
249
  } finally {
166
250
  tokenBuffer.close();
167
251
  if (abortRef.current === ctrl) abortRef.current = null;
@@ -172,13 +256,17 @@ export function useChat(config: UseChatConfig): UseChatReturn {
172
256
  switch (ev.type) {
173
257
  case 'message_start':
174
258
  serverMessageId = ev.messageId;
259
+ log.stream.debug('message_start', { messageId: ev.messageId });
175
260
  return;
176
261
  case 'chunk':
177
262
  tokenBuffer.push(ev.delta);
263
+ chunkCount += 1;
264
+ charsReceived += ev.delta.length;
178
265
  return;
179
266
  case 'tool_activity':
180
267
  tokenBuffer.flush();
181
268
  dispatch({ type: 'STREAM_TOOL_ACTIVITY', tool: ev.tool });
269
+ log.tools.debug('activity', { tool: ev.tool, status: ev.status });
182
270
  return;
183
271
  case 'tool_call_start': {
184
272
  tokenBuffer.flush();
@@ -195,6 +283,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
195
283
  messageId: assistantId,
196
284
  toolCall,
197
285
  });
286
+ log.tools.info('call_start', { toolId: ev.toolId, name: ev.name });
198
287
  return;
199
288
  }
200
289
  case 'tool_call_delta':
@@ -213,6 +302,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
213
302
  output: ev.output,
214
303
  status: ev.status,
215
304
  });
305
+ log.tools.info('call_end', { toolId: ev.toolId, status: ev.status });
216
306
  return;
217
307
  case 'message_end':
218
308
  tokenBuffer.flush();
@@ -223,6 +313,11 @@ export function useChat(config: UseChatConfig): UseChatReturn {
223
313
  tokensOut: ev.tokensOut,
224
314
  sources: ev.sources,
225
315
  });
316
+ log.stream.debug('message_end', {
317
+ tokensIn: ev.tokensIn,
318
+ tokensOut: ev.tokensOut,
319
+ sources: ev.sources?.length ?? 0,
320
+ });
226
321
  return;
227
322
  case 'error':
228
323
  tokenBuffer.flush();
@@ -231,6 +326,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
231
326
  id: assistantId,
232
327
  message: ev.message,
233
328
  });
329
+ log.error.error('stream event error', { code: ev.code, message: ev.message });
234
330
  return;
235
331
  }
236
332
  // unreachable; prevents unused-var on serverMessageId
@@ -270,16 +366,31 @@ export function useChat(config: UseChatConfig): UseChatReturn {
270
366
 
271
367
  const sendMessage = useCallback(
272
368
  async (content: string, attachments?: ChatAttachment[]) => {
273
- const sessionId = stateRef.current.sessionId;
369
+ // Wait for the initial session bootstrap if it's still in flight.
370
+ // Without this, fast typers hit "No active session" before
371
+ // transport.createSession resolves.
372
+ log.lifecycle.info('sendMessage', {
373
+ chars: content.length,
374
+ attachments: attachments?.length ?? 0,
375
+ hasSession: !!stateRef.current.sessionId,
376
+ });
377
+ const sessionId = await awaitSession();
274
378
  if (!sessionId) {
275
379
  const e = new Error('No active session');
276
380
  lastErrorRef.current = e;
277
381
  dispatch({ type: 'ERROR_SET', error: e.message });
278
382
  config.onError?.(e);
383
+ log.error.error('sendMessage aborted: no session');
384
+ return;
385
+ }
386
+ if (!content.trim() && !(attachments && attachments.length > 0)) {
387
+ log.lifecycle.debug('sendMessage skipped (empty)');
388
+ return;
389
+ }
390
+ if (stateRef.current.isStreaming) {
391
+ log.lifecycle.debug('sendMessage skipped (already streaming)');
279
392
  return;
280
393
  }
281
- if (!content.trim() && !(attachments && attachments.length > 0)) return;
282
- if (stateRef.current.isStreaming) return;
283
394
 
284
395
  const userMsg: ChatMessage = {
285
396
  id: createId('u'),
@@ -298,7 +409,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
298
409
  await consumeBuffered(sessionId, content, attachments);
299
410
  }
300
411
  },
301
- [streaming, consumeStream, consumeBuffered, config],
412
+ [streaming, consumeStream, consumeBuffered, config, awaitSession],
302
413
  );
303
414
 
304
415
  const cancelStream = useCallback(() => {
@@ -307,6 +418,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
307
418
 
308
419
  const regenerate = useCallback(
309
420
  async (messageId?: string) => {
421
+ log.lifecycle.info('regenerate', { messageId: messageId ?? '(last)' });
310
422
  const messages = stateRef.current.messages;
311
423
  let targetUserIdx = -1;
312
424
  if (messageId) {
@@ -324,7 +436,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
324
436
  for (let i = messages.length - 1; i > targetUserIdx; i -= 1) {
325
437
  dispatch({ type: 'MESSAGE_DELETE', id: messages[i].id });
326
438
  }
327
- const sessionId = stateRef.current.sessionId;
439
+ const sessionId = await awaitSession();
328
440
  if (!sessionId) return;
329
441
  if (streaming) {
330
442
  await consumeStream(sessionId, userMsg.content, userMsg.attachments);
@@ -332,7 +444,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
332
444
  await consumeBuffered(sessionId, userMsg.content, userMsg.attachments);
333
445
  }
334
446
  },
335
- [streaming, consumeStream, consumeBuffered],
447
+ [streaming, consumeStream, consumeBuffered, awaitSession],
336
448
  );
337
449
 
338
450
  const editMessage = useCallback(
@@ -381,6 +493,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
381
493
  }, [transport, pageSize, config]);
382
494
 
383
495
  const newSession = useCallback(async () => {
496
+ log.lifecycle.info('newSession', { previous: stateRef.current.sessionId });
384
497
  abortRef.current?.abort();
385
498
  const previous = stateRef.current.sessionId;
386
499
  if (previous) {
@@ -400,11 +513,13 @@ export function useChat(config: UseChatConfig): UseChatReturn {
400
513
  hasMore: info.hasMore ?? false,
401
514
  cursor: info.cursor ?? null,
402
515
  });
516
+ log.lifecycle.success('newSession ok', { sessionId: info.sessionId });
403
517
  } catch (err) {
404
518
  const e = err instanceof Error ? err : new Error(String(err));
405
519
  lastErrorRef.current = e;
406
520
  dispatch({ type: 'ERROR_SET', error: e.message });
407
521
  config.onError?.(e);
522
+ log.error.error('newSession failed', { message: e.message });
408
523
  }
409
524
  }, [transport, config]);
410
525
 
@@ -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