@hef2024/llmasaservice-ui 0.22.11 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.css +632 -1
- package/dist/index.d.mts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +3829 -3465
- package/dist/index.mjs +3760 -3398
- package/package.json +1 -1
- package/src/AIChatPanel.css +365 -0
- package/src/AIChatPanel.tsx +251 -88
- package/src/ChatPanel.css +379 -3
- package/src/ChatPanel.tsx +264 -190
- package/src/components/ui/ThinkingBlock.tsx +150 -0
- package/src/components/ui/WordFadeIn.tsx +101 -0
- package/src/components/ui/index.ts +6 -0
package/src/ChatPanel.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import materialDark from "react-syntax-highlighter/dist/esm/styles/prism/materia
|
|
|
18
18
|
import materialLight from "react-syntax-highlighter/dist/esm/styles/prism/material-light.js";
|
|
19
19
|
import EmailModal from "./EmailModal";
|
|
20
20
|
import ToolInfoModal from "./ToolInfoModal";
|
|
21
|
+
import { ThinkingBlock as ThinkingBlockComponent } from './components/ui';
|
|
21
22
|
|
|
22
23
|
export interface ChatPanelProps {
|
|
23
24
|
project_id: string;
|
|
@@ -218,12 +219,19 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
218
219
|
|
|
219
220
|
// State for tracking thinking content and navigation
|
|
220
221
|
const [thinkingBlocks, setThinkingBlocks] = useState<
|
|
221
|
-
Array<{ type: "reasoning" | "searching"; content: string; index: number }>
|
|
222
|
+
Array<{ type: "thinking" | "reasoning" | "searching"; content: string; index: number; isStreaming?: boolean }>
|
|
222
223
|
>([]);
|
|
223
224
|
const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
|
|
225
|
+
// NOTE: activeThinkingBlock is now computed via useMemo, not useState - see below after processThinkingTags
|
|
226
|
+
// Track collapsed state per block (key: "block-{index}" or "active" for streaming block)
|
|
227
|
+
const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
|
|
228
|
+
const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
|
|
229
|
+
const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
|
|
224
230
|
|
|
225
231
|
// State for error handling
|
|
226
232
|
const [error, setError] = useState<{ message: string; code?: string } | null>(null);
|
|
233
|
+
// Ref to track last processed error to prevent re-showing same error from useLLM
|
|
234
|
+
const lastProcessedErrorRef = useRef<string | null>(null);
|
|
227
235
|
|
|
228
236
|
// State for tracking user-resized textarea height
|
|
229
237
|
const [userResizedHeight, setUserResizedHeight] = useState<number | null>(null);
|
|
@@ -310,6 +318,7 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
310
318
|
// Memoized regex patterns to avoid recreation on every render
|
|
311
319
|
const THINKING_PATTERNS = useMemo(
|
|
312
320
|
() => ({
|
|
321
|
+
thinking: /<thinking>([\s\S]*?)<\/thinking>/gi,
|
|
313
322
|
reasoning: /<reasoning>([\s\S]*?)<\/reasoning>/gi,
|
|
314
323
|
searching: /<searching>([\s\S]*?)<\/searching>/gi,
|
|
315
324
|
}),
|
|
@@ -317,6 +326,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
317
326
|
);
|
|
318
327
|
|
|
319
328
|
// Memoized regex instances for better performance
|
|
329
|
+
const thinkingRegex = useMemo(
|
|
330
|
+
() => new RegExp(THINKING_PATTERNS.thinking.source, "gi"),
|
|
331
|
+
[THINKING_PATTERNS.thinking.source]
|
|
332
|
+
);
|
|
320
333
|
const reasoningRegex = useMemo(
|
|
321
334
|
() => new RegExp(THINKING_PATTERNS.reasoning.source, "gi"),
|
|
322
335
|
[THINKING_PATTERNS.reasoning.source]
|
|
@@ -343,23 +356,26 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
343
356
|
return cleaned || "Thinking";
|
|
344
357
|
}, []);
|
|
345
358
|
|
|
346
|
-
// Optimized function to extract thinking blocks in order
|
|
359
|
+
// Optimized function to extract thinking blocks in order - handles both complete and incomplete (streaming) tags
|
|
347
360
|
const processThinkingTags = useCallback(
|
|
348
361
|
(
|
|
349
362
|
text: string
|
|
350
363
|
): {
|
|
351
364
|
cleanedText: string;
|
|
352
|
-
|
|
353
|
-
type: "reasoning" | "searching";
|
|
365
|
+
completedBlocks: Array<{
|
|
366
|
+
type: "thinking" | "reasoning" | "searching";
|
|
354
367
|
content: string;
|
|
355
368
|
index: number;
|
|
369
|
+
isStreaming?: boolean;
|
|
356
370
|
}>;
|
|
371
|
+
activeBlock: { type: "thinking" | "reasoning" | "searching"; content: string } | null;
|
|
357
372
|
lastThinkingContent: string;
|
|
358
373
|
} => {
|
|
359
374
|
if (!text) {
|
|
360
375
|
return {
|
|
361
376
|
cleanedText: "",
|
|
362
|
-
|
|
377
|
+
completedBlocks: [],
|
|
378
|
+
activeBlock: null,
|
|
363
379
|
lastThinkingContent: "Thinking",
|
|
364
380
|
};
|
|
365
381
|
}
|
|
@@ -371,14 +387,30 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
371
387
|
const allMatches: Array<{
|
|
372
388
|
content: string;
|
|
373
389
|
index: number;
|
|
374
|
-
type: "reasoning" | "searching";
|
|
390
|
+
type: "thinking" | "reasoning" | "searching";
|
|
391
|
+
isStreaming?: boolean;
|
|
375
392
|
}> = [];
|
|
376
393
|
|
|
377
394
|
// Reset regex state for fresh matching
|
|
395
|
+
thinkingRegex.lastIndex = 0;
|
|
378
396
|
reasoningRegex.lastIndex = 0;
|
|
379
397
|
searchingRegex.lastIndex = 0;
|
|
380
398
|
|
|
381
|
-
// Process
|
|
399
|
+
// Process complete thinking blocks (Claude-style)
|
|
400
|
+
let thinkingMatch;
|
|
401
|
+
while ((thinkingMatch = thinkingRegex.exec(processedText)) !== null) {
|
|
402
|
+
const content = thinkingMatch[1]?.trim();
|
|
403
|
+
if (content) {
|
|
404
|
+
allMatches.push({
|
|
405
|
+
content,
|
|
406
|
+
index: thinkingMatch.index,
|
|
407
|
+
type: "thinking",
|
|
408
|
+
isStreaming: false,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Process complete reasoning blocks
|
|
382
414
|
let reasoningMatch;
|
|
383
415
|
while ((reasoningMatch = reasoningRegex.exec(processedText)) !== null) {
|
|
384
416
|
const content = reasoningMatch[1]?.trim();
|
|
@@ -387,11 +419,12 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
387
419
|
content,
|
|
388
420
|
index: reasoningMatch.index,
|
|
389
421
|
type: "reasoning",
|
|
422
|
+
isStreaming: false,
|
|
390
423
|
});
|
|
391
424
|
}
|
|
392
425
|
}
|
|
393
426
|
|
|
394
|
-
// Process searching blocks
|
|
427
|
+
// Process complete searching blocks
|
|
395
428
|
let searchingMatch;
|
|
396
429
|
while ((searchingMatch = searchingRegex.exec(processedText)) !== null) {
|
|
397
430
|
const content = searchingMatch[1]?.trim();
|
|
@@ -400,127 +433,105 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
400
433
|
content,
|
|
401
434
|
index: searchingMatch.index,
|
|
402
435
|
type: "searching",
|
|
436
|
+
isStreaming: false,
|
|
403
437
|
});
|
|
404
438
|
}
|
|
405
439
|
}
|
|
406
440
|
|
|
407
441
|
// Sort by index to preserve original order
|
|
408
|
-
const
|
|
442
|
+
const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
|
|
409
443
|
|
|
410
|
-
//
|
|
444
|
+
// Check for incomplete (streaming) tags at the end of the text
|
|
445
|
+
// We need to find the LAST opening tag that doesn't have a corresponding closing tag
|
|
446
|
+
let activeBlock: { type: "thinking" | "reasoning" | "searching"; content: string } | null = null;
|
|
447
|
+
const tagTypes = ['thinking', 'reasoning', 'searching'] as const;
|
|
448
|
+
let latestIncompletePos = -1;
|
|
449
|
+
|
|
450
|
+
for (const tagType of tagTypes) {
|
|
451
|
+
const openTag = `<${tagType}>`;
|
|
452
|
+
const closeTag = `</${tagType}>`;
|
|
453
|
+
const textLower = processedText.toLowerCase();
|
|
454
|
+
|
|
455
|
+
// Find the last occurrence of this opening tag
|
|
456
|
+
const lastOpenIndex = textLower.lastIndexOf(openTag);
|
|
457
|
+
if (lastOpenIndex === -1) continue;
|
|
458
|
+
|
|
459
|
+
// Check if there's a closing tag after this opening tag
|
|
460
|
+
const afterOpen = processedText.slice(lastOpenIndex + openTag.length);
|
|
461
|
+
const closeIndex = afterOpen.toLowerCase().indexOf(closeTag);
|
|
462
|
+
|
|
463
|
+
// If no closing tag found after the opening tag, this is incomplete
|
|
464
|
+
if (closeIndex === -1 && lastOpenIndex > latestIncompletePos) {
|
|
465
|
+
latestIncompletePos = lastOpenIndex;
|
|
466
|
+
activeBlock = {
|
|
467
|
+
type: tagType,
|
|
468
|
+
content: afterOpen,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Also check for partial opening tags (e.g., "<reas" before full "<reasoning>")
|
|
474
|
+
// This helps show the thinking indicator earlier during streaming
|
|
475
|
+
if (!activeBlock) {
|
|
476
|
+
const partialTagPatterns = [
|
|
477
|
+
{ pattern: /<think(?:i(?:n(?:g)?)?)?$/i, type: 'thinking' as const },
|
|
478
|
+
{ pattern: /<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'reasoning' as const },
|
|
479
|
+
{ pattern: /<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'searching' as const },
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
for (const { pattern, type } of partialTagPatterns) {
|
|
483
|
+
if (pattern.test(processedText)) {
|
|
484
|
+
activeBlock = { type, content: '' };
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Clean the text by removing all thinking-related tags (complete and incomplete)
|
|
491
|
+
// Incomplete tags (opening without closing) are also removed so they don't show as raw text
|
|
411
492
|
let cleanedText = processedText
|
|
493
|
+
.replace(THINKING_PATTERNS.thinking, "")
|
|
412
494
|
.replace(THINKING_PATTERNS.reasoning, "")
|
|
413
495
|
.replace(THINKING_PATTERNS.searching, "")
|
|
496
|
+
// Also remove partial opening tags
|
|
497
|
+
.replace(/<think(?:i(?:n(?:g)?)?)?$/i, "")
|
|
498
|
+
.replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, "")
|
|
499
|
+
.replace(/<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, "")
|
|
500
|
+
.replace(/<thinking>[\s\S]*$/i, "")
|
|
501
|
+
.replace(/<reasoning>[\s\S]*$/i, "")
|
|
502
|
+
.replace(/<searching>[\s\S]*$/i, "")
|
|
414
503
|
.trim();
|
|
415
504
|
|
|
416
505
|
// Get last thinking content
|
|
417
506
|
let lastThinkingContent = "Thinking";
|
|
418
|
-
if (
|
|
419
|
-
const lastBlock =
|
|
507
|
+
if (completedBlocks.length > 0) {
|
|
508
|
+
const lastBlock = completedBlocks[completedBlocks.length - 1];
|
|
420
509
|
if (lastBlock?.content) {
|
|
421
510
|
lastThinkingContent = cleanContentForDisplay(lastBlock.content);
|
|
422
511
|
}
|
|
512
|
+
} else if (activeBlock?.content) {
|
|
513
|
+
lastThinkingContent = cleanContentForDisplay(activeBlock.content);
|
|
423
514
|
}
|
|
424
515
|
|
|
425
516
|
return {
|
|
426
517
|
cleanedText,
|
|
427
|
-
|
|
518
|
+
completedBlocks,
|
|
519
|
+
activeBlock,
|
|
428
520
|
lastThinkingContent,
|
|
429
521
|
};
|
|
430
522
|
},
|
|
431
523
|
[
|
|
524
|
+
THINKING_PATTERNS.thinking,
|
|
432
525
|
THINKING_PATTERNS.reasoning,
|
|
433
526
|
THINKING_PATTERNS.searching,
|
|
527
|
+
thinkingRegex,
|
|
434
528
|
reasoningRegex,
|
|
435
529
|
searchingRegex,
|
|
436
530
|
cleanContentForDisplay,
|
|
437
531
|
]
|
|
438
532
|
);
|
|
439
533
|
|
|
440
|
-
//
|
|
441
|
-
const renderThinkingBlocks = useCallback((): React.ReactElement | null => {
|
|
442
|
-
if (thinkingBlocks.length === 0) return null;
|
|
443
|
-
|
|
444
|
-
const currentBlock = thinkingBlocks[currentThinkingIndex];
|
|
445
|
-
if (!currentBlock) return null;
|
|
446
|
-
|
|
447
|
-
const icon = currentBlock.type === "reasoning" ? "🤔" : "🔍";
|
|
448
|
-
const baseTitle =
|
|
449
|
-
currentBlock.type === "reasoning" ? "Reasoning" : "Searching";
|
|
450
|
-
|
|
451
|
-
// Extract title from **[title]** at the beginning of content and strip formatting
|
|
452
|
-
const extractTitleAndContent = (
|
|
453
|
-
text: string
|
|
454
|
-
): { displayTitle: string; content: string } => {
|
|
455
|
-
// Handle potential whitespace at the beginning and be more flexible with the pattern
|
|
456
|
-
const trimmedText = text.trim();
|
|
457
|
-
const titleMatch = trimmedText.match(/^\*\*\[(.*?)\]\*\*/);
|
|
458
|
-
if (titleMatch) {
|
|
459
|
-
const extractedTitle = titleMatch[1];
|
|
460
|
-
// Remove the title pattern and any following whitespace/newlines
|
|
461
|
-
const remainingContent = trimmedText
|
|
462
|
-
.replace(/^\*\*\[.*?\]\*\*\s*\n?/, "")
|
|
463
|
-
.replace(/\*\*(.*?)\*\*/g, "$1")
|
|
464
|
-
.trim();
|
|
465
|
-
return {
|
|
466
|
-
displayTitle: `${baseTitle}: ${extractedTitle}`,
|
|
467
|
-
content: remainingContent,
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
// If no title found, just strip bold formatting
|
|
471
|
-
return {
|
|
472
|
-
displayTitle: baseTitle,
|
|
473
|
-
content: trimmedText.replace(/\*\*(.*?)\*\*/g, "$1"),
|
|
474
|
-
};
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
const { displayTitle, content } = extractTitleAndContent(
|
|
478
|
-
currentBlock.content
|
|
479
|
-
);
|
|
480
|
-
|
|
481
|
-
return (
|
|
482
|
-
<div className="thinking-block-container">
|
|
483
|
-
<div className={`thinking-section ${currentBlock.type}-section`}>
|
|
484
|
-
<div className="thinking-header">
|
|
485
|
-
{icon} {displayTitle}
|
|
486
|
-
{thinkingBlocks.length > 1 && (
|
|
487
|
-
<div className="thinking-navigation">
|
|
488
|
-
<button
|
|
489
|
-
onClick={() =>
|
|
490
|
-
setCurrentThinkingIndex(
|
|
491
|
-
Math.max(0, currentThinkingIndex - 1)
|
|
492
|
-
)
|
|
493
|
-
}
|
|
494
|
-
disabled={currentThinkingIndex === 0}
|
|
495
|
-
className="thinking-nav-btn"
|
|
496
|
-
>
|
|
497
|
-
←
|
|
498
|
-
</button>
|
|
499
|
-
<span className="thinking-counter">
|
|
500
|
-
{currentThinkingIndex + 1} / {thinkingBlocks.length}
|
|
501
|
-
</span>
|
|
502
|
-
<button
|
|
503
|
-
onClick={() =>
|
|
504
|
-
setCurrentThinkingIndex(
|
|
505
|
-
Math.min(
|
|
506
|
-
thinkingBlocks.length - 1,
|
|
507
|
-
currentThinkingIndex + 1
|
|
508
|
-
)
|
|
509
|
-
)
|
|
510
|
-
}
|
|
511
|
-
disabled={currentThinkingIndex === thinkingBlocks.length - 1}
|
|
512
|
-
className="thinking-nav-btn"
|
|
513
|
-
>
|
|
514
|
-
→
|
|
515
|
-
</button>
|
|
516
|
-
</div>
|
|
517
|
-
)}
|
|
518
|
-
</div>
|
|
519
|
-
<div className="thinking-content">{content}</div>
|
|
520
|
-
</div>
|
|
521
|
-
</div>
|
|
522
|
-
);
|
|
523
|
-
}, [thinkingBlocks, currentThinkingIndex]);
|
|
534
|
+
// NOTE: activeThinkingBlock and renderThinkingBlocks are defined after useLLM hook (needs response)
|
|
524
535
|
|
|
525
536
|
const getBrowserInfo = () => {
|
|
526
537
|
try {
|
|
@@ -700,10 +711,66 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
700
711
|
|
|
701
712
|
const { send, response, idle, stop, lastCallId, setResponse, error: llmError } = llmResult;
|
|
702
713
|
|
|
714
|
+
// Compute active thinking block directly from response during render (avoids state batching issues)
|
|
715
|
+
const activeThinkingBlock = useMemo(() => {
|
|
716
|
+
if (!response || justReset) return null;
|
|
717
|
+
const { activeBlock } = processThinkingTags(response);
|
|
718
|
+
return activeBlock;
|
|
719
|
+
}, [response, justReset, processThinkingTags]);
|
|
720
|
+
|
|
721
|
+
// Render thinking blocks using new collapsible ThinkingBlock component
|
|
722
|
+
const renderThinkingBlocks = useCallback((isStreaming: boolean = false): React.ReactElement | null => {
|
|
723
|
+
const hasActiveBlock = activeThinkingBlock !== null;
|
|
724
|
+
const hasCompletedBlocks = thinkingBlocks.length > 0;
|
|
725
|
+
|
|
726
|
+
if (!hasActiveBlock && !hasCompletedBlocks) return null;
|
|
727
|
+
|
|
728
|
+
const handleToggleCollapse = (blockKey: string) => {
|
|
729
|
+
setCollapsedBlocks(prev => {
|
|
730
|
+
const next = new Set(prev);
|
|
731
|
+
if (next.has(blockKey)) {
|
|
732
|
+
next.delete(blockKey);
|
|
733
|
+
} else {
|
|
734
|
+
next.add(blockKey);
|
|
735
|
+
}
|
|
736
|
+
return next;
|
|
737
|
+
});
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
return (
|
|
741
|
+
<>
|
|
742
|
+
{/* Render completed blocks first */}
|
|
743
|
+
{thinkingBlocks.map((block, index) => {
|
|
744
|
+
const blockKey = `block-${index}`;
|
|
745
|
+
return (
|
|
746
|
+
<ThinkingBlockComponent
|
|
747
|
+
key={blockKey}
|
|
748
|
+
type={block.type}
|
|
749
|
+
content={block.content}
|
|
750
|
+
isStreaming={false}
|
|
751
|
+
isCollapsed={collapsedBlocks.has(blockKey)}
|
|
752
|
+
onToggleCollapse={() => handleToggleCollapse(blockKey)}
|
|
753
|
+
/>
|
|
754
|
+
);
|
|
755
|
+
})}
|
|
756
|
+
|
|
757
|
+
{/* Render active (streaming) block */}
|
|
758
|
+
{activeThinkingBlock && (
|
|
759
|
+
<ThinkingBlockComponent
|
|
760
|
+
key="active-streaming"
|
|
761
|
+
type={activeThinkingBlock.type}
|
|
762
|
+
content={activeThinkingBlock.content}
|
|
763
|
+
isStreaming={true}
|
|
764
|
+
isCollapsed={collapsedBlocks.has('active')}
|
|
765
|
+
onToggleCollapse={() => handleToggleCollapse('active')}
|
|
766
|
+
/>
|
|
767
|
+
)}
|
|
768
|
+
</>
|
|
769
|
+
);
|
|
770
|
+
}, [thinkingBlocks, activeThinkingBlock, collapsedBlocks]);
|
|
771
|
+
|
|
703
772
|
// Error handler function - reusable for both error state and callbacks
|
|
704
773
|
const handleError = useCallback((errorMessage: string, historyKey?: string) => {
|
|
705
|
-
console.log('[ChatPanel] Error detected:', errorMessage);
|
|
706
|
-
|
|
707
774
|
// Detect 413 Content Too Large error
|
|
708
775
|
if (errorMessage.includes('413') || errorMessage.toLowerCase().includes('content too large')) {
|
|
709
776
|
setError({
|
|
@@ -745,6 +812,16 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
745
812
|
// Monitor for errors from useLLM hook (for errors not caught by callback)
|
|
746
813
|
useEffect(() => {
|
|
747
814
|
if (llmError && llmError.trim()) {
|
|
815
|
+
// Skip if we've already processed this exact error
|
|
816
|
+
// This prevents re-showing the same error when a new call starts but llmError
|
|
817
|
+
// hasn't been cleared yet by the useLLM hook
|
|
818
|
+
if (lastProcessedErrorRef.current === llmError) {
|
|
819
|
+
console.log('[ChatPanel] Skipping duplicate error:', llmError);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
console.log('[ChatPanel] Error detected:', llmError);
|
|
824
|
+
lastProcessedErrorRef.current = llmError;
|
|
748
825
|
handleError(llmError);
|
|
749
826
|
}
|
|
750
827
|
}, [llmError, handleError]);
|
|
@@ -1568,13 +1645,45 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
1568
1645
|
}
|
|
1569
1646
|
|
|
1570
1647
|
// Step 3: Process thinking tags on the response without tool JSON
|
|
1571
|
-
const { cleanedText,
|
|
1648
|
+
const { cleanedText, completedBlocks, activeBlock } =
|
|
1572
1649
|
processThinkingTags(responseWithoutTools);
|
|
1573
1650
|
|
|
1574
|
-
//
|
|
1575
|
-
setThinkingBlocks(
|
|
1651
|
+
// Update completed thinking blocks
|
|
1652
|
+
setThinkingBlocks(completedBlocks);
|
|
1576
1653
|
// Always show the latest (last) thinking block
|
|
1577
|
-
setCurrentThinkingIndex(Math.max(0,
|
|
1654
|
+
setCurrentThinkingIndex(Math.max(0, completedBlocks.length - 1));
|
|
1655
|
+
|
|
1656
|
+
// Note: activeThinkingBlock is now computed via useMemo from response directly
|
|
1657
|
+
|
|
1658
|
+
// When a new block appears, collapse all previous blocks
|
|
1659
|
+
if (completedBlocks.length > prevBlockCountRef.current) {
|
|
1660
|
+
setCollapsedBlocks(prev => {
|
|
1661
|
+
const next = new Set(prev);
|
|
1662
|
+
// Collapse all blocks except the newest one
|
|
1663
|
+
for (let i = 0; i < completedBlocks.length - 1; i++) {
|
|
1664
|
+
next.add(`block-${i}`);
|
|
1665
|
+
}
|
|
1666
|
+
return next;
|
|
1667
|
+
});
|
|
1668
|
+
prevBlockCountRef.current = completedBlocks.length;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Auto-collapse all thinking blocks when main content starts appearing
|
|
1672
|
+
const hasThinkingContent = completedBlocks.length > 0 || activeBlock;
|
|
1673
|
+
const hasMainContent = cleanedText.trim().length > 0;
|
|
1674
|
+
|
|
1675
|
+
if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
|
|
1676
|
+
hasAutoCollapsedRef.current = true;
|
|
1677
|
+
setTimeout(() => {
|
|
1678
|
+
// Collapse all blocks including active
|
|
1679
|
+
setCollapsedBlocks(prev => {
|
|
1680
|
+
const next = new Set(prev);
|
|
1681
|
+
completedBlocks.forEach((_, index) => next.add(`block-${index}`));
|
|
1682
|
+
next.add('active');
|
|
1683
|
+
return next;
|
|
1684
|
+
});
|
|
1685
|
+
}, 500);
|
|
1686
|
+
}
|
|
1578
1687
|
|
|
1579
1688
|
// Step 4: Process other non-tool actions on the cleaned response with two-phase option
|
|
1580
1689
|
const { processedContent: newResponse, buttonAttachments } = processActionsOnContent(
|
|
@@ -1631,6 +1740,8 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
1631
1740
|
if (!idle) return; // only finalize at end
|
|
1632
1741
|
if (!lastKey) return;
|
|
1633
1742
|
if (finalizedForCallRef.current === lastCallId) return; // already finalized
|
|
1743
|
+
|
|
1744
|
+
// Note: activeThinkingBlock is computed via useMemo from response
|
|
1634
1745
|
|
|
1635
1746
|
// Replace data-pending buttons in the stored history content for the lastKey
|
|
1636
1747
|
setHistory((prev) => {
|
|
@@ -1673,6 +1784,9 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
1673
1784
|
finalizedForCallRef.current = lastCallId;
|
|
1674
1785
|
}, [idle, progressiveActions, lastCallId, lastKey]);
|
|
1675
1786
|
|
|
1787
|
+
// Note: activeThinkingBlock is computed via useMemo from response
|
|
1788
|
+
// It will automatically become null when response no longer has incomplete tags
|
|
1789
|
+
|
|
1676
1790
|
// More reliable button attachment with retry mechanism and MutationObserver
|
|
1677
1791
|
const attachButtonHandlers = useCallback(
|
|
1678
1792
|
(
|
|
@@ -1979,6 +2093,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
1979
2093
|
// Clear thinking blocks for new response
|
|
1980
2094
|
setThinkingBlocks([]);
|
|
1981
2095
|
setCurrentThinkingIndex(0);
|
|
2096
|
+
// Note: activeThinkingBlock is computed via useMemo from response
|
|
2097
|
+
setCollapsedBlocks(new Set());
|
|
2098
|
+
hasAutoCollapsedRef.current = false;
|
|
2099
|
+
prevBlockCountRef.current = 0;
|
|
1982
2100
|
|
|
1983
2101
|
ensureConversation().then((convId) => {
|
|
1984
2102
|
if (lastController) stop(lastController);
|
|
@@ -2211,6 +2329,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
2211
2329
|
// Clear thinking blocks for new response
|
|
2212
2330
|
setThinkingBlocks([]);
|
|
2213
2331
|
setCurrentThinkingIndex(0);
|
|
2332
|
+
// Note: activeThinkingBlock is computed via useMemo from response
|
|
2333
|
+
setCollapsedBlocks(new Set());
|
|
2334
|
+
hasAutoCollapsedRef.current = false;
|
|
2335
|
+
prevBlockCountRef.current = 0;
|
|
2214
2336
|
|
|
2215
2337
|
// Clear any previous errors
|
|
2216
2338
|
setError(null);
|
|
@@ -2843,103 +2965,51 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
2843
2965
|
{index === Object.keys(history).length - 1 &&
|
|
2844
2966
|
(isLoading || !idle) &&
|
|
2845
2967
|
!justReset ? (
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
{(
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
);
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
// If no thinking blocks yet but no main content, show generic thinking
|
|
2879
|
-
if (!cleanedText || cleanedText.length === 0) {
|
|
2880
|
-
return (
|
|
2968
|
+
(() => {
|
|
2969
|
+
// During streaming, compute content directly from response (not from history which may be stale)
|
|
2970
|
+
const { cleanedText: streamingCleanedText } = processThinkingTags(response || '');
|
|
2971
|
+
const hasStreamingContent = streamingCleanedText.trim().length > 0;
|
|
2972
|
+
|
|
2973
|
+
return (
|
|
2974
|
+
<div className="streaming-response">
|
|
2975
|
+
{/* Display thinking blocks (completed and streaming) */}
|
|
2976
|
+
{(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
|
|
2977
|
+
|
|
2978
|
+
{/* Display streaming content or loading indicator */}
|
|
2979
|
+
{hasStreamingContent ? (
|
|
2980
|
+
(() => {
|
|
2981
|
+
const content = (
|
|
2982
|
+
<ReactMarkdown
|
|
2983
|
+
remarkPlugins={[remarkGfm]}
|
|
2984
|
+
rehypePlugins={[rehypeRaw]}
|
|
2985
|
+
components={{ /*a: CustomLink,*/ code: CodeBlock }}
|
|
2986
|
+
>
|
|
2987
|
+
{streamingCleanedText}
|
|
2988
|
+
</ReactMarkdown>
|
|
2989
|
+
);
|
|
2990
|
+
return markdownClass ? (
|
|
2991
|
+
<div className={markdownClass}>{content}</div>
|
|
2992
|
+
) : (
|
|
2993
|
+
content
|
|
2994
|
+
);
|
|
2995
|
+
})()
|
|
2996
|
+
) : (
|
|
2881
2997
|
<div className="loading-text">
|
|
2882
|
-
Thinking...
|
|
2998
|
+
{thinkingBlocks.length > 0 || activeThinkingBlock ? 'Still thinking' : 'Thinking'}...
|
|
2883
2999
|
<div className="dot"></div>
|
|
2884
3000
|
<div className="dot"></div>
|
|
2885
3001
|
<div className="dot"></div>
|
|
2886
3002
|
</div>
|
|
2887
|
-
)
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
})()}
|
|
2892
|
-
|
|
2893
|
-
{/* Display the main content (processed with actions) */}
|
|
2894
|
-
{(() => {
|
|
2895
|
-
// Get the processed content that includes action buttons from history
|
|
2896
|
-
// During streaming, use the most recent history entry if it exists
|
|
2897
|
-
if (lastKey && history[lastKey] && history[lastKey].content) {
|
|
2898
|
-
const content = (
|
|
2899
|
-
<ReactMarkdown
|
|
2900
|
-
remarkPlugins={[remarkGfm]}
|
|
2901
|
-
rehypePlugins={[rehypeRaw]}
|
|
2902
|
-
components={{ /*a: CustomLink,*/ code: CodeBlock }}
|
|
2903
|
-
>
|
|
2904
|
-
{history[lastKey].content}
|
|
2905
|
-
</ReactMarkdown>
|
|
2906
|
-
);
|
|
2907
|
-
return markdownClass ? (
|
|
2908
|
-
<div className={markdownClass}>{content}</div>
|
|
2909
|
-
) : (
|
|
2910
|
-
content
|
|
2911
|
-
);
|
|
2912
|
-
}
|
|
2913
|
-
|
|
2914
|
-
// Fallback to cleaned text if no processed history exists yet
|
|
2915
|
-
const { cleanedText } = processThinkingTags(
|
|
2916
|
-
response || ""
|
|
2917
|
-
);
|
|
2918
|
-
if (cleanedText && cleanedText.length > 0) {
|
|
2919
|
-
const content = (
|
|
2920
|
-
<ReactMarkdown
|
|
2921
|
-
remarkPlugins={[remarkGfm]}
|
|
2922
|
-
rehypePlugins={[rehypeRaw]}
|
|
2923
|
-
components={{ /*a: CustomLink,*/ code: CodeBlock }}
|
|
2924
|
-
>
|
|
2925
|
-
{cleanedText}
|
|
2926
|
-
</ReactMarkdown>
|
|
2927
|
-
);
|
|
2928
|
-
return markdownClass ? (
|
|
2929
|
-
<div className={markdownClass}>{content}</div>
|
|
2930
|
-
) : (
|
|
2931
|
-
content
|
|
2932
|
-
);
|
|
2933
|
-
}
|
|
2934
|
-
return null;
|
|
2935
|
-
})()}
|
|
2936
|
-
</div>
|
|
3003
|
+
)}
|
|
3004
|
+
</div>
|
|
3005
|
+
);
|
|
3006
|
+
})()
|
|
2937
3007
|
) : (
|
|
2938
3008
|
<div>
|
|
2939
3009
|
{/* For completed responses, show stored thinking blocks if this is the last entry */}
|
|
2940
3010
|
{isLastEntry &&
|
|
2941
3011
|
thinkingBlocks.length > 0 &&
|
|
2942
|
-
renderThinkingBlocks()}
|
|
3012
|
+
renderThinkingBlocks(false)}
|
|
2943
3013
|
|
|
2944
3014
|
{/* Show the main content (cleaned of thinking tags) */}
|
|
2945
3015
|
{markdownClass ? (
|
|
@@ -3287,6 +3357,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
3287
3357
|
setSessionApprovedTools([]);
|
|
3288
3358
|
setThinkingBlocks([]);
|
|
3289
3359
|
setCurrentThinkingIndex(0);
|
|
3360
|
+
// Note: activeThinkingBlock is computed via useMemo from response
|
|
3361
|
+
setCollapsedBlocks(new Set());
|
|
3362
|
+
hasAutoCollapsedRef.current = false;
|
|
3363
|
+
prevBlockCountRef.current = 0;
|
|
3290
3364
|
setPendingButtonAttachments([]);
|
|
3291
3365
|
setPendingToolRequests([]);
|
|
3292
3366
|
setIsEmailModalOpen(false);
|