@extrachill/chat 0.3.0 → 0.5.0

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.
@@ -1,8 +1,9 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
- import type { ChatMessage } from '../types/message.ts';
2
+ import type { ChatMessage, ToolCall } from '../types/message.ts';
3
3
  import type { ChatSession } from '../types/session.ts';
4
4
  import type { ChatAvailability } from '../types/session.ts';
5
- import type { FetchFn, ChatApiConfig } from '../api.ts';
5
+ import type { MediaAttachment } from '../types/message.ts';
6
+ import type { FetchFn, ChatApiConfig, SendAttachment } from '../api.ts';
6
7
  import {
7
8
  sendMessage as apiSendMessage,
8
9
  continueResponse as apiContinueResponse,
@@ -50,6 +51,23 @@ export interface UseChatOptions {
50
51
  * Called when an error occurs.
51
52
  */
52
53
  onError?: (error: Error) => void;
54
+ /**
55
+ * Called after each turn when tool calls are present in the response.
56
+ * Use this to react to tool executions (e.g. invalidate caches,
57
+ * apply diffs to the editor, update external state).
58
+ */
59
+ onToolCalls?: (toolCalls: ToolCall[]) => void;
60
+ /**
61
+ * Arbitrary metadata forwarded to the backend with each message.
62
+ * Use for context scoping (e.g. `{ selected_pipeline_id: 42 }`,
63
+ * `{ post_id: 100, context: 'editor' }`).
64
+ */
65
+ metadata?: Record<string, unknown>;
66
+ /**
67
+ * Optional context filter for session listing.
68
+ * Only sessions created in the matching context are shown.
69
+ */
70
+ sessionContext?: string;
53
71
  }
54
72
 
55
73
  /**
@@ -66,12 +84,19 @@ export interface UseChatReturn {
66
84
  availability: ChatAvailability;
67
85
  /** Active session ID. */
68
86
  sessionId: string | null;
87
+ /**
88
+ * The session ID that initiated the current request.
89
+ * Use to avoid stale loading indicators when the user switches
90
+ * sessions while a request is in flight.
91
+ * Null when idle.
92
+ */
93
+ processingSessionId: string | null;
69
94
  /** List of sessions. */
70
95
  sessions: ChatSession[];
71
96
  /** Whether sessions are loading. */
72
97
  sessionsLoading: boolean;
73
- /** Send a user message. */
74
- sendMessage: (content: string) => void;
98
+ /** Send a user message (with optional file attachments). */
99
+ sendMessage: (content: string, files?: File[]) => void;
75
100
  /** Switch to a different session. */
76
101
  switchSession: (sessionId: string) => void;
77
102
  /** Create a new session. */
@@ -141,12 +166,16 @@ export function useChat({
141
166
  maxContinueTurns = 20,
142
167
  onMessage,
143
168
  onError,
169
+ onToolCalls,
170
+ metadata,
171
+ sessionContext,
144
172
  }: UseChatOptions): UseChatReturn {
145
173
  const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? []);
146
174
  const [isLoading, setIsLoading] = useState(false);
147
175
  const [turnCount, setTurnCount] = useState(0);
148
176
  const [availability, setAvailability] = useState<ChatAvailability>({ status: 'ready' });
149
177
  const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
178
+ const [processingSessionId, setProcessingSessionId] = useState<string | null>(null);
150
179
  const [sessions, setSessions] = useState<ChatSession[]>([]);
151
180
  const [sessionsLoading, setSessionsLoading] = useState(false);
152
181
 
@@ -157,12 +186,26 @@ export function useChat({
157
186
  const sessionIdRef = useRef(sessionId);
158
187
  sessionIdRef.current = sessionId;
159
188
 
189
+ // Refs for latest callback/metadata values (avoid stale closures).
190
+ const onToolCallsRef = useRef(onToolCalls);
191
+ onToolCallsRef.current = onToolCalls;
192
+ const metadataRef = useRef(metadata);
193
+ metadataRef.current = metadata;
194
+ const sessionContextRef = useRef(sessionContext);
195
+ sessionContextRef.current = sessionContext;
196
+ // Guard against concurrent session creation.
197
+ const isCreatingRef = useRef(false);
198
+
160
199
  // Load sessions on mount
161
200
  useEffect(() => {
162
201
  const loadSessions = async () => {
163
202
  setSessionsLoading(true);
164
203
  try {
165
- const list = await apiListSessions(configRef.current);
204
+ const list = await apiListSessions(
205
+ configRef.current,
206
+ 20,
207
+ sessionContextRef.current,
208
+ );
166
209
  setSessions(list);
167
210
  } catch (err) {
168
211
  // Sessions not available — degrade gracefully
@@ -176,8 +219,47 @@ export function useChat({
176
219
  // eslint-disable-next-line react-hooks/exhaustive-deps
177
220
  }, []);
178
221
 
179
- const sendMessage = useCallback(async (content: string) => {
180
- if (isLoading) return;
222
+ /**
223
+ * Collect tool calls from a list of messages and fire the onToolCalls callback.
224
+ */
225
+ const fireToolCalls = useCallback((msgs: ChatMessage[]) => {
226
+ const cb = onToolCallsRef.current;
227
+ if (!cb) return;
228
+
229
+ const allToolCalls: ToolCall[] = [];
230
+ for (const msg of msgs) {
231
+ if (msg.toolCalls?.length) {
232
+ allToolCalls.push(...msg.toolCalls);
233
+ }
234
+ }
235
+ if (allToolCalls.length > 0) {
236
+ cb(allToolCalls);
237
+ }
238
+ }, []);
239
+
240
+ const sendMessage = useCallback(async (content: string, files?: File[]) => {
241
+ if (isLoading || isCreatingRef.current) return;
242
+
243
+ // Build optimistic attachment previews from local files.
244
+ let optimisticAttachments: MediaAttachment[] | undefined;
245
+ let sendAttachments: SendAttachment[] | undefined;
246
+
247
+ if (files?.length) {
248
+ optimisticAttachments = files.map((file) => ({
249
+ type: file.type.startsWith('image/') ? 'image' as const
250
+ : file.type.startsWith('video/') ? 'video' as const
251
+ : 'file' as const,
252
+ url: URL.createObjectURL(file),
253
+ filename: file.name,
254
+ mimeType: file.type,
255
+ size: file.size,
256
+ }));
257
+
258
+ sendAttachments = files.map((file) => ({
259
+ filename: file.name,
260
+ mime_type: file.type,
261
+ }));
262
+ }
181
263
 
182
264
  // Optimistically add user message
183
265
  const userMessage: ChatMessage = {
@@ -185,27 +267,45 @@ export function useChat({
185
267
  role: 'user',
186
268
  content,
187
269
  timestamp: new Date().toISOString(),
270
+ attachments: optimisticAttachments,
188
271
  };
189
272
 
273
+ // Guard against concurrent session creation.
274
+ if (!sessionIdRef.current) {
275
+ isCreatingRef.current = true;
276
+ }
277
+
190
278
  setMessages((prev) => [...prev, userMessage]);
191
279
  onMessage?.(userMessage);
192
280
  setIsLoading(true);
193
281
  setTurnCount(0);
194
282
 
283
+ // Track which session initiated the request.
284
+ const initiatingSessionId = sessionIdRef.current;
285
+ setProcessingSessionId(initiatingSessionId);
286
+
195
287
  try {
196
288
  const result = await apiSendMessage(
197
289
  configRef.current,
198
290
  content,
199
291
  sessionIdRef.current ?? undefined,
292
+ sendAttachments,
293
+ metadataRef.current,
200
294
  );
201
295
 
296
+ isCreatingRef.current = false;
297
+
202
298
  // Update session ID (may be newly created)
203
299
  setSessionId(result.sessionId);
204
300
  sessionIdRef.current = result.sessionId;
301
+ setProcessingSessionId(result.sessionId);
205
302
 
206
303
  // Replace all messages with the full normalized conversation
207
304
  setMessages(result.messages);
208
305
 
306
+ // Fire tool call callback for the initial response.
307
+ fireToolCalls(result.messages);
308
+
209
309
  // Handle multi-turn continuation
210
310
  if (!result.completed && !result.maxTurnsReached) {
211
311
  let completed = false;
@@ -225,16 +325,20 @@ export function useChat({
225
325
  onMessage?.(msg);
226
326
  }
227
327
 
328
+ // Fire tool call callback for each continuation turn.
329
+ fireToolCalls(continuation.messages);
330
+
228
331
  completed = continuation.completed || continuation.maxTurnsReached;
229
332
  }
230
333
  }
231
334
 
232
335
  // Refresh sessions list after a message
233
- apiListSessions(configRef.current)
336
+ apiListSessions(configRef.current, 20, sessionContextRef.current)
234
337
  .then(setSessions)
235
338
  .catch(() => { /* ignore */ });
236
339
 
237
340
  } catch (err) {
341
+ isCreatingRef.current = false;
238
342
  const error = toError(err);
239
343
  onError?.(error);
240
344
 
@@ -254,8 +358,9 @@ export function useChat({
254
358
  } finally {
255
359
  setIsLoading(false);
256
360
  setTurnCount(0);
361
+ setProcessingSessionId(null);
257
362
  }
258
- }, [isLoading, maxContinueTurns, onMessage, onError]);
363
+ }, [isLoading, maxContinueTurns, onMessage, onError, fireToolCalls]);
259
364
 
260
365
  const switchSession = useCallback(async (newSessionId: string) => {
261
366
  setSessionId(newSessionId);
@@ -301,7 +406,11 @@ export function useChat({
301
406
  const refreshSessions = useCallback(async () => {
302
407
  setSessionsLoading(true);
303
408
  try {
304
- const list = await apiListSessions(configRef.current);
409
+ const list = await apiListSessions(
410
+ configRef.current,
411
+ 20,
412
+ sessionContextRef.current,
413
+ );
305
414
  setSessions(list);
306
415
  } catch (err) {
307
416
  onError?.(toError(err));
@@ -316,6 +425,7 @@ export function useChat({
316
425
  turnCount,
317
426
  availability,
318
427
  sessionId,
428
+ processingSessionId,
319
429
  sessions,
320
430
  sessionsLoading,
321
431
  sendMessage,
package/src/index.ts CHANGED
@@ -3,18 +3,20 @@ export type {
3
3
  MessageRole,
4
4
  ToolCall,
5
5
  ToolResultMeta,
6
+ MediaAttachment,
6
7
  ChatMessage,
7
8
  ContentFormat,
8
9
  ChatSession,
9
10
  ChatAvailability,
10
11
  ChatInitialState,
12
+ RawAttachment,
11
13
  RawMessage,
12
14
  RawSession,
13
15
  SessionMetadata,
14
16
  } from './types/index.ts';
15
17
 
16
18
  // API
17
- export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult } from './api.ts';
19
+ export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult, SendAttachment } from './api.ts';
18
20
  export {
19
21
  sendMessage,
20
22
  continueResponse,
package/src/normalizer.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * maps them into the package's role-based message model.
8
8
  */
9
9
 
10
- import type { ChatMessage, ToolCall } from './types/message.ts';
10
+ import type { ChatMessage, MediaAttachment, ToolCall } from './types/message.ts';
11
11
  import type { ChatSession } from './types/session.ts';
12
- import type { RawMessage, RawSession } from './types/api.ts';
12
+ import type { RawMessage, RawAttachment, RawSession } from './types/api.ts';
13
13
 
14
14
  let idCounter = 0;
15
15
  function generateId(): string {
@@ -75,10 +75,16 @@ export function normalizeMessage(raw: RawMessage, index: number): ChatMessage {
75
75
  const message: ChatMessage = {
76
76
  id: generateId(),
77
77
  role: raw.role === 'user' ? 'user' : 'assistant',
78
- content: raw.content,
78
+ content: extractTextContent(raw.content),
79
79
  timestamp,
80
80
  };
81
81
 
82
+ // Extract attachments from metadata (user uploads and tool-produced media).
83
+ const attachments = normalizeAttachments(raw);
84
+ if (attachments.length > 0) {
85
+ message.attachments = attachments;
86
+ }
87
+
82
88
  // Assistant messages may carry tool_calls at the top level
83
89
  if (raw.role === 'assistant' && raw.tool_calls?.length) {
84
90
  message.toolCalls = raw.tool_calls.map((tc, i) => ({
@@ -110,3 +116,82 @@ export function normalizeSession(raw: RawSession): ChatSession {
110
116
  messageCount: raw.message_count,
111
117
  };
112
118
  }
119
+
120
+ /**
121
+ * Extract text content from a message that may be multi-modal.
122
+ *
123
+ * When the backend sends multi-modal content, `content` is an array of
124
+ * content blocks. We extract the text portion for display.
125
+ */
126
+ function extractTextContent(content: unknown): string {
127
+ if (typeof content === 'string') return content;
128
+
129
+ if (Array.isArray(content)) {
130
+ const textParts = content
131
+ .filter((block: Record<string, unknown>) => block.type === 'text' && typeof block.text === 'string')
132
+ .map((block: Record<string, unknown>) => block.text as string);
133
+ return textParts.join('\n');
134
+ }
135
+
136
+ return '';
137
+ }
138
+
139
+ /**
140
+ * Normalize a single raw attachment into a MediaAttachment.
141
+ */
142
+ function normalizeRawAttachment(raw: RawAttachment): MediaAttachment | null {
143
+ if (!raw.url) return null;
144
+
145
+ const mimeType = raw.mime_type ?? '';
146
+ let type: MediaAttachment['type'] = 'file';
147
+
148
+ if (raw.type === 'image' || raw.type === 'video' || raw.type === 'file') {
149
+ type = raw.type;
150
+ } else if (mimeType.startsWith('image/')) {
151
+ type = 'image';
152
+ } else if (mimeType.startsWith('video/')) {
153
+ type = 'video';
154
+ }
155
+
156
+ const attachment: MediaAttachment = {
157
+ type,
158
+ url: raw.url,
159
+ };
160
+
161
+ if (raw.alt) attachment.alt = raw.alt;
162
+ if (raw.filename) attachment.filename = raw.filename;
163
+ if (mimeType) attachment.mimeType = mimeType;
164
+ if (raw.size) attachment.size = raw.size;
165
+ if (raw.media_id) attachment.mediaId = raw.media_id;
166
+ if (raw.thumbnail_url) attachment.thumbnailUrl = raw.thumbnail_url;
167
+
168
+ return attachment;
169
+ }
170
+
171
+ /**
172
+ * Extract all attachments from a raw message.
173
+ *
174
+ * Checks metadata.attachments (user uploads) and metadata.media
175
+ * (tool-produced media) for renderable media.
176
+ */
177
+ function normalizeAttachments(raw: RawMessage): MediaAttachment[] {
178
+ const attachments: MediaAttachment[] = [];
179
+
180
+ // User-uploaded attachments.
181
+ if (raw.metadata?.attachments) {
182
+ for (const rawAtt of raw.metadata.attachments) {
183
+ const att = normalizeRawAttachment(rawAtt);
184
+ if (att) attachments.push(att);
185
+ }
186
+ }
187
+
188
+ // Tool-produced media.
189
+ if (raw.metadata?.media) {
190
+ for (const rawMedia of raw.metadata.media) {
191
+ const att = normalizeRawAttachment(rawMedia);
192
+ if (att) attachments.push(att);
193
+ }
194
+ }
195
+
196
+ return attachments;
197
+ }
package/src/types/api.ts CHANGED
@@ -10,18 +10,36 @@
10
10
  * A raw message as stored/returned by the backend.
11
11
  * The package normalizes these into ChatMessage before rendering.
12
12
  */
13
+ /**
14
+ * A raw media attachment as returned by the backend.
15
+ */
16
+ export interface RawAttachment {
17
+ type?: string;
18
+ url?: string;
19
+ alt?: string;
20
+ filename?: string;
21
+ mime_type?: string;
22
+ size?: number;
23
+ media_id?: number;
24
+ thumbnail_url?: string;
25
+ }
26
+
13
27
  export interface RawMessage {
14
28
  role: 'user' | 'assistant';
15
29
  content: string;
16
30
  metadata?: {
17
31
  timestamp?: string;
18
- type?: 'text' | 'tool_call' | 'tool_result';
32
+ type?: 'text' | 'multimodal' | 'tool_call' | 'tool_result';
19
33
  tool_name?: string;
20
34
  parameters?: Record<string, unknown>;
21
35
  success?: boolean;
22
36
  error?: string;
23
37
  turn?: number;
24
38
  tool_data?: Record<string, unknown>;
39
+ /** Attachments sent with the message. */
40
+ attachments?: RawAttachment[];
41
+ /** Media produced by tool results. */
42
+ media?: RawAttachment[];
25
43
  };
26
44
  tool_calls?: Array<{
27
45
  tool_name: string;
@@ -36,6 +54,13 @@ export interface SendRequest {
36
54
  message: string;
37
55
  session_id?: string;
38
56
  agent_id?: number;
57
+ /** Media attachments to send with the message. */
58
+ attachments?: Array<{
59
+ url?: string;
60
+ media_id?: number;
61
+ mime_type?: string;
62
+ filename?: string;
63
+ }>;
39
64
  }
40
65
 
41
66
  export interface SendResponse {
@@ -2,6 +2,7 @@ export type {
2
2
  MessageRole,
3
3
  ToolCall,
4
4
  ToolResultMeta,
5
+ MediaAttachment,
5
6
  ChatMessage,
6
7
  ContentFormat,
7
8
  } from './message.ts';
@@ -13,6 +14,7 @@ export type {
13
14
  } from './session.ts';
14
15
 
15
16
  export type {
17
+ RawAttachment,
16
18
  RawMessage,
17
19
  RawSession,
18
20
  SendRequest,
@@ -38,6 +38,28 @@ export interface ToolResultMeta {
38
38
  success: boolean;
39
39
  }
40
40
 
41
+ /**
42
+ * A media attachment on a chat message (image, video, or file).
43
+ */
44
+ export interface MediaAttachment {
45
+ /** Media type. */
46
+ type: 'image' | 'video' | 'file';
47
+ /** Public URL of the media. */
48
+ url: string;
49
+ /** Alt text or description. */
50
+ alt?: string;
51
+ /** Original filename. */
52
+ filename?: string;
53
+ /** MIME type (e.g. 'image/jpeg'). */
54
+ mimeType?: string;
55
+ /** File size in bytes. */
56
+ size?: number;
57
+ /** WordPress media library attachment ID. */
58
+ mediaId?: number;
59
+ /** Thumbnail URL for previews. */
60
+ thumbnailUrl?: string;
61
+ }
62
+
41
63
  /**
42
64
  * A single message in a chat conversation.
43
65
  */
@@ -54,6 +76,8 @@ export interface ChatMessage {
54
76
  toolCalls?: ToolCall[];
55
77
  /** Tool result metadata (only on tool_result messages). */
56
78
  toolResult?: ToolResultMeta;
79
+ /** Media attachments (images, videos, files) on this message. */
80
+ attachments?: MediaAttachment[];
57
81
  }
58
82
 
59
83
  /**