@hef2024/llmasaservice-ui 0.24.2 → 0.24.4
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.
- package/dist/index.css +256 -62
- package/dist/index.d.mts +158 -115
- package/dist/index.d.ts +158 -115
- package/dist/index.js +3099 -568
- package/dist/index.mjs +3099 -568
- package/index.ts +6 -1
- package/package.json +1 -1
- package/src/AIAgentPanel.tsx +647 -207
- package/src/AIChatPanel.css +286 -37
- package/src/AIChatPanel.tsx +2871 -358
- package/src/AgentPanel.tsx +4 -0
- package/src/ChatPanel.tsx +254 -104
- package/src/hooks/useAgentRegistry.ts +2 -1
- package/src/mcpAuth.ts +36 -0
- package/src/toolArgsParser.ts +346 -0
package/src/AIChatPanel.tsx
CHANGED
|
@@ -21,6 +21,12 @@ import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/materia
|
|
|
21
21
|
import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
|
|
22
22
|
import { Button, ScrollArea, Tooltip, ThinkingBlock as ThinkingBlockComponent } from './components/ui';
|
|
23
23
|
import ToolInfoModal from './components/ui/ToolInfoModal';
|
|
24
|
+
import {
|
|
25
|
+
MCPAuthPhase,
|
|
26
|
+
MCPAuthHeaderResolver,
|
|
27
|
+
normalizeMcpHeaders,
|
|
28
|
+
} from './mcpAuth';
|
|
29
|
+
import { parseToolArguments } from './toolArgsParser';
|
|
24
30
|
import './AIChatPanel.css';
|
|
25
31
|
|
|
26
32
|
// ============================================================================
|
|
@@ -38,6 +44,28 @@ export interface AgentOption {
|
|
|
38
44
|
avatarUrl?: string;
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
export interface BeforeSendPayload {
|
|
48
|
+
prompt: string;
|
|
49
|
+
conversationId: string | null;
|
|
50
|
+
agentId?: string | null;
|
|
51
|
+
service?: string | null;
|
|
52
|
+
messages: { role: string; content: string }[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TraceContextMode = 'standard' | 'full';
|
|
56
|
+
|
|
57
|
+
export interface LocalToolExecutorContext {
|
|
58
|
+
toolName: string;
|
|
59
|
+
callId: string;
|
|
60
|
+
serviceTag: string;
|
|
61
|
+
mcpTool: Record<string, unknown> | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type LocalToolExecutor = (
|
|
65
|
+
args: Record<string, unknown>,
|
|
66
|
+
context: LocalToolExecutorContext,
|
|
67
|
+
) => Promise<unknown> | unknown;
|
|
68
|
+
|
|
41
69
|
export interface AIChatPanelProps {
|
|
42
70
|
project_id: string;
|
|
43
71
|
initialPrompt?: string;
|
|
@@ -74,7 +102,11 @@ export interface AIChatPanelProps {
|
|
|
74
102
|
initialHistory?: Record<string, { content: string; callId: string }>;
|
|
75
103
|
hideRagContextInPrompt?: boolean;
|
|
76
104
|
createConversationOnFirstChat?: boolean;
|
|
105
|
+
autoApproveTools?: boolean | string[];
|
|
77
106
|
mcpServers?: any[];
|
|
107
|
+
resolveMcpAuthHeaders?: MCPAuthHeaderResolver;
|
|
108
|
+
localToolExecutors?: Record<string, LocalToolExecutor>;
|
|
109
|
+
traceContextMode?: TraceContextMode;
|
|
78
110
|
progressiveActions?: boolean;
|
|
79
111
|
|
|
80
112
|
// Agent selector props (Cursor-style at bottom of input)
|
|
@@ -94,6 +126,9 @@ export interface AIChatPanelProps {
|
|
|
94
126
|
// Callback when a new conversation is created via API
|
|
95
127
|
onConversationCreated?: (conversationId: string) => void;
|
|
96
128
|
|
|
129
|
+
// Callback invoked before each send() call
|
|
130
|
+
onBeforeSend?: (payload: BeforeSendPayload) => Promise<void> | void;
|
|
131
|
+
|
|
97
132
|
// UI Customization Props (from ChatPanel)
|
|
98
133
|
cssUrl?: string;
|
|
99
134
|
markdownClass?: string;
|
|
@@ -143,8 +178,957 @@ interface ThinkingBlock {
|
|
|
143
178
|
type: 'thinking' | 'reasoning' | 'searching';
|
|
144
179
|
content: string;
|
|
145
180
|
index: number;
|
|
181
|
+
signature: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface ToolRequestMatch {
|
|
185
|
+
match: string;
|
|
186
|
+
groups: string[];
|
|
187
|
+
toolName: string;
|
|
188
|
+
callId: string;
|
|
189
|
+
serviceTag: string;
|
|
190
|
+
start: number;
|
|
191
|
+
end: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error';
|
|
195
|
+
|
|
196
|
+
interface ToolCallStatusRow {
|
|
197
|
+
signature: string;
|
|
198
|
+
toolName: string;
|
|
199
|
+
callId: string;
|
|
200
|
+
status: ToolCallStatus;
|
|
201
|
+
statusLabel: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface ToolReplaySummaryEntry {
|
|
205
|
+
toolName: string;
|
|
206
|
+
callId: string;
|
|
207
|
+
status: 'ok' | 'error';
|
|
208
|
+
argsText: string;
|
|
209
|
+
resultText: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface InlineToolMarker {
|
|
213
|
+
toolName: string;
|
|
214
|
+
callId: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface InlineThinkingMarker {
|
|
218
|
+
type: 'thinking' | 'reasoning' | 'searching';
|
|
219
|
+
signature: string;
|
|
146
220
|
}
|
|
147
221
|
|
|
222
|
+
const areToolRequestListsEqual = (a: ToolRequestMatch[], b: ToolRequestMatch[]): boolean => {
|
|
223
|
+
if (a.length !== b.length) return false;
|
|
224
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
225
|
+
const left = a[index];
|
|
226
|
+
const right = b[index];
|
|
227
|
+
if (!left || !right) return false;
|
|
228
|
+
if (left.callId !== right.callId) return false;
|
|
229
|
+
if (left.toolName !== right.toolName) return false;
|
|
230
|
+
if (left.serviceTag !== right.serviceTag) return false;
|
|
231
|
+
if (left.match !== right.match) return false;
|
|
232
|
+
if (left.start !== right.start || left.end !== right.end) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const mergeContinuationResponseText = (baseText: string, continuationText: string): string => {
|
|
238
|
+
const base = typeof baseText === 'string' ? baseText : '';
|
|
239
|
+
const continuation = typeof continuationText === 'string' ? continuationText : '';
|
|
240
|
+
if (!base) return continuation;
|
|
241
|
+
if (!continuation) return base;
|
|
242
|
+
|
|
243
|
+
if (base.includes(continuation)) {
|
|
244
|
+
return base;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const maxOverlap = Math.min(base.length, continuation.length);
|
|
248
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
249
|
+
if (base.slice(-overlap) === continuation.slice(0, overlap)) {
|
|
250
|
+
return `${base}${continuation.slice(overlap)}`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return `${base}\n\n${continuation}`;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const INLINE_TOOL_MARKER_PREFIX = '[[AI_TOOL_CALL:';
|
|
258
|
+
const INLINE_TOOL_MARKER_SUFFIX = ']]';
|
|
259
|
+
const INLINE_TOOL_MARKER_REGEX = /\[\[AI_TOOL_CALL:([^|\]]+)\|([^\]]+)\]\]/g;
|
|
260
|
+
const INLINE_THINKING_MARKER_PREFIX = '[[AI_THINK_BLOCK:';
|
|
261
|
+
const INLINE_THINKING_MARKER_SUFFIX = ']]';
|
|
262
|
+
const INLINE_THINKING_MARKER_REGEX = /\[\[AI_THINK_BLOCK:([^|\]]+)\|([^\]]+)\]\]/g;
|
|
263
|
+
const MAX_TOOL_CONTINUATIONS_PER_TURN = 20;
|
|
264
|
+
const MAX_TOOL_REPLAY_PAYLOAD_CHARS = 750_000;
|
|
265
|
+
const MAX_TRACE_SUMMARY_CHARS = 1_800;
|
|
266
|
+
const MAX_TRACE_REASONING_BLOCKS = 4;
|
|
267
|
+
const MAX_TRACE_TOOL_LINES = 6;
|
|
268
|
+
const MAX_TRACE_ITEM_CHARS = 220;
|
|
269
|
+
const MAX_TRACE_LINE_CHARS = 420;
|
|
270
|
+
|
|
271
|
+
const hasInlineRuntimeMarkers = (value: string): boolean => {
|
|
272
|
+
const source = typeof value === 'string' ? value : '';
|
|
273
|
+
return source.includes(INLINE_TOOL_MARKER_PREFIX) || source.includes(INLINE_THINKING_MARKER_PREFIX);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const toNonEmptyLines = (value: string): string[] =>
|
|
277
|
+
String(value || '')
|
|
278
|
+
.split('\n')
|
|
279
|
+
.map((line) => line.trim())
|
|
280
|
+
.filter((line) => line.length > 0);
|
|
281
|
+
|
|
282
|
+
const isBoundarySubsetByLines = (existingLines: string[], incomingLines: string[]): boolean => {
|
|
283
|
+
if (incomingLines.length === 0 || existingLines.length === 0) return false;
|
|
284
|
+
if (incomingLines.length >= existingLines.length) return false;
|
|
285
|
+
|
|
286
|
+
const prefixMatch = incomingLines.every((line, index) => existingLines[index] === line);
|
|
287
|
+
if (prefixMatch) return true;
|
|
288
|
+
|
|
289
|
+
const suffixStart = existingLines.length - incomingLines.length;
|
|
290
|
+
return incomingLines.every((line, index) => existingLines[suffixStart + index] === line);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const shouldPreserveBoundaryDroppedStreamText = (
|
|
294
|
+
existingContent: string,
|
|
295
|
+
incomingContent: string,
|
|
296
|
+
): boolean => {
|
|
297
|
+
const existing = String(existingContent || '').trim();
|
|
298
|
+
const incoming = String(incomingContent || '').trim();
|
|
299
|
+
if (!existing) return false;
|
|
300
|
+
if (!incoming) return true;
|
|
301
|
+
if (incoming === existing) return false;
|
|
302
|
+
if (!hasInlineRuntimeMarkers(existing)) return false;
|
|
303
|
+
|
|
304
|
+
if (existing.includes(incoming)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const existingLines = toNonEmptyLines(existing);
|
|
309
|
+
const incomingLines = toNonEmptyLines(incoming);
|
|
310
|
+
return isBoundarySubsetByLines(existingLines, incomingLines);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
314
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
315
|
+
|
|
316
|
+
const stringifyToolArgs = (value: unknown): string => {
|
|
317
|
+
if (typeof value === 'string') return value;
|
|
318
|
+
try {
|
|
319
|
+
return JSON.stringify(value ?? {});
|
|
320
|
+
} catch (_error) {
|
|
321
|
+
return '{}';
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const truncateTraceText = (value: string, maxChars: number): string => {
|
|
326
|
+
const text = String(value || '').trim();
|
|
327
|
+
if (!text) return '';
|
|
328
|
+
if (text.length <= maxChars) return text;
|
|
329
|
+
return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const normalizeTraceText = (value: unknown): string => {
|
|
333
|
+
if (value === null || value === undefined) return '';
|
|
334
|
+
const raw =
|
|
335
|
+
typeof value === 'string'
|
|
336
|
+
? value
|
|
337
|
+
: (() => {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.stringify(value);
|
|
340
|
+
} catch (_error) {
|
|
341
|
+
return String(value);
|
|
342
|
+
}
|
|
343
|
+
})();
|
|
344
|
+
return String(raw || '')
|
|
345
|
+
.replace(/\s+/g, ' ')
|
|
346
|
+
.trim();
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const toTraceObjectArray = (value: unknown): Record<string, unknown>[] => {
|
|
350
|
+
if (!Array.isArray(value)) return [];
|
|
351
|
+
return value.filter((item): item is Record<string, unknown> => isObjectRecord(item));
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const getTraceStatusLabel = (response: Record<string, unknown> | undefined): 'ok' | 'error' | 'pending' => {
|
|
355
|
+
if (!response) return 'pending';
|
|
356
|
+
if (response.isError === true || response.error === true) return 'error';
|
|
357
|
+
return 'ok';
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const buildCompactTraceSummary = ({
|
|
361
|
+
reasoningBlocks,
|
|
362
|
+
toolCalls,
|
|
363
|
+
toolResponses,
|
|
364
|
+
}: {
|
|
365
|
+
reasoningBlocks: ThinkingBlock[];
|
|
366
|
+
toolCalls?: unknown[];
|
|
367
|
+
toolResponses?: unknown[];
|
|
368
|
+
}): string => {
|
|
369
|
+
const sections: string[] = [];
|
|
370
|
+
|
|
371
|
+
const normalizedReasoning = (Array.isArray(reasoningBlocks) ? reasoningBlocks : [])
|
|
372
|
+
.filter((block) => !!block && typeof block.content === 'string' && block.content.trim().length > 0)
|
|
373
|
+
.slice(-MAX_TRACE_REASONING_BLOCKS)
|
|
374
|
+
.map((block) => {
|
|
375
|
+
const content = truncateTraceText(normalizeTraceText(block.content), MAX_TRACE_ITEM_CHARS);
|
|
376
|
+
if (!content) return '';
|
|
377
|
+
const type = typeof block.type === 'string' ? block.type : 'thinking';
|
|
378
|
+
return `- ${type}: ${content}`;
|
|
379
|
+
})
|
|
380
|
+
.filter(Boolean);
|
|
381
|
+
|
|
382
|
+
if (normalizedReasoning.length > 0) {
|
|
383
|
+
sections.push(['reasoning:', ...normalizedReasoning].join('\n'));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const calls = toTraceObjectArray(toolCalls).slice(-MAX_TRACE_TOOL_LINES);
|
|
387
|
+
const responses = toTraceObjectArray(toolResponses);
|
|
388
|
+
if (calls.length > 0) {
|
|
389
|
+
const responsesByCallId = new Map<string, Record<string, unknown>>();
|
|
390
|
+
responses.forEach((response) => {
|
|
391
|
+
const key = typeof response.tool_call_id === 'string' ? response.tool_call_id.trim() : '';
|
|
392
|
+
if (!key || responsesByCallId.has(key)) return;
|
|
393
|
+
responsesByCallId.set(key, response);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const responseOffset = Math.max(0, responses.length - calls.length);
|
|
397
|
+
const toolLines = calls
|
|
398
|
+
.map((call, index) => {
|
|
399
|
+
const toolName =
|
|
400
|
+
typeof call.name === 'string'
|
|
401
|
+
? call.name.trim()
|
|
402
|
+
: typeof call.tool_name === 'string'
|
|
403
|
+
? call.tool_name.trim()
|
|
404
|
+
: 'tool';
|
|
405
|
+
const callId =
|
|
406
|
+
typeof call.id === 'string'
|
|
407
|
+
? call.id.trim()
|
|
408
|
+
: typeof call.tool_call_id === 'string'
|
|
409
|
+
? call.tool_call_id.trim()
|
|
410
|
+
: '';
|
|
411
|
+
const response =
|
|
412
|
+
(callId ? responsesByCallId.get(callId) : undefined) || responses[responseOffset + index] || responses[index];
|
|
413
|
+
const status = getTraceStatusLabel(response);
|
|
414
|
+
|
|
415
|
+
const rawArgs = call.input ?? call.args ?? call.arguments ?? {};
|
|
416
|
+
const parsedArgs = parseToolArguments(rawArgs);
|
|
417
|
+
const normalizedArgs = truncateTraceText(
|
|
418
|
+
normalizeTraceText(parsedArgs ?? (isObjectRecord(rawArgs) ? rawArgs : rawArgs || {})),
|
|
419
|
+
MAX_TRACE_ITEM_CHARS,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const normalizedResult = truncateTraceText(
|
|
423
|
+
normalizeTraceText(response?.result ?? response?.content ?? response?.error ?? ''),
|
|
424
|
+
MAX_TRACE_ITEM_CHARS,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const base = callId ? `- ${toolName} (${callId}) ${status}` : `- ${toolName} ${status}`;
|
|
428
|
+
const withArgs = normalizedArgs ? `${base} args=${normalizedArgs}` : base;
|
|
429
|
+
const withResult = normalizedResult ? `${withArgs} result=${normalizedResult}` : withArgs;
|
|
430
|
+
return truncateTraceText(withResult, MAX_TRACE_LINE_CHARS);
|
|
431
|
+
})
|
|
432
|
+
.filter(Boolean);
|
|
433
|
+
|
|
434
|
+
if (toolLines.length > 0) {
|
|
435
|
+
sections.push(['tools:', ...toolLines].join('\n'));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (sections.length === 0) return '';
|
|
440
|
+
return truncateTraceText(['TRACE SUMMARY (compact)', ...sections].join('\n'), MAX_TRACE_SUMMARY_CHARS);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const findPreviousNonWhitespaceChar = (text: string, startIndex: number): string | null => {
|
|
444
|
+
for (let index = startIndex; index >= 0; index -= 1) {
|
|
445
|
+
const char = text[index];
|
|
446
|
+
if (typeof char !== 'string') continue;
|
|
447
|
+
if (!/\s/.test(char)) {
|
|
448
|
+
return char;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const findNextNonWhitespaceChar = (text: string, startIndex: number): string | null => {
|
|
455
|
+
for (let index = startIndex; index < text.length; index += 1) {
|
|
456
|
+
const char = text[index];
|
|
457
|
+
if (typeof char !== 'string') continue;
|
|
458
|
+
if (!/\s/.test(char)) {
|
|
459
|
+
return char;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const isStandaloneToolObjectSegment = (text: string, openIndex: number, closeIndex: number): boolean => {
|
|
466
|
+
const lineStart = text.lastIndexOf('\n', openIndex - 1) + 1;
|
|
467
|
+
const lineEndRaw = text.indexOf('\n', closeIndex + 1);
|
|
468
|
+
const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
|
|
469
|
+
|
|
470
|
+
const linePrefix = text.slice(lineStart, openIndex);
|
|
471
|
+
const lineSuffix = text.slice(closeIndex + 1, lineEnd);
|
|
472
|
+
const startsLine = linePrefix.trim().length === 0;
|
|
473
|
+
const endsLine = lineSuffix.trim().length === 0;
|
|
474
|
+
|
|
475
|
+
const prevChar = findPreviousNonWhitespaceChar(text, openIndex - 1);
|
|
476
|
+
const nextChar = findNextNonWhitespaceChar(text, closeIndex + 1);
|
|
477
|
+
const prevDelimited = prevChar !== null && ['{', '}', '[', ']', ','].includes(prevChar);
|
|
478
|
+
const nextDelimited = nextChar !== null && ['{', '}', '[', ']', ','].includes(nextChar);
|
|
479
|
+
|
|
480
|
+
// Guard against replay-summary lines such as "Args: {...}" / "Result: {...}".
|
|
481
|
+
const prefixWindow = text.slice(Math.max(0, openIndex - 64), openIndex);
|
|
482
|
+
const hasLabelPrefix = /(?:^|[\r\n])\s*[A-Za-z][A-Za-z0-9 _-]{0,30}:\s*$/.test(prefixWindow);
|
|
483
|
+
if (hasLabelPrefix) return false;
|
|
484
|
+
|
|
485
|
+
return (startsLine || prevDelimited) && (endsLine || nextDelimited);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
type ParsedJsonSegment = {
|
|
489
|
+
start: number;
|
|
490
|
+
end: number;
|
|
491
|
+
raw: string;
|
|
492
|
+
value: Record<string, unknown>;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const extractTopLevelJsonObjectSegments = (source: string): ParsedJsonSegment[] => {
|
|
496
|
+
const text = typeof source === 'string' ? source : '';
|
|
497
|
+
if (!text) return [];
|
|
498
|
+
|
|
499
|
+
const segments: ParsedJsonSegment[] = [];
|
|
500
|
+
let cursor = 0;
|
|
501
|
+
|
|
502
|
+
while (cursor < text.length) {
|
|
503
|
+
const openIndex = text.indexOf('{', cursor);
|
|
504
|
+
if (openIndex === -1) break;
|
|
505
|
+
|
|
506
|
+
let depth = 0;
|
|
507
|
+
let inString = false;
|
|
508
|
+
let escaped = false;
|
|
509
|
+
let closeIndex = -1;
|
|
510
|
+
|
|
511
|
+
for (let index = openIndex; index < text.length; index += 1) {
|
|
512
|
+
const char = text[index];
|
|
513
|
+
|
|
514
|
+
if (inString) {
|
|
515
|
+
if (escaped) {
|
|
516
|
+
escaped = false;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (char === '\\') {
|
|
520
|
+
escaped = true;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (char === '"') {
|
|
524
|
+
inString = false;
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (char === '"') {
|
|
530
|
+
inString = true;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (char === '{') {
|
|
535
|
+
depth += 1;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (char === '}') {
|
|
540
|
+
depth -= 1;
|
|
541
|
+
if (depth === 0) {
|
|
542
|
+
closeIndex = index;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
if (depth < 0) {
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (closeIndex === -1) {
|
|
552
|
+
cursor = openIndex + 1;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const raw = text.slice(openIndex, closeIndex + 1);
|
|
557
|
+
try {
|
|
558
|
+
const value = JSON.parse(raw);
|
|
559
|
+
if (isObjectRecord(value) && isStandaloneToolObjectSegment(text, openIndex, closeIndex)) {
|
|
560
|
+
segments.push({
|
|
561
|
+
start: openIndex,
|
|
562
|
+
end: closeIndex + 1,
|
|
563
|
+
raw,
|
|
564
|
+
value,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
} catch (_error) {
|
|
568
|
+
// Ignore parse failures for non-tool JSON snippets.
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
cursor = closeIndex + 1;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return segments;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const resolveToolRequestFromSegment = (
|
|
578
|
+
segment: ParsedJsonSegment,
|
|
579
|
+
fallbackIndex: number,
|
|
580
|
+
): ToolRequestMatch | null => {
|
|
581
|
+
const record = segment.value;
|
|
582
|
+
const serviceTag = typeof record.service === 'string' ? record.service : '';
|
|
583
|
+
const rawId = typeof record.id === 'string' ? record.id.trim() : '';
|
|
584
|
+
|
|
585
|
+
if (record.type === 'tool_use' && typeof record.name === 'string') {
|
|
586
|
+
const toolName = String(record.name).trim();
|
|
587
|
+
if (!toolName) return null;
|
|
588
|
+
const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
|
|
589
|
+
return {
|
|
590
|
+
match: segment.raw,
|
|
591
|
+
groups: [callId, toolName, stringifyToolArgs(record.input ?? {}), serviceTag],
|
|
592
|
+
toolName,
|
|
593
|
+
callId,
|
|
594
|
+
serviceTag,
|
|
595
|
+
start: segment.start,
|
|
596
|
+
end: segment.end,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (record.type === 'function' && isObjectRecord(record.function)) {
|
|
601
|
+
const functionRecord = record.function;
|
|
602
|
+
const toolName = typeof functionRecord.name === 'string' ? functionRecord.name.trim() : '';
|
|
603
|
+
if (!toolName) return null;
|
|
604
|
+
const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
|
|
605
|
+
return {
|
|
606
|
+
match: segment.raw,
|
|
607
|
+
groups: [callId, toolName, stringifyToolArgs(functionRecord.arguments ?? {}), serviceTag],
|
|
608
|
+
toolName,
|
|
609
|
+
callId,
|
|
610
|
+
serviceTag,
|
|
611
|
+
start: segment.start,
|
|
612
|
+
end: segment.end,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (isObjectRecord(record.functionCall) && typeof record.functionCall.name === 'string') {
|
|
617
|
+
const functionCall = record.functionCall;
|
|
618
|
+
const toolName = String(functionCall.name).trim();
|
|
619
|
+
if (!toolName) return null;
|
|
620
|
+
const callId = rawId || `${toolName}-${fallbackIndex + 1}`;
|
|
621
|
+
return {
|
|
622
|
+
match: segment.raw,
|
|
623
|
+
groups: [callId, toolName, stringifyToolArgs(functionCall.args ?? {}), serviceTag],
|
|
624
|
+
toolName,
|
|
625
|
+
callId,
|
|
626
|
+
serviceTag,
|
|
627
|
+
start: segment.start,
|
|
628
|
+
end: segment.end,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return null;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const extractBalancedObjectCandidate = (input: string): string | null => {
|
|
636
|
+
const text = typeof input === 'string' ? input : '';
|
|
637
|
+
const start = text.indexOf('{');
|
|
638
|
+
if (start === -1) return null;
|
|
639
|
+
|
|
640
|
+
let depth = 0;
|
|
641
|
+
let inString = false;
|
|
642
|
+
let escaped = false;
|
|
643
|
+
|
|
644
|
+
for (let index = start; index < text.length; index += 1) {
|
|
645
|
+
const char = text[index];
|
|
646
|
+
|
|
647
|
+
if (inString) {
|
|
648
|
+
if (escaped) {
|
|
649
|
+
escaped = false;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (char === '\\') {
|
|
653
|
+
escaped = true;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (char === '"') {
|
|
657
|
+
inString = false;
|
|
658
|
+
}
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (char === '"') {
|
|
663
|
+
inString = true;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (char === '{') {
|
|
667
|
+
depth += 1;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (char === '}') {
|
|
671
|
+
depth -= 1;
|
|
672
|
+
if (depth === 0) {
|
|
673
|
+
return text.slice(start, index + 1);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return null;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const extractLineLevelFallbackToolRequests = (
|
|
682
|
+
source: string,
|
|
683
|
+
existing: ToolRequestMatch[],
|
|
684
|
+
): ToolRequestMatch[] => {
|
|
685
|
+
const text = typeof source === 'string' ? source : '';
|
|
686
|
+
if (!text) return [];
|
|
687
|
+
|
|
688
|
+
const takenRanges = existing.map((request) => ({
|
|
689
|
+
start: request.start,
|
|
690
|
+
end: request.end,
|
|
691
|
+
}));
|
|
692
|
+
|
|
693
|
+
const overlapsExisting = (start: number, end: number): boolean =>
|
|
694
|
+
takenRanges.some((range) => start < range.end && end > range.start);
|
|
695
|
+
|
|
696
|
+
const requests: ToolRequestMatch[] = [];
|
|
697
|
+
let offset = 0;
|
|
698
|
+
const lines = text.split('\n');
|
|
699
|
+
|
|
700
|
+
const normalizeStandaloneToolJsonLine = (line: string): string | null => {
|
|
701
|
+
const trimmed = line.trim();
|
|
702
|
+
if (!trimmed) return null;
|
|
703
|
+
|
|
704
|
+
let normalized = trimmed;
|
|
705
|
+
const first = normalized[0];
|
|
706
|
+
const last = normalized[normalized.length - 1];
|
|
707
|
+
const hasSymmetricQuote =
|
|
708
|
+
normalized.length >= 2 &&
|
|
709
|
+
((first === "'" && last === "'") || (first === '"' && last === '"') || (first === '`' && last === '`'));
|
|
710
|
+
if (hasSymmetricQuote) {
|
|
711
|
+
const inner = normalized.slice(1, -1).trim();
|
|
712
|
+
if (first === '"') {
|
|
713
|
+
try {
|
|
714
|
+
const decoded = JSON.parse(normalized);
|
|
715
|
+
if (typeof decoded === 'string') {
|
|
716
|
+
normalized = decoded.trim();
|
|
717
|
+
} else {
|
|
718
|
+
normalized = inner;
|
|
719
|
+
}
|
|
720
|
+
} catch (_error) {
|
|
721
|
+
normalized = inner;
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
normalized = inner;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const extractedCandidate = extractBalancedObjectCandidate(normalized);
|
|
729
|
+
if (extractedCandidate) {
|
|
730
|
+
normalized = extractedCandidate.trim();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!normalized.startsWith('{')) return null;
|
|
734
|
+
if (!normalized.includes('"type"')) return null;
|
|
735
|
+
return normalized;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const extractBalancedObjectAfterField = (input: string, fieldName: string): string | null => {
|
|
739
|
+
const fieldRegex = new RegExp(`"${fieldName}"\\s*:\\s*`, 'i');
|
|
740
|
+
const fieldMatch = fieldRegex.exec(input);
|
|
741
|
+
if (!fieldMatch) return null;
|
|
742
|
+
|
|
743
|
+
const startIndex = input.indexOf('{', fieldMatch.index + fieldMatch[0].length);
|
|
744
|
+
if (startIndex === -1) return null;
|
|
745
|
+
|
|
746
|
+
let depth = 0;
|
|
747
|
+
let inString = false;
|
|
748
|
+
let escaped = false;
|
|
749
|
+
|
|
750
|
+
for (let index = startIndex; index < input.length; index += 1) {
|
|
751
|
+
const char = input[index];
|
|
752
|
+
|
|
753
|
+
if (inString) {
|
|
754
|
+
if (escaped) {
|
|
755
|
+
escaped = false;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (char === '\\') {
|
|
759
|
+
escaped = true;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (char === '"') {
|
|
763
|
+
inString = false;
|
|
764
|
+
}
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (char === '"') {
|
|
769
|
+
inString = true;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (char === '{') {
|
|
773
|
+
depth += 1;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (char === '}') {
|
|
777
|
+
depth -= 1;
|
|
778
|
+
if (depth === 0) {
|
|
779
|
+
return input.slice(startIndex, index + 1);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return null;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
lines.forEach((line, index) => {
|
|
788
|
+
const lineStart = offset;
|
|
789
|
+
const lineEnd = lineStart + line.length;
|
|
790
|
+
offset = lineEnd + (index < lines.length - 1 ? 1 : 0);
|
|
791
|
+
|
|
792
|
+
const normalized = normalizeStandaloneToolJsonLine(line);
|
|
793
|
+
if (!normalized) return;
|
|
794
|
+
if (!(normalized.includes('"tool_use"') || normalized.includes('"function"'))) return;
|
|
795
|
+
if (/(^|[\r\n])\s*[A-Za-z][A-Za-z0-9 _-]{0,30}:\s*\{/.test(line)) return;
|
|
796
|
+
if (overlapsExisting(lineStart, lineEnd)) return;
|
|
797
|
+
|
|
798
|
+
// Prefer strict JSON parsing after line-level normalization to handle
|
|
799
|
+
// quoted/escaped raw tool payload lines without brittle regex capture.
|
|
800
|
+
try {
|
|
801
|
+
const parsedLine = JSON.parse(normalized);
|
|
802
|
+
if (isObjectRecord(parsedLine)) {
|
|
803
|
+
const type = typeof parsedLine.type === 'string' ? parsedLine.type.trim().toLowerCase() : '';
|
|
804
|
+
if (type === 'tool_use') {
|
|
805
|
+
const toolName = typeof parsedLine.name === 'string' ? parsedLine.name.trim() : '';
|
|
806
|
+
if (!toolName) return;
|
|
807
|
+
const callId =
|
|
808
|
+
typeof parsedLine.id === 'string' && parsedLine.id.trim().length > 0
|
|
809
|
+
? parsedLine.id.trim()
|
|
810
|
+
: `tool-call-${index + 1}`;
|
|
811
|
+
const serviceTag = typeof parsedLine.service === 'string' ? parsedLine.service : '';
|
|
812
|
+
const parsedArgs = parseToolArguments(parsedLine.input ?? {});
|
|
813
|
+
if (!parsedArgs) return;
|
|
814
|
+
|
|
815
|
+
requests.push({
|
|
816
|
+
match: line,
|
|
817
|
+
groups: [callId, toolName, stringifyToolArgs(parsedArgs), serviceTag],
|
|
818
|
+
toolName,
|
|
819
|
+
callId,
|
|
820
|
+
serviceTag,
|
|
821
|
+
start: lineStart,
|
|
822
|
+
end: lineEnd,
|
|
823
|
+
});
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (type === 'function' && isObjectRecord(parsedLine.function)) {
|
|
828
|
+
const functionRecord = parsedLine.function;
|
|
829
|
+
const toolName = typeof functionRecord.name === 'string' ? functionRecord.name.trim() : '';
|
|
830
|
+
if (!toolName) return;
|
|
831
|
+
const callId =
|
|
832
|
+
typeof parsedLine.id === 'string' && parsedLine.id.trim().length > 0
|
|
833
|
+
? parsedLine.id.trim()
|
|
834
|
+
: `tool-call-${index + 1}`;
|
|
835
|
+
const serviceTag = typeof parsedLine.service === 'string' ? parsedLine.service : '';
|
|
836
|
+
const parsedArgs = parseToolArguments(functionRecord.arguments ?? {});
|
|
837
|
+
if (!parsedArgs) return;
|
|
838
|
+
|
|
839
|
+
requests.push({
|
|
840
|
+
match: line,
|
|
841
|
+
groups: [callId, toolName, stringifyToolArgs(parsedArgs), serviceTag],
|
|
842
|
+
toolName,
|
|
843
|
+
callId,
|
|
844
|
+
serviceTag,
|
|
845
|
+
start: lineStart,
|
|
846
|
+
end: lineEnd,
|
|
847
|
+
});
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} catch (_error) {
|
|
852
|
+
// Fall through to permissive regex extraction for malformed-yet-recoverable payloads.
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const typeMatch = normalized.match(/"type"\s*:\s*"([^"]+)"/i);
|
|
856
|
+
const type = typeMatch?.[1]?.trim().toLowerCase();
|
|
857
|
+
if (type !== 'tool_use' && type !== 'function') return;
|
|
858
|
+
|
|
859
|
+
const idMatch = normalized.match(/"id"\s*:\s*"([^"]+)"/i);
|
|
860
|
+
const callId = idMatch?.[1]?.trim() || `tool-call-${index + 1}`;
|
|
861
|
+
|
|
862
|
+
let toolName = '';
|
|
863
|
+
let argsRaw = '{}';
|
|
864
|
+
if (type === 'tool_use') {
|
|
865
|
+
const nameMatch = normalized.match(/"name"\s*:\s*"([^"]+)"/i);
|
|
866
|
+
toolName = nameMatch?.[1]?.trim() || '';
|
|
867
|
+
const inputObject = extractBalancedObjectAfterField(normalized, 'input');
|
|
868
|
+
if (inputObject) {
|
|
869
|
+
argsRaw = inputObject;
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
const fnNameMatch = normalized.match(/"function"\s*:\s*\{[\s\S]*?"name"\s*:\s*"([^"]+)"/i);
|
|
873
|
+
toolName = fnNameMatch?.[1]?.trim() || '';
|
|
874
|
+
|
|
875
|
+
const argumentsObject = extractBalancedObjectAfterField(normalized, 'arguments');
|
|
876
|
+
if (argumentsObject) {
|
|
877
|
+
argsRaw = argumentsObject;
|
|
878
|
+
} else {
|
|
879
|
+
const argsStringMatch = normalized.match(/"arguments"\s*:\s*"([\s\S]*?)"(?:\s*,|\s*})/i);
|
|
880
|
+
if (argsStringMatch?.[1]) {
|
|
881
|
+
const rawArgs = argsStringMatch[1];
|
|
882
|
+
try {
|
|
883
|
+
argsRaw = JSON.parse(`"${rawArgs}"`);
|
|
884
|
+
} catch (_error) {
|
|
885
|
+
argsRaw = rawArgs;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!toolName) return;
|
|
892
|
+
const parsedArgs = parseToolArguments(argsRaw);
|
|
893
|
+
if (!parsedArgs) return;
|
|
894
|
+
|
|
895
|
+
requests.push({
|
|
896
|
+
match: line,
|
|
897
|
+
groups: [callId, toolName, stringifyToolArgs(parsedArgs), ''],
|
|
898
|
+
toolName,
|
|
899
|
+
callId,
|
|
900
|
+
serviceTag: '',
|
|
901
|
+
start: lineStart,
|
|
902
|
+
end: lineEnd,
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
return requests;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const stripStandaloneRawToolJsonLines = (source: string): string => {
|
|
910
|
+
const text = typeof source === 'string' ? source : '';
|
|
911
|
+
if (!text) return text;
|
|
912
|
+
|
|
913
|
+
const normalizeStandaloneToolJsonLine = (line: string): string | null => {
|
|
914
|
+
const trimmed = line.trim();
|
|
915
|
+
if (!trimmed) return null;
|
|
916
|
+
|
|
917
|
+
let normalized = trimmed;
|
|
918
|
+
const first = normalized[0];
|
|
919
|
+
const last = normalized[normalized.length - 1];
|
|
920
|
+
const hasSymmetricQuote =
|
|
921
|
+
normalized.length >= 2 &&
|
|
922
|
+
((first === "'" && last === "'") || (first === '"' && last === '"') || (first === '`' && last === '`'));
|
|
923
|
+
if (hasSymmetricQuote) {
|
|
924
|
+
const inner = normalized.slice(1, -1).trim();
|
|
925
|
+
if (first === '"') {
|
|
926
|
+
try {
|
|
927
|
+
const decoded = JSON.parse(normalized);
|
|
928
|
+
if (typeof decoded === 'string') {
|
|
929
|
+
normalized = decoded.trim();
|
|
930
|
+
} else {
|
|
931
|
+
normalized = inner;
|
|
932
|
+
}
|
|
933
|
+
} catch (_error) {
|
|
934
|
+
normalized = inner;
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
normalized = inner;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const extractedCandidate = extractBalancedObjectCandidate(normalized);
|
|
942
|
+
if (extractedCandidate) {
|
|
943
|
+
normalized = extractedCandidate.trim();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (!normalized.startsWith('{') || !normalized.endsWith('}')) return null;
|
|
947
|
+
return normalized;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const lines = text.split('\n');
|
|
951
|
+
let removedAny = false;
|
|
952
|
+
const kept = lines.filter((line) => {
|
|
953
|
+
const normalized = normalizeStandaloneToolJsonLine(line);
|
|
954
|
+
if (!normalized) return true;
|
|
955
|
+
|
|
956
|
+
const hasToolType = /"type"\s*:\s*"(tool_use|function)"/i.test(normalized);
|
|
957
|
+
if (!hasToolType) return true;
|
|
958
|
+
|
|
959
|
+
const hasName = /"name"\s*:\s*"[^"]+"/i.test(normalized);
|
|
960
|
+
const hasFunctionName = /"function"\s*:\s*\{[\s\S]*?"name"\s*:\s*"[^"]+"/i.test(normalized);
|
|
961
|
+
if (!hasName && !hasFunctionName) return true;
|
|
962
|
+
|
|
963
|
+
removedAny = true;
|
|
964
|
+
return false;
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
if (!removedAny) return text;
|
|
968
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const extractToolRequestMatchesFromText = (rawResponse: string): ToolRequestMatch[] => {
|
|
972
|
+
const requests: ToolRequestMatch[] = [];
|
|
973
|
+
const segments = extractTopLevelJsonObjectSegments(rawResponse);
|
|
974
|
+
segments.forEach((segment, segmentIndex) => {
|
|
975
|
+
const parsed = resolveToolRequestFromSegment(segment, segmentIndex);
|
|
976
|
+
if (!parsed) return;
|
|
977
|
+
requests.push(parsed);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
const fallbackRequests = extractLineLevelFallbackToolRequests(rawResponse, requests);
|
|
981
|
+
fallbackRequests.forEach((request) => {
|
|
982
|
+
requests.push(request);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (requests.length === 0) return [];
|
|
986
|
+
requests.sort((a, b) => a.start - b.start);
|
|
987
|
+
|
|
988
|
+
const seenSignatures = new Set<string>();
|
|
989
|
+
const deduped = requests.filter((request) => {
|
|
990
|
+
const signature = `${String(request.toolName || '').trim().toLowerCase()}::${String(
|
|
991
|
+
request.callId || '',
|
|
992
|
+
).trim().toLowerCase()}::${request.start}`;
|
|
993
|
+
if (seenSignatures.has(signature)) return false;
|
|
994
|
+
seenSignatures.add(signature);
|
|
995
|
+
return true;
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
deduped.sort((a, b) => a.start - b.start);
|
|
999
|
+
return deduped;
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const hashInlineMarkerValue = (value: string): string => {
|
|
1003
|
+
let hash = 5381;
|
|
1004
|
+
const input = String(value || '');
|
|
1005
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1006
|
+
hash = ((hash << 5) + hash) ^ input.charCodeAt(index);
|
|
1007
|
+
}
|
|
1008
|
+
return (hash >>> 0).toString(36);
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
const getThinkingBlockSignature = (
|
|
1012
|
+
type: 'thinking' | 'reasoning' | 'searching',
|
|
1013
|
+
content: string,
|
|
1014
|
+
): string => {
|
|
1015
|
+
const normalizedContent = String(content || '').trim();
|
|
1016
|
+
return `${type}-${hashInlineMarkerValue(normalizedContent)}-${normalizedContent.length}`;
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const buildThinkingBlockMarker = (
|
|
1020
|
+
type: 'thinking' | 'reasoning' | 'searching',
|
|
1021
|
+
signature: string,
|
|
1022
|
+
): string => {
|
|
1023
|
+
const normalizedType = String(type || 'thinking').trim() as 'thinking' | 'reasoning' | 'searching';
|
|
1024
|
+
const normalizedSignature = String(signature || '').trim() || `${normalizedType}-block`;
|
|
1025
|
+
return `${INLINE_THINKING_MARKER_PREFIX}${encodeURIComponent(normalizedType)}|${encodeURIComponent(
|
|
1026
|
+
normalizedSignature,
|
|
1027
|
+
)}${INLINE_THINKING_MARKER_SUFFIX}`;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const buildInlineToolMarker = (toolName: string, callId: string): string => {
|
|
1031
|
+
const normalizedToolName = String(toolName || '').trim() || 'tool';
|
|
1032
|
+
const normalizedCallId = String(callId || '').trim() || `${normalizedToolName}-call`;
|
|
1033
|
+
return `${INLINE_TOOL_MARKER_PREFIX}${encodeURIComponent(normalizedToolName)}|${encodeURIComponent(
|
|
1034
|
+
normalizedCallId,
|
|
1035
|
+
)}${INLINE_TOOL_MARKER_SUFFIX}`;
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const parseInlineToolMarkers = (text: string): { parts: string[]; markers: InlineToolMarker[] } => {
|
|
1039
|
+
const source = typeof text === 'string' ? text : '';
|
|
1040
|
+
if (!source) return { parts: [''], markers: [] };
|
|
1041
|
+
|
|
1042
|
+
const parts: string[] = [];
|
|
1043
|
+
const markers: InlineToolMarker[] = [];
|
|
1044
|
+
let lastIndex = 0;
|
|
1045
|
+
INLINE_TOOL_MARKER_REGEX.lastIndex = 0;
|
|
1046
|
+
let match: RegExpExecArray | null;
|
|
1047
|
+
|
|
1048
|
+
while ((match = INLINE_TOOL_MARKER_REGEX.exec(source)) !== null) {
|
|
1049
|
+
parts.push(source.slice(lastIndex, match.index));
|
|
1050
|
+
|
|
1051
|
+
const encodedToolName = match[1] || '';
|
|
1052
|
+
const encodedCallId = match[2] || '';
|
|
1053
|
+
let toolName = encodedToolName;
|
|
1054
|
+
let callId = encodedCallId;
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
toolName = decodeURIComponent(encodedToolName);
|
|
1058
|
+
} catch (_error) {
|
|
1059
|
+
toolName = encodedToolName;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
callId = decodeURIComponent(encodedCallId);
|
|
1064
|
+
} catch (_error) {
|
|
1065
|
+
callId = encodedCallId;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
markers.push({
|
|
1069
|
+
toolName: String(toolName || '').trim() || 'tool',
|
|
1070
|
+
callId: String(callId || '').trim() || 'call',
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
lastIndex = match.index + match[0].length;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
parts.push(source.slice(lastIndex));
|
|
1077
|
+
return { parts, markers };
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
const parseInlineThinkingMarkers = (
|
|
1081
|
+
text: string,
|
|
1082
|
+
): { parts: string[]; markers: InlineThinkingMarker[] } => {
|
|
1083
|
+
const source = typeof text === 'string' ? text : '';
|
|
1084
|
+
if (!source) return { parts: [''], markers: [] };
|
|
1085
|
+
|
|
1086
|
+
const parts: string[] = [];
|
|
1087
|
+
const markers: InlineThinkingMarker[] = [];
|
|
1088
|
+
let lastIndex = 0;
|
|
1089
|
+
INLINE_THINKING_MARKER_REGEX.lastIndex = 0;
|
|
1090
|
+
let match: RegExpExecArray | null;
|
|
1091
|
+
|
|
1092
|
+
while ((match = INLINE_THINKING_MARKER_REGEX.exec(source)) !== null) {
|
|
1093
|
+
parts.push(source.slice(lastIndex, match.index));
|
|
1094
|
+
|
|
1095
|
+
const encodedType = match[1] || '';
|
|
1096
|
+
const encodedSignature = match[2] || '';
|
|
1097
|
+
let rawType = encodedType;
|
|
1098
|
+
let signature = encodedSignature;
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
rawType = decodeURIComponent(encodedType);
|
|
1102
|
+
} catch (_error) {
|
|
1103
|
+
rawType = encodedType;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
try {
|
|
1107
|
+
signature = decodeURIComponent(encodedSignature);
|
|
1108
|
+
} catch (_error) {
|
|
1109
|
+
signature = encodedSignature;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const normalizedType = String(rawType || '').trim().toLowerCase();
|
|
1113
|
+
const type: 'thinking' | 'reasoning' | 'searching' =
|
|
1114
|
+
normalizedType === 'reasoning'
|
|
1115
|
+
? 'reasoning'
|
|
1116
|
+
: normalizedType === 'searching'
|
|
1117
|
+
? 'searching'
|
|
1118
|
+
: 'thinking';
|
|
1119
|
+
|
|
1120
|
+
markers.push({
|
|
1121
|
+
type,
|
|
1122
|
+
signature: String(signature || '').trim(),
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
lastIndex = match.index + match[0].length;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
parts.push(source.slice(lastIndex));
|
|
1129
|
+
return { parts, markers };
|
|
1130
|
+
};
|
|
1131
|
+
|
|
148
1132
|
// ============================================================================
|
|
149
1133
|
// Icons
|
|
150
1134
|
// ============================================================================
|
|
@@ -218,6 +1202,12 @@ const AgentIcon = () => (
|
|
|
218
1202
|
</svg>
|
|
219
1203
|
);
|
|
220
1204
|
|
|
1205
|
+
const ToolIcon = () => (
|
|
1206
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
1207
|
+
<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6 6a2 2 0 0 0 2.8 2.8l6-6a4 4 0 0 0 5.4-5.4l-2.1 2.1-3.3-3.3 2.6-1.6Z" />
|
|
1208
|
+
</svg>
|
|
1209
|
+
);
|
|
1210
|
+
|
|
221
1211
|
const CheckIcon = () => (
|
|
222
1212
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
223
1213
|
<polyline points="20 6 9 17 4 12" />
|
|
@@ -287,7 +1277,7 @@ const CloseIcon = () => (
|
|
|
287
1277
|
|
|
288
1278
|
interface ChatInputProps {
|
|
289
1279
|
placeholder: string;
|
|
290
|
-
|
|
1280
|
+
isBusy: boolean;
|
|
291
1281
|
onSubmit: (text: string) => void;
|
|
292
1282
|
onStop: () => void;
|
|
293
1283
|
agentOptions: AgentOption[];
|
|
@@ -308,7 +1298,7 @@ interface ChatInputProps {
|
|
|
308
1298
|
|
|
309
1299
|
const ChatInput = React.memo<ChatInputProps>(({
|
|
310
1300
|
placeholder,
|
|
311
|
-
|
|
1301
|
+
isBusy,
|
|
312
1302
|
onSubmit,
|
|
313
1303
|
onStop,
|
|
314
1304
|
agentOptions,
|
|
@@ -345,7 +1335,7 @@ const ChatInput = React.memo<ChatInputProps>(({
|
|
|
345
1335
|
// Handle submit
|
|
346
1336
|
const handleSubmit = useCallback(() => {
|
|
347
1337
|
const trimmed = inputValue.trim();
|
|
348
|
-
if (trimmed &&
|
|
1338
|
+
if (trimmed && !isBusy) {
|
|
349
1339
|
onSubmit(trimmed);
|
|
350
1340
|
setInputValue('');
|
|
351
1341
|
// Reset textarea height
|
|
@@ -353,7 +1343,7 @@ const ChatInput = React.memo<ChatInputProps>(({
|
|
|
353
1343
|
textareaRef.current.style.height = 'auto';
|
|
354
1344
|
}
|
|
355
1345
|
}
|
|
356
|
-
}, [inputValue,
|
|
1346
|
+
}, [inputValue, isBusy, onSubmit]);
|
|
357
1347
|
|
|
358
1348
|
// Close dropdown on outside click
|
|
359
1349
|
useEffect(() => {
|
|
@@ -531,9 +1521,9 @@ const ChatInput = React.memo<ChatInputProps>(({
|
|
|
531
1521
|
</div>
|
|
532
1522
|
</div>
|
|
533
1523
|
<div className="ai-chat-context-popover__sections">
|
|
534
|
-
{contextSections.map((section) => (
|
|
1524
|
+
{contextSections.map((section, index) => (
|
|
535
1525
|
<div
|
|
536
|
-
key={section.id}
|
|
1526
|
+
key={`${section.id}-${index}`}
|
|
537
1527
|
className={`ai-chat-context-popover__section-item ${enableContextDetailView ? 'ai-chat-context-popover__section-item--clickable' : ''}`}
|
|
538
1528
|
onClick={() => {
|
|
539
1529
|
if (enableContextDetailView) {
|
|
@@ -608,12 +1598,12 @@ const ChatInput = React.memo<ChatInputProps>(({
|
|
|
608
1598
|
</div>
|
|
609
1599
|
</div>
|
|
610
1600
|
<div className="ai-chat-context-popover__detail-sections">
|
|
611
|
-
{contextSections.map((section) => {
|
|
1601
|
+
{contextSections.map((section, index) => {
|
|
612
1602
|
const isRawSection = hasRawData(section);
|
|
613
1603
|
const isEnabled = !disabledSectionIds.has(section.id);
|
|
614
1604
|
return (
|
|
615
1605
|
<details
|
|
616
|
-
key={section.id}
|
|
1606
|
+
key={`${section.id}-${index}`}
|
|
617
1607
|
className={`ai-chat-context-popover__detail-section ${!isEnabled ? 'ai-chat-context-popover__detail-section--disabled' : ''}`}
|
|
618
1608
|
open={expandedSectionId === section.id}
|
|
619
1609
|
>
|
|
@@ -661,11 +1651,11 @@ const ChatInput = React.memo<ChatInputProps>(({
|
|
|
661
1651
|
)}
|
|
662
1652
|
|
|
663
1653
|
<button
|
|
664
|
-
className={`ai-chat-send-button ${
|
|
665
|
-
onClick={() =>
|
|
666
|
-
disabled={
|
|
1654
|
+
className={`ai-chat-send-button ${!isBusy && !inputValue.trim() ? 'ai-chat-send-button--disabled' : ''} ${isBusy ? 'ai-chat-send-button--stop' : ''}`}
|
|
1655
|
+
onClick={() => isBusy ? onStop() : handleSubmit()}
|
|
1656
|
+
disabled={!isBusy && !inputValue.trim()}
|
|
667
1657
|
>
|
|
668
|
-
{
|
|
1658
|
+
{isBusy ? <StopIcon /> : <ArrowUpIcon />}
|
|
669
1659
|
</button>
|
|
670
1660
|
</div>
|
|
671
1661
|
|
|
@@ -748,7 +1738,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
748
1738
|
initialHistory = {},
|
|
749
1739
|
hideRagContextInPrompt = true,
|
|
750
1740
|
createConversationOnFirstChat = true,
|
|
1741
|
+
autoApproveTools = false,
|
|
751
1742
|
mcpServers = [],
|
|
1743
|
+
resolveMcpAuthHeaders,
|
|
1744
|
+
localToolExecutors,
|
|
1745
|
+
traceContextMode = 'standard',
|
|
752
1746
|
progressiveActions = true,
|
|
753
1747
|
agentOptions = [],
|
|
754
1748
|
currentAgentId,
|
|
@@ -761,6 +1755,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
761
1755
|
disabledSectionIds: propDisabledSectionIds,
|
|
762
1756
|
onToggleSection: propOnToggleSection,
|
|
763
1757
|
onConversationCreated,
|
|
1758
|
+
onBeforeSend,
|
|
764
1759
|
// UI Customization Props
|
|
765
1760
|
cssUrl,
|
|
766
1761
|
markdownClass,
|
|
@@ -801,10 +1796,12 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
801
1796
|
const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
|
|
802
1797
|
const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
|
|
803
1798
|
// NOTE: activeThinkingBlock is computed via useMemo, not useState - see below after processThinkingTags
|
|
804
|
-
// Track collapsed state per block (key: "block-{index}" or "active"
|
|
1799
|
+
// Track collapsed state per block (key: "{entryKey}::block-{index}" or "{entryKey}::active")
|
|
805
1800
|
const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
|
|
806
1801
|
const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
|
|
807
1802
|
const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
|
|
1803
|
+
const thinkingBlocksByKeyRef = useRef<Record<string, ThinkingBlock[]>>({});
|
|
1804
|
+
const [thinkingBlocksByKey, setThinkingBlocksByKey] = useState<Record<string, ThinkingBlock[]>>({});
|
|
808
1805
|
const [newConversationConfirm, setNewConversationConfirm] = useState(false);
|
|
809
1806
|
const [justReset, setJustReset] = useState(false);
|
|
810
1807
|
const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
|
|
@@ -831,15 +1828,80 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
831
1828
|
const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
|
|
832
1829
|
|
|
833
1830
|
// Tool Approval state (for MCP tools)
|
|
834
|
-
const [pendingToolRequests, setPendingToolRequests] = useState<
|
|
1831
|
+
const [pendingToolRequests, setPendingToolRequests] = useState<ToolRequestMatch[]>([]);
|
|
835
1832
|
const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>([]);
|
|
836
1833
|
const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
|
|
1834
|
+
const [toolList, setToolList] = useState<any[]>([]);
|
|
1835
|
+
const [toolsLoading, setToolsLoading] = useState(false);
|
|
1836
|
+
const [toolsFetchError, setToolsFetchError] = useState(false);
|
|
1837
|
+
const [resolvedMcpServers, setResolvedMcpServers] = useState<any[]>(mcpServers || []);
|
|
1838
|
+
const [activeToolCalls, setActiveToolCalls] = useState<Array<{ toolName: string; callId: string }>>([]);
|
|
1839
|
+
const normalizeToolName = useCallback((toolName: string): string => {
|
|
1840
|
+
return String(toolName ?? '').trim().toLowerCase();
|
|
1841
|
+
}, []);
|
|
1842
|
+
const getToolCallSignature = useCallback(
|
|
1843
|
+
(toolName: string, callId: string): string => {
|
|
1844
|
+
const normalizedToolName = normalizeToolName(toolName);
|
|
1845
|
+
const normalizedCallId = String(callId ?? '').trim();
|
|
1846
|
+
if (!normalizedToolName || !normalizedCallId) return '';
|
|
1847
|
+
return `${normalizedToolName}::${normalizedCallId}`;
|
|
1848
|
+
},
|
|
1849
|
+
[normalizeToolName],
|
|
1850
|
+
);
|
|
1851
|
+
const alwaysApprovedToolsStorageKey = useMemo(() => {
|
|
1852
|
+
const customerId =
|
|
1853
|
+
(customer as any)?.customer_id ||
|
|
1854
|
+
(customer as any)?.id ||
|
|
1855
|
+
(customer as any)?.customer_user_email ||
|
|
1856
|
+
'anonymous';
|
|
1857
|
+
const agentIdForScope = currentAgentId || agent || 'default';
|
|
1858
|
+
return `llmasaservice-ui:always-approved-tools:${project_id}:${customerId}:${agentIdForScope}`;
|
|
1859
|
+
}, [project_id, customer, currentAgentId, agent]);
|
|
837
1860
|
|
|
838
1861
|
// Context section toggle state (disabled sections)
|
|
839
1862
|
// Use internal state only if prop is not provided
|
|
840
1863
|
const [internalDisabledSectionIds, setInternalDisabledSectionIds] = useState<Set<string>>(new Set());
|
|
841
1864
|
const disabledSectionIds = propDisabledSectionIds ?? internalDisabledSectionIds;
|
|
842
1865
|
|
|
1866
|
+
useEffect(() => {
|
|
1867
|
+
if (typeof window === 'undefined') return;
|
|
1868
|
+
try {
|
|
1869
|
+
const stored = window.localStorage.getItem(alwaysApprovedToolsStorageKey);
|
|
1870
|
+
if (!stored) return;
|
|
1871
|
+
const parsed = JSON.parse(stored);
|
|
1872
|
+
if (!Array.isArray(parsed)) return;
|
|
1873
|
+
|
|
1874
|
+
const normalized = Array.from(
|
|
1875
|
+
new Set(
|
|
1876
|
+
parsed
|
|
1877
|
+
.map((value) => normalizeToolName(String(value)))
|
|
1878
|
+
.filter((value) => value.length > 0),
|
|
1879
|
+
),
|
|
1880
|
+
);
|
|
1881
|
+
setAlwaysApprovedTools(normalized);
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
console.warn('[AIChatPanel] Failed to load always-approved tools from localStorage:', error);
|
|
1884
|
+
}
|
|
1885
|
+
}, [alwaysApprovedToolsStorageKey, normalizeToolName]);
|
|
1886
|
+
|
|
1887
|
+
useEffect(() => {
|
|
1888
|
+
if (typeof window === 'undefined') return;
|
|
1889
|
+
try {
|
|
1890
|
+
if (alwaysApprovedTools.length === 0) {
|
|
1891
|
+
window.localStorage.removeItem(alwaysApprovedToolsStorageKey);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
window.localStorage.setItem(
|
|
1895
|
+
alwaysApprovedToolsStorageKey,
|
|
1896
|
+
JSON.stringify(
|
|
1897
|
+
Array.from(new Set(alwaysApprovedTools.map((value) => normalizeToolName(value)).filter(Boolean))),
|
|
1898
|
+
),
|
|
1899
|
+
);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
console.warn('[AIChatPanel] Failed to persist always-approved tools to localStorage:', error);
|
|
1902
|
+
}
|
|
1903
|
+
}, [alwaysApprovedToolsStorageKey, alwaysApprovedTools, normalizeToolName]);
|
|
1904
|
+
|
|
843
1905
|
// Email capture mode effect - like ChatPanel
|
|
844
1906
|
useEffect(() => {
|
|
845
1907
|
setShowEmailPanel(customerEmailCaptureMode !== 'HIDE');
|
|
@@ -875,6 +1937,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
875
1937
|
const initialPromptSentRef = useRef<boolean>(false);
|
|
876
1938
|
// Track the last followOnPrompt to detect changes (for auto-submit trigger)
|
|
877
1939
|
const lastFollowOnPromptRef = useRef<string>('');
|
|
1940
|
+
const handledToolCallSignaturesRef = useRef<Set<string>>(new Set());
|
|
1941
|
+
const inFlightToolCallSignaturesRef = useRef<Set<string>>(new Set());
|
|
1942
|
+
const toolContinuationCountRef = useRef<number>(0);
|
|
1943
|
+
const activeStreamAppendBaseRef = useRef<{ key: string; base: string } | null>(null);
|
|
1944
|
+
const toolRequestProcessingRef = useRef<boolean>(false);
|
|
1945
|
+
const queuedToolRequestsRef = useRef<ToolRequestMatch[] | null>(null);
|
|
1946
|
+
const suppressAbortHistoryUpdateRef = useRef<boolean>(false);
|
|
1947
|
+
const toolReplaySummariesByKeyRef = useRef<Record<string, ToolReplaySummaryEntry[]>>({});
|
|
878
1948
|
|
|
879
1949
|
// Sync new entries from initialHistory into local history state
|
|
880
1950
|
// This allows parent components to inject messages (e.g., page-based agent suggestions)
|
|
@@ -903,16 +1973,202 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
903
1973
|
});
|
|
904
1974
|
}, [initialHistory]);
|
|
905
1975
|
|
|
1976
|
+
// Keep latest history ref synchronized so send() can always build context from
|
|
1977
|
+
// the freshest state, even when callbacks execute between render commits.
|
|
1978
|
+
useEffect(() => {
|
|
1979
|
+
latestHistoryRef.current = history;
|
|
1980
|
+
}, [history]);
|
|
1981
|
+
|
|
906
1982
|
// ============================================================================
|
|
907
1983
|
// useLLM Hook
|
|
908
1984
|
// ============================================================================
|
|
1985
|
+
useEffect(() => {
|
|
1986
|
+
let cancelled = false;
|
|
1987
|
+
|
|
1988
|
+
const resolveServers = async () => {
|
|
1989
|
+
if (!mcpServers || mcpServers.length === 0) {
|
|
1990
|
+
if (!cancelled) {
|
|
1991
|
+
setResolvedMcpServers((prev) => (prev.length === 0 ? prev : []));
|
|
1992
|
+
}
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
if (!resolveMcpAuthHeaders) {
|
|
1997
|
+
if (!cancelled) {
|
|
1998
|
+
setResolvedMcpServers((prev) => {
|
|
1999
|
+
const hasSameServers =
|
|
2000
|
+
prev.length === mcpServers.length &&
|
|
2001
|
+
prev.every((server, index) => server === mcpServers[index]);
|
|
2002
|
+
return hasSameServers ? prev : mcpServers;
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
try {
|
|
2009
|
+
const enriched = await Promise.all(
|
|
2010
|
+
mcpServers.map(async (server) => {
|
|
2011
|
+
const resolved = await resolveMcpAuthHeaders({
|
|
2012
|
+
phase: 'list',
|
|
2013
|
+
mcpServer: (server || {}) as Record<string, unknown>,
|
|
2014
|
+
projectId: project_id,
|
|
2015
|
+
customer: customer as LLMAsAServiceCustomer | undefined,
|
|
2016
|
+
});
|
|
2017
|
+
return {
|
|
2018
|
+
...(server || {}),
|
|
2019
|
+
headers: {
|
|
2020
|
+
...(typeof server?.headers === 'object' && server?.headers ? server.headers : {}),
|
|
2021
|
+
...normalizeMcpHeaders(
|
|
2022
|
+
resolved as Record<string, unknown> | null | undefined
|
|
2023
|
+
),
|
|
2024
|
+
},
|
|
2025
|
+
};
|
|
2026
|
+
})
|
|
2027
|
+
);
|
|
2028
|
+
if (!cancelled) setResolvedMcpServers(enriched);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
console.error('[AIChatPanel] Failed to resolve MCP auth headers:', error);
|
|
2031
|
+
if (!cancelled) setResolvedMcpServers(mcpServers);
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
void resolveServers();
|
|
2036
|
+
|
|
2037
|
+
return () => {
|
|
2038
|
+
cancelled = true;
|
|
2039
|
+
};
|
|
2040
|
+
}, [mcpServers, resolveMcpAuthHeaders, project_id, customer]);
|
|
2041
|
+
|
|
2042
|
+
const buildMcpRequestHeaders = useCallback(
|
|
2043
|
+
async ({
|
|
2044
|
+
phase,
|
|
2045
|
+
mcpServer,
|
|
2046
|
+
toolName,
|
|
2047
|
+
toolArgs,
|
|
2048
|
+
}: {
|
|
2049
|
+
phase: MCPAuthPhase;
|
|
2050
|
+
mcpServer: Record<string, unknown>;
|
|
2051
|
+
toolName?: string;
|
|
2052
|
+
toolArgs?: unknown;
|
|
2053
|
+
}): Promise<Record<string, string>> => {
|
|
2054
|
+
const merged: Record<string, string> = {};
|
|
2055
|
+
const packScopeIntoAccessToken = (headers: Record<string, string>): Record<string, string> => {
|
|
2056
|
+
const accessToken = typeof headers['x-mcp-access-token'] === 'string' ? headers['x-mcp-access-token'].trim() : '';
|
|
2057
|
+
const scopeToken = typeof headers['x-client-id'] === 'string' ? headers['x-client-id'].trim() : '';
|
|
2058
|
+
if (!accessToken || !scopeToken) return headers;
|
|
2059
|
+
|
|
2060
|
+
return {
|
|
2061
|
+
...headers,
|
|
2062
|
+
'x-mcp-access-token': `${accessToken}::scope::${scopeToken}`,
|
|
2063
|
+
};
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
const baseAccessToken =
|
|
2067
|
+
typeof mcpServer.accessToken === 'string' ? mcpServer.accessToken.trim() : '';
|
|
2068
|
+
if (baseAccessToken) {
|
|
2069
|
+
merged['x-mcp-access-token'] = baseAccessToken;
|
|
2070
|
+
}
|
|
2071
|
+
if (project_id) {
|
|
2072
|
+
merged['x-project-id'] = project_id;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (!resolveMcpAuthHeaders) return merged;
|
|
2076
|
+
|
|
2077
|
+
try {
|
|
2078
|
+
const resolved = await resolveMcpAuthHeaders({
|
|
2079
|
+
phase,
|
|
2080
|
+
mcpServer,
|
|
2081
|
+
projectId: project_id,
|
|
2082
|
+
customer: customer as LLMAsAServiceCustomer | undefined,
|
|
2083
|
+
toolName,
|
|
2084
|
+
toolArgs,
|
|
2085
|
+
});
|
|
2086
|
+
return packScopeIntoAccessToken({
|
|
2087
|
+
...merged,
|
|
2088
|
+
...normalizeMcpHeaders(
|
|
2089
|
+
resolved as Record<string, unknown> | null | undefined
|
|
2090
|
+
),
|
|
2091
|
+
});
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
console.error(
|
|
2094
|
+
`Failed to resolve MCP auth headers for ${phase} request:`,
|
|
2095
|
+
error
|
|
2096
|
+
);
|
|
2097
|
+
return merged;
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
[project_id, customer, resolveMcpAuthHeaders]
|
|
2101
|
+
);
|
|
2102
|
+
|
|
2103
|
+
useEffect(() => {
|
|
2104
|
+
const fetchAndSetTools = async () => {
|
|
2105
|
+
if (!resolvedMcpServers || resolvedMcpServers.length === 0) {
|
|
2106
|
+
setToolList((prev) => (prev.length === 0 ? prev : []));
|
|
2107
|
+
setToolsLoading((prev) => (prev ? false : prev));
|
|
2108
|
+
setToolsFetchError((prev) => (prev ? false : prev));
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
setToolsLoading(true);
|
|
2113
|
+
setToolsFetchError(false);
|
|
2114
|
+
|
|
2115
|
+
try {
|
|
2116
|
+
const fetchPromises = (resolvedMcpServers ?? []).map(async (m: any) => {
|
|
2117
|
+
const urlToFetch = `${publicAPIUrl}/tools/${encodeURIComponent(m.url)}`;
|
|
2118
|
+
|
|
2119
|
+
const requestHeaders = await buildMcpRequestHeaders({
|
|
2120
|
+
phase: 'list',
|
|
2121
|
+
mcpServer: m,
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
const response = await fetch(urlToFetch, {
|
|
2125
|
+
headers: requestHeaders,
|
|
2126
|
+
});
|
|
2127
|
+
if (!response.ok) {
|
|
2128
|
+
const errorBody = await response.text();
|
|
2129
|
+
throw new Error(
|
|
2130
|
+
`HTTP ${response.status}: ${response.statusText} ${errorBody}`
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const toolsFromServer = await response.json();
|
|
2135
|
+
if (!Array.isArray(toolsFromServer)) return [];
|
|
2136
|
+
|
|
2137
|
+
return toolsFromServer.map((tool) => ({
|
|
2138
|
+
...tool,
|
|
2139
|
+
url: m.url,
|
|
2140
|
+
accessToken: m.accessToken || '',
|
|
2141
|
+
headers: requestHeaders,
|
|
2142
|
+
}));
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
const results = await Promise.all(fetchPromises);
|
|
2146
|
+
const allTools = results.flat();
|
|
2147
|
+
setToolList(allTools);
|
|
2148
|
+
setToolsFetchError(false);
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
console.error('[AIChatPanel] Failed to load MCP tools:', error);
|
|
2151
|
+
setToolList([]);
|
|
2152
|
+
setToolsFetchError(true);
|
|
2153
|
+
} finally {
|
|
2154
|
+
setToolsLoading(false);
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
void fetchAndSetTools();
|
|
2159
|
+
}, [resolvedMcpServers, publicAPIUrl, buildMcpRequestHeaders]);
|
|
2160
|
+
|
|
909
2161
|
const llmResult = useLLM({
|
|
910
2162
|
project_id,
|
|
911
2163
|
customer: customer as LLMAsAServiceCustomer | undefined,
|
|
912
2164
|
...(url && { url }),
|
|
913
2165
|
...(service && { group_id: service }),
|
|
914
2166
|
...(agent && { agent }),
|
|
915
|
-
|
|
2167
|
+
tools: toolList.map((item) => ({
|
|
2168
|
+
name: item.name,
|
|
2169
|
+
description: item.description,
|
|
2170
|
+
parameters: item.parameters,
|
|
2171
|
+
})) as [],
|
|
916
2172
|
});
|
|
917
2173
|
|
|
918
2174
|
const {
|
|
@@ -925,11 +2181,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
925
2181
|
error: llmError,
|
|
926
2182
|
} = llmResult;
|
|
927
2183
|
|
|
928
|
-
// Tool-related properties (may not exist on all versions of useLLM)
|
|
929
|
-
const toolList = (llmResult as any).toolList || [];
|
|
930
|
-
const toolsLoading = (llmResult as any).toolsLoading || false;
|
|
931
|
-
const toolsFetchError = (llmResult as any).toolsFetchError || null;
|
|
932
|
-
|
|
933
2184
|
// Refs to track latest values for cleanup and callbacks (must be after useLLM)
|
|
934
2185
|
const historyCallbackRef = useRef(historyChangedCallback);
|
|
935
2186
|
const responseCompleteCallbackRef = useRef(responseCompleteCallback);
|
|
@@ -985,23 +2236,34 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
985
2236
|
// Ensure a conversation exists before sending the first message
|
|
986
2237
|
// This creates a conversation on the server and returns the conversation ID
|
|
987
2238
|
const ensureConversation = useCallback(() => {
|
|
2239
|
+
const normalizedConversationId =
|
|
2240
|
+
typeof currentConversation === 'string' ? currentConversation.trim() : '';
|
|
2241
|
+
|
|
988
2242
|
console.log('ensureConversation - called with:', {
|
|
989
|
-
currentConversation,
|
|
2243
|
+
currentConversation: normalizedConversationId || null,
|
|
990
2244
|
createConversationOnFirstChat,
|
|
991
2245
|
project_id,
|
|
992
2246
|
publicAPIUrl,
|
|
993
2247
|
});
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
2248
|
+
|
|
2249
|
+
// Existing conversation supplied by caller: use it directly.
|
|
2250
|
+
if (normalizedConversationId) {
|
|
2251
|
+
console.log('ensureConversation - using existing conversation:', normalizedConversationId);
|
|
2252
|
+
return Promise.resolve(normalizedConversationId);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if (!createConversationOnFirstChat) {
|
|
2256
|
+
return Promise.resolve('');
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Guard: Don't create/ensure conversations without a project_id
|
|
2260
|
+
if (!project_id) {
|
|
2261
|
+
console.error('ensureConversation - Cannot create conversation without project_id');
|
|
2262
|
+
return Promise.resolve('');
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const createConversation = () => {
|
|
2266
|
+
const requestBody: Record<string, unknown> = {
|
|
1005
2267
|
project_id: project_id,
|
|
1006
2268
|
agentId: agent,
|
|
1007
2269
|
customerId: customer?.customer_id ?? null,
|
|
@@ -1009,9 +2271,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1009
2271
|
timezone: browserInfo?.userTimezone,
|
|
1010
2272
|
language: browserInfo?.userLanguage,
|
|
1011
2273
|
};
|
|
2274
|
+
|
|
1012
2275
|
console.log('ensureConversation - Creating conversation with:', requestBody);
|
|
1013
2276
|
console.log('ensureConversation - API URL:', `${publicAPIUrl}/conversations`);
|
|
1014
|
-
|
|
2277
|
+
|
|
1015
2278
|
return fetch(`${publicAPIUrl}/conversations`, {
|
|
1016
2279
|
method: 'POST',
|
|
1017
2280
|
headers: {
|
|
@@ -1030,13 +2293,21 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1030
2293
|
})
|
|
1031
2294
|
.then((newConvo) => {
|
|
1032
2295
|
console.log('ensureConversation - API response:', newConvo);
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
2296
|
+
const createdId =
|
|
2297
|
+
(typeof newConvo?.id === 'string' && newConvo.id.trim()) ||
|
|
2298
|
+
(typeof newConvo?.conversationId === 'string' && newConvo.conversationId.trim()) ||
|
|
2299
|
+
(typeof newConvo?.conversation_id === 'string' && newConvo.conversation_id.trim()) ||
|
|
2300
|
+
(typeof newConvo?.conversation?.id === 'string' && newConvo.conversation.id.trim()) ||
|
|
2301
|
+
'';
|
|
2302
|
+
|
|
2303
|
+
if (createdId) {
|
|
2304
|
+
console.log('ensureConversation - New conversation ID:', createdId);
|
|
2305
|
+
setCurrentConversation(createdId);
|
|
1036
2306
|
// NOTE: Don't call onConversationCreated here - it causes a re-render
|
|
1037
2307
|
// before send() is called. The caller should notify after send() starts.
|
|
1038
|
-
return
|
|
2308
|
+
return createdId;
|
|
1039
2309
|
}
|
|
2310
|
+
|
|
1040
2311
|
console.warn('ensureConversation - No ID in response');
|
|
1041
2312
|
return '';
|
|
1042
2313
|
})
|
|
@@ -1044,10 +2315,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1044
2315
|
console.error('Error creating new conversation', error);
|
|
1045
2316
|
return '';
|
|
1046
2317
|
});
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
return Promise.resolve(currentConversation);
|
|
2318
|
+
};
|
|
2319
|
+
|
|
2320
|
+
return createConversation();
|
|
1051
2321
|
}, [currentConversation, createConversationOnFirstChat, publicAPIUrl, project_id, agent, customer, browserInfo]);
|
|
1052
2322
|
|
|
1053
2323
|
// Data with extras (matches ChatPanel's dataWithExtras)
|
|
@@ -1267,25 +2537,717 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1267
2537
|
return false;
|
|
1268
2538
|
}, [customerEmailCaptureMode, emailInputSet]);
|
|
1269
2539
|
|
|
1270
|
-
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
2540
|
+
const pendingToolRequestsRef = useRef<ToolRequestMatch[]>(pendingToolRequests);
|
|
2541
|
+
const streamIdleRef = useRef(idle);
|
|
2542
|
+
streamIdleRef.current = idle;
|
|
2543
|
+
|
|
2544
|
+
const waitForStreamIdle = useCallback(async (timeoutMs: number = 2500) => {
|
|
2545
|
+
const startedAt = Date.now();
|
|
2546
|
+
while (!streamIdleRef.current && Date.now() - startedAt < timeoutMs) {
|
|
2547
|
+
await new Promise<void>((resolve) => {
|
|
2548
|
+
setTimeout(resolve, 25);
|
|
2549
|
+
});
|
|
1277
2550
|
}
|
|
2551
|
+
}, []);
|
|
2552
|
+
|
|
2553
|
+
useEffect(() => {
|
|
2554
|
+
pendingToolRequestsRef.current = pendingToolRequests;
|
|
2555
|
+
}, [pendingToolRequests]);
|
|
2556
|
+
|
|
2557
|
+
const processGivenToolRequests = useCallback(
|
|
2558
|
+
async (requests: ToolRequestMatch[]) => {
|
|
2559
|
+
const dedupeToolRequests = (input: ToolRequestMatch[]): ToolRequestMatch[] => {
|
|
2560
|
+
const seen = new Set<string>();
|
|
2561
|
+
const deduped: ToolRequestMatch[] = [];
|
|
2562
|
+
for (const request of Array.isArray(input) ? input : []) {
|
|
2563
|
+
if (!request) continue;
|
|
2564
|
+
const signature = getToolCallSignature(request.toolName, request.callId) || request.match;
|
|
2565
|
+
if (!signature || seen.has(signature)) continue;
|
|
2566
|
+
seen.add(signature);
|
|
2567
|
+
deduped.push(request);
|
|
2568
|
+
}
|
|
2569
|
+
return deduped;
|
|
2570
|
+
};
|
|
2571
|
+
|
|
2572
|
+
if (toolRequestProcessingRef.current) {
|
|
2573
|
+
const queued = dedupeToolRequests([
|
|
2574
|
+
...(queuedToolRequestsRef.current || []),
|
|
2575
|
+
...(Array.isArray(requests) ? requests : []),
|
|
2576
|
+
]);
|
|
2577
|
+
if (queued.length > 0) {
|
|
2578
|
+
queuedToolRequestsRef.current = queued;
|
|
2579
|
+
}
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
toolRequestProcessingRef.current = true;
|
|
2583
|
+
|
|
2584
|
+
try {
|
|
2585
|
+
let requestsToProcess = requests;
|
|
2586
|
+
if (!requestsToProcess || requestsToProcess.length === 0) {
|
|
2587
|
+
requestsToProcess = pendingToolRequestsRef.current || [];
|
|
2588
|
+
}
|
|
2589
|
+
if (!requestsToProcess || requestsToProcess.length === 0) return;
|
|
2590
|
+
|
|
2591
|
+
setIsLoading(true);
|
|
2592
|
+
|
|
2593
|
+
const userPrompt = lastPromptRef.current || lastKeyRef.current || '';
|
|
2594
|
+
const lastPromptKey = lastKeyRef.current;
|
|
2595
|
+
const assistantSeedText =
|
|
2596
|
+
lastPromptKey && latestHistoryRef.current[lastPromptKey]?.content
|
|
2597
|
+
? latestHistoryRef.current[lastPromptKey]?.content
|
|
2598
|
+
: '';
|
|
2599
|
+
const historyForContinuation = latestHistoryRef.current || {};
|
|
2600
|
+
const newMessages: any[] = [];
|
|
2601
|
+
|
|
2602
|
+
Object.entries(historyForContinuation).forEach(([historyPrompt, historyEntry]) => {
|
|
2603
|
+
let promptForHistory = String(historyPrompt || '');
|
|
2604
|
+
const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
|
|
2605
|
+
if (isoTimestampRegex.test(promptForHistory)) {
|
|
2606
|
+
const colonIndex = promptForHistory.indexOf(':', 19);
|
|
2607
|
+
promptForHistory = promptForHistory.substring(colonIndex + 1);
|
|
2608
|
+
} else if (/^\d+:/.test(promptForHistory)) {
|
|
2609
|
+
const colonIndex = promptForHistory.indexOf(':');
|
|
2610
|
+
promptForHistory = promptForHistory.substring(colonIndex + 1);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
const typedHistoryEntry = (historyEntry || { content: '', callId: '' }) as HistoryEntry;
|
|
2614
|
+
const assistantBaseContent =
|
|
2615
|
+
typeof typedHistoryEntry.content === 'string' ? typedHistoryEntry.content : '';
|
|
2616
|
+
let assistantContextContent = assistantBaseContent;
|
|
2617
|
+
if (traceContextMode === 'full') {
|
|
2618
|
+
const traceSummary = buildCompactTraceSummary({
|
|
2619
|
+
reasoningBlocks: thinkingBlocksByKeyRef.current[historyPrompt] || [],
|
|
2620
|
+
toolCalls: typedHistoryEntry.toolCalls,
|
|
2621
|
+
toolResponses: typedHistoryEntry.toolResponses,
|
|
2622
|
+
});
|
|
2623
|
+
if (traceSummary) {
|
|
2624
|
+
assistantContextContent = assistantBaseContent
|
|
2625
|
+
? `${assistantBaseContent}\n\n${traceSummary}`
|
|
2626
|
+
: traceSummary;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
newMessages.push({
|
|
2631
|
+
role: 'user',
|
|
2632
|
+
content: [
|
|
2633
|
+
{
|
|
2634
|
+
type: 'text',
|
|
2635
|
+
text: promptForHistory,
|
|
2636
|
+
},
|
|
2637
|
+
],
|
|
2638
|
+
});
|
|
2639
|
+
newMessages.push({
|
|
2640
|
+
role: 'assistant',
|
|
2641
|
+
content: [
|
|
2642
|
+
{
|
|
2643
|
+
type: 'text',
|
|
2644
|
+
text: assistantContextContent,
|
|
2645
|
+
},
|
|
2646
|
+
],
|
|
2647
|
+
});
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
// Safety fallback if no history was materialized.
|
|
2651
|
+
if (newMessages.length === 0 && userPrompt.trim().length > 0) {
|
|
2652
|
+
newMessages.push({
|
|
2653
|
+
role: 'user',
|
|
2654
|
+
content: [
|
|
2655
|
+
{
|
|
2656
|
+
type: 'text',
|
|
2657
|
+
text: userPrompt,
|
|
2658
|
+
},
|
|
2659
|
+
],
|
|
2660
|
+
});
|
|
2661
|
+
if (assistantSeedText.trim().length > 0) {
|
|
2662
|
+
newMessages.push({
|
|
2663
|
+
role: 'assistant',
|
|
2664
|
+
content: [
|
|
2665
|
+
{
|
|
2666
|
+
type: 'text',
|
|
2667
|
+
text: assistantSeedText,
|
|
2668
|
+
},
|
|
2669
|
+
],
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const parsedToolCalls = await Promise.all(
|
|
2675
|
+
requestsToProcess.map(async (req, index) => {
|
|
2676
|
+
let parsedToolCall: any = null;
|
|
2677
|
+
try {
|
|
2678
|
+
parsedToolCall = JSON.parse(req.match);
|
|
2679
|
+
} catch (error) {
|
|
2680
|
+
console.error('[AIChatPanel] Failed to parse tool call payload:', error);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const toolName =
|
|
2684
|
+
req.groups[1] ||
|
|
2685
|
+
req.toolName ||
|
|
2686
|
+
(typeof parsedToolCall?.name === 'string' ? parsedToolCall.name : '') ||
|
|
2687
|
+
(typeof parsedToolCall?.function?.name === 'string' ? parsedToolCall.function.name : '');
|
|
2688
|
+
if (!toolName) return null;
|
|
2689
|
+
|
|
2690
|
+
const rawCallId =
|
|
2691
|
+
req.callId ||
|
|
2692
|
+
req.groups[0] ||
|
|
2693
|
+
parsedToolCall?.id ||
|
|
2694
|
+
parsedToolCall?.tool_call_id ||
|
|
2695
|
+
`${toolName}-${index + 1}`;
|
|
2696
|
+
const callId =
|
|
2697
|
+
typeof rawCallId === 'string' &&
|
|
2698
|
+
rawCallId.trim().length > 0 &&
|
|
2699
|
+
rawCallId !== 'functionCall'
|
|
2700
|
+
? rawCallId
|
|
2701
|
+
: `${toolName}-${index + 1}`;
|
|
2702
|
+
|
|
2703
|
+
let args: Record<string, unknown> = {};
|
|
2704
|
+
const rawArgs =
|
|
2705
|
+
req.groups[2] ??
|
|
2706
|
+
parsedToolCall?.input ??
|
|
2707
|
+
parsedToolCall?.args ??
|
|
2708
|
+
parsedToolCall?.function?.arguments ??
|
|
2709
|
+
'{}';
|
|
2710
|
+
|
|
2711
|
+
const parsedArgs = parseToolArguments(rawArgs);
|
|
2712
|
+
if (!parsedArgs) {
|
|
2713
|
+
console.error('[AIChatPanel] Failed to parse tool arguments', {
|
|
2714
|
+
toolName,
|
|
2715
|
+
callId,
|
|
2716
|
+
rawArgsPreview:
|
|
2717
|
+
typeof rawArgs === 'string'
|
|
2718
|
+
? rawArgs.slice(0, 500)
|
|
2719
|
+
: JSON.stringify(rawArgs).slice(0, 500),
|
|
2720
|
+
});
|
|
2721
|
+
return null;
|
|
2722
|
+
}
|
|
2723
|
+
args = parsedArgs;
|
|
2724
|
+
|
|
2725
|
+
const serviceTag =
|
|
2726
|
+
(typeof req.serviceTag === 'string' && req.serviceTag) ||
|
|
2727
|
+
(typeof req.groups[3] === 'string' && req.groups[3]) ||
|
|
2728
|
+
(typeof parsedToolCall?.service === 'string' && parsedToolCall.service) ||
|
|
2729
|
+
'';
|
|
2730
|
+
|
|
2731
|
+
const callSignature = getToolCallSignature(toolName, callId);
|
|
2732
|
+
if (!callSignature) return null;
|
|
2733
|
+
|
|
2734
|
+
return {
|
|
2735
|
+
req,
|
|
2736
|
+
toolName,
|
|
2737
|
+
callId,
|
|
2738
|
+
args,
|
|
2739
|
+
serviceTag,
|
|
2740
|
+
callSignature,
|
|
2741
|
+
};
|
|
2742
|
+
}),
|
|
2743
|
+
);
|
|
2744
|
+
|
|
2745
|
+
const toolCallBatch = parsedToolCalls.filter(Boolean) as Array<{
|
|
2746
|
+
req: ToolRequestMatch;
|
|
2747
|
+
toolName: string;
|
|
2748
|
+
callId: string;
|
|
2749
|
+
args: Record<string, unknown>;
|
|
2750
|
+
serviceTag: string;
|
|
2751
|
+
callSignature: string;
|
|
2752
|
+
}>;
|
|
2753
|
+
|
|
2754
|
+
const seenCallSignatures = new Set<string>();
|
|
2755
|
+
const callsToRun: Array<{
|
|
2756
|
+
req: ToolRequestMatch;
|
|
2757
|
+
toolName: string;
|
|
2758
|
+
callId: string;
|
|
2759
|
+
args: Record<string, unknown>;
|
|
2760
|
+
serviceTag: string;
|
|
2761
|
+
callSignature: string;
|
|
2762
|
+
}> = [];
|
|
2763
|
+
|
|
2764
|
+
toolCallBatch.forEach((toolCall) => {
|
|
2765
|
+
if (seenCallSignatures.has(toolCall.callSignature)) return;
|
|
2766
|
+
seenCallSignatures.add(toolCall.callSignature);
|
|
2767
|
+
|
|
2768
|
+
if (handledToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
|
|
2769
|
+
if (inFlightToolCallSignaturesRef.current.has(toolCall.callSignature)) return;
|
|
2770
|
+
|
|
2771
|
+
callsToRun.push(toolCall);
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
if (callsToRun.length === 0) {
|
|
2775
|
+
setPendingToolRequests((prev) =>
|
|
2776
|
+
prev.filter((request) => {
|
|
2777
|
+
const signature = getToolCallSignature(request.toolName, request.callId);
|
|
2778
|
+
if (!signature) return true;
|
|
2779
|
+
return !seenCallSignatures.has(signature);
|
|
2780
|
+
}),
|
|
2781
|
+
);
|
|
2782
|
+
setActiveToolCalls([]);
|
|
2783
|
+
setIsLoading(false);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const callsToRunSignatures = new Set(callsToRun.map((toolCall) => toolCall.callSignature));
|
|
2788
|
+
setPendingToolRequests((prev) =>
|
|
2789
|
+
prev.filter((request) => {
|
|
2790
|
+
const signature = getToolCallSignature(request.toolName, request.callId);
|
|
2791
|
+
return !signature || !callsToRunSignatures.has(signature);
|
|
2792
|
+
}),
|
|
2793
|
+
);
|
|
2794
|
+
callsToRun.forEach((toolCall) => {
|
|
2795
|
+
inFlightToolCallSignaturesRef.current.add(toolCall.callSignature);
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
setActiveToolCalls(
|
|
2799
|
+
callsToRun.map((toolCall) => ({
|
|
2800
|
+
toolName: toolCall.toolName,
|
|
2801
|
+
callId: toolCall.callId,
|
|
2802
|
+
})),
|
|
2803
|
+
);
|
|
2804
|
+
|
|
2805
|
+
const finalToolCalls = callsToRun.map((toolCall) => ({
|
|
2806
|
+
id: toolCall.callId,
|
|
2807
|
+
type: 'tool_use',
|
|
2808
|
+
name: toolCall.toolName,
|
|
2809
|
+
input: toolCall.args,
|
|
2810
|
+
service: toolCall.serviceTag,
|
|
2811
|
+
}));
|
|
2812
|
+
|
|
2813
|
+
let finalToolResponses: any[] = [];
|
|
2814
|
+
try {
|
|
2815
|
+
const toolResponses = await Promise.all(
|
|
2816
|
+
callsToRun.map(async (toolCall) => {
|
|
2817
|
+
const mcpTool = (toolList.find((tool) => tool.name === toolCall.toolName) ||
|
|
2818
|
+
null) as Record<string, unknown> | null;
|
|
2819
|
+
const localExecutor =
|
|
2820
|
+
localToolExecutors && typeof localToolExecutors[toolCall.toolName] === 'function'
|
|
2821
|
+
? localToolExecutors[toolCall.toolName]
|
|
2822
|
+
: null;
|
|
2823
|
+
|
|
2824
|
+
if (localExecutor) {
|
|
2825
|
+
try {
|
|
2826
|
+
const localResult = await localExecutor(toolCall.args, {
|
|
2827
|
+
toolName: toolCall.toolName,
|
|
2828
|
+
callId: toolCall.callId,
|
|
2829
|
+
serviceTag: toolCall.serviceTag,
|
|
2830
|
+
mcpTool,
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
if (localResult && typeof localResult === 'object' && !Array.isArray(localResult)) {
|
|
2834
|
+
const objectResult = localResult as Record<string, unknown>;
|
|
2835
|
+
const isError = objectResult.isError === true || objectResult.error === true;
|
|
2836
|
+
const maybeResult = Object.prototype.hasOwnProperty.call(objectResult, 'result')
|
|
2837
|
+
? objectResult.result
|
|
2838
|
+
: localResult;
|
|
2839
|
+
const textResult =
|
|
2840
|
+
typeof maybeResult === 'string' ? maybeResult : JSON.stringify(maybeResult ?? {});
|
|
2841
|
+
return {
|
|
2842
|
+
tool_call_id: toolCall.callId,
|
|
2843
|
+
tool_name: toolCall.toolName,
|
|
2844
|
+
result: textResult || '',
|
|
2845
|
+
isError,
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
return {
|
|
2850
|
+
tool_call_id: toolCall.callId,
|
|
2851
|
+
tool_name: toolCall.toolName,
|
|
2852
|
+
result:
|
|
2853
|
+
typeof localResult === 'string'
|
|
2854
|
+
? localResult
|
|
2855
|
+
: JSON.stringify(localResult ?? {}),
|
|
2856
|
+
isError: false,
|
|
2857
|
+
};
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
return {
|
|
2860
|
+
tool_call_id: toolCall.callId,
|
|
2861
|
+
tool_name: toolCall.toolName,
|
|
2862
|
+
result: error instanceof Error ? error.message : `Unhandled error calling ${toolCall.toolName}`,
|
|
2863
|
+
isError: true,
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
if (!mcpTool) {
|
|
2869
|
+
console.error(`[AIChatPanel] Tool ${toolCall.toolName} not found in tool list`);
|
|
2870
|
+
return {
|
|
2871
|
+
tool_call_id: toolCall.callId,
|
|
2872
|
+
tool_name: toolCall.toolName,
|
|
2873
|
+
result: `Tool ${toolCall.toolName} not found in current tool list.`,
|
|
2874
|
+
isError: true,
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
const toolUrl = typeof mcpTool.url === 'string' ? mcpTool.url : '';
|
|
2878
|
+
if (!toolUrl) {
|
|
2879
|
+
return {
|
|
2880
|
+
tool_call_id: toolCall.callId,
|
|
2881
|
+
tool_name: toolCall.toolName,
|
|
2882
|
+
result: `Tool ${toolCall.toolName} is missing url metadata.`,
|
|
2883
|
+
isError: true,
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
const body = {
|
|
2888
|
+
tool: toolCall.toolName,
|
|
2889
|
+
args: toolCall.args,
|
|
2890
|
+
};
|
|
2891
|
+
|
|
2892
|
+
try {
|
|
2893
|
+
const result = await fetch(
|
|
2894
|
+
`${publicAPIUrl}/tools/${encodeURIComponent(toolUrl)}`,
|
|
2895
|
+
{
|
|
2896
|
+
method: 'POST',
|
|
2897
|
+
headers: {
|
|
2898
|
+
'Content-Type': 'application/json',
|
|
2899
|
+
...(await buildMcpRequestHeaders({
|
|
2900
|
+
phase: 'call',
|
|
2901
|
+
mcpServer: mcpTool as Record<string, unknown>,
|
|
2902
|
+
toolName: toolCall.toolName,
|
|
2903
|
+
toolArgs: toolCall.args,
|
|
2904
|
+
})),
|
|
2905
|
+
},
|
|
2906
|
+
body: JSON.stringify(body),
|
|
2907
|
+
},
|
|
2908
|
+
);
|
|
2909
|
+
|
|
2910
|
+
if (!result.ok) {
|
|
2911
|
+
const errorBody = await result.text();
|
|
2912
|
+
console.error(
|
|
2913
|
+
`[AIChatPanel] Tool call failed ${toolCall.toolName}: ${result.status} ${result.statusText} ${errorBody}`,
|
|
2914
|
+
);
|
|
2915
|
+
return {
|
|
2916
|
+
tool_call_id: toolCall.callId,
|
|
2917
|
+
tool_name: toolCall.toolName,
|
|
2918
|
+
result: `HTTP ${result.status} ${result.statusText}: ${errorBody || 'Tool call failed'}`,
|
|
2919
|
+
isError: true,
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
let resultData: any = null;
|
|
2924
|
+
try {
|
|
2925
|
+
resultData = await result.json();
|
|
2926
|
+
} catch (error) {
|
|
2927
|
+
console.error('[AIChatPanel] Failed parsing tool call JSON response:', error);
|
|
2928
|
+
return {
|
|
2929
|
+
tool_call_id: toolCall.callId,
|
|
2930
|
+
tool_name: toolCall.toolName,
|
|
2931
|
+
result: 'Tool returned a non-JSON response.',
|
|
2932
|
+
isError: true,
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
const textResult =
|
|
2937
|
+
resultData?.content?.[0]?.text ??
|
|
2938
|
+
(resultData?.result ? JSON.stringify(resultData.result) : JSON.stringify(resultData));
|
|
2939
|
+
const inferredError =
|
|
2940
|
+
resultData?.isError === true ||
|
|
2941
|
+
resultData?.error === true ||
|
|
2942
|
+
typeof resultData?.error === 'string' ||
|
|
2943
|
+
resultData?.status === 'error' ||
|
|
2944
|
+
resultData?.result?.isError === true ||
|
|
2945
|
+
resultData?.result?.error === true ||
|
|
2946
|
+
typeof resultData?.result?.error === 'string';
|
|
2947
|
+
const normalizedResultText =
|
|
2948
|
+
typeof textResult === 'string' && textResult.trim().length > 0
|
|
2949
|
+
? textResult
|
|
2950
|
+
: inferredError && typeof resultData?.error === 'string'
|
|
2951
|
+
? resultData.error
|
|
2952
|
+
: inferredError && typeof resultData?.result?.error === 'string'
|
|
2953
|
+
? resultData.result.error
|
|
2954
|
+
: '';
|
|
2955
|
+
|
|
2956
|
+
return {
|
|
2957
|
+
tool_call_id: toolCall.callId,
|
|
2958
|
+
tool_name: toolCall.toolName,
|
|
2959
|
+
result: normalizedResultText,
|
|
2960
|
+
isError: inferredError,
|
|
2961
|
+
};
|
|
2962
|
+
} catch (error) {
|
|
2963
|
+
console.error(`[AIChatPanel] Error calling tool ${toolCall.toolName}:`, error);
|
|
2964
|
+
return {
|
|
2965
|
+
tool_call_id: toolCall.callId,
|
|
2966
|
+
tool_name: toolCall.toolName,
|
|
2967
|
+
result:
|
|
2968
|
+
error instanceof Error ? error.message : `Unhandled error calling ${toolCall.toolName}`,
|
|
2969
|
+
isError: true,
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
}),
|
|
2973
|
+
);
|
|
2974
|
+
|
|
2975
|
+
finalToolResponses = toolResponses.filter(Boolean) as any[];
|
|
2976
|
+
} finally {
|
|
2977
|
+
callsToRun.forEach((toolCall) => {
|
|
2978
|
+
inFlightToolCallSignaturesRef.current.delete(toolCall.callSignature);
|
|
2979
|
+
handledToolCallSignaturesRef.current.add(toolCall.callSignature);
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// Keep the running state visible during execution; clear it only after completion.
|
|
2984
|
+
setActiveToolCalls([]);
|
|
2985
|
+
|
|
2986
|
+
const currentLastKey = lastKeyRef.current;
|
|
2987
|
+
if (currentLastKey) {
|
|
2988
|
+
setHistory((prev) => {
|
|
2989
|
+
const existingEntry = prev[currentLastKey] || { content: '', callId: '' };
|
|
2990
|
+
return {
|
|
2991
|
+
...prev,
|
|
2992
|
+
[currentLastKey]: {
|
|
2993
|
+
...existingEntry,
|
|
2994
|
+
toolCalls: [...((existingEntry as any).toolCalls || []), ...finalToolCalls],
|
|
2995
|
+
toolResponses: [...((existingEntry as any).toolResponses || []), ...finalToolResponses],
|
|
2996
|
+
},
|
|
2997
|
+
};
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
const toReplayText = (value: unknown): string => {
|
|
3002
|
+
const raw =
|
|
3003
|
+
typeof value === 'string'
|
|
3004
|
+
? value
|
|
3005
|
+
: (() => {
|
|
3006
|
+
try {
|
|
3007
|
+
return JSON.stringify(value);
|
|
3008
|
+
} catch (_error) {
|
|
3009
|
+
return String(value ?? '');
|
|
3010
|
+
}
|
|
3011
|
+
})();
|
|
3012
|
+
return String(raw ?? '');
|
|
3013
|
+
};
|
|
3014
|
+
|
|
3015
|
+
const replayEntryKey = lastKeyRef.current || '';
|
|
3016
|
+
const previousReplayEntries = replayEntryKey
|
|
3017
|
+
? toolReplaySummariesByKeyRef.current[replayEntryKey] || []
|
|
3018
|
+
: [];
|
|
3019
|
+
|
|
3020
|
+
const currentReplayEntries: ToolReplaySummaryEntry[] = callsToRun.map((toolCall, index) => {
|
|
3021
|
+
const matchedResponse =
|
|
3022
|
+
finalToolResponses.find((response) => response?.tool_call_id === toolCall.callId) ||
|
|
3023
|
+
finalToolResponses[index];
|
|
3024
|
+
return {
|
|
3025
|
+
toolName: toolCall.toolName,
|
|
3026
|
+
callId: toolCall.callId,
|
|
3027
|
+
status: matchedResponse?.isError ? 'error' : 'ok',
|
|
3028
|
+
argsText: toReplayText(toolCall.args),
|
|
3029
|
+
resultText: toReplayText(matchedResponse?.result ?? 'No result returned'),
|
|
3030
|
+
};
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
const replayEntries = [...previousReplayEntries, ...currentReplayEntries];
|
|
3034
|
+
if (replayEntryKey) {
|
|
3035
|
+
toolReplaySummariesByKeyRef.current[replayEntryKey] = replayEntries;
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
const replayLines = replayEntries.map((entry) =>
|
|
3039
|
+
[
|
|
3040
|
+
`Tool: ${entry.toolName}`,
|
|
3041
|
+
`Call ID: ${entry.callId}`,
|
|
3042
|
+
`Status: ${entry.status}`,
|
|
3043
|
+
`Args: ${entry.argsText}`,
|
|
3044
|
+
`Result: ${entry.resultText}`,
|
|
3045
|
+
].join('\n'),
|
|
3046
|
+
);
|
|
3047
|
+
|
|
3048
|
+
const originalRequest =
|
|
3049
|
+
(typeof lastPromptRef.current === 'string' && lastPromptRef.current.trim()) ||
|
|
3050
|
+
(typeof userPrompt === 'string' && userPrompt.trim()) ||
|
|
3051
|
+
'';
|
|
3052
|
+
|
|
3053
|
+
const continuationPromptText = [
|
|
3054
|
+
originalRequest ? `Original request: ${originalRequest}` : '',
|
|
3055
|
+
'Tool execution summary for the previous request:',
|
|
3056
|
+
...replayLines,
|
|
3057
|
+
'Continue the same assistant response from exactly where you paused using these tool results.',
|
|
3058
|
+
'Treat successful mutating tool results above as already completed actions. Do not repeat those same mutating tool calls unless the user explicitly asks to retry.',
|
|
3059
|
+
'If you include meta tags, use only <thinking>, <reasoning>, <searching> for internal process.',
|
|
3060
|
+
'Put the final user-facing answer outside all meta tags.',
|
|
3061
|
+
]
|
|
3062
|
+
.filter(Boolean)
|
|
3063
|
+
.join('\n\n');
|
|
3064
|
+
|
|
3065
|
+
if (continuationPromptText.length > MAX_TOOL_REPLAY_PAYLOAD_CHARS) {
|
|
3066
|
+
setActiveToolCalls([]);
|
|
3067
|
+
setIsLoading(false);
|
|
3068
|
+
setError({
|
|
3069
|
+
message: 'Tool result payload is too large to continue safely in a single turn. Narrow the query or fetch steps in chunks.',
|
|
3070
|
+
code: 'TOOL_REPLAY_TOO_LARGE',
|
|
3071
|
+
});
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
newMessages.push({
|
|
3076
|
+
role: 'user',
|
|
3077
|
+
content: [
|
|
3078
|
+
{
|
|
3079
|
+
type: 'text',
|
|
3080
|
+
text: continuationPromptText,
|
|
3081
|
+
},
|
|
3082
|
+
],
|
|
3083
|
+
});
|
|
3084
|
+
|
|
3085
|
+
if (toolContinuationCountRef.current >= MAX_TOOL_CONTINUATIONS_PER_TURN) {
|
|
3086
|
+
setActiveToolCalls([]);
|
|
3087
|
+
setIsLoading(false);
|
|
3088
|
+
setError({
|
|
3089
|
+
message: 'Tool continuation limit reached for this response. Please refine the prompt and retry.',
|
|
3090
|
+
code: 'TOOL_CONTINUATION_LIMIT',
|
|
3091
|
+
});
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
toolContinuationCountRef.current += 1;
|
|
3095
|
+
|
|
3096
|
+
if (!streamIdleRef.current) {
|
|
3097
|
+
await waitForStreamIdle(10_000);
|
|
3098
|
+
}
|
|
3099
|
+
if (!streamIdleRef.current) {
|
|
3100
|
+
suppressAbortHistoryUpdateRef.current = true;
|
|
3101
|
+
try {
|
|
3102
|
+
stop(lastController);
|
|
3103
|
+
await waitForStreamIdle(3_000);
|
|
3104
|
+
} finally {
|
|
3105
|
+
suppressAbortHistoryUpdateRef.current = false;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
if (!streamIdleRef.current) {
|
|
3109
|
+
setActiveToolCalls([]);
|
|
3110
|
+
setIsLoading(false);
|
|
3111
|
+
setError({
|
|
3112
|
+
message: 'Timed out waiting for the previous stream to settle before tool continuation.',
|
|
3113
|
+
code: 'TOOL_CONTINUATION_WAIT_TIMEOUT',
|
|
3114
|
+
});
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
const newController = new AbortController();
|
|
3119
|
+
setLastController(newController);
|
|
3120
|
+
const continuationKey = lastKeyRef.current;
|
|
3121
|
+
if (continuationKey) {
|
|
3122
|
+
const continuationBase = latestHistoryRef.current[continuationKey]?.content || '';
|
|
3123
|
+
activeStreamAppendBaseRef.current =
|
|
3124
|
+
continuationBase.trim().length > 0
|
|
3125
|
+
? { key: continuationKey, base: continuationBase }
|
|
3126
|
+
: null;
|
|
3127
|
+
} else {
|
|
3128
|
+
activeStreamAppendBaseRef.current = null;
|
|
3129
|
+
}
|
|
3130
|
+
send(
|
|
3131
|
+
'',
|
|
3132
|
+
newMessages,
|
|
3133
|
+
[
|
|
3134
|
+
...dataWithExtras(),
|
|
3135
|
+
{
|
|
3136
|
+
key: '--messages',
|
|
3137
|
+
data: newMessages.length.toString(),
|
|
3138
|
+
},
|
|
3139
|
+
],
|
|
3140
|
+
true,
|
|
3141
|
+
true,
|
|
3142
|
+
service,
|
|
3143
|
+
currentConversation,
|
|
3144
|
+
newController,
|
|
3145
|
+
undefined,
|
|
3146
|
+
(errorMsg: string) => {
|
|
3147
|
+
setActiveToolCalls([]);
|
|
3148
|
+
setIsLoading(false);
|
|
3149
|
+
setError({
|
|
3150
|
+
message: errorMsg,
|
|
3151
|
+
code: 'TOOL_ERROR',
|
|
3152
|
+
});
|
|
3153
|
+
},
|
|
3154
|
+
);
|
|
3155
|
+
} finally {
|
|
3156
|
+
toolRequestProcessingRef.current = false;
|
|
3157
|
+
const queued = queuedToolRequestsRef.current;
|
|
3158
|
+
if (queued && queued.length > 0) {
|
|
3159
|
+
queuedToolRequestsRef.current = null;
|
|
3160
|
+
queueMicrotask(() => {
|
|
3161
|
+
void processGivenToolRequests(queued);
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
},
|
|
3166
|
+
[
|
|
3167
|
+
toolList,
|
|
3168
|
+
localToolExecutors,
|
|
3169
|
+
publicAPIUrl,
|
|
3170
|
+
buildMcpRequestHeaders,
|
|
3171
|
+
dataWithExtras,
|
|
3172
|
+
send,
|
|
3173
|
+
service,
|
|
3174
|
+
currentConversation,
|
|
3175
|
+
setActiveToolCalls,
|
|
3176
|
+
getToolCallSignature,
|
|
3177
|
+
traceContextMode,
|
|
3178
|
+
idle,
|
|
3179
|
+
stop,
|
|
3180
|
+
lastController,
|
|
3181
|
+
waitForStreamIdle,
|
|
3182
|
+
],
|
|
3183
|
+
);
|
|
3184
|
+
|
|
3185
|
+
// Handle tool approval for MCP tools
|
|
3186
|
+
const handleToolApproval = useCallback(
|
|
3187
|
+
(toolName: string, scope: 'once' | 'session' | 'always') => {
|
|
3188
|
+
const normalizedToolName = normalizeToolName(toolName);
|
|
3189
|
+
if (!normalizedToolName) return;
|
|
3190
|
+
|
|
3191
|
+
if (scope === 'session' || scope === 'always') {
|
|
3192
|
+
setSessionApprovedTools((prev) => Array.from(new Set([...prev, normalizedToolName])));
|
|
3193
|
+
}
|
|
3194
|
+
if (scope === 'always') {
|
|
3195
|
+
setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, normalizedToolName])));
|
|
3196
|
+
}
|
|
1278
3197
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
3198
|
+
const requestsToRun = (pendingToolRequestsRef.current || []).filter(
|
|
3199
|
+
(r) => normalizeToolName(r.toolName) === normalizedToolName
|
|
3200
|
+
);
|
|
3201
|
+
void processGivenToolRequests(requestsToRun);
|
|
3202
|
+
setPendingToolRequests((prev) =>
|
|
3203
|
+
prev.filter((r) => normalizeToolName(r.toolName) !== normalizedToolName),
|
|
3204
|
+
);
|
|
3205
|
+
},
|
|
3206
|
+
[processGivenToolRequests, normalizeToolName]
|
|
3207
|
+
);
|
|
1284
3208
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
3209
|
+
useEffect(() => {
|
|
3210
|
+
if (pendingToolRequests.length === 0) return;
|
|
3211
|
+
|
|
3212
|
+
const configuredAutoApproveTools = Array.isArray(autoApproveTools)
|
|
3213
|
+
? new Set(
|
|
3214
|
+
autoApproveTools
|
|
3215
|
+
.map((toolName) => normalizeToolName(String(toolName)))
|
|
3216
|
+
.filter(Boolean)
|
|
3217
|
+
)
|
|
3218
|
+
: null;
|
|
3219
|
+
|
|
3220
|
+
const toAuto = pendingToolRequests.filter(
|
|
3221
|
+
(r) => {
|
|
3222
|
+
const normalized = normalizeToolName(r.toolName);
|
|
3223
|
+
if (!normalized) return false;
|
|
3224
|
+
if (autoApproveTools === true) return true;
|
|
3225
|
+
if (configuredAutoApproveTools?.has(normalized)) return true;
|
|
3226
|
+
return sessionApprovedTools.includes(normalized) || alwaysApprovedTools.includes(normalized);
|
|
3227
|
+
}
|
|
3228
|
+
) as ToolRequestMatch[];
|
|
3229
|
+
if (toAuto.length > 0) {
|
|
3230
|
+
void processGivenToolRequests(toAuto);
|
|
3231
|
+
setPendingToolRequests((prev) =>
|
|
3232
|
+
prev.filter(
|
|
3233
|
+
(r) => {
|
|
3234
|
+
const normalized = normalizeToolName(r.toolName);
|
|
3235
|
+
if (!normalized) return true;
|
|
3236
|
+
if (autoApproveTools === true) return false;
|
|
3237
|
+
if (configuredAutoApproveTools?.has(normalized)) return false;
|
|
3238
|
+
return !sessionApprovedTools.includes(normalized) && !alwaysApprovedTools.includes(normalized);
|
|
3239
|
+
}
|
|
3240
|
+
)
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
}, [
|
|
3244
|
+
autoApproveTools,
|
|
3245
|
+
pendingToolRequests,
|
|
3246
|
+
sessionApprovedTools,
|
|
3247
|
+
alwaysApprovedTools,
|
|
3248
|
+
processGivenToolRequests,
|
|
3249
|
+
normalizeToolName,
|
|
3250
|
+
]);
|
|
1289
3251
|
|
|
1290
3252
|
// ============================================================================
|
|
1291
3253
|
// Callbacks
|
|
@@ -1324,38 +3286,31 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1324
3286
|
// Remove zero-width space characters from keepalive before processing
|
|
1325
3287
|
const processedText = text.replace(/\u200B/g, '');
|
|
1326
3288
|
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
if (content) {
|
|
1353
|
-
allMatches.push({ content, index: match.index, type: 'searching' });
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
// Sort by position in original text
|
|
1358
|
-
const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
|
|
3289
|
+
const completedBlocks: ThinkingBlock[] = [];
|
|
3290
|
+
const textWithCompleteMarkers = processedText.replace(
|
|
3291
|
+
/<(thinking|reasoning|searching)>([\s\S]*?)<\/\1>/gi,
|
|
3292
|
+
(_fullMatch, rawType, rawContent, offset) => {
|
|
3293
|
+
const normalizedType = String(rawType || '').trim().toLowerCase();
|
|
3294
|
+
const type: 'thinking' | 'reasoning' | 'searching' =
|
|
3295
|
+
normalizedType === 'reasoning'
|
|
3296
|
+
? 'reasoning'
|
|
3297
|
+
: normalizedType === 'searching'
|
|
3298
|
+
? 'searching'
|
|
3299
|
+
: 'thinking';
|
|
3300
|
+
const content = String(rawContent || '').trim();
|
|
3301
|
+
if (!content) return '\n\n';
|
|
3302
|
+
|
|
3303
|
+
const signature = getThinkingBlockSignature(type, content);
|
|
3304
|
+
completedBlocks.push({
|
|
3305
|
+
type,
|
|
3306
|
+
content,
|
|
3307
|
+
index: Number(offset || 0),
|
|
3308
|
+
signature,
|
|
3309
|
+
});
|
|
3310
|
+
|
|
3311
|
+
return `\n\n${buildThinkingBlockMarker(type, signature)}\n\n`;
|
|
3312
|
+
},
|
|
3313
|
+
);
|
|
1359
3314
|
|
|
1360
3315
|
// Check for incomplete (streaming) tags at the end of the text
|
|
1361
3316
|
let activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null = null;
|
|
@@ -1399,10 +3354,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1399
3354
|
}
|
|
1400
3355
|
|
|
1401
3356
|
// Clean the text by removing all thinking-related tags (complete and incomplete)
|
|
1402
|
-
let cleanedText =
|
|
1403
|
-
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
|
|
1404
|
-
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
|
|
1405
|
-
.replace(/<searching>[\s\S]*?<\/searching>/gi, '')
|
|
3357
|
+
let cleanedText = textWithCompleteMarkers
|
|
1406
3358
|
// Also remove partial opening tags
|
|
1407
3359
|
.replace(/<think(?:i(?:n(?:g)?)?)?$/i, '')
|
|
1408
3360
|
.replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, '')
|
|
@@ -1426,12 +3378,44 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1426
3378
|
return { cleanedText, completedBlocks, activeBlock, lastThinkingContent };
|
|
1427
3379
|
}, [cleanContentForDisplay]);
|
|
1428
3380
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
3381
|
+
const mergeThinkingBlocks = useCallback(
|
|
3382
|
+
(existing: ThinkingBlock[], incoming: ThinkingBlock[]): ThinkingBlock[] => {
|
|
3383
|
+
if (incoming.length === 0) return existing;
|
|
3384
|
+
if (existing.length === 0) return incoming;
|
|
3385
|
+
|
|
3386
|
+
// During a single stream, incoming blocks are usually a superset prefix of existing blocks.
|
|
3387
|
+
// In that case, replace to keep indexes/content fully aligned with latest parse.
|
|
3388
|
+
const incomingSupersetPrefix =
|
|
3389
|
+
incoming.length >= existing.length &&
|
|
3390
|
+
existing.every((block, index) => {
|
|
3391
|
+
const next = incoming[index];
|
|
3392
|
+
return !!next && next.type === block.type && next.content === block.content;
|
|
3393
|
+
});
|
|
3394
|
+
if (incomingSupersetPrefix) {
|
|
3395
|
+
return incoming;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
// For follow-on streams (e.g., tool continuations), append only unseen blocks.
|
|
3399
|
+
const merged = [...existing];
|
|
3400
|
+
const seen = new Set(existing.map((block) => block.signature || `${block.type}::${block.content}`));
|
|
3401
|
+
for (const block of incoming) {
|
|
3402
|
+
const signature = block.signature || `${block.type}::${block.content}`;
|
|
3403
|
+
if (seen.has(signature)) continue;
|
|
3404
|
+
seen.add(signature);
|
|
3405
|
+
merged.push(block);
|
|
3406
|
+
}
|
|
3407
|
+
return merged;
|
|
3408
|
+
},
|
|
3409
|
+
[],
|
|
3410
|
+
);
|
|
3411
|
+
|
|
3412
|
+
const getThinkingBlockCollapseKey = useCallback((entryKey: string, blockKey: string): string => {
|
|
3413
|
+
return `${entryKey}::${blockKey}`;
|
|
3414
|
+
}, []);
|
|
3415
|
+
|
|
3416
|
+
const getThinkingBlockRenderKey = useCallback((block: ThinkingBlock, fallbackIndex: number): string => {
|
|
3417
|
+
return String(block?.signature || '').trim() || `block-${fallbackIndex}`;
|
|
3418
|
+
}, []);
|
|
1435
3419
|
|
|
1436
3420
|
// Built-in action for agent suggestion cards
|
|
1437
3421
|
// Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
|
|
@@ -1440,6 +3424,37 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1440
3424
|
markdown: '<agent-suggestion data-agent-id="$1" data-agent-name="$2" data-reason="$3"></agent-suggestion>',
|
|
1441
3425
|
};
|
|
1442
3426
|
|
|
3427
|
+
const extractToolRequests = useCallback((rawResponse: string): ToolRequestMatch[] => {
|
|
3428
|
+
return extractToolRequestMatchesFromText(rawResponse);
|
|
3429
|
+
}, []);
|
|
3430
|
+
|
|
3431
|
+
const formatToolRequestsForDisplay = useCallback((rawResponse: string): string => {
|
|
3432
|
+
if (!rawResponse) return rawResponse;
|
|
3433
|
+
const requests = extractToolRequestMatchesFromText(rawResponse);
|
|
3434
|
+
if (requests.length === 0) return stripStandaloneRawToolJsonLines(rawResponse);
|
|
3435
|
+
|
|
3436
|
+
const output: string[] = [];
|
|
3437
|
+
let cursor = 0;
|
|
3438
|
+
requests.forEach((request) => {
|
|
3439
|
+
const start = Math.max(0, Math.min(rawResponse.length, request.start));
|
|
3440
|
+
const end = Math.max(start, Math.min(rawResponse.length, request.end));
|
|
3441
|
+
output.push(rawResponse.slice(cursor, start));
|
|
3442
|
+
output.push(`\n\n${buildInlineToolMarker(request.toolName, request.callId)}\n\n`);
|
|
3443
|
+
cursor = end;
|
|
3444
|
+
});
|
|
3445
|
+
output.push(rawResponse.slice(cursor));
|
|
3446
|
+
return stripStandaloneRawToolJsonLines(output.join(''));
|
|
3447
|
+
}, []);
|
|
3448
|
+
|
|
3449
|
+
// Compute active thinking block from the raw response during render.
|
|
3450
|
+
// Using raw response preserves streaming reasoning/searching blocks even when
|
|
3451
|
+
// tool-call payload cleanup temporarily mutates the display text.
|
|
3452
|
+
const activeThinkingBlock = useMemo(() => {
|
|
3453
|
+
if (!response || justReset) return null;
|
|
3454
|
+
const { activeBlock } = processThinkingTags(response);
|
|
3455
|
+
return activeBlock;
|
|
3456
|
+
}, [response, justReset, processThinkingTags]);
|
|
3457
|
+
|
|
1443
3458
|
// Process actions in content
|
|
1444
3459
|
const processActions = useCallback((content: string): string => {
|
|
1445
3460
|
// Combine built-in actions with user-provided actions
|
|
@@ -1485,6 +3500,42 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1485
3500
|
return displayPrompt;
|
|
1486
3501
|
}, [hideRagContextInPrompt]);
|
|
1487
3502
|
|
|
3503
|
+
const normalizeHistoryPromptForContext = useCallback((historyPrompt: string): string => {
|
|
3504
|
+
let promptForHistory = String(historyPrompt || '');
|
|
3505
|
+
const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
|
|
3506
|
+
if (isoTimestampRegex.test(promptForHistory)) {
|
|
3507
|
+
const colonIndex = promptForHistory.indexOf(':', 19);
|
|
3508
|
+
promptForHistory = promptForHistory.substring(colonIndex + 1);
|
|
3509
|
+
} else if (/^\d+:/.test(promptForHistory)) {
|
|
3510
|
+
const colonIndex = promptForHistory.indexOf(':');
|
|
3511
|
+
promptForHistory = promptForHistory.substring(colonIndex + 1);
|
|
3512
|
+
}
|
|
3513
|
+
return promptForHistory;
|
|
3514
|
+
}, []);
|
|
3515
|
+
|
|
3516
|
+
const buildAssistantContextContent = useCallback(
|
|
3517
|
+
(historyPrompt: string, historyEntry: HistoryEntry): string => {
|
|
3518
|
+
const assistantBaseContent =
|
|
3519
|
+
typeof historyEntry?.content === 'string' ? historyEntry.content : '';
|
|
3520
|
+
if (traceContextMode !== 'full') {
|
|
3521
|
+
return assistantBaseContent;
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
const traceSummary = buildCompactTraceSummary({
|
|
3525
|
+
reasoningBlocks: thinkingBlocksByKeyRef.current[historyPrompt] || [],
|
|
3526
|
+
toolCalls: historyEntry?.toolCalls,
|
|
3527
|
+
toolResponses: historyEntry?.toolResponses,
|
|
3528
|
+
});
|
|
3529
|
+
if (!traceSummary) {
|
|
3530
|
+
return assistantBaseContent;
|
|
3531
|
+
}
|
|
3532
|
+
return assistantBaseContent
|
|
3533
|
+
? `${assistantBaseContent}\n\n${traceSummary}`
|
|
3534
|
+
: traceSummary;
|
|
3535
|
+
},
|
|
3536
|
+
[traceContextMode],
|
|
3537
|
+
);
|
|
3538
|
+
|
|
1488
3539
|
// Built-in interaction tracking - reports to LLMAsAService API
|
|
1489
3540
|
const interactionClicked = useCallback(async (
|
|
1490
3541
|
callId: string,
|
|
@@ -1626,11 +3677,29 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1626
3677
|
// Continue chat (send message) - matches ChatPanel behavior exactly
|
|
1627
3678
|
// promptText is now required - comes from the isolated ChatInput component
|
|
1628
3679
|
const continueChat = useCallback((promptText: string) => {
|
|
3680
|
+
handledToolCallSignaturesRef.current = new Set();
|
|
3681
|
+
inFlightToolCallSignaturesRef.current = new Set();
|
|
3682
|
+
toolContinuationCountRef.current = 0;
|
|
3683
|
+
activeStreamAppendBaseRef.current = null;
|
|
3684
|
+
toolReplaySummariesByKeyRef.current = {};
|
|
3685
|
+
setPendingToolRequests([]);
|
|
3686
|
+
setActiveToolCalls([]);
|
|
3687
|
+
|
|
3688
|
+
// Preserve completed blocks from prior prompts and collapse them when a new prompt starts.
|
|
3689
|
+
setCollapsedBlocks((prev) => {
|
|
3690
|
+
const next = new Set(prev);
|
|
3691
|
+
Object.entries(thinkingBlocksByKeyRef.current).forEach(([entryKey, blocks]) => {
|
|
3692
|
+
blocks.forEach((block, index) => {
|
|
3693
|
+
next.add(getThinkingBlockCollapseKey(entryKey, getThinkingBlockRenderKey(block, index)));
|
|
3694
|
+
});
|
|
3695
|
+
});
|
|
3696
|
+
return next;
|
|
3697
|
+
});
|
|
3698
|
+
|
|
1629
3699
|
// Clear thinking blocks for new response
|
|
1630
3700
|
// Note: activeThinkingBlock is computed via useMemo from response
|
|
1631
3701
|
setThinkingBlocks([]);
|
|
1632
3702
|
setCurrentThinkingIndex(0);
|
|
1633
|
-
setCollapsedBlocks(new Set());
|
|
1634
3703
|
hasAutoCollapsedRef.current = false;
|
|
1635
3704
|
prevBlockCountRef.current = 0;
|
|
1636
3705
|
|
|
@@ -1680,6 +3749,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1680
3749
|
...prevHistory,
|
|
1681
3750
|
[promptKey]: { content: '', callId: '' },
|
|
1682
3751
|
}));
|
|
3752
|
+
toolReplaySummariesByKeyRef.current[promptKey] = [];
|
|
1683
3753
|
|
|
1684
3754
|
// Store the key for later use
|
|
1685
3755
|
setLastPrompt(promptToSend.trim());
|
|
@@ -1699,24 +3769,16 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1699
3769
|
console.log('AIChatPanel.continueChat - ensureConversation resolved with:', convId);
|
|
1700
3770
|
// Build messagesAndHistory from history (matches ChatPanel)
|
|
1701
3771
|
// IMPORTANT: Exclude the current prompt (promptKey) since it's new and we're sending it now
|
|
3772
|
+
const historyForCall = latestHistoryRef.current || {};
|
|
1702
3773
|
const messagesAndHistory: { role: string; content: string }[] = [];
|
|
1703
|
-
Object.entries(
|
|
3774
|
+
Object.entries(historyForCall).forEach(([historyPrompt, historyEntry]) => {
|
|
1704
3775
|
// Skip the current prompt we just added optimistically
|
|
1705
3776
|
if (historyPrompt === promptKey) return;
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
|
|
1710
|
-
if (isoTimestampRegex.test(historyPrompt)) {
|
|
1711
|
-
const colonIndex = historyPrompt.indexOf(':', 19);
|
|
1712
|
-
promptForHistory = historyPrompt.substring(colonIndex + 1);
|
|
1713
|
-
} else if (/^\d+:/.test(historyPrompt)) {
|
|
1714
|
-
const colonIndex = historyPrompt.indexOf(':');
|
|
1715
|
-
promptForHistory = historyPrompt.substring(colonIndex + 1);
|
|
1716
|
-
}
|
|
1717
|
-
|
|
3777
|
+
const promptForHistory = normalizeHistoryPromptForContext(historyPrompt);
|
|
3778
|
+
const assistantContextContent = buildAssistantContextContent(historyPrompt, historyEntry);
|
|
3779
|
+
|
|
1718
3780
|
messagesAndHistory.push({ role: 'user', content: promptForHistory });
|
|
1719
|
-
messagesAndHistory.push({ role: 'assistant', content:
|
|
3781
|
+
messagesAndHistory.push({ role: 'assistant', content: assistantContextContent });
|
|
1720
3782
|
});
|
|
1721
3783
|
|
|
1722
3784
|
// Build the full prompt - only apply template for first message (matches ChatPanel)
|
|
@@ -1728,6 +3790,20 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1728
3790
|
|
|
1729
3791
|
const newController = new AbortController();
|
|
1730
3792
|
setLastController(newController);
|
|
3793
|
+
|
|
3794
|
+
if (onBeforeSend) {
|
|
3795
|
+
void Promise.resolve(
|
|
3796
|
+
onBeforeSend({
|
|
3797
|
+
prompt: promptToSend.trim(),
|
|
3798
|
+
conversationId: convId || null,
|
|
3799
|
+
agentId: agent,
|
|
3800
|
+
service,
|
|
3801
|
+
messages: messagesAndHistory,
|
|
3802
|
+
})
|
|
3803
|
+
).catch((error) => {
|
|
3804
|
+
console.warn('[AIChatPanel] onBeforeSend callback failed:', error);
|
|
3805
|
+
});
|
|
3806
|
+
}
|
|
1731
3807
|
|
|
1732
3808
|
// Pass data array to send() for template replacement (e.g., {{Context}})
|
|
1733
3809
|
// Pass service (group_id) and customer data just like ChatPanel does
|
|
@@ -1755,19 +3831,27 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1755
3831
|
errorMsg.toLowerCase().includes('cancelled');
|
|
1756
3832
|
|
|
1757
3833
|
if (isAbortError) {
|
|
3834
|
+
if (suppressAbortHistoryUpdateRef.current) {
|
|
3835
|
+
setIsLoading(false);
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
1758
3838
|
// User canceled the request - don't show error banner
|
|
1759
3839
|
console.log('[AIChatPanel] Request was aborted by user');
|
|
1760
3840
|
// Don't set error state - no red banner
|
|
1761
3841
|
|
|
1762
3842
|
// Update history to show cancellation
|
|
1763
3843
|
if (promptKey) {
|
|
1764
|
-
setHistory((prev) =>
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
3844
|
+
setHistory((prev) => {
|
|
3845
|
+
const existingEntry = prev[promptKey] || { content: '', callId: '' };
|
|
3846
|
+
return {
|
|
3847
|
+
...prev,
|
|
3848
|
+
[promptKey]: {
|
|
3849
|
+
...existingEntry,
|
|
3850
|
+
content: 'Response canceled',
|
|
3851
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
3852
|
+
},
|
|
3853
|
+
};
|
|
3854
|
+
});
|
|
1771
3855
|
}
|
|
1772
3856
|
}
|
|
1773
3857
|
// Detect 413 Content Too Large error
|
|
@@ -1779,13 +3863,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1779
3863
|
|
|
1780
3864
|
// Update history to show error
|
|
1781
3865
|
if (promptKey) {
|
|
1782
|
-
setHistory((prev) =>
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
3866
|
+
setHistory((prev) => {
|
|
3867
|
+
const existingEntry = prev[promptKey] || { content: '', callId: '' };
|
|
3868
|
+
return {
|
|
3869
|
+
...prev,
|
|
3870
|
+
[promptKey]: {
|
|
3871
|
+
...existingEntry,
|
|
3872
|
+
content: `Error: ${errorMsg}`,
|
|
3873
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
3874
|
+
},
|
|
3875
|
+
};
|
|
3876
|
+
});
|
|
1789
3877
|
}
|
|
1790
3878
|
}
|
|
1791
3879
|
// Detect other network errors
|
|
@@ -1797,13 +3885,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1797
3885
|
|
|
1798
3886
|
// Update history to show error
|
|
1799
3887
|
if (promptKey) {
|
|
1800
|
-
setHistory((prev) =>
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
3888
|
+
setHistory((prev) => {
|
|
3889
|
+
const existingEntry = prev[promptKey] || { content: '', callId: '' };
|
|
3890
|
+
return {
|
|
3891
|
+
...prev,
|
|
3892
|
+
[promptKey]: {
|
|
3893
|
+
...existingEntry,
|
|
3894
|
+
content: `Error: ${errorMsg}`,
|
|
3895
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
3896
|
+
},
|
|
3897
|
+
};
|
|
3898
|
+
});
|
|
1807
3899
|
}
|
|
1808
3900
|
}
|
|
1809
3901
|
// Generic error
|
|
@@ -1815,13 +3907,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1815
3907
|
|
|
1816
3908
|
// Update history to show error
|
|
1817
3909
|
if (promptKey) {
|
|
1818
|
-
setHistory((prev) =>
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
3910
|
+
setHistory((prev) => {
|
|
3911
|
+
const existingEntry = prev[promptKey] || { content: '', callId: '' };
|
|
3912
|
+
return {
|
|
3913
|
+
...prev,
|
|
3914
|
+
[promptKey]: {
|
|
3915
|
+
...existingEntry,
|
|
3916
|
+
content: `Error: ${errorMsg}`,
|
|
3917
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
3918
|
+
},
|
|
3919
|
+
};
|
|
3920
|
+
});
|
|
1825
3921
|
}
|
|
1826
3922
|
}
|
|
1827
3923
|
|
|
@@ -1850,14 +3946,20 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1850
3946
|
lastCallId,
|
|
1851
3947
|
processThinkingTags,
|
|
1852
3948
|
clearFollowOnQuestionsNextPrompt,
|
|
1853
|
-
history,
|
|
1854
3949
|
promptTemplate,
|
|
1855
3950
|
send,
|
|
1856
3951
|
service,
|
|
3952
|
+
agent,
|
|
1857
3953
|
ensureConversation,
|
|
3954
|
+
normalizeHistoryPromptForContext,
|
|
3955
|
+
buildAssistantContextContent,
|
|
3956
|
+
traceContextMode,
|
|
1858
3957
|
dataWithExtras,
|
|
1859
3958
|
scrollToBottom,
|
|
1860
3959
|
onConversationCreated,
|
|
3960
|
+
onBeforeSend,
|
|
3961
|
+
getThinkingBlockCollapseKey,
|
|
3962
|
+
getThinkingBlockRenderKey,
|
|
1861
3963
|
setResponse,
|
|
1862
3964
|
]);
|
|
1863
3965
|
|
|
@@ -1894,6 +3996,14 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1894
3996
|
setLastKey(null);
|
|
1895
3997
|
setIsLoading(false);
|
|
1896
3998
|
setCurrentConversation(null);
|
|
3999
|
+
thinkingBlocksByKeyRef.current = {};
|
|
4000
|
+
setThinkingBlocksByKey({});
|
|
4001
|
+
handledToolCallSignaturesRef.current = new Set();
|
|
4002
|
+
inFlightToolCallSignaturesRef.current = new Set();
|
|
4003
|
+
toolContinuationCountRef.current = 0;
|
|
4004
|
+
activeStreamAppendBaseRef.current = null;
|
|
4005
|
+
toolReplaySummariesByKeyRef.current = {};
|
|
4006
|
+
setPendingToolRequests([]);
|
|
1897
4007
|
setFollowOnQuestionsState(followOnQuestions);
|
|
1898
4008
|
setThinkingBlocks([]);
|
|
1899
4009
|
setCurrentThinkingIndex(0);
|
|
@@ -1905,6 +4015,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1905
4015
|
setLastController(new AbortController());
|
|
1906
4016
|
setUserHasScrolled(false);
|
|
1907
4017
|
setError(null); // Clear any errors
|
|
4018
|
+
setActiveToolCalls([]);
|
|
1908
4019
|
|
|
1909
4020
|
setTimeout(() => {
|
|
1910
4021
|
setJustReset(false);
|
|
@@ -1923,54 +4034,110 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
1923
4034
|
// actions will re-process the history correctly.
|
|
1924
4035
|
useEffect(() => {
|
|
1925
4036
|
if (!response || !lastKey || justReset) return;
|
|
1926
|
-
|
|
1927
|
-
const
|
|
4037
|
+
|
|
4038
|
+
const extractedToolRequests = extractToolRequests(response);
|
|
4039
|
+
const seenToolCallSignatures = new Set<string>();
|
|
4040
|
+
const unseenToolRequests = extractedToolRequests.filter((request) => {
|
|
4041
|
+
const callSignature = getToolCallSignature(request.toolName, request.callId);
|
|
4042
|
+
if (!callSignature) return false;
|
|
4043
|
+
if (seenToolCallSignatures.has(callSignature)) return false;
|
|
4044
|
+
seenToolCallSignatures.add(callSignature);
|
|
4045
|
+
if (handledToolCallSignaturesRef.current.has(callSignature)) return false;
|
|
4046
|
+
if (inFlightToolCallSignaturesRef.current.has(callSignature)) return false;
|
|
4047
|
+
return true;
|
|
4048
|
+
});
|
|
4049
|
+
|
|
4050
|
+
setPendingToolRequests((prev) => {
|
|
4051
|
+
if (areToolRequestListsEqual(prev, unseenToolRequests)) {
|
|
4052
|
+
return prev;
|
|
4053
|
+
}
|
|
4054
|
+
return unseenToolRequests;
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
const responseWithInlineToolLabels = formatToolRequestsForDisplay(response);
|
|
4058
|
+
const { cleanedText: parsedCleanedText, completedBlocks } = processThinkingTags(responseWithInlineToolLabels);
|
|
4059
|
+
const cleanedText = parsedCleanedText.trim();
|
|
4060
|
+
const existingBlocks = thinkingBlocksByKeyRef.current[lastKey] || [];
|
|
4061
|
+
const mergedBlocks = mergeThinkingBlocks(existingBlocks, completedBlocks);
|
|
4062
|
+
thinkingBlocksByKeyRef.current[lastKey] = mergedBlocks;
|
|
4063
|
+
setThinkingBlocksByKey((prev) => {
|
|
4064
|
+
const existing = prev[lastKey];
|
|
4065
|
+
const isSame =
|
|
4066
|
+
!!existing &&
|
|
4067
|
+
existing.length === mergedBlocks.length &&
|
|
4068
|
+
existing.every(
|
|
4069
|
+
(block, index) =>
|
|
4070
|
+
block.type === mergedBlocks[index]?.type &&
|
|
4071
|
+
block.content === mergedBlocks[index]?.content,
|
|
4072
|
+
);
|
|
4073
|
+
|
|
4074
|
+
if (isSame) return prev;
|
|
4075
|
+
return {
|
|
4076
|
+
...prev,
|
|
4077
|
+
[lastKey]: mergedBlocks,
|
|
4078
|
+
};
|
|
4079
|
+
});
|
|
1928
4080
|
|
|
1929
4081
|
// Update display state
|
|
1930
4082
|
// Note: activeThinkingBlock is computed via useMemo from response directly
|
|
1931
|
-
setThinkingBlocks(
|
|
4083
|
+
setThinkingBlocks(mergedBlocks);
|
|
4084
|
+
setCurrentThinkingIndex(Math.max(0, mergedBlocks.length - 1));
|
|
1932
4085
|
|
|
1933
4086
|
// When a new block appears, collapse all previous blocks
|
|
1934
|
-
if (
|
|
4087
|
+
if (mergedBlocks.length > prevBlockCountRef.current) {
|
|
1935
4088
|
setCollapsedBlocks(prev => {
|
|
1936
4089
|
const next = new Set(prev);
|
|
1937
4090
|
// Collapse all blocks except the newest one
|
|
1938
|
-
for (let i = 0; i <
|
|
1939
|
-
|
|
4091
|
+
for (let i = 0; i < mergedBlocks.length - 1; i++) {
|
|
4092
|
+
const block = mergedBlocks[i];
|
|
4093
|
+
if (!block) continue;
|
|
4094
|
+
next.add(getThinkingBlockCollapseKey(lastKey, getThinkingBlockRenderKey(block, i)));
|
|
1940
4095
|
}
|
|
1941
4096
|
return next;
|
|
1942
4097
|
});
|
|
1943
|
-
prevBlockCountRef.current =
|
|
4098
|
+
prevBlockCountRef.current = mergedBlocks.length;
|
|
1944
4099
|
}
|
|
1945
4100
|
|
|
1946
|
-
//
|
|
1947
|
-
|
|
1948
|
-
const hasThinkingContent = completedBlocks.length > 0 || processThinkingTags(response).activeBlock !== null;
|
|
1949
|
-
if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
|
|
1950
|
-
hasAutoCollapsedRef.current = true;
|
|
1951
|
-
setTimeout(() => {
|
|
1952
|
-
// Collapse all blocks including active
|
|
1953
|
-
setCollapsedBlocks(prev => {
|
|
1954
|
-
const next = new Set(prev);
|
|
1955
|
-
completedBlocks.forEach((_, index) => next.add(`block-${index}`));
|
|
1956
|
-
next.add('active');
|
|
1957
|
-
return next;
|
|
1958
|
-
});
|
|
1959
|
-
}, 500);
|
|
1960
|
-
}
|
|
4101
|
+
// Keep reasoning blocks visible; users can collapse manually if needed.
|
|
4102
|
+
// Auto-collapse here made blocks appear and then seem to disappear.
|
|
1961
4103
|
|
|
1962
4104
|
// Update history state with RAW content (actions applied at render time)
|
|
1963
4105
|
setHistory((prev) => {
|
|
1964
4106
|
const newHistory = { ...prev };
|
|
4107
|
+
const existingEntry = newHistory[lastKey] || { content: '', callId: '' };
|
|
4108
|
+
const appendBase = activeStreamAppendBaseRef.current;
|
|
4109
|
+
const mergedContinuationContent =
|
|
4110
|
+
appendBase && appendBase.key === lastKey
|
|
4111
|
+
? mergeContinuationResponseText(appendBase.base, cleanedText)
|
|
4112
|
+
: cleanedText;
|
|
4113
|
+
const existingContent = typeof existingEntry.content === 'string' ? existingEntry.content : '';
|
|
4114
|
+
const nextContent =
|
|
4115
|
+
appendBase && appendBase.key === lastKey && existingContent.length > mergedContinuationContent.length
|
|
4116
|
+
? existingContent
|
|
4117
|
+
: shouldPreserveBoundaryDroppedStreamText(existingContent, mergedContinuationContent)
|
|
4118
|
+
? existingContent
|
|
4119
|
+
: mergedContinuationContent;
|
|
1965
4120
|
newHistory[lastKey] = {
|
|
1966
|
-
|
|
1967
|
-
|
|
4121
|
+
...existingEntry,
|
|
4122
|
+
content: nextContent, // Store raw content without tool JSON or thinking tags
|
|
4123
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
1968
4124
|
};
|
|
1969
4125
|
// Keep ref in sync for callbacks (this doesn't trigger re-renders)
|
|
1970
4126
|
latestHistoryRef.current = newHistory;
|
|
1971
4127
|
return newHistory;
|
|
1972
4128
|
});
|
|
1973
|
-
}, [
|
|
4129
|
+
}, [
|
|
4130
|
+
response,
|
|
4131
|
+
lastKey,
|
|
4132
|
+
lastCallId,
|
|
4133
|
+
processThinkingTags,
|
|
4134
|
+
justReset,
|
|
4135
|
+
extractToolRequests,
|
|
4136
|
+
getToolCallSignature,
|
|
4137
|
+
getThinkingBlockCollapseKey,
|
|
4138
|
+
getThinkingBlockRenderKey,
|
|
4139
|
+
formatToolRequestsForDisplay,
|
|
4140
|
+
]);
|
|
1974
4141
|
|
|
1975
4142
|
// Effect 2: Handle response completion - SINGLE POINT for all completion logic
|
|
1976
4143
|
// Triggers ONLY when idle transitions from false → true
|
|
@@ -2013,6 +4180,19 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2013
4180
|
hasNotifiedCompletionRef.current = false;
|
|
2014
4181
|
// Reset response length tracking for new stream
|
|
2015
4182
|
prevResponseLengthRef.current = 0;
|
|
4183
|
+
const currentLastKey = lastKeyRef.current;
|
|
4184
|
+
const existingContent = currentLastKey
|
|
4185
|
+
? latestHistoryRef.current[currentLastKey]?.content || ''
|
|
4186
|
+
: '';
|
|
4187
|
+
activeStreamAppendBaseRef.current =
|
|
4188
|
+
currentLastKey && existingContent.trim().length > 0
|
|
4189
|
+
? {
|
|
4190
|
+
key: currentLastKey,
|
|
4191
|
+
base: existingContent,
|
|
4192
|
+
}
|
|
4193
|
+
: null;
|
|
4194
|
+
// Keep thinking UI state across follow-on streams triggered by tool calls.
|
|
4195
|
+
// New user prompts already reset this state explicitly before send().
|
|
2016
4196
|
}
|
|
2017
4197
|
}, [idle]); // ONLY depends on idle - no history, no callbacks in deps
|
|
2018
4198
|
|
|
@@ -2052,10 +4232,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2052
4232
|
}
|
|
2053
4233
|
}, [response, idle]); // ONLY response and idle - no other dependencies!
|
|
2054
4234
|
|
|
2055
|
-
// Ref to track idle state for scroll handler (avoids stale closure)
|
|
2056
|
-
const idleRef = useRef(idle);
|
|
2057
|
-
idleRef.current = idle;
|
|
2058
|
-
|
|
2059
4235
|
// Detect user scroll intent via wheel event (fires before scroll position changes)
|
|
2060
4236
|
useEffect(() => {
|
|
2061
4237
|
const scrollArea = responseAreaRef.current;
|
|
@@ -2068,7 +4244,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2068
4244
|
// Wheel event detects user intent immediately (before scroll position changes)
|
|
2069
4245
|
const handleWheel = (e: WheelEvent) => {
|
|
2070
4246
|
// Skip if not streaming
|
|
2071
|
-
if (
|
|
4247
|
+
if (streamIdleRef.current) return;
|
|
2072
4248
|
|
|
2073
4249
|
// deltaY < 0 means scrolling UP (toward top of document)
|
|
2074
4250
|
if (e.deltaY < 0 && !userHasScrolledRef.current) {
|
|
@@ -2079,7 +4255,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2079
4255
|
// Scroll event for detecting when user returns to bottom
|
|
2080
4256
|
const handleScroll = () => {
|
|
2081
4257
|
// Skip if not streaming or user hasn't scrolled up
|
|
2082
|
-
if (
|
|
4258
|
+
if (streamIdleRef.current || !userHasScrolledRef.current) return;
|
|
2083
4259
|
|
|
2084
4260
|
const scrollHeight = scrollElement.scrollHeight;
|
|
2085
4261
|
const currentScrollTop = scrollElement.scrollTop;
|
|
@@ -2102,7 +4278,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2102
4278
|
|
|
2103
4279
|
// Update follow-on questions from props
|
|
2104
4280
|
useEffect(() => {
|
|
2105
|
-
setFollowOnQuestionsState(
|
|
4281
|
+
setFollowOnQuestionsState((prev) => {
|
|
4282
|
+
if (
|
|
4283
|
+
prev.length === followOnQuestions.length &&
|
|
4284
|
+
prev.every((question, index) => question === followOnQuestions[index])
|
|
4285
|
+
) {
|
|
4286
|
+
return prev;
|
|
4287
|
+
}
|
|
4288
|
+
return followOnQuestions;
|
|
4289
|
+
});
|
|
2106
4290
|
}, [followOnQuestions]);
|
|
2107
4291
|
|
|
2108
4292
|
// Notify loading state changes
|
|
@@ -2129,9 +4313,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2129
4313
|
|
|
2130
4314
|
// If there's a response in progress, make sure it's saved
|
|
2131
4315
|
if (currentLastKey && currentResponse) {
|
|
4316
|
+
const existingEntry = currentHistory[currentLastKey] || { content: '', callId: '' };
|
|
2132
4317
|
currentHistory[currentLastKey] = {
|
|
4318
|
+
...existingEntry,
|
|
2133
4319
|
content: currentResponse + '\n\n(response interrupted)',
|
|
2134
|
-
callId: currentLastCallId || '',
|
|
4320
|
+
callId: currentLastCallId || existingEntry.callId || '',
|
|
2135
4321
|
};
|
|
2136
4322
|
}
|
|
2137
4323
|
|
|
@@ -2198,13 +4384,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2198
4384
|
|
|
2199
4385
|
// Update history to show error
|
|
2200
4386
|
if (lastKey) {
|
|
2201
|
-
setHistory((prev) =>
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
4387
|
+
setHistory((prev) => {
|
|
4388
|
+
const existingEntry = prev[lastKey] || { content: '', callId: '' };
|
|
4389
|
+
return {
|
|
4390
|
+
...prev,
|
|
4391
|
+
[lastKey]: {
|
|
4392
|
+
...existingEntry,
|
|
4393
|
+
content: `Error: ${errorMessage}`,
|
|
4394
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
4395
|
+
},
|
|
4396
|
+
};
|
|
4397
|
+
});
|
|
2208
4398
|
}
|
|
2209
4399
|
}
|
|
2210
4400
|
// Detect other network errors
|
|
@@ -2216,13 +4406,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2216
4406
|
|
|
2217
4407
|
// Update history to show error
|
|
2218
4408
|
if (lastKey) {
|
|
2219
|
-
setHistory((prev) =>
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
4409
|
+
setHistory((prev) => {
|
|
4410
|
+
const existingEntry = prev[lastKey] || { content: '', callId: '' };
|
|
4411
|
+
return {
|
|
4412
|
+
...prev,
|
|
4413
|
+
[lastKey]: {
|
|
4414
|
+
...existingEntry,
|
|
4415
|
+
content: `Error: ${errorMessage}`,
|
|
4416
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
4417
|
+
},
|
|
4418
|
+
};
|
|
4419
|
+
});
|
|
2226
4420
|
}
|
|
2227
4421
|
}
|
|
2228
4422
|
// Generic error
|
|
@@ -2234,13 +4428,17 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2234
4428
|
|
|
2235
4429
|
// Update history to show error
|
|
2236
4430
|
if (lastKey) {
|
|
2237
|
-
setHistory((prev) =>
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
4431
|
+
setHistory((prev) => {
|
|
4432
|
+
const existingEntry = prev[lastKey] || { content: '', callId: '' };
|
|
4433
|
+
return {
|
|
4434
|
+
...prev,
|
|
4435
|
+
[lastKey]: {
|
|
4436
|
+
...existingEntry,
|
|
4437
|
+
content: `Error: ${errorMessage}`,
|
|
4438
|
+
callId: lastCallId || existingEntry.callId || '',
|
|
4439
|
+
},
|
|
4440
|
+
};
|
|
4441
|
+
});
|
|
2244
4442
|
}
|
|
2245
4443
|
}
|
|
2246
4444
|
|
|
@@ -2466,62 +4664,271 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2466
4664
|
},
|
|
2467
4665
|
}), [CodeBlock, AgentSuggestionCard]);
|
|
2468
4666
|
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
4667
|
+
const toggleThinkingBlockCollapsed = useCallback((entryKey: string, blockKey: string) => {
|
|
4668
|
+
const collapseKey = getThinkingBlockCollapseKey(entryKey, blockKey);
|
|
4669
|
+
setCollapsedBlocks((prev) => {
|
|
4670
|
+
const next = new Set(prev);
|
|
4671
|
+
if (next.has(collapseKey)) {
|
|
4672
|
+
next.delete(collapseKey);
|
|
4673
|
+
} else {
|
|
4674
|
+
next.add(collapseKey);
|
|
4675
|
+
}
|
|
4676
|
+
return next;
|
|
4677
|
+
});
|
|
4678
|
+
}, [getThinkingBlockCollapseKey]);
|
|
4679
|
+
|
|
4680
|
+
const renderThinkingBlockCard = (
|
|
4681
|
+
entryKey: string,
|
|
4682
|
+
block: { type: 'thinking' | 'reasoning' | 'searching'; content: string },
|
|
4683
|
+
blockKey: string,
|
|
4684
|
+
renderKey: string,
|
|
4685
|
+
isStreaming: boolean,
|
|
4686
|
+
): React.ReactNode => {
|
|
4687
|
+
const collapseKey = getThinkingBlockCollapseKey(entryKey, blockKey);
|
|
2488
4688
|
return (
|
|
2489
|
-
|
|
2490
|
-
{
|
|
2491
|
-
{
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
content={activeThinkingBlock.content}
|
|
2511
|
-
isStreaming={true}
|
|
2512
|
-
isCollapsed={collapsedBlocks.has('active')}
|
|
2513
|
-
onToggleCollapse={() => handleToggleCollapse('active')}
|
|
2514
|
-
/>
|
|
2515
|
-
)}
|
|
2516
|
-
</>
|
|
4689
|
+
<ThinkingBlockComponent
|
|
4690
|
+
key={renderKey}
|
|
4691
|
+
type={block.type}
|
|
4692
|
+
content={block.content}
|
|
4693
|
+
isStreaming={isStreaming}
|
|
4694
|
+
isCollapsed={collapsedBlocks.has(collapseKey)}
|
|
4695
|
+
onToggleCollapse={() => toggleThinkingBlockCollapsed(entryKey, blockKey)}
|
|
4696
|
+
/>
|
|
4697
|
+
);
|
|
4698
|
+
};
|
|
4699
|
+
|
|
4700
|
+
const renderActiveThinkingBlock = (
|
|
4701
|
+
entryKey: string,
|
|
4702
|
+
activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null,
|
|
4703
|
+
keyPrefix: string,
|
|
4704
|
+
): React.ReactNode => {
|
|
4705
|
+
if (!activeBlock) return null;
|
|
4706
|
+
return (
|
|
4707
|
+
<div className="ai-chat-inline-thinking-events">
|
|
4708
|
+
{renderThinkingBlockCard(entryKey, activeBlock, 'active', `${keyPrefix}-active`, true)}
|
|
4709
|
+
</div>
|
|
2517
4710
|
);
|
|
2518
|
-
}
|
|
4711
|
+
};
|
|
2519
4712
|
|
|
2520
4713
|
// ============================================================================
|
|
2521
4714
|
// Render
|
|
2522
4715
|
// ============================================================================
|
|
2523
4716
|
|
|
2524
4717
|
const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
|
|
4718
|
+
const getToolStatusRank = (status: ToolCallStatus): number => {
|
|
4719
|
+
switch (status) {
|
|
4720
|
+
case 'error':
|
|
4721
|
+
return 4;
|
|
4722
|
+
case 'completed':
|
|
4723
|
+
return 3;
|
|
4724
|
+
case 'running':
|
|
4725
|
+
return 2;
|
|
4726
|
+
case 'pending':
|
|
4727
|
+
default:
|
|
4728
|
+
return 1;
|
|
4729
|
+
}
|
|
4730
|
+
};
|
|
4731
|
+
|
|
4732
|
+
const formatToolCallId = (callId: string): string => {
|
|
4733
|
+
const normalized = String(callId || '').trim();
|
|
4734
|
+
if (normalized.length <= 22) return normalized;
|
|
4735
|
+
return `${normalized.slice(0, 10)}...${normalized.slice(-8)}`;
|
|
4736
|
+
};
|
|
4737
|
+
|
|
4738
|
+
const renderMarkdownContent = (content: string, key: string): React.ReactNode => {
|
|
4739
|
+
if (!content || !content.trim()) return null;
|
|
4740
|
+
|
|
4741
|
+
if (markdownClass) {
|
|
4742
|
+
return (
|
|
4743
|
+
<div key={key} className={markdownClass}>
|
|
4744
|
+
<ReactMarkdown
|
|
4745
|
+
remarkPlugins={[remarkGfm]}
|
|
4746
|
+
rehypePlugins={[rehypeRaw]}
|
|
4747
|
+
components={markdownComponents}
|
|
4748
|
+
>
|
|
4749
|
+
{content}
|
|
4750
|
+
</ReactMarkdown>
|
|
4751
|
+
</div>
|
|
4752
|
+
);
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
return (
|
|
4756
|
+
<ReactMarkdown
|
|
4757
|
+
key={key}
|
|
4758
|
+
remarkPlugins={[remarkGfm]}
|
|
4759
|
+
rehypePlugins={[rehypeRaw]}
|
|
4760
|
+
components={markdownComponents}
|
|
4761
|
+
>
|
|
4762
|
+
{content}
|
|
4763
|
+
</ReactMarkdown>
|
|
4764
|
+
);
|
|
4765
|
+
};
|
|
4766
|
+
|
|
4767
|
+
const renderToolStatusRow = (toolStatusRow: ToolCallStatusRow, key: string): React.ReactNode => (
|
|
4768
|
+
<div
|
|
4769
|
+
key={key}
|
|
4770
|
+
className={`ai-chat-tool-status-row ai-chat-tool-status-row--${toolStatusRow.status}`}
|
|
4771
|
+
>
|
|
4772
|
+
<div className="ai-chat-tool-status-row__main">
|
|
4773
|
+
<ToolIcon />
|
|
4774
|
+
<span className="ai-chat-tool-status-row__label">{toolStatusRow.statusLabel}</span>
|
|
4775
|
+
<span className="ai-chat-tool-status-row__call-id">
|
|
4776
|
+
{formatToolCallId(toolStatusRow.callId)}
|
|
4777
|
+
</span>
|
|
4778
|
+
</div>
|
|
4779
|
+
{toolStatusRow.status === 'pending' && (
|
|
4780
|
+
<div className="ai-chat-tool-status-row__actions">
|
|
4781
|
+
<Button
|
|
4782
|
+
size="sm"
|
|
4783
|
+
variant="ghost"
|
|
4784
|
+
className="ai-chat-tool-status-row__button"
|
|
4785
|
+
onClick={() => handleToolApproval(toolStatusRow.toolName, 'once')}
|
|
4786
|
+
>
|
|
4787
|
+
once
|
|
4788
|
+
</Button>
|
|
4789
|
+
<Button
|
|
4790
|
+
size="sm"
|
|
4791
|
+
variant="ghost"
|
|
4792
|
+
className="ai-chat-tool-status-row__button"
|
|
4793
|
+
onClick={() => handleToolApproval(toolStatusRow.toolName, 'session')}
|
|
4794
|
+
>
|
|
4795
|
+
session
|
|
4796
|
+
</Button>
|
|
4797
|
+
<Button
|
|
4798
|
+
size="sm"
|
|
4799
|
+
variant="ghost"
|
|
4800
|
+
className="ai-chat-tool-status-row__button"
|
|
4801
|
+
onClick={() => handleToolApproval(toolStatusRow.toolName, 'always')}
|
|
4802
|
+
>
|
|
4803
|
+
always
|
|
4804
|
+
</Button>
|
|
4805
|
+
</div>
|
|
4806
|
+
)}
|
|
4807
|
+
</div>
|
|
4808
|
+
);
|
|
4809
|
+
|
|
4810
|
+
const renderContentWithInlineToolCards = (
|
|
4811
|
+
content: string,
|
|
4812
|
+
toolStatusRows: ToolCallStatusRow[],
|
|
4813
|
+
thinkingBlocksForEntry: ThinkingBlock[],
|
|
4814
|
+
entryKey: string,
|
|
4815
|
+
keyPrefix: string,
|
|
4816
|
+
): React.ReactNode => {
|
|
4817
|
+
const { parts, markers } = parseInlineToolMarkers(content);
|
|
4818
|
+
const thinkingBlocksBySignature = new Map<string, ThinkingBlock>();
|
|
4819
|
+
thinkingBlocksForEntry.forEach((block, index) => {
|
|
4820
|
+
const signature = String(block?.signature || '').trim() || getThinkingBlockRenderKey(block, index);
|
|
4821
|
+
if (!thinkingBlocksBySignature.has(signature)) {
|
|
4822
|
+
thinkingBlocksBySignature.set(signature, {
|
|
4823
|
+
...block,
|
|
4824
|
+
signature,
|
|
4825
|
+
});
|
|
4826
|
+
}
|
|
4827
|
+
});
|
|
4828
|
+
|
|
4829
|
+
const pendingBySignature = new Map<string, ToolCallStatusRow>();
|
|
4830
|
+
const pendingByCallId = new Map<string, ToolCallStatusRow>();
|
|
4831
|
+
toolStatusRows.forEach((row) => {
|
|
4832
|
+
if (!pendingBySignature.has(row.signature)) {
|
|
4833
|
+
pendingBySignature.set(row.signature, row);
|
|
4834
|
+
}
|
|
4835
|
+
if (!pendingByCallId.has(row.callId)) {
|
|
4836
|
+
pendingByCallId.set(row.callId, row);
|
|
4837
|
+
}
|
|
4838
|
+
});
|
|
4839
|
+
|
|
4840
|
+
const nodes: React.ReactNode[] = [];
|
|
4841
|
+
|
|
4842
|
+
parts.forEach((part, partIndex) => {
|
|
4843
|
+
const { parts: thinkingParts, markers: thinkingMarkers } = parseInlineThinkingMarkers(part);
|
|
4844
|
+
thinkingParts.forEach((thinkingPart, thinkingIndex) => {
|
|
4845
|
+
const markdownNode = renderMarkdownContent(
|
|
4846
|
+
thinkingPart,
|
|
4847
|
+
`${keyPrefix}-md-${partIndex}-${thinkingIndex}`,
|
|
4848
|
+
);
|
|
4849
|
+
if (markdownNode) {
|
|
4850
|
+
nodes.push(markdownNode);
|
|
4851
|
+
}
|
|
4852
|
+
|
|
4853
|
+
const thinkingMarker = thinkingMarkers[thinkingIndex];
|
|
4854
|
+
if (!thinkingMarker) return;
|
|
4855
|
+
const matchedBlock = thinkingBlocksBySignature.get(thinkingMarker.signature);
|
|
4856
|
+
if (!matchedBlock) return;
|
|
4857
|
+
|
|
4858
|
+
const blockKey = getThinkingBlockRenderKey(matchedBlock, thinkingIndex);
|
|
4859
|
+
nodes.push(
|
|
4860
|
+
<div
|
|
4861
|
+
key={`${keyPrefix}-thinking-${partIndex}-${thinkingIndex}-${blockKey}`}
|
|
4862
|
+
className="ai-chat-inline-thinking-events"
|
|
4863
|
+
>
|
|
4864
|
+
{renderThinkingBlockCard(
|
|
4865
|
+
entryKey,
|
|
4866
|
+
matchedBlock,
|
|
4867
|
+
blockKey,
|
|
4868
|
+
`${keyPrefix}-thinking-card-${partIndex}-${thinkingIndex}-${blockKey}`,
|
|
4869
|
+
false,
|
|
4870
|
+
)}
|
|
4871
|
+
</div>,
|
|
4872
|
+
);
|
|
4873
|
+
});
|
|
4874
|
+
|
|
4875
|
+
const marker = markers[partIndex];
|
|
4876
|
+
if (!marker) return;
|
|
4877
|
+
|
|
4878
|
+
const markerSignature = getToolCallSignature(marker.toolName, marker.callId);
|
|
4879
|
+
const matchedRow =
|
|
4880
|
+
(markerSignature ? pendingBySignature.get(markerSignature) : undefined) ||
|
|
4881
|
+
pendingByCallId.get(marker.callId);
|
|
4882
|
+
const fallbackSignature =
|
|
4883
|
+
markerSignature ||
|
|
4884
|
+
getToolCallSignature(marker.toolName, `${marker.callId}-${partIndex + 1}`) ||
|
|
4885
|
+
`${marker.toolName}::${marker.callId}::${partIndex}`;
|
|
4886
|
+
const rowToRender: ToolCallStatusRow = matchedRow || {
|
|
4887
|
+
signature: fallbackSignature,
|
|
4888
|
+
toolName: marker.toolName,
|
|
4889
|
+
callId: marker.callId,
|
|
4890
|
+
status: 'running',
|
|
4891
|
+
statusLabel: `tool call ${marker.toolName} running`,
|
|
4892
|
+
};
|
|
4893
|
+
|
|
4894
|
+
if (matchedRow) {
|
|
4895
|
+
pendingBySignature.delete(matchedRow.signature);
|
|
4896
|
+
const pendingByCallIdMatch = pendingByCallId.get(matchedRow.callId);
|
|
4897
|
+
if (pendingByCallIdMatch?.signature === matchedRow.signature) {
|
|
4898
|
+
pendingByCallId.delete(matchedRow.callId);
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
|
|
4902
|
+
nodes.push(
|
|
4903
|
+
<div
|
|
4904
|
+
key={`${keyPrefix}-inline-${rowToRender.signature}-${partIndex}`}
|
|
4905
|
+
className="ai-chat-inline-tool-events"
|
|
4906
|
+
role="status"
|
|
4907
|
+
aria-live="polite"
|
|
4908
|
+
>
|
|
4909
|
+
{renderToolStatusRow(rowToRender, `${keyPrefix}-row-${rowToRender.signature}-${partIndex}`)}
|
|
4910
|
+
</div>,
|
|
4911
|
+
);
|
|
4912
|
+
});
|
|
4913
|
+
|
|
4914
|
+
const unmatchedRows = Array.from(pendingBySignature.values());
|
|
4915
|
+
if (unmatchedRows.length > 0) {
|
|
4916
|
+
nodes.push(
|
|
4917
|
+
<div
|
|
4918
|
+
key={`${keyPrefix}-tail`}
|
|
4919
|
+
className="ai-chat-inline-tool-events ai-chat-inline-tool-events--tail"
|
|
4920
|
+
role="status"
|
|
4921
|
+
aria-live="polite"
|
|
4922
|
+
>
|
|
4923
|
+
{unmatchedRows.map((row, rowIndex) =>
|
|
4924
|
+
renderToolStatusRow(row, `${keyPrefix}-tail-${row.signature}-${rowIndex}`),
|
|
4925
|
+
)}
|
|
4926
|
+
</div>,
|
|
4927
|
+
);
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
return <>{nodes}</>;
|
|
4931
|
+
};
|
|
2525
4932
|
|
|
2526
4933
|
return (
|
|
2527
4934
|
<div
|
|
@@ -2588,15 +4995,101 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2588
4995
|
{/* History */}
|
|
2589
4996
|
{Object.entries(history).map(([prompt, entry], index, entries) => {
|
|
2590
4997
|
const isLastEntry = index === entries.length - 1;
|
|
4998
|
+
const isStreamingEntry = isLastEntry && (isLoading || !idle) && !justReset;
|
|
4999
|
+
const continuationAppendBase =
|
|
5000
|
+
isStreamingEntry &&
|
|
5001
|
+
activeStreamAppendBaseRef.current &&
|
|
5002
|
+
activeStreamAppendBaseRef.current.key === prompt &&
|
|
5003
|
+
activeStreamAppendBaseRef.current.base.trim().length > 0
|
|
5004
|
+
? activeStreamAppendBaseRef.current
|
|
5005
|
+
: null;
|
|
5006
|
+
const isContinuationStreamingEntry = !!continuationAppendBase;
|
|
2591
5007
|
// Check if this is a system message (injected by page context, etc.)
|
|
2592
5008
|
const isSystemMessage = prompt.startsWith('__system__:');
|
|
2593
5009
|
// Process thinking tags first, then apply actions at render time
|
|
2594
5010
|
// This ensures actions are always applied with current props (e.g., after agent switch)
|
|
2595
5011
|
const { cleanedText } = processThinkingTags(entry.content);
|
|
2596
5012
|
const processedContent = processActions(cleanedText);
|
|
5013
|
+
const entryThinkingBlocks = thinkingBlocksByKey[prompt] || [];
|
|
5014
|
+
const statusBySignature = new Map<string, ToolCallStatusRow>();
|
|
5015
|
+
const upsertToolStatus = (row: ToolCallStatusRow) => {
|
|
5016
|
+
const existing = statusBySignature.get(row.signature);
|
|
5017
|
+
if (!existing || getToolStatusRank(row.status) >= getToolStatusRank(existing.status)) {
|
|
5018
|
+
statusBySignature.set(row.signature, row);
|
|
5019
|
+
}
|
|
5020
|
+
};
|
|
5021
|
+
|
|
5022
|
+
const entryToolCalls = Array.isArray((entry as any).toolCalls) ? ((entry as any).toolCalls as any[]) : [];
|
|
5023
|
+
const entryToolResponses = Array.isArray((entry as any).toolResponses)
|
|
5024
|
+
? ((entry as any).toolResponses as any[])
|
|
5025
|
+
: [];
|
|
5026
|
+
|
|
5027
|
+
entryToolCalls.forEach((toolCall, toolIndex) => {
|
|
5028
|
+
const toolName = String((toolCall as any)?.name || '').trim() || 'tool';
|
|
5029
|
+
const callId = String((toolCall as any)?.id || '').trim() || `${toolName}-${toolIndex + 1}`;
|
|
5030
|
+
const signature =
|
|
5031
|
+
getToolCallSignature(toolName, callId) ||
|
|
5032
|
+
`completed-${prompt}-${toolIndex}-${toolName}-${callId}`;
|
|
5033
|
+
const matchedResponse =
|
|
5034
|
+
entryToolResponses.find((response) => String((response as any)?.tool_call_id || '').trim() === callId) ||
|
|
5035
|
+
entryToolResponses[toolIndex];
|
|
5036
|
+
const isError = Boolean((matchedResponse as any)?.isError);
|
|
5037
|
+
upsertToolStatus({
|
|
5038
|
+
signature,
|
|
5039
|
+
toolName,
|
|
5040
|
+
callId,
|
|
5041
|
+
status: isError ? 'error' : 'completed',
|
|
5042
|
+
statusLabel: isError ? `tool call ${toolName} errored` : `tool call ${toolName} completed`,
|
|
5043
|
+
});
|
|
5044
|
+
});
|
|
5045
|
+
|
|
5046
|
+
if (isLastEntry && !justReset) {
|
|
5047
|
+
pendingToolRequests.forEach((request, requestIndex) => {
|
|
5048
|
+
const toolName = String(request?.toolName || '').trim() || 'tool';
|
|
5049
|
+
const callId = String(request?.callId || '').trim() || `${toolName}-pending-${requestIndex + 1}`;
|
|
5050
|
+
const signature =
|
|
5051
|
+
getToolCallSignature(toolName, callId) ||
|
|
5052
|
+
`pending-${prompt}-${requestIndex}-${toolName}-${callId}`;
|
|
5053
|
+
upsertToolStatus({
|
|
5054
|
+
signature,
|
|
5055
|
+
toolName,
|
|
5056
|
+
callId,
|
|
5057
|
+
status: 'pending',
|
|
5058
|
+
statusLabel: `tool call ${toolName} awaiting approval`,
|
|
5059
|
+
});
|
|
5060
|
+
});
|
|
5061
|
+
|
|
5062
|
+
activeToolCalls.forEach((activeToolCall, activeIndex) => {
|
|
5063
|
+
const toolName = String(activeToolCall?.toolName || '').trim() || 'tool';
|
|
5064
|
+
const callId =
|
|
5065
|
+
String(activeToolCall?.callId || '').trim() || `${toolName}-running-${activeIndex + 1}`;
|
|
5066
|
+
const signature =
|
|
5067
|
+
getToolCallSignature(toolName, callId) ||
|
|
5068
|
+
`running-${prompt}-${activeIndex}-${toolName}-${callId}`;
|
|
5069
|
+
upsertToolStatus({
|
|
5070
|
+
signature,
|
|
5071
|
+
toolName,
|
|
5072
|
+
callId,
|
|
5073
|
+
status: 'running',
|
|
5074
|
+
statusLabel: `tool call ${toolName} running`,
|
|
5075
|
+
});
|
|
5076
|
+
});
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
const entryToolStatusRows = Array.from(statusBySignature.values());
|
|
5080
|
+
const hasInFlightToolStatus = entryToolStatusRows.some(
|
|
5081
|
+
(row) => row.status === 'pending' || row.status === 'running',
|
|
5082
|
+
);
|
|
5083
|
+
const isActivePromptEntry = !!lastKey && prompt === lastKey;
|
|
5084
|
+
const shouldShowBusyGapThinkingFallback =
|
|
5085
|
+
isActivePromptEntry &&
|
|
5086
|
+
!isStreamingEntry &&
|
|
5087
|
+
(isLoading || !idle) &&
|
|
5088
|
+
!activeThinkingBlock &&
|
|
5089
|
+
!hasInFlightToolStatus;
|
|
2597
5090
|
|
|
2598
5091
|
return (
|
|
2599
|
-
<div key={
|
|
5092
|
+
<div key={prompt} className="ai-chat-entry">
|
|
2600
5093
|
{/* User Message - hidden for initial prompt or system messages */}
|
|
2601
5094
|
{!(hideInitialPrompt && index === 0) && !isSystemMessage && (
|
|
2602
5095
|
<div className="ai-chat-message ai-chat-message--user">
|
|
@@ -2610,42 +5103,113 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2610
5103
|
<div className="ai-chat-message ai-chat-message--assistant">
|
|
2611
5104
|
<div className="ai-chat-message__content">
|
|
2612
5105
|
{/* Streaming state */}
|
|
2613
|
-
{
|
|
5106
|
+
{isStreamingEntry ? (
|
|
2614
5107
|
(() => {
|
|
5108
|
+
if (isContinuationStreamingEntry) {
|
|
5109
|
+
const continuationResponseWithInlineToolLabels = formatToolRequestsForDisplay(response || '');
|
|
5110
|
+
const { cleanedText: continuationCleanedText } = processThinkingTags(
|
|
5111
|
+
continuationResponseWithInlineToolLabels,
|
|
5112
|
+
);
|
|
5113
|
+
const appendBase = continuationAppendBase || activeStreamAppendBaseRef.current;
|
|
5114
|
+
const continuationMergedText =
|
|
5115
|
+
appendBase && appendBase.key === prompt
|
|
5116
|
+
? mergeContinuationResponseText(appendBase.base, continuationCleanedText.trim())
|
|
5117
|
+
: continuationCleanedText;
|
|
5118
|
+
const continuationDisplaySource =
|
|
5119
|
+
continuationMergedText.trim().length > 0
|
|
5120
|
+
? continuationMergedText
|
|
5121
|
+
: cleanedText;
|
|
5122
|
+
const continuationDisplayContent = processActions(continuationDisplaySource);
|
|
5123
|
+
const hasVisibleContinuationContent =
|
|
5124
|
+
continuationDisplayContent.trim().length > 0;
|
|
5125
|
+
const hasFreshContinuationContent = continuationCleanedText.trim().length > 0;
|
|
5126
|
+
const showThinkingFallback =
|
|
5127
|
+
!activeThinkingBlock && !hasFreshContinuationContent && !hasInFlightToolStatus;
|
|
5128
|
+
|
|
5129
|
+
return (
|
|
5130
|
+
<div className="ai-chat-streaming">
|
|
5131
|
+
{renderActiveThinkingBlock(prompt, activeThinkingBlock, `${prompt}-continuation`)}
|
|
5132
|
+
{hasVisibleContinuationContent ? (
|
|
5133
|
+
<>
|
|
5134
|
+
{renderContentWithInlineToolCards(
|
|
5135
|
+
continuationDisplayContent,
|
|
5136
|
+
entryToolStatusRows,
|
|
5137
|
+
entryThinkingBlocks,
|
|
5138
|
+
prompt,
|
|
5139
|
+
`${prompt}-continuation`,
|
|
5140
|
+
)}
|
|
5141
|
+
{showThinkingFallback && (
|
|
5142
|
+
<div className="ai-chat-loading ai-chat-loading--inline">
|
|
5143
|
+
<span>Thinking</span>
|
|
5144
|
+
<span className="ai-chat-loading__dots">
|
|
5145
|
+
<span className="ai-chat-loading__dot" />
|
|
5146
|
+
<span className="ai-chat-loading__dot" />
|
|
5147
|
+
<span className="ai-chat-loading__dot" />
|
|
5148
|
+
</span>
|
|
5149
|
+
</div>
|
|
5150
|
+
)}
|
|
5151
|
+
</>
|
|
5152
|
+
) : (
|
|
5153
|
+
<div className="ai-chat-loading">
|
|
5154
|
+
<span>Continuing response</span>
|
|
5155
|
+
<span className="ai-chat-loading__dots">
|
|
5156
|
+
<span className="ai-chat-loading__dot" />
|
|
5157
|
+
<span className="ai-chat-loading__dot" />
|
|
5158
|
+
<span className="ai-chat-loading__dot" />
|
|
5159
|
+
</span>
|
|
5160
|
+
</div>
|
|
5161
|
+
)}
|
|
5162
|
+
</div>
|
|
5163
|
+
);
|
|
5164
|
+
}
|
|
5165
|
+
|
|
2615
5166
|
// During streaming, compute content directly from response (not from history which may be stale)
|
|
2616
|
-
const
|
|
2617
|
-
const
|
|
5167
|
+
const streamingResponseWithInlineToolLabels = formatToolRequestsForDisplay(response || '');
|
|
5168
|
+
const { cleanedText: streamingCleanedText } = processThinkingTags(streamingResponseWithInlineToolLabels);
|
|
5169
|
+
const appendBase = activeStreamAppendBaseRef.current;
|
|
5170
|
+
const streamingMergedText =
|
|
5171
|
+
appendBase && appendBase.key === prompt
|
|
5172
|
+
? mergeContinuationResponseText(appendBase.base, streamingCleanedText.trim())
|
|
5173
|
+
: streamingCleanedText;
|
|
5174
|
+
const streamingContent = processActions(streamingMergedText);
|
|
2618
5175
|
const hasStreamingContent = streamingContent.trim().length > 0;
|
|
5176
|
+
const hasFreshStreamingContent = streamingCleanedText.trim().length > 0;
|
|
5177
|
+
const fallbackHistoryContent = processedContent.trim();
|
|
5178
|
+
const streamingDisplayContent = hasStreamingContent
|
|
5179
|
+
? streamingContent
|
|
5180
|
+
: fallbackHistoryContent;
|
|
5181
|
+
const hasDisplayContent = streamingDisplayContent.trim().length > 0;
|
|
5182
|
+
const showThinkingFallback =
|
|
5183
|
+
!activeThinkingBlock && !hasFreshStreamingContent && !hasInFlightToolStatus;
|
|
2619
5184
|
|
|
2620
5185
|
return (
|
|
2621
5186
|
<div className="ai-chat-streaming">
|
|
2622
|
-
{
|
|
2623
|
-
{(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
|
|
5187
|
+
{renderActiveThinkingBlock(prompt, activeThinkingBlock, `${prompt}-streaming`)}
|
|
2624
5188
|
|
|
2625
5189
|
{/* Show streaming content or loading indicator */}
|
|
2626
|
-
{
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
5190
|
+
{hasDisplayContent ? (
|
|
5191
|
+
<>
|
|
5192
|
+
{renderContentWithInlineToolCards(
|
|
5193
|
+
streamingDisplayContent,
|
|
5194
|
+
entryToolStatusRows,
|
|
5195
|
+
entryThinkingBlocks,
|
|
5196
|
+
prompt,
|
|
5197
|
+
`${prompt}-streaming`,
|
|
5198
|
+
)}
|
|
5199
|
+
{showThinkingFallback && (
|
|
5200
|
+
<div className="ai-chat-loading ai-chat-loading--inline">
|
|
5201
|
+
<span>Thinking</span>
|
|
5202
|
+
<span className="ai-chat-loading__dots">
|
|
5203
|
+
<span className="ai-chat-loading__dot" />
|
|
5204
|
+
<span className="ai-chat-loading__dot" />
|
|
5205
|
+
<span className="ai-chat-loading__dot" />
|
|
5206
|
+
</span>
|
|
5207
|
+
</div>
|
|
5208
|
+
)}
|
|
5209
|
+
</>
|
|
2646
5210
|
) : (
|
|
2647
5211
|
<div className="ai-chat-loading">
|
|
2648
|
-
<span>{
|
|
5212
|
+
<span>{activeThinkingBlock ? 'Still thinking' : 'Thinking'}</span>
|
|
2649
5213
|
<span className="ai-chat-loading__dots">
|
|
2650
5214
|
<span className="ai-chat-loading__dot" />
|
|
2651
5215
|
<span className="ai-chat-loading__dot" />
|
|
@@ -2658,26 +5222,22 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2658
5222
|
})()
|
|
2659
5223
|
) : (
|
|
2660
5224
|
<>
|
|
2661
|
-
{
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
5225
|
+
{renderContentWithInlineToolCards(
|
|
5226
|
+
processedContent,
|
|
5227
|
+
entryToolStatusRows,
|
|
5228
|
+
entryThinkingBlocks,
|
|
5229
|
+
prompt,
|
|
5230
|
+
`${prompt}-final`,
|
|
5231
|
+
)}
|
|
5232
|
+
{shouldShowBusyGapThinkingFallback && (
|
|
5233
|
+
<div className="ai-chat-loading ai-chat-loading--inline" aria-live="polite">
|
|
5234
|
+
<span>Thinking</span>
|
|
5235
|
+
<span className="ai-chat-loading__dots">
|
|
5236
|
+
<span className="ai-chat-loading__dot" />
|
|
5237
|
+
<span className="ai-chat-loading__dot" />
|
|
5238
|
+
<span className="ai-chat-loading__dot" />
|
|
5239
|
+
</span>
|
|
2672
5240
|
</div>
|
|
2673
|
-
) : (
|
|
2674
|
-
<ReactMarkdown
|
|
2675
|
-
remarkPlugins={[remarkGfm]}
|
|
2676
|
-
rehypePlugins={[rehypeRaw]}
|
|
2677
|
-
components={markdownComponents}
|
|
2678
|
-
>
|
|
2679
|
-
{processedContent}
|
|
2680
|
-
</ReactMarkdown>
|
|
2681
5241
|
)}
|
|
2682
5242
|
</>
|
|
2683
5243
|
)}
|
|
@@ -2731,26 +5291,23 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2731
5291
|
)}
|
|
2732
5292
|
</button>
|
|
2733
5293
|
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
<
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
</svg>
|
|
2752
|
-
</button>
|
|
2753
|
-
)}
|
|
5294
|
+
<button
|
|
5295
|
+
className="ai-chat-action-button"
|
|
5296
|
+
onClick={() => {
|
|
5297
|
+
setToolInfoData({
|
|
5298
|
+
calls: entry.toolCalls || [],
|
|
5299
|
+
responses: entry.toolResponses || [],
|
|
5300
|
+
});
|
|
5301
|
+
setIsToolInfoModalOpen(true);
|
|
5302
|
+
}}
|
|
5303
|
+
title="View tool information"
|
|
5304
|
+
>
|
|
5305
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
5306
|
+
<circle cx="12" cy="12" r="10" />
|
|
5307
|
+
<line x1="12" x2="12" y1="16" y2="12" />
|
|
5308
|
+
<line x1="12" x2="12.01" y1="8" y2="8" />
|
|
5309
|
+
</svg>
|
|
5310
|
+
</button>
|
|
2754
5311
|
</div>
|
|
2755
5312
|
)}
|
|
2756
5313
|
</div>
|
|
@@ -2777,50 +5334,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2777
5334
|
|
|
2778
5335
|
<div ref={bottomRef} />
|
|
2779
5336
|
</ScrollArea>
|
|
2780
|
-
|
|
2781
|
-
{/* Tool Approval Panel */}
|
|
2782
|
-
{pendingToolRequests.length > 0 && (
|
|
2783
|
-
<div className="ai-chat-approve-tools-panel">
|
|
2784
|
-
<div className="ai-chat-approve-tools-header">
|
|
2785
|
-
Tool Approval Required
|
|
2786
|
-
</div>
|
|
2787
|
-
<div className="ai-chat-approve-tools-description">
|
|
2788
|
-
The AI wants to use the following tools:
|
|
2789
|
-
</div>
|
|
2790
|
-
{getUniqueToolNames().map((toolName) => (
|
|
2791
|
-
<div key={toolName} className="ai-chat-approve-tool-item">
|
|
2792
|
-
<div className="ai-chat-approve-tool-name">{toolName}</div>
|
|
2793
|
-
<div className="ai-chat-approve-tools-buttons">
|
|
2794
|
-
<Button
|
|
2795
|
-
size="sm"
|
|
2796
|
-
variant="outline"
|
|
2797
|
-
className="ai-chat-approve-tools-button"
|
|
2798
|
-
onClick={() => handleToolApproval(toolName, 'once')}
|
|
2799
|
-
>
|
|
2800
|
-
Once
|
|
2801
|
-
</Button>
|
|
2802
|
-
<Button
|
|
2803
|
-
size="sm"
|
|
2804
|
-
variant="outline"
|
|
2805
|
-
className="ai-chat-approve-tools-button"
|
|
2806
|
-
onClick={() => handleToolApproval(toolName, 'session')}
|
|
2807
|
-
>
|
|
2808
|
-
This Session
|
|
2809
|
-
</Button>
|
|
2810
|
-
<Button
|
|
2811
|
-
size="sm"
|
|
2812
|
-
variant="default"
|
|
2813
|
-
className="ai-chat-approve-tools-button"
|
|
2814
|
-
onClick={() => handleToolApproval(toolName, 'always')}
|
|
2815
|
-
>
|
|
2816
|
-
Always
|
|
2817
|
-
</Button>
|
|
2818
|
-
</div>
|
|
2819
|
-
</div>
|
|
2820
|
-
))}
|
|
2821
|
-
</div>
|
|
2822
|
-
)}
|
|
2823
|
-
|
|
5337
|
+
|
|
2824
5338
|
{/* Button Container - Save, Email, CTA */}
|
|
2825
5339
|
{(showSaveButton || showEmailButton || showCallToAction) && (
|
|
2826
5340
|
<div className="ai-chat-button-container">
|
|
@@ -2991,11 +5505,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
2991
5505
|
</div>
|
|
2992
5506
|
</>
|
|
2993
5507
|
)}
|
|
2994
|
-
|
|
5508
|
+
|
|
2995
5509
|
{/* Input Area - Isolated component for performance */}
|
|
2996
5510
|
<ChatInput
|
|
2997
5511
|
placeholder={placeholder}
|
|
2998
|
-
|
|
5512
|
+
isBusy={isLoading || !idle}
|
|
2999
5513
|
onSubmit={continueChat}
|
|
3000
5514
|
onStop={handleStop}
|
|
3001
5515
|
agentOptions={agentOptions}
|
|
@@ -3056,4 +5570,3 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
|
3056
5570
|
|
|
3057
5571
|
// Memoize to prevent re-renders when parent state changes but our props don't
|
|
3058
5572
|
export default React.memo(AIChatPanel);
|
|
3059
|
-
|