lemma-sdk 0.2.10 → 0.2.11

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,1089 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useAssistantRuntime } from "./useAssistantRuntime.js";
3
+ import { useAssistantSession } from "./useAssistantSession.js";
4
+ const EMPTY_SCOPE_KEY = JSON.stringify({
5
+ podId: null,
6
+ assistantId: null,
7
+ organizationId: null,
8
+ });
9
+ function isRecord(value) {
10
+ return !!value && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+ function stringifyContent(value) {
13
+ if (typeof value === "string")
14
+ return value;
15
+ if (Array.isArray(value)) {
16
+ const text = value
17
+ .map((entry) => extractTextFromStructuredContentEntry(entry))
18
+ .filter((entry) => entry.length > 0)
19
+ .join("\n\n")
20
+ .trim();
21
+ if (text.length > 0)
22
+ return text;
23
+ return "";
24
+ }
25
+ if (!isRecord(value))
26
+ return "";
27
+ const direct = value.content;
28
+ if (typeof direct === "string")
29
+ return direct;
30
+ if (Array.isArray(direct)) {
31
+ const text = direct
32
+ .map((entry) => extractTextFromStructuredContentEntry(entry))
33
+ .filter((entry) => entry.length > 0)
34
+ .join("\n\n")
35
+ .trim();
36
+ if (text.length > 0)
37
+ return text;
38
+ }
39
+ const text = value.text;
40
+ if (typeof text === "string")
41
+ return text;
42
+ try {
43
+ return JSON.stringify(value, null, 2);
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ function parseMaybeJsonObject(value) {
50
+ if (isRecord(value))
51
+ return value;
52
+ if (typeof value === "string") {
53
+ try {
54
+ const parsed = JSON.parse(value);
55
+ return isRecord(parsed) ? parsed : {};
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ return {};
62
+ }
63
+ function parseMaybeJsonValue(value) {
64
+ if (typeof value !== "string")
65
+ return value;
66
+ try {
67
+ return JSON.parse(value);
68
+ }
69
+ catch {
70
+ return value;
71
+ }
72
+ }
73
+ function extractTextFromStructuredContentEntry(entry) {
74
+ if (typeof entry === "string")
75
+ return entry.trim();
76
+ if (!isRecord(entry))
77
+ return "";
78
+ if (typeof entry.text === "string")
79
+ return entry.text.trim();
80
+ if (typeof entry.content === "string")
81
+ return entry.content.trim();
82
+ if (typeof entry.value === "string")
83
+ return entry.value.trim();
84
+ if (Array.isArray(entry.content)) {
85
+ const nested = entry.content
86
+ .map((child) => extractTextFromStructuredContentEntry(child))
87
+ .filter((text) => text.length > 0)
88
+ .join("\n")
89
+ .trim();
90
+ if (nested.length > 0)
91
+ return nested;
92
+ }
93
+ if (Array.isArray(entry.summary)) {
94
+ const summary = entry.summary
95
+ .map((child) => extractTextFromStructuredContentEntry(child))
96
+ .filter((text) => text.length > 0)
97
+ .join("\n")
98
+ .trim();
99
+ if (summary.length > 0)
100
+ return summary;
101
+ }
102
+ return "";
103
+ }
104
+ function parseTimestampMs(value) {
105
+ if (typeof value === "number" && Number.isFinite(value)) {
106
+ return value;
107
+ }
108
+ if (typeof value === "string") {
109
+ const timestamp = new Date(value).getTime();
110
+ if (Number.isFinite(timestamp) && timestamp > 0) {
111
+ return timestamp;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ function parseDurationMs(value) {
117
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
118
+ return Math.round(value);
119
+ }
120
+ if (typeof value === "string") {
121
+ const parsed = Number(value);
122
+ if (Number.isFinite(parsed) && parsed > 0) {
123
+ return Math.round(parsed);
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+ function getFileKey(file) {
129
+ return `${file.name}:${file.size}:${file.lastModified}`;
130
+ }
131
+ function parseThinkingDurationFromRecord(record) {
132
+ return parseDurationMs(record.duration_ms)
133
+ ?? parseDurationMs(record.durationMs)
134
+ ?? parseDurationMs(record.elapsed_ms)
135
+ ?? parseDurationMs(record.elapsedMs)
136
+ ?? parseDurationMs(record.thought_duration_ms)
137
+ ?? parseDurationMs(record.thoughtDurationMs);
138
+ }
139
+ function extractThinkingPartFromContent(content) {
140
+ if (!isRecord(content))
141
+ return null;
142
+ const rawType = typeof content.type === "string" ? content.type.toLowerCase() : "";
143
+ if (rawType !== "thinking" && rawType !== "reasoning") {
144
+ return null;
145
+ }
146
+ const text = extractTextFromStructuredContentEntry(content.content ?? content.text ?? content.value ?? content.summary);
147
+ if (!text)
148
+ return null;
149
+ const stateValue = [content.state, content.status, content.phase]
150
+ .find((value) => typeof value === "string");
151
+ const normalizedState = stateValue?.toLowerCase() || "";
152
+ const isStreaming = normalizedState.includes("stream")
153
+ || normalizedState.includes("progress")
154
+ || normalizedState.includes("running")
155
+ || normalizedState.includes("thinking");
156
+ return {
157
+ text,
158
+ state: isStreaming ? "streaming" : "done",
159
+ durationMs: parseThinkingDurationFromRecord(content),
160
+ };
161
+ }
162
+ function normalizeToolResult(value) {
163
+ if (isRecord(value))
164
+ return value;
165
+ if (Array.isArray(value))
166
+ return { output: value };
167
+ if (typeof value === "undefined" || value === null)
168
+ return {};
169
+ return { output: value };
170
+ }
171
+ function getMessageMetadata(msg) {
172
+ return (msg.message_metadata || msg.metadata || undefined);
173
+ }
174
+ function getNativeToolPayload(content) {
175
+ if (!isRecord(content))
176
+ return null;
177
+ const toolCallId = typeof content.tool_call_id === "string" ? content.tool_call_id : null;
178
+ if (!toolCallId)
179
+ return null;
180
+ const toolName = typeof content.tool_name === "string" ? content.tool_name : undefined;
181
+ if (typeof content.tool_name === "string") {
182
+ return {
183
+ kind: "call",
184
+ toolCallId,
185
+ toolName,
186
+ args: parseMaybeJsonObject(parseMaybeJsonValue(content.tool_input)),
187
+ };
188
+ }
189
+ if ("tool_output" in content) {
190
+ return {
191
+ kind: "result",
192
+ toolCallId,
193
+ toolName,
194
+ result: normalizeToolResult(content.tool_output),
195
+ };
196
+ }
197
+ return null;
198
+ }
199
+ function hasNativeToolPayloadContent(content) {
200
+ return getNativeToolPayload(content) !== null;
201
+ }
202
+ function toolInvocationKey(tool) {
203
+ return `${tool.toolCallId}:${tool.state}`;
204
+ }
205
+ function toolInvocationFromStructuredContentEntry(entry, fallbackId) {
206
+ const type = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
207
+ const functionObj = isRecord(entry.function) ? entry.function : {};
208
+ const hasToolShape = type.includes("tool")
209
+ || type.includes("function_call")
210
+ || type.includes("function_result")
211
+ || typeof entry.tool_call_id === "string"
212
+ || typeof entry.call_id === "string"
213
+ || typeof entry.tool_name === "string"
214
+ || typeof functionObj.name === "string"
215
+ || "tool_output" in entry
216
+ || ("result" in entry && typeof entry.call_id === "string");
217
+ if (!hasToolShape)
218
+ return null;
219
+ const rawResult = entry.tool_output ?? entry.output ?? entry.result;
220
+ const isResultLike = type.includes("result")
221
+ || type.includes("output")
222
+ || type.includes("return")
223
+ || typeof rawResult !== "undefined";
224
+ const toolCallId = ((typeof entry.tool_call_id === "string" && entry.tool_call_id)
225
+ || (typeof entry.toolCallId === "string" && entry.toolCallId)
226
+ || (typeof entry.call_id === "string" && entry.call_id)
227
+ || (typeof entry.id === "string" && entry.id)
228
+ || fallbackId);
229
+ const toolName = ((typeof entry.tool_name === "string" && entry.tool_name)
230
+ || (typeof entry.toolName === "string" && entry.toolName)
231
+ || (typeof functionObj.name === "string" && functionObj.name)
232
+ || (typeof entry.name === "string" && entry.name)
233
+ || "tool");
234
+ const argsRaw = functionObj.arguments
235
+ ?? entry.tool_input
236
+ ?? entry.input
237
+ ?? entry.args
238
+ ?? entry.arguments;
239
+ const state = isResultLike ? "result" : "call";
240
+ return {
241
+ toolCallId,
242
+ toolName,
243
+ args: parseMaybeJsonObject(parseMaybeJsonValue(argsRaw)),
244
+ state,
245
+ ...(isResultLike ? { result: normalizeToolResult(rawResult) } : {}),
246
+ };
247
+ }
248
+ function parseStructuredAssistantParts(content) {
249
+ if (!Array.isArray(content))
250
+ return null;
251
+ const parts = [];
252
+ const textChunks = [];
253
+ const representedToolKeys = new Set();
254
+ content.forEach((rawPart, index) => {
255
+ if (!isRecord(rawPart))
256
+ return;
257
+ const partType = typeof rawPart.type === "string" ? rawPart.type.toLowerCase() : "";
258
+ const partId = (typeof rawPart.id === "string" && rawPart.id) || `content-part-${index}`;
259
+ const toolInvocation = toolInvocationFromStructuredContentEntry(rawPart, `${partId}-tool`);
260
+ if (toolInvocation) {
261
+ representedToolKeys.add(toolInvocationKey(toolInvocation));
262
+ parts.push({
263
+ id: `${partId}-tool`,
264
+ type: "tool",
265
+ toolInvocation,
266
+ });
267
+ return;
268
+ }
269
+ const text = extractTextFromStructuredContentEntry(rawPart);
270
+ if (!text)
271
+ return;
272
+ if (partType.includes("reasoning") || partType.includes("thinking")) {
273
+ const stateValue = [rawPart.state, rawPart.status, rawPart.phase]
274
+ .find((value) => typeof value === "string");
275
+ const normalizedState = stateValue?.toLowerCase() || partType;
276
+ const isStreaming = normalizedState.includes("stream")
277
+ || normalizedState.includes("progress")
278
+ || normalizedState.includes("running")
279
+ || normalizedState.includes("thinking");
280
+ parts.push({
281
+ id: `${partId}-reasoning`,
282
+ type: "reasoning",
283
+ text,
284
+ state: isStreaming ? "streaming" : "done",
285
+ durationMs: parseThinkingDurationFromRecord(rawPart),
286
+ startedAtMs: parseTimestampMs(rawPart.started_at)
287
+ ?? parseTimestampMs(rawPart.startedAt)
288
+ ?? parseTimestampMs(rawPart.created_at)
289
+ ?? parseTimestampMs(rawPart.createdAt)
290
+ ?? undefined,
291
+ });
292
+ return;
293
+ }
294
+ textChunks.push(text);
295
+ parts.push({
296
+ id: `${partId}-text`,
297
+ type: "text",
298
+ text,
299
+ });
300
+ });
301
+ return {
302
+ parts,
303
+ textContent: textChunks.join("\n\n").trim(),
304
+ representedToolKeys,
305
+ };
306
+ }
307
+ function mapToolInvocations(msg) {
308
+ const invocations = [];
309
+ const metadata = getMessageMetadata(msg);
310
+ const nativeToolPayload = getNativeToolPayload(msg.content);
311
+ if (metadata?.message_type === "tool_call") {
312
+ invocations.push({
313
+ toolCallId: metadata.tool_call_id || `${msg.id}-tool-call`,
314
+ toolName: metadata.tool_name || "tool",
315
+ args: metadata.args || {},
316
+ state: "call",
317
+ });
318
+ }
319
+ if (metadata?.message_type === "tool_return") {
320
+ invocations.push({
321
+ toolCallId: metadata.tool_call_id || `${msg.id}-tool-result`,
322
+ toolName: metadata.tool_name || "tool",
323
+ args: metadata.args || {},
324
+ state: "result",
325
+ result: metadata.result,
326
+ });
327
+ }
328
+ if (Array.isArray(msg.tool_calls)) {
329
+ msg.tool_calls.forEach((rawTool, index) => {
330
+ const tool = isRecord(rawTool) ? rawTool : {};
331
+ const fn = isRecord(tool.function) ? tool.function : {};
332
+ const toolName = ((typeof fn.name === "string" && fn.name)
333
+ || (typeof tool.tool_name === "string" && tool.tool_name)
334
+ || (typeof tool.name === "string" && tool.name)
335
+ || "tool");
336
+ const argsRaw = fn.arguments ?? tool.args ?? tool.arguments ?? tool.input;
337
+ invocations.push({
338
+ toolCallId: (typeof tool.id === "string" && tool.id)
339
+ || (typeof tool.tool_call_id === "string" && tool.tool_call_id)
340
+ || `${msg.id}-tool-${index}`,
341
+ toolName,
342
+ args: parseMaybeJsonObject(argsRaw),
343
+ state: "call",
344
+ });
345
+ });
346
+ }
347
+ if (nativeToolPayload?.kind === "call") {
348
+ invocations.push({
349
+ toolCallId: nativeToolPayload.toolCallId,
350
+ toolName: nativeToolPayload.toolName || metadata?.tool_name || "tool",
351
+ args: nativeToolPayload.args || metadata?.args || {},
352
+ state: "call",
353
+ });
354
+ }
355
+ if (nativeToolPayload?.kind === "result") {
356
+ invocations.push({
357
+ toolCallId: nativeToolPayload.toolCallId,
358
+ toolName: nativeToolPayload.toolName || metadata?.tool_name || "tool",
359
+ args: metadata?.args || {},
360
+ state: "result",
361
+ result: nativeToolPayload.result || {},
362
+ });
363
+ }
364
+ const contentObj = isRecord(msg.content) ? msg.content : null;
365
+ if (contentObj && nativeToolPayload === null && "tool_output" in contentObj) {
366
+ invocations.push({
367
+ toolCallId: (typeof contentObj.tool_call_id === "string" && contentObj.tool_call_id)
368
+ || metadata?.tool_call_id
369
+ || `${msg.id}-tool-output`,
370
+ toolName: (typeof contentObj.tool_name === "string" && contentObj.tool_name)
371
+ || metadata?.tool_name
372
+ || "tool",
373
+ args: metadata?.args || {},
374
+ state: "result",
375
+ result: normalizeToolResult(contentObj.tool_output),
376
+ });
377
+ }
378
+ const seen = new Set();
379
+ return invocations.filter((invocation) => {
380
+ const key = toolInvocationKey(invocation);
381
+ if (seen.has(key))
382
+ return false;
383
+ seen.add(key);
384
+ return true;
385
+ });
386
+ }
387
+ function mapConversationMessage(msg, options) {
388
+ const toolInvocations = mapToolInvocations(msg);
389
+ const structured = parseStructuredAssistantParts(msg.content);
390
+ const explicitThinkingPart = extractThinkingPartFromContent(msg.content);
391
+ const createdAtMs = parseTimestampMs(msg.created_at) ?? undefined;
392
+ const parts = structured
393
+ ? structured.parts.map((part) => (part.type === "reasoning"
394
+ ? {
395
+ ...part,
396
+ startedAtMs: part.startedAtMs ?? createdAtMs,
397
+ }
398
+ : part))
399
+ : [];
400
+ const representedToolKeys = structured?.representedToolKeys || new Set();
401
+ let content = structured
402
+ ? structured.textContent
403
+ : (hasNativeToolPayloadContent(msg.content) ? "" : stringifyContent(msg.content));
404
+ if (explicitThinkingPart) {
405
+ content = "";
406
+ parts.push({
407
+ id: `${msg.id}-reasoning`,
408
+ type: "reasoning",
409
+ text: explicitThinkingPart.text,
410
+ state: explicitThinkingPart.state,
411
+ durationMs: explicitThinkingPart.durationMs ?? options?.thinkingDurationMs,
412
+ startedAtMs: createdAtMs,
413
+ });
414
+ }
415
+ else if (!structured && content.trim()) {
416
+ parts.push({
417
+ id: `${msg.id}-text`,
418
+ type: "text",
419
+ text: content,
420
+ });
421
+ }
422
+ toolInvocations.forEach((toolInvocation, index) => {
423
+ const key = toolInvocationKey(toolInvocation);
424
+ if (representedToolKeys.has(key))
425
+ return;
426
+ parts.push({
427
+ id: `${msg.id}-tool-${index}`,
428
+ type: "tool",
429
+ toolInvocation,
430
+ });
431
+ });
432
+ return {
433
+ id: msg.id,
434
+ role: msg.role === "user" ? "user" : "assistant",
435
+ content,
436
+ toolInvocations,
437
+ parts,
438
+ createdAt: msg.created_at ? new Date(msg.created_at) : new Date(),
439
+ };
440
+ }
441
+ function mapConversationMessages(messages) {
442
+ const mappedMessages = [];
443
+ const pendingToolCalls = new Map();
444
+ const estimateThinkingDurationMs = (index) => {
445
+ const message = messages[index];
446
+ if (!message || !extractThinkingPartFromContent(message.content))
447
+ return undefined;
448
+ const startedAtMs = parseTimestampMs(message.created_at);
449
+ if (!startedAtMs)
450
+ return undefined;
451
+ for (let i = index + 1; i < messages.length; i += 1) {
452
+ const nextCreatedAtMs = parseTimestampMs(messages[i]?.created_at);
453
+ if (!nextCreatedAtMs || nextCreatedAtMs <= startedAtMs)
454
+ continue;
455
+ const durationMs = nextCreatedAtMs - startedAtMs;
456
+ if (durationMs > 0 && durationMs <= 30 * 60 * 1000) {
457
+ return durationMs;
458
+ }
459
+ break;
460
+ }
461
+ return undefined;
462
+ };
463
+ messages.forEach((rawMessage, index) => {
464
+ const mappedMessage = mapConversationMessage(rawMessage, {
465
+ thinkingDurationMs: estimateThinkingDurationMs(index),
466
+ });
467
+ mappedMessage.toolInvocations?.forEach((invocation) => {
468
+ if (invocation.state === "call") {
469
+ pendingToolCalls.set(invocation.toolCallId, invocation);
470
+ }
471
+ });
472
+ const nativePayload = getNativeToolPayload(rawMessage.content);
473
+ const isToolRole = rawMessage.role === "tool";
474
+ if (isToolRole && nativePayload?.kind === "result" && mappedMessage.toolInvocations && mappedMessage.toolInvocations.length > 0) {
475
+ let mergedIntoPriorCall = false;
476
+ mappedMessage.toolInvocations.forEach((resultInvocation) => {
477
+ if (resultInvocation.state !== "result")
478
+ return;
479
+ const pendingInvocation = pendingToolCalls.get(resultInvocation.toolCallId);
480
+ if (!pendingInvocation)
481
+ return;
482
+ pendingInvocation.state = "result";
483
+ pendingInvocation.result = resultInvocation.result || {};
484
+ if (pendingInvocation.toolName === "tool" && resultInvocation.toolName !== "tool") {
485
+ pendingInvocation.toolName = resultInvocation.toolName;
486
+ }
487
+ mergedIntoPriorCall = true;
488
+ });
489
+ if (mergedIntoPriorCall) {
490
+ return;
491
+ }
492
+ }
493
+ if (mappedMessage.toolInvocations) {
494
+ mappedMessage.toolInvocations.forEach((invocation) => {
495
+ if (invocation.state === "result") {
496
+ const pendingInvocation = pendingToolCalls.get(invocation.toolCallId);
497
+ if (pendingInvocation) {
498
+ if ((invocation.toolName === "tool" || !invocation.toolName) && pendingInvocation.toolName) {
499
+ invocation.toolName = pendingInvocation.toolName;
500
+ }
501
+ if (Object.keys(invocation.args).length === 0 && Object.keys(pendingInvocation.args).length > 0) {
502
+ invocation.args = pendingInvocation.args;
503
+ }
504
+ }
505
+ }
506
+ });
507
+ }
508
+ mappedMessages.push(mappedMessage);
509
+ });
510
+ return mappedMessages;
511
+ }
512
+ function sortConversationsByUpdatedAt(conversations) {
513
+ return [...conversations].sort((a, b) => {
514
+ const aTime = new Date(a.updated_at || a.created_at).getTime();
515
+ const bTime = new Date(b.updated_at || b.created_at).getTime();
516
+ return bTime - aTime;
517
+ });
518
+ }
519
+ function sortMessagesByCreatedAt(messages) {
520
+ return [...messages].sort((a, b) => {
521
+ const aTime = Number.isFinite(new Date(a.created_at).getTime()) ? new Date(a.created_at).getTime() : 0;
522
+ const bTime = Number.isFinite(new Date(b.created_at).getTime()) ? new Date(b.created_at).getTime() : 0;
523
+ return aTime - bTime;
524
+ });
525
+ }
526
+ function isConversationRunning(status) {
527
+ if (typeof status !== "string")
528
+ return false;
529
+ const normalized = status.trim().toLowerCase();
530
+ if (!normalized)
531
+ return false;
532
+ if (normalized === "waiting"
533
+ || normalized === "completed"
534
+ || normalized === "failed"
535
+ || normalized === "cancelled"
536
+ || normalized === "stopped") {
537
+ return false;
538
+ }
539
+ return true;
540
+ }
541
+ export function useAssistantController({ client, podId, assistantId, organizationId, enabled = true, }) {
542
+ const [localError, setLocalError] = useState(null);
543
+ const [messages, setMessages] = useState([]);
544
+ const [conversations, setConversations] = useState([]);
545
+ const [activeConversationId, setActiveConversationId] = useState(null);
546
+ const [conversationModel, setConversationModelState] = useState(null);
547
+ const [isStreaming, setIsStreaming] = useState(false);
548
+ const [isLoadingConversations, setIsLoadingConversations] = useState(false);
549
+ const [isLoadingMessages, setIsLoadingMessages] = useState(false);
550
+ const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false);
551
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false);
552
+ const [pendingFiles, setPendingFiles] = useState([]);
553
+ const [olderMessagesCursor, setOlderMessagesCursor] = useState(null);
554
+ const activeConversationIdRef = useRef(null);
555
+ const conversationsRef = useRef([]);
556
+ const suppressAutoSelectRef = useRef(false);
557
+ const lastAutoLoadedConversationIdRef = useRef(null);
558
+ const loadingConversationIdRef = useRef(null);
559
+ const skipInitialLoadConversationIdsRef = useRef(new Set());
560
+ const scope = useMemo(() => ({
561
+ podId: podId ?? null,
562
+ assistantId: assistantId ?? null,
563
+ organizationId: organizationId ?? null,
564
+ }), [assistantId, organizationId, podId]);
565
+ const scopeKey = useMemo(() => JSON.stringify({
566
+ podId: scope.podId ?? null,
567
+ assistantId: scope.assistantId ?? null,
568
+ organizationId: scope.organizationId ?? null,
569
+ }), [scope.assistantId, scope.organizationId, scope.podId]);
570
+ const handleAssistantSessionError = useCallback((sessionError) => {
571
+ setLocalError((prev) => prev || (sessionError instanceof Error ? sessionError.message : "Assistant session failed"));
572
+ }, []);
573
+ const assistantSession = useAssistantSession({
574
+ client,
575
+ podId: scope.podId ?? undefined,
576
+ assistantId: scope.assistantId ?? undefined,
577
+ organizationId: scope.organizationId ?? undefined,
578
+ conversationId: activeConversationId ?? undefined,
579
+ autoLoad: false,
580
+ onError: handleAssistantSessionError,
581
+ });
582
+ const { listConversations: sessionListConversations, loadMessages: sessionLoadMessages, sendMessage: sessionSendMessage, createConversation: sessionCreateConversation, resumeIfRunning: sessionResumeIfRunning, stop: sessionStop, cancel: sessionCancel, isStreaming: sessionIsStreaming, messages: sessionMessages, streamingText: sessionStreamingText, status: sessionStatus, } = assistantSession;
583
+ const { runtimeMessages, appendOptimisticUserMessage, replaceLoadedMessages, mergeMessages, clear: clearRuntimeMessages, } = useAssistantRuntime({
584
+ conversationId: activeConversationId,
585
+ sessionMessages,
586
+ });
587
+ const error = localError;
588
+ const isLoading = isStreaming || sessionIsStreaming;
589
+ const touchConversation = useCallback((conversationId, updates) => {
590
+ setConversations((prev) => {
591
+ const now = new Date().toISOString();
592
+ let found = false;
593
+ const next = prev.map((conversation) => {
594
+ if (conversation.id !== conversationId)
595
+ return conversation;
596
+ found = true;
597
+ return {
598
+ ...conversation,
599
+ ...updates,
600
+ updated_at: updates?.updated_at || now,
601
+ };
602
+ });
603
+ return found ? sortConversationsByUpdatedAt(next) : next;
604
+ });
605
+ }, []);
606
+ const setConversationModel = useCallback(async (model) => {
607
+ setConversationModelState(model);
608
+ const conversationId = activeConversationIdRef.current;
609
+ if (!conversationId)
610
+ return;
611
+ const knownConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
612
+ const resolvedPodId = knownConversation?.pod_id ?? scope.podId;
613
+ const previousModel = knownConversation?.model ?? null;
614
+ touchConversation(conversationId, { model: model });
615
+ try {
616
+ const updatedConversation = await client.conversations.update(conversationId, { model: model }, { pod_id: resolvedPodId ?? undefined });
617
+ touchConversation(conversationId, {
618
+ model: (updatedConversation.model ?? model),
619
+ updated_at: updatedConversation.updated_at,
620
+ });
621
+ setConversationModelState((updatedConversation.model ?? model));
622
+ }
623
+ catch (error) {
624
+ touchConversation(conversationId, { model: previousModel });
625
+ setConversationModelState(previousModel);
626
+ throw error;
627
+ }
628
+ }, [client, scope.podId, touchConversation]);
629
+ const loadConversations = useCallback(async () => {
630
+ setIsLoadingConversations(true);
631
+ try {
632
+ const response = await sessionListConversations({ scope });
633
+ const nextConversations = sortConversationsByUpdatedAt(response.items || []);
634
+ setConversations(nextConversations);
635
+ setActiveConversationId((current) => {
636
+ if (current && nextConversations.some((conversation) => conversation.id === current)) {
637
+ return current;
638
+ }
639
+ if (suppressAutoSelectRef.current) {
640
+ return null;
641
+ }
642
+ return nextConversations[0]?.id ?? null;
643
+ });
644
+ }
645
+ catch (err) {
646
+ setLocalError((prev) => prev || (err instanceof Error ? err.message : "Failed to load conversations"));
647
+ }
648
+ finally {
649
+ setIsLoadingConversations(false);
650
+ }
651
+ }, [scope, sessionListConversations]);
652
+ const loadConversationMessages = useCallback(async (conversationId) => {
653
+ setIsLoadingMessages(true);
654
+ try {
655
+ const response = await sessionLoadMessages({
656
+ conversationId,
657
+ limit: 100,
658
+ });
659
+ if (activeConversationIdRef.current !== conversationId) {
660
+ return;
661
+ }
662
+ const sorted = sortMessagesByCreatedAt((response.items || []));
663
+ replaceLoadedMessages(sorted);
664
+ setOlderMessagesCursor(response.next_page_token ?? null);
665
+ }
666
+ catch (err) {
667
+ setLocalError((prev) => prev || (err instanceof Error ? err.message : "Failed to load messages"));
668
+ setOlderMessagesCursor(null);
669
+ }
670
+ finally {
671
+ setIsLoadingMessages(false);
672
+ }
673
+ }, [replaceLoadedMessages, sessionLoadMessages]);
674
+ const loadOlderMessages = useCallback(async () => {
675
+ const conversationId = activeConversationIdRef.current;
676
+ const cursor = olderMessagesCursor;
677
+ if (!conversationId || !cursor || isLoadingMessages || isLoadingOlderMessages) {
678
+ return false;
679
+ }
680
+ setIsLoadingOlderMessages(true);
681
+ try {
682
+ const response = await sessionLoadMessages({
683
+ conversationId,
684
+ limit: 100,
685
+ pageToken: cursor,
686
+ });
687
+ if (activeConversationIdRef.current !== conversationId) {
688
+ return false;
689
+ }
690
+ const older = sortMessagesByCreatedAt((response.items || []));
691
+ mergeMessages(older);
692
+ setOlderMessagesCursor(response.next_page_token ?? null);
693
+ return older.length > 0;
694
+ }
695
+ catch (err) {
696
+ setLocalError((prev) => prev || (err instanceof Error ? err.message : "Failed to load older messages"));
697
+ return false;
698
+ }
699
+ finally {
700
+ setIsLoadingOlderMessages(false);
701
+ }
702
+ }, [isLoadingMessages, isLoadingOlderMessages, mergeMessages, olderMessagesCursor, sessionLoadMessages]);
703
+ useEffect(() => {
704
+ activeConversationIdRef.current = activeConversationId;
705
+ }, [activeConversationId]);
706
+ useEffect(() => {
707
+ conversationsRef.current = conversations;
708
+ }, [conversations]);
709
+ useEffect(() => {
710
+ const conversationId = activeConversationIdRef.current;
711
+ if (!conversationId)
712
+ return;
713
+ if (!runtimeMessages || runtimeMessages.length === 0)
714
+ return;
715
+ const normalized = sortMessagesByCreatedAt(runtimeMessages)
716
+ .filter((message) => !message.conversation_id || message.conversation_id === conversationId);
717
+ if (normalized.length === 0)
718
+ return;
719
+ const nextMessages = mapConversationMessages(normalized);
720
+ const pendingText = sessionStreamingText.trim();
721
+ if (pendingText.length > 0) {
722
+ const streamingId = `streaming-${conversationId}`;
723
+ nextMessages.push({
724
+ id: streamingId,
725
+ role: "assistant",
726
+ content: pendingText,
727
+ createdAt: new Date(),
728
+ parts: [{ id: `${streamingId}-text`, type: "text", text: pendingText }],
729
+ });
730
+ }
731
+ setMessages(nextMessages);
732
+ }, [runtimeMessages, sessionStreamingText]);
733
+ useEffect(() => {
734
+ if (!activeConversationId)
735
+ return;
736
+ if (!sessionStatus)
737
+ return;
738
+ touchConversation(activeConversationId, {
739
+ status: sessionStatus.toLowerCase(),
740
+ });
741
+ }, [activeConversationId, sessionStatus, touchConversation]);
742
+ useEffect(() => {
743
+ if (!activeConversationId)
744
+ return;
745
+ const activeConversation = conversations.find((conversation) => conversation.id === activeConversationId);
746
+ if (!activeConversation)
747
+ return;
748
+ setConversationModelState(activeConversation.model ?? null);
749
+ }, [activeConversationId, conversations]);
750
+ useEffect(() => {
751
+ if (!enabled) {
752
+ sessionCancel();
753
+ clearRuntimeMessages();
754
+ suppressAutoSelectRef.current = false;
755
+ activeConversationIdRef.current = null;
756
+ lastAutoLoadedConversationIdRef.current = null;
757
+ loadingConversationIdRef.current = null;
758
+ skipInitialLoadConversationIdsRef.current.clear();
759
+ setActiveConversationId(null);
760
+ setConversationModelState(null);
761
+ setConversations([]);
762
+ setMessages([]);
763
+ setLocalError(null);
764
+ setOlderMessagesCursor(null);
765
+ setIsLoadingConversations(false);
766
+ setIsLoadingMessages(false);
767
+ setIsLoadingOlderMessages(false);
768
+ return;
769
+ }
770
+ suppressAutoSelectRef.current = false;
771
+ activeConversationIdRef.current = null;
772
+ lastAutoLoadedConversationIdRef.current = null;
773
+ loadingConversationIdRef.current = null;
774
+ skipInitialLoadConversationIdsRef.current.clear();
775
+ setActiveConversationId(null);
776
+ setConversationModelState(null);
777
+ setConversations([]);
778
+ setMessages([]);
779
+ setLocalError(null);
780
+ clearRuntimeMessages();
781
+ setOlderMessagesCursor(null);
782
+ if (scopeKey !== EMPTY_SCOPE_KEY) {
783
+ void loadConversations();
784
+ }
785
+ }, [clearRuntimeMessages, enabled, loadConversations, scopeKey, sessionCancel]);
786
+ useEffect(() => {
787
+ if (!enabled || !activeConversationId) {
788
+ clearRuntimeMessages();
789
+ lastAutoLoadedConversationIdRef.current = null;
790
+ loadingConversationIdRef.current = null;
791
+ setMessages([]);
792
+ setOlderMessagesCursor(null);
793
+ return;
794
+ }
795
+ if (skipInitialLoadConversationIdsRef.current.has(activeConversationId)) {
796
+ skipInitialLoadConversationIdsRef.current.delete(activeConversationId);
797
+ lastAutoLoadedConversationIdRef.current = activeConversationId;
798
+ return;
799
+ }
800
+ if (lastAutoLoadedConversationIdRef.current === activeConversationId) {
801
+ return;
802
+ }
803
+ if (loadingConversationIdRef.current === activeConversationId) {
804
+ return;
805
+ }
806
+ let cancelled = false;
807
+ loadingConversationIdRef.current = activeConversationId;
808
+ const loadConversation = async () => {
809
+ setOlderMessagesCursor(null);
810
+ await loadConversationMessages(activeConversationId);
811
+ if (cancelled)
812
+ return;
813
+ lastAutoLoadedConversationIdRef.current = activeConversationId;
814
+ try {
815
+ await sessionResumeIfRunning(activeConversationId);
816
+ }
817
+ catch (error) {
818
+ if (cancelled)
819
+ return;
820
+ setLocalError((prev) => prev || (error instanceof Error ? error.message : "Failed to resume conversation"));
821
+ }
822
+ };
823
+ void loadConversation().finally(() => {
824
+ if (loadingConversationIdRef.current === activeConversationId) {
825
+ loadingConversationIdRef.current = null;
826
+ }
827
+ });
828
+ return () => {
829
+ cancelled = true;
830
+ };
831
+ }, [activeConversationId, clearRuntimeMessages, enabled, loadConversationMessages, sessionResumeIfRunning]);
832
+ const stop = useCallback(() => {
833
+ const hadActiveStream = sessionIsStreaming || isStreaming;
834
+ sessionCancel();
835
+ setIsStreaming(false);
836
+ const conversationId = activeConversationIdRef.current;
837
+ if (!conversationId)
838
+ return;
839
+ const activeConversation = conversationsRef.current.find((conversation) => conversation.id === conversationId);
840
+ const conversationIsRunning = isConversationRunning(activeConversation?.status);
841
+ if (!hadActiveStream && !conversationIsRunning)
842
+ return;
843
+ touchConversation(conversationId, { status: "waiting" });
844
+ void sessionStop(conversationId).catch(() => undefined);
845
+ }, [isStreaming, sessionCancel, sessionIsStreaming, sessionStop, touchConversation]);
846
+ const selectConversation = useCallback((conversationId) => {
847
+ if (sessionIsStreaming || isStreaming) {
848
+ sessionCancel();
849
+ setIsStreaming(false);
850
+ }
851
+ const wasSameConversation = conversationId && conversationId === activeConversationIdRef.current;
852
+ suppressAutoSelectRef.current = conversationId === null;
853
+ setLocalError(null);
854
+ activeConversationIdRef.current = conversationId;
855
+ lastAutoLoadedConversationIdRef.current = null;
856
+ loadingConversationIdRef.current = null;
857
+ setOlderMessagesCursor(null);
858
+ clearRuntimeMessages();
859
+ setMessages([]);
860
+ if (wasSameConversation) {
861
+ void loadConversationMessages(conversationId);
862
+ void sessionResumeIfRunning(conversationId).catch((error) => {
863
+ setLocalError((prev) => prev || (error instanceof Error ? error.message : "Failed to resume conversation"));
864
+ });
865
+ }
866
+ setActiveConversationId(conversationId);
867
+ }, [clearRuntimeMessages, isStreaming, loadConversationMessages, sessionCancel, sessionIsStreaming, sessionResumeIfRunning]);
868
+ const resetConversationState = useCallback((keepPendingFiles = false) => {
869
+ stop();
870
+ clearRuntimeMessages();
871
+ suppressAutoSelectRef.current = true;
872
+ activeConversationIdRef.current = null;
873
+ lastAutoLoadedConversationIdRef.current = null;
874
+ loadingConversationIdRef.current = null;
875
+ skipInitialLoadConversationIdsRef.current.clear();
876
+ setActiveConversationId(null);
877
+ setMessages([]);
878
+ setLocalError(null);
879
+ setOlderMessagesCursor(null);
880
+ if (!keepPendingFiles) {
881
+ setPendingFiles([]);
882
+ }
883
+ }, [clearRuntimeMessages, stop]);
884
+ const clearMessages = useCallback(() => {
885
+ resetConversationState(false);
886
+ }, [resetConversationState]);
887
+ const ensureConversation = useCallback(async (titleSeed) => {
888
+ const existingConversationId = activeConversationIdRef.current;
889
+ if (existingConversationId) {
890
+ return existingConversationId;
891
+ }
892
+ const createdConversation = await sessionCreateConversation({
893
+ title: titleSeed.slice(0, 120),
894
+ model: conversationModel,
895
+ ...scope,
896
+ });
897
+ suppressAutoSelectRef.current = false;
898
+ setConversations((prev) => sortConversationsByUpdatedAt([
899
+ createdConversation,
900
+ ...prev.filter((conversation) => conversation.id !== createdConversation.id),
901
+ ]));
902
+ activeConversationIdRef.current = createdConversation.id;
903
+ lastAutoLoadedConversationIdRef.current = createdConversation.id;
904
+ loadingConversationIdRef.current = null;
905
+ skipInitialLoadConversationIdsRef.current.add(createdConversation.id);
906
+ setActiveConversationId(createdConversation.id);
907
+ setConversationModelState((createdConversation.model ?? conversationModel ?? null));
908
+ clearRuntimeMessages();
909
+ setMessages([]);
910
+ setOlderMessagesCursor(null);
911
+ return createdConversation.id;
912
+ }, [clearRuntimeMessages, conversationModel, scope, sessionCreateConversation]);
913
+ const queuePendingFiles = useCallback((files) => {
914
+ if (files.length === 0)
915
+ return;
916
+ setPendingFiles((prev) => {
917
+ const byKey = new Map();
918
+ prev.forEach((file) => byKey.set(getFileKey(file), file));
919
+ files.forEach((file) => byKey.set(getFileKey(file), file));
920
+ return Array.from(byKey.values());
921
+ });
922
+ }, []);
923
+ const removePendingFile = useCallback((fileKey) => {
924
+ setPendingFiles((prev) => prev.filter((file) => getFileKey(file) !== fileKey));
925
+ }, []);
926
+ const clearPendingFiles = useCallback(() => {
927
+ setPendingFiles([]);
928
+ }, []);
929
+ const sendMessage = useCallback(async (content, options) => {
930
+ const trimmed = content.trim();
931
+ if (!enabled || !trimmed || isStreaming || sessionIsStreaming)
932
+ return;
933
+ const forceNewConversation = options?.forceNewConversation === true;
934
+ setLocalError(null);
935
+ if (forceNewConversation) {
936
+ resetConversationState(true);
937
+ }
938
+ let conversationId = forceNewConversation ? null : activeConversationId;
939
+ try {
940
+ if (!conversationId) {
941
+ conversationId = await ensureConversation(trimmed);
942
+ }
943
+ if (!conversationId) {
944
+ throw new Error("Conversation could not be initialized");
945
+ }
946
+ const finalConversationId = conversationId;
947
+ if (pendingFiles.length > 0) {
948
+ setIsUploadingFiles(true);
949
+ try {
950
+ await Promise.all(pendingFiles.map((file) => client.resources.upload("conversation", finalConversationId, file, {
951
+ name: file.name,
952
+ })));
953
+ setPendingFiles([]);
954
+ touchConversation(finalConversationId, { updated_at: new Date().toISOString() });
955
+ }
956
+ finally {
957
+ setIsUploadingFiles(false);
958
+ }
959
+ }
960
+ appendOptimisticUserMessage(trimmed, {
961
+ conversationId: finalConversationId,
962
+ });
963
+ setIsStreaming(true);
964
+ touchConversation(finalConversationId, { status: "running" });
965
+ await sessionSendMessage(trimmed, {
966
+ conversationId: finalConversationId,
967
+ createIfMissing: false,
968
+ });
969
+ touchConversation(finalConversationId, { updated_at: new Date().toISOString() });
970
+ }
971
+ catch (err) {
972
+ if (err instanceof DOMException && err.name === "AbortError") {
973
+ return;
974
+ }
975
+ setLocalError(err instanceof Error ? err.message : "Failed to send message");
976
+ }
977
+ finally {
978
+ setIsStreaming(false);
979
+ }
980
+ }, [
981
+ activeConversationId,
982
+ appendOptimisticUserMessage,
983
+ client.resources,
984
+ enabled,
985
+ ensureConversation,
986
+ isStreaming,
987
+ pendingFiles,
988
+ resetConversationState,
989
+ sessionIsStreaming,
990
+ sessionSendMessage,
991
+ touchConversation,
992
+ ]);
993
+ const uploadFiles = useCallback(async (files, options) => {
994
+ const normalizedFiles = files.filter((file) => file instanceof File);
995
+ if (!enabled || normalizedFiles.length === 0 || isLoading || isUploadingFiles)
996
+ return;
997
+ setLocalError(null);
998
+ const activeId = activeConversationIdRef.current;
999
+ const shouldQueueForNextSend = options?.deferUntilSend === true;
1000
+ if (!activeId || shouldQueueForNextSend) {
1001
+ queuePendingFiles(normalizedFiles);
1002
+ return;
1003
+ }
1004
+ setIsUploadingFiles(true);
1005
+ try {
1006
+ await Promise.all(normalizedFiles.map((file) => client.resources.upload("conversation", activeId, file, {
1007
+ name: file.name,
1008
+ })));
1009
+ await loadConversationMessages(activeId);
1010
+ touchConversation(activeId, { updated_at: new Date().toISOString() });
1011
+ }
1012
+ catch (err) {
1013
+ setLocalError(err instanceof Error ? err.message : "Failed to upload files");
1014
+ throw err;
1015
+ }
1016
+ finally {
1017
+ setIsUploadingFiles(false);
1018
+ }
1019
+ }, [
1020
+ client.resources,
1021
+ enabled,
1022
+ isLoading,
1023
+ isUploadingFiles,
1024
+ loadConversationMessages,
1025
+ queuePendingFiles,
1026
+ touchConversation,
1027
+ ]);
1028
+ const { pendingActions, completedActions } = useMemo(() => {
1029
+ const pending = [];
1030
+ const completed = [];
1031
+ messages.forEach((message) => {
1032
+ if (!message.toolInvocations)
1033
+ return;
1034
+ message.toolInvocations.forEach((toolInvocation) => {
1035
+ const status = toolInvocation.state === "result"
1036
+ ? (toolInvocation.result?.success === false ? "failed" : "completed")
1037
+ : "executing";
1038
+ const action = {
1039
+ id: toolInvocation.toolCallId,
1040
+ type: "tool_call",
1041
+ status,
1042
+ toolName: toolInvocation.toolName,
1043
+ toolArgs: toolInvocation.args,
1044
+ result: toolInvocation.result,
1045
+ timestamp: message.createdAt || new Date(),
1046
+ };
1047
+ if (status === "executing") {
1048
+ pending.push(action);
1049
+ }
1050
+ else {
1051
+ completed.push(action);
1052
+ }
1053
+ });
1054
+ });
1055
+ return { pendingActions: pending, completedActions: completed };
1056
+ }, [messages]);
1057
+ const isActiveConversationRunning = useMemo(() => {
1058
+ if (!activeConversationId)
1059
+ return false;
1060
+ const activeConversation = conversations.find((conversation) => conversation.id === activeConversationId);
1061
+ return isConversationRunning(activeConversation?.status);
1062
+ }, [activeConversationId, conversations]);
1063
+ return {
1064
+ messages,
1065
+ conversations,
1066
+ activeConversationId,
1067
+ conversationModel,
1068
+ isActiveConversationRunning,
1069
+ isLoading,
1070
+ isLoadingConversations,
1071
+ isLoadingMessages,
1072
+ isLoadingOlderMessages,
1073
+ hasOlderMessages: !!olderMessagesCursor,
1074
+ isUploadingFiles,
1075
+ pendingFiles,
1076
+ error,
1077
+ pendingActions,
1078
+ completedActions,
1079
+ selectConversation,
1080
+ setConversationModel,
1081
+ sendMessage,
1082
+ uploadFiles,
1083
+ removePendingFile,
1084
+ clearPendingFiles,
1085
+ loadOlderMessages,
1086
+ clearMessages,
1087
+ stop,
1088
+ };
1089
+ }