@hef2024/llmasaservice-ui 0.22.11 → 0.23.1

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.
@@ -19,7 +19,7 @@ import rehypeRaw from 'rehype-raw';
19
19
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
20
20
  import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
21
21
  import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
22
- import { Button, ScrollArea, Tooltip } from './components/ui';
22
+ import { Button, ScrollArea, Tooltip, ThinkingBlock as ThinkingBlockComponent } from './components/ui';
23
23
  import ToolInfoModal from './components/ui/ToolInfoModal';
24
24
  import './AIChatPanel.css';
25
25
 
@@ -136,7 +136,7 @@ interface HistoryEntry {
136
136
  }
137
137
 
138
138
  interface ThinkingBlock {
139
- type: 'reasoning' | 'searching';
139
+ type: 'thinking' | 'reasoning' | 'searching';
140
140
  content: string;
141
141
  index: number;
142
142
  }
@@ -792,6 +792,11 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
792
792
  const [followOnQuestionsState, setFollowOnQuestionsState] = useState(followOnQuestions);
793
793
  const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
794
794
  const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
795
+ // NOTE: activeThinkingBlock is computed via useMemo, not useState - see below after processThinkingTags
796
+ // Track collapsed state per block (key: "block-{index}" or "active" for streaming block)
797
+ const [collapsedBlocks, setCollapsedBlocks] = useState<Set<string>>(new Set());
798
+ const hasAutoCollapsedRef = useRef(false); // Track if we've auto-collapsed for current response
799
+ const prevBlockCountRef = useRef(0); // Track previous block count to detect new blocks
795
800
  const [newConversationConfirm, setNewConversationConfirm] = useState(false);
796
801
  const [justReset, setJustReset] = useState(false);
797
802
  const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
@@ -1293,34 +1298,132 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1293
1298
  }, []);
1294
1299
 
1295
1300
  // Process thinking tags from response
1296
- const processThinkingTags = useCallback((text: string): { cleanedText: string; blocks: ThinkingBlock[] } => {
1297
- const blocks: ThinkingBlock[] = [];
1298
- let index = 0;
1301
+ const processThinkingTags = useCallback((text: string): {
1302
+ cleanedText: string;
1303
+ completedBlocks: ThinkingBlock[];
1304
+ activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null;
1305
+ lastThinkingContent: string;
1306
+ } => {
1307
+ if (!text) {
1308
+ return {
1309
+ cleanedText: '',
1310
+ completedBlocks: [],
1311
+ activeBlock: null,
1312
+ lastThinkingContent: 'Thinking',
1313
+ };
1314
+ }
1315
+
1316
+ // Remove zero-width space characters from keepalive before processing
1317
+ const processedText = text.replace(/\u200B/g, '');
1318
+
1319
+ const allMatches: ThinkingBlock[] = [];
1299
1320
 
1300
- // Extract reasoning blocks
1301
- const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
1321
+ // Extract complete thinking blocks
1322
+ const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/gi;
1302
1323
  let match;
1303
- while ((match = reasoningRegex.exec(text)) !== null) {
1304
- blocks.push({ type: 'reasoning', content: match[1] ?? '', index: index++ });
1324
+ while ((match = thinkingRegex.exec(processedText)) !== null) {
1325
+ const content = match[1]?.trim();
1326
+ if (content) {
1327
+ allMatches.push({ content, index: match.index, type: 'thinking' });
1328
+ }
1329
+ }
1330
+
1331
+ // Extract complete reasoning blocks
1332
+ const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
1333
+ while ((match = reasoningRegex.exec(processedText)) !== null) {
1334
+ const content = match[1]?.trim();
1335
+ if (content) {
1336
+ allMatches.push({ content, index: match.index, type: 'reasoning' });
1337
+ }
1305
1338
  }
1306
1339
 
1307
- // Extract searching blocks
1340
+ // Extract complete searching blocks
1308
1341
  const searchingRegex = /<searching>([\s\S]*?)<\/searching>/gi;
1309
- while ((match = searchingRegex.exec(text)) !== null) {
1310
- blocks.push({ type: 'searching', content: match[1] ?? '', index: index++ });
1342
+ while ((match = searchingRegex.exec(processedText)) !== null) {
1343
+ const content = match[1]?.trim();
1344
+ if (content) {
1345
+ allMatches.push({ content, index: match.index, type: 'searching' });
1346
+ }
1311
1347
  }
1312
1348
 
1313
1349
  // Sort by position in original text
1314
- blocks.sort((a, b) => a.index - b.index);
1350
+ const completedBlocks = allMatches.sort((a, b) => a.index - b.index);
1351
+
1352
+ // Check for incomplete (streaming) tags at the end of the text
1353
+ let activeBlock: { type: 'thinking' | 'reasoning' | 'searching'; content: string } | null = null;
1354
+ const tagTypes = ['thinking', 'reasoning', 'searching'] as const;
1355
+ let latestIncompletePos = -1;
1315
1356
 
1316
- // Clean the text
1317
- let cleanedText = text
1357
+ for (const tagType of tagTypes) {
1358
+ const openTag = `<${tagType}>`;
1359
+ const closeTag = `</${tagType}>`;
1360
+ const textLower = processedText.toLowerCase();
1361
+
1362
+ // Find the last occurrence of this opening tag
1363
+ const lastOpenIndex = textLower.lastIndexOf(openTag);
1364
+ if (lastOpenIndex === -1) continue;
1365
+
1366
+ // Check if there's a closing tag after this opening tag
1367
+ const afterOpen = processedText.slice(lastOpenIndex + openTag.length);
1368
+ const closeIndex = afterOpen.toLowerCase().indexOf(closeTag);
1369
+
1370
+ // If no closing tag found after the opening tag, this is incomplete
1371
+ if (closeIndex === -1 && lastOpenIndex > latestIncompletePos) {
1372
+ latestIncompletePos = lastOpenIndex;
1373
+ activeBlock = { type: tagType, content: afterOpen };
1374
+ }
1375
+ }
1376
+
1377
+ // Also check for partial opening tags (e.g., "<reas" before full "<reasoning>")
1378
+ if (!activeBlock) {
1379
+ const partialTagPatterns = [
1380
+ { pattern: /<think(?:i(?:n(?:g)?)?)?$/i, type: 'thinking' as const },
1381
+ { pattern: /<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'reasoning' as const },
1382
+ { pattern: /<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, type: 'searching' as const },
1383
+ ];
1384
+
1385
+ for (const { pattern, type } of partialTagPatterns) {
1386
+ if (pattern.test(processedText)) {
1387
+ activeBlock = { type, content: '' };
1388
+ break;
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Clean the text by removing all thinking-related tags (complete and incomplete)
1394
+ let cleanedText = processedText
1395
+ .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
1318
1396
  .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
1319
1397
  .replace(/<searching>[\s\S]*?<\/searching>/gi, '')
1398
+ // Also remove partial opening tags
1399
+ .replace(/<think(?:i(?:n(?:g)?)?)?$/i, '')
1400
+ .replace(/<reas(?:o(?:n(?:i(?:n(?:g)?)?)?)?)?$/i, '')
1401
+ .replace(/<sear(?:c(?:h(?:i(?:n(?:g)?)?)?)?)?$/i, '')
1402
+ .replace(/<thinking>[\s\S]*$/i, '')
1403
+ .replace(/<reasoning>[\s\S]*$/i, '')
1404
+ .replace(/<searching>[\s\S]*$/i, '')
1320
1405
  .trim();
1321
-
1322
- return { cleanedText, blocks };
1323
- }, []);
1406
+
1407
+ // Get last thinking content for display
1408
+ let lastThinkingContent = 'Thinking';
1409
+ if (completedBlocks.length > 0) {
1410
+ const lastBlock = completedBlocks[completedBlocks.length - 1];
1411
+ if (lastBlock?.content) {
1412
+ lastThinkingContent = cleanContentForDisplay(lastBlock.content);
1413
+ }
1414
+ } else if (activeBlock?.content) {
1415
+ lastThinkingContent = cleanContentForDisplay(activeBlock.content);
1416
+ }
1417
+
1418
+ return { cleanedText, completedBlocks, activeBlock, lastThinkingContent };
1419
+ }, [cleanContentForDisplay]);
1420
+
1421
+ // Compute active thinking block directly from response during render (avoids state batching issues)
1422
+ const activeThinkingBlock = useMemo(() => {
1423
+ if (!response || justReset) return null;
1424
+ const { activeBlock } = processThinkingTags(response);
1425
+ return activeBlock;
1426
+ }, [response, justReset, processThinkingTags]);
1324
1427
 
1325
1428
  // Built-in action for agent suggestion cards
1326
1429
  // Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
@@ -1516,8 +1619,12 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1516
1619
  // promptText is now required - comes from the isolated ChatInput component
1517
1620
  const continueChat = useCallback((promptText: string) => {
1518
1621
  // Clear thinking blocks for new response
1622
+ // Note: activeThinkingBlock is computed via useMemo from response
1519
1623
  setThinkingBlocks([]);
1520
1624
  setCurrentThinkingIndex(0);
1625
+ setCollapsedBlocks(new Set());
1626
+ hasAutoCollapsedRef.current = false;
1627
+ prevBlockCountRef.current = 0;
1521
1628
 
1522
1629
  // Clear any previous errors
1523
1630
  setError(null);
@@ -1782,6 +1889,10 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1782
1889
  setFollowOnQuestionsState(followOnQuestions);
1783
1890
  setThinkingBlocks([]);
1784
1891
  setCurrentThinkingIndex(0);
1892
+ setCollapsedBlocks(new Set());
1893
+ // Note: activeThinkingBlock is computed via useMemo from response
1894
+ hasAutoCollapsedRef.current = false;
1895
+ prevBlockCountRef.current = 0;
1785
1896
  setJustReset(true);
1786
1897
  setLastController(new AbortController());
1787
1898
  setUserHasScrolled(false);
@@ -1805,10 +1916,40 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1805
1916
  useEffect(() => {
1806
1917
  if (!response || !lastKey || justReset) return;
1807
1918
 
1808
- const { cleanedText, blocks } = processThinkingTags(response);
1919
+ const { cleanedText, completedBlocks } = processThinkingTags(response);
1809
1920
 
1810
1921
  // Update display state
1811
- setThinkingBlocks(blocks);
1922
+ // Note: activeThinkingBlock is computed via useMemo from response directly
1923
+ setThinkingBlocks(completedBlocks);
1924
+
1925
+ // When a new block appears, collapse all previous blocks
1926
+ if (completedBlocks.length > prevBlockCountRef.current) {
1927
+ setCollapsedBlocks(prev => {
1928
+ const next = new Set(prev);
1929
+ // Collapse all blocks except the newest one
1930
+ for (let i = 0; i < completedBlocks.length - 1; i++) {
1931
+ next.add(`block-${i}`);
1932
+ }
1933
+ return next;
1934
+ });
1935
+ prevBlockCountRef.current = completedBlocks.length;
1936
+ }
1937
+
1938
+ // Auto-collapse all thinking blocks when main content starts appearing
1939
+ const hasMainContent = cleanedText.trim().length > 0;
1940
+ const hasThinkingContent = completedBlocks.length > 0 || processThinkingTags(response).activeBlock !== null;
1941
+ if (hasMainContent && hasThinkingContent && !hasAutoCollapsedRef.current) {
1942
+ hasAutoCollapsedRef.current = true;
1943
+ setTimeout(() => {
1944
+ // Collapse all blocks including active
1945
+ setCollapsedBlocks(prev => {
1946
+ const next = new Set(prev);
1947
+ completedBlocks.forEach((_, index) => next.add(`block-${index}`));
1948
+ next.add('active');
1949
+ return next;
1950
+ });
1951
+ }, 500);
1952
+ }
1812
1953
 
1813
1954
  // Update history state with RAW content (actions applied at render time)
1814
1955
  setHistory((prev) => {
@@ -2307,46 +2448,56 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2307
2448
  },
2308
2449
  }), [CodeBlock, AgentSuggestionCard]);
2309
2450
 
2310
- // Render thinking blocks
2311
- const renderThinkingBlocks = useCallback(() => {
2312
- if (thinkingBlocks.length === 0) return null;
2451
+ // Render thinking blocks with new collapsible design
2452
+ const renderThinkingBlocks = useCallback((isStreaming: boolean = false): React.ReactElement | null => {
2453
+ const hasActiveBlock = activeThinkingBlock !== null;
2454
+ const hasCompletedBlocks = thinkingBlocks.length > 0;
2313
2455
 
2314
- const currentBlock = thinkingBlocks[currentThinkingIndex];
2315
- if (!currentBlock) return null;
2456
+ if (!hasActiveBlock && !hasCompletedBlocks) return null;
2316
2457
 
2317
- const isReasoning = currentBlock.type === 'reasoning';
2318
- const icon = isReasoning ? <BrainIcon /> : <SearchIcon />;
2319
- const title = isReasoning ? 'Reasoning' : 'Searching';
2458
+ const handleToggleCollapse = (blockKey: string) => {
2459
+ setCollapsedBlocks(prev => {
2460
+ const next = new Set(prev);
2461
+ if (next.has(blockKey)) {
2462
+ next.delete(blockKey);
2463
+ } else {
2464
+ next.add(blockKey);
2465
+ }
2466
+ return next;
2467
+ });
2468
+ };
2320
2469
 
2321
2470
  return (
2322
- <div className="ai-chat-thinking">
2323
- <div className="ai-chat-thinking__header">
2324
- <span className="ai-chat-thinking__icon">{icon}</span>
2325
- <span className="ai-chat-thinking__title">{title}</span>
2326
- {thinkingBlocks.length > 1 && (
2327
- <span className="ai-chat-thinking__nav">
2328
- <button
2329
- onClick={() => setCurrentThinkingIndex(Math.max(0, currentThinkingIndex - 1))}
2330
- disabled={currentThinkingIndex === 0}
2331
- >
2332
-
2333
- </button>
2334
- <span>{currentThinkingIndex + 1} / {thinkingBlocks.length}</span>
2335
- <button
2336
- onClick={() => setCurrentThinkingIndex(Math.min(thinkingBlocks.length - 1, currentThinkingIndex + 1))}
2337
- disabled={currentThinkingIndex === thinkingBlocks.length - 1}
2338
- >
2339
-
2340
- </button>
2341
- </span>
2342
- )}
2343
- </div>
2344
- <div className="ai-chat-thinking__content">
2345
- {cleanContentForDisplay(currentBlock.content)}
2346
- </div>
2347
- </div>
2471
+ <>
2472
+ {/* Render completed blocks first */}
2473
+ {thinkingBlocks.map((block, index) => {
2474
+ const blockKey = `block-${index}`;
2475
+ return (
2476
+ <ThinkingBlockComponent
2477
+ key={blockKey}
2478
+ type={block.type}
2479
+ content={block.content}
2480
+ isStreaming={false}
2481
+ isCollapsed={collapsedBlocks.has(blockKey)}
2482
+ onToggleCollapse={() => handleToggleCollapse(blockKey)}
2483
+ />
2484
+ );
2485
+ })}
2486
+
2487
+ {/* Render active (streaming) block */}
2488
+ {activeThinkingBlock && (
2489
+ <ThinkingBlockComponent
2490
+ key="active-streaming"
2491
+ type={activeThinkingBlock.type}
2492
+ content={activeThinkingBlock.content}
2493
+ isStreaming={true}
2494
+ isCollapsed={collapsedBlocks.has('active')}
2495
+ onToggleCollapse={() => handleToggleCollapse('active')}
2496
+ />
2497
+ )}
2498
+ </>
2348
2499
  );
2349
- }, [thinkingBlocks, currentThinkingIndex, cleanContentForDisplay]);
2500
+ }, [thinkingBlocks, activeThinkingBlock, collapsedBlocks]);
2350
2501
 
2351
2502
  // ============================================================================
2352
2503
  // Render
@@ -2442,43 +2593,55 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2442
2593
  <div className="ai-chat-message__content">
2443
2594
  {/* Streaming state */}
2444
2595
  {isLastEntry && (isLoading || !idle) && !justReset ? (
2445
- <div className="ai-chat-streaming">
2446
- {thinkingBlocks.length > 0 && renderThinkingBlocks()}
2596
+ (() => {
2597
+ // During streaming, compute content directly from response (not from history which may be stale)
2598
+ const { cleanedText: streamingCleanedText } = processThinkingTags(response || '');
2599
+ const streamingContent = processActions(streamingCleanedText);
2600
+ const hasStreamingContent = streamingContent.trim().length > 0;
2447
2601
 
2448
- {processedContent ? (
2449
- markdownClass ? (
2450
- <div className={markdownClass}>
2451
- <ReactMarkdown
2452
- remarkPlugins={[remarkGfm]}
2453
- rehypePlugins={[rehypeRaw]}
2454
- components={markdownComponents}
2455
- >
2456
- {processedContent}
2457
- </ReactMarkdown>
2458
- </div>
2459
- ) : (
2460
- <ReactMarkdown
2461
- remarkPlugins={[remarkGfm]}
2462
- rehypePlugins={[rehypeRaw]}
2463
- components={markdownComponents}
2464
- >
2465
- {processedContent}
2466
- </ReactMarkdown>
2467
- )
2468
- ) : (
2469
- <div className="ai-chat-loading">
2470
- <span>Thinking</span>
2471
- <span className="ai-chat-loading__dots">
2472
- <span className="ai-chat-loading__dot" />
2473
- <span className="ai-chat-loading__dot" />
2474
- <span className="ai-chat-loading__dot" />
2475
- </span>
2602
+ return (
2603
+ <div className="ai-chat-streaming">
2604
+ {/* Show thinking blocks (both completed and active/streaming) */}
2605
+ {(thinkingBlocks.length > 0 || activeThinkingBlock) && renderThinkingBlocks(true)}
2606
+
2607
+ {/* Show streaming content or loading indicator */}
2608
+ {hasStreamingContent ? (
2609
+ markdownClass ? (
2610
+ <div className={markdownClass}>
2611
+ <ReactMarkdown
2612
+ remarkPlugins={[remarkGfm]}
2613
+ rehypePlugins={[rehypeRaw]}
2614
+ components={markdownComponents}
2615
+ >
2616
+ {streamingContent}
2617
+ </ReactMarkdown>
2618
+ </div>
2619
+ ) : (
2620
+ <ReactMarkdown
2621
+ remarkPlugins={[remarkGfm]}
2622
+ rehypePlugins={[rehypeRaw]}
2623
+ components={markdownComponents}
2624
+ >
2625
+ {streamingContent}
2626
+ </ReactMarkdown>
2627
+ )
2628
+ ) : (
2629
+ <div className="ai-chat-loading">
2630
+ <span>{thinkingBlocks.length > 0 || activeThinkingBlock ? 'Still thinking' : 'Thinking'}</span>
2631
+ <span className="ai-chat-loading__dots">
2632
+ <span className="ai-chat-loading__dot" />
2633
+ <span className="ai-chat-loading__dot" />
2634
+ <span className="ai-chat-loading__dot" />
2635
+ </span>
2636
+ </div>
2637
+ )}
2476
2638
  </div>
2477
- )}
2478
- </div>
2639
+ );
2640
+ })()
2479
2641
  ) : (
2480
2642
  <>
2481
- {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
2643
+ {/* Show completed thinking blocks after streaming */}
2644
+ {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks(false)}
2482
2645
  {markdownClass ? (
2483
2646
  <div className={markdownClass}>
2484
2647
  <ReactMarkdown