From a6d75e038cf1b1bbfd744cc4312206049d6c3b20 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 28 Aug 2026 23:50:03 +0800 Subject: [PATCH 01/15] feat(chat): stream answers, task depth presets, citation badges & collapsible timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository Q&A upgrade modeled on mature chatbot UX (ChatGPT-style): - Fix sticky header vanishing when Radix popups open: neutralize react-remove-scroll's overflow:hidden with overflow:clip so the viewport stays the sticky containing block; drop Header to z-40. - Real streaming for the final answer: new AIService.requestTextStream with SSE parsing for openai/openai-responses/openai-compatible/ deepseek/mimo/claude/gemini; incremental message rendering (60ms throttle), stick-to-bottom scrolling, partial answer kept on stop, silent downgrade to blocking on any stream failure. Reword the settings copy that misleadingly referenced "browser mode". - Task depth selector in the chat composer (default/quick/deep/ unlimited, min 2 evidence rounds); 'default' follows the advanced settings budget, presets bypass clamps with fixed safety ceilings and couple to answer length directives. Persisted via settings. - Collapsible "This turn's work" timeline: collapsed by default with a live current-step line and total duration; expanded view keeps the grouped details. Hover action bar with copy (citations stripped) and regenerate (re-runs the last turn at the current depth). - Citation badges: inline file:line references resolve against the turn's evidence and render as hover cards showing the excerpt, with a click-through to the pinned-SHA GitHub blob line anchor. - Answer quality: replace the structured-JSON answer pipeline with a unified free-form markdown prompt (conclusion first, ## sections, fenced code blocks, GFM tables, mermaid only when needed) plus the existing source-verification/repair/digest fallback chain. - Retrieval quality & harness: tolerant heading matching, up to 6 sections per target, 24k excerpt cap, 96k evidence block cap, wider code windows (±24/60, 4 windows), up to 3 parallel reads per round with in-flight dedupe, 20s tool-level timeouts, answer step timeout independent of the research budget, fake tool event names removed. Tests: SSE parser suite, streaming turn (increment + fallback), task-depth presets, citation utils; schema normalization extended. Fix a pre-existing sessionRepository test broken by jsdom 24's localStorage prototype change. --- .gitignore | 1 + package-lock.json | 320 ++----------- package.json | 1 + src/components/Header.tsx | 2 +- src/components/MarkdownRenderer.tsx | 13 +- src/components/RepositoryChatSheet.tsx | 451 ++++++++++++------ src/components/settings/AIConfigPanel.tsx | 5 +- src/components/ui/hover-card.tsx | 27 ++ .../components/CitationBadge.tsx | 57 +++ .../hooks/useRepositoryChat.ts | 135 ++++-- .../repositories/sessionRepository.test.ts | 4 +- .../utils/citationUtils.test.ts | 91 ++++ .../repository-chat/utils/citationUtils.ts | 100 ++++ src/index.css | 5 + src/services/aiService.streaming.test.ts | 72 +++ src/services/aiService.ts | 304 ++++++++++++ src/services/repositoryChatService.test.ts | 102 +++- src/services/repositoryChatService.ts | 407 ++++++++++------ src/store/schema.test.ts | 9 + src/store/schema.ts | 4 + src/types/repositoryChat.ts | 32 ++ 21 files changed, 1485 insertions(+), 657 deletions(-) create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/features/repository-chat/components/CitationBadge.tsx create mode 100644 src/features/repository-chat/utils/citationUtils.test.ts create mode 100644 src/features/repository-chat/utils/citationUtils.ts create mode 100644 src/services/aiService.streaming.test.ts diff --git a/.gitignore b/.gitignore index f3c76af8..ae9d4d01 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ LandingPage/ versions/.version-info.xml.*.tmp package-lock.json.*.tmp .omo/ +.zcode/ diff --git a/package-lock.json b/package-lock.json index 4940f563..041c2f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "github-stars-manager", - "version": "0.7.7", + "version": "0.7.8", "dependencies": { "@ai-sdk/openai-compatible": "^3.0.37", "@fontsource-variable/dm-sans": "^5.3.0", @@ -30,6 +30,7 @@ "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-radio-group": "^1.4.7", @@ -2084,6 +2085,37 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", @@ -6847,18 +6879,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -8795,280 +8815,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/package.json b/package.json index 4670f023..8a8cb96a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@radix-ui/react-checkbox": "^1.3.11", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-radio-group": "^1.4.7", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 41946436..0cdad429 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -56,7 +56,7 @@ export const Header: React.FC = () => { const t = (zh: string, en: string) => language === 'zh' ? zh : en; return ( -
+
{/* Logo and Title */} diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 80fe0629..423891d7 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -30,6 +30,11 @@ interface MarkdownRendererProps { fontSize?: 'small' | 'medium' | 'large'; /** Convert single newlines to
(GitHub READMEs do not; AI summaries rely on it). */ breaks?: boolean; + /** + * 可选的行内 code 自定义渲染(如仓库问答的引用 Badge):返回一个 ReactNode + * 时替换默认的 ;返回 null/undefined 时保持原样式。 + */ + renderInlineCode?: (text: string) => React.ReactNode | null; } // GitHub-style remark pipeline: GFM tables/tasklists/strikethrough, [!NOTE]-style @@ -756,7 +761,8 @@ const MarkdownRenderer: React.FC = memo(({ baseUrl, headingIds, fontSize = 'medium', - breaks = false + breaks = false, + renderInlineCode }) => { const headingCounterRef = useRef(headingIds?.size ?? 0); const headingTextCountMapRef = useRef(new Map()); @@ -868,6 +874,9 @@ const MarkdownRenderer: React.FC = memo(({ const language = match ? match[1] : ''; if (isInline) { + const codeText = typeof children === 'string' ? children : extractTextFromChildren(children); + const rendered = renderInlineCode?.(codeText); + if (rendered) return rendered; return {children}; } @@ -892,7 +901,7 @@ const MarkdownRenderer: React.FC = memo(({ } return ; }, - }), [baseUrl, headingIds, getHeadingId]); + }), [baseUrl, headingIds, getHeadingId, renderInlineCode]); if (!shouldRender) { return
Loading...
; diff --git a/src/components/RepositoryChatSheet.tsx b/src/components/RepositoryChatSheet.tsx index 94e6e96c..64c56245 100644 --- a/src/components/RepositoryChatSheet.tsx +++ b/src/components/RepositoryChatSheet.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { AlertCircle, CheckCircle2, ChevronRight, CircleDot, Copy, ExternalLink, History, Loader2, MessageSquareText, Plus, RotateCcw, Send, Square } from 'lucide-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { AlertCircle, ArrowDown, CheckCircle2, ChevronDown, ChevronRight, CircleDot, Copy, ExternalLink, Gauge, History, Loader2, MessageSquareText, Plus, RotateCcw, Send, Square } from 'lucide-react'; import type { Repository } from '../types'; -import type { RepositoryChatToolEvent } from '../types/repositoryChat'; +import type { RepositoryChatMessage, RepositoryChatTaskDepth, RepositoryChatToolEvent, ToolEvidence } from '../types/repositoryChat'; +import { TASK_DEPTH_PRESETS } from '../types/repositoryChat'; import { useAppStore } from '../store/useAppStore'; import { useDialog } from '../hooks/useDialog'; import { safeWriteText } from '../utils/clipboardUtils'; @@ -10,9 +11,12 @@ import { useRepositoryChatSessions } from '../features/repository-chat/hooks/use import { useRepositoryChat } from '../features/repository-chat/hooks/useRepositoryChat'; import { RepositoryChatHistoryPanel } from './RepositoryChatHistoryPanel'; import MarkdownRenderer from './MarkdownRenderer'; +import { CitationBadge } from '../features/repository-chat/components/CitationBadge'; +import { resolveCitation, stripCitationsForCopy } from '../features/repository-chat/utils/citationUtils'; import { Button } from './ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Textarea } from './ui/textarea'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from './ui/dropdown-menu'; interface RepositoryChatSheetProps { isOpen: boolean; @@ -41,11 +45,28 @@ const stageLabels = (stage: RepositoryChatToolEvent['stage'], language: 'zh' | ' return zh ? '工具调用' : 'Tool call'; }; +const TASK_DEPTH_OPTIONS: Array<{ value: RepositoryChatTaskDepth; zh: string; en: string; descZh: string; descEn: string }> = [ + { value: 'default', zh: '默认', en: 'Default', descZh: '跟随设置中的高级参数', descEn: 'Follow the advanced settings' }, + { value: 'quick', zh: '快速', en: 'Quick', descZh: '少量文档取证,尽快出答', descEn: 'Read fewer documents, answer fast' }, + { value: 'deep', zh: '深入', en: 'Deep', descZh: '多轮取证并读代码,适合细节与对比', descEn: 'More rounds, reads code too; best for details' }, + { value: 'unlimited', zh: '不限', en: 'Unlimited', descZh: '放开所有限制,注意耗时与额度', descEn: 'No limits; expect longer runs and higher usage' }, +]; + +const depthMeta = (depth: RepositoryChatTaskDepth, language: 'zh' | 'en'): { label: string; description: string } => { + const option = TASK_DEPTH_OPTIONS.find((item) => item.value === depth) ?? TASK_DEPTH_OPTIONS[0]; + const budget = depth !== 'default' ? TASK_DEPTH_PRESETS[depth].budget : null; + const label = budget + ? `${language === 'zh' ? option.zh : option.en} · ${budget.maxTurns}${language === 'zh' ? '轮' : ' rounds'}/${Math.round(budget.maxDurationMs / 1000)}s` + : (language === 'zh' ? option.zh : option.en); + return { label, description: language === 'zh' ? option.descZh : option.descEn }; +}; + const ExecutionTimeline: React.FC<{ events: RepositoryChatToolEvent[]; language: 'zh' | 'en'; isRunning: boolean }> = ({ events, language, isRunning }) => { const t = (zh: string, en: string) => language === 'zh' ? zh : en; const completed = events.filter((event) => event.status === 'success').length; const failed = events.filter((event) => event.status === 'error').length; - const latest = events[events.length - 1]; + const runningEvent = [...events].reverse().find((event) => event.status === 'running' || event.status === 'pending'); + const totalDuration = events.reduce((total, event) => total + (event.durationMs ?? 0), 0); const grouped = events.reduce>((groups, event) => { const previous = groups[groups.length - 1]; if (previous && previous.stage === event.stage && previous.round === event.round) { @@ -55,68 +76,95 @@ const ExecutionTimeline: React.FC<{ events: RepositoryChatToolEvent[]; language: } return groups; }, []); + const HeaderIcon = isRunning ? Loader2 : failed > 0 ? AlertCircle : CheckCircle2; return ( -
-
-
-

{t('本轮任务执行', 'This turn’s work')}

-

{t('这里会展示为回答问题实际查阅的文档、章节和补充资料。只有用户问题仍缺少必要来源时,助手才会继续阅读;展开单项可查看读取原因、已确认内容和仍需确认的信息。', 'This shows the documents, sections, and supplementary sources actually read to answer your question. The assistant continues only when a necessary part of your question still lacks evidence. Expand an item to see why it was read, what is confirmed, and what still needs confirmation.')}

+
+ + 0 ? 'text-destructive' : 'text-emerald-500'}`} aria-hidden="true" /> + {t('本轮任务执行', 'This turn’s work')} + + {completed}/{events.length} + {!isRunning && totalDuration > 0 ? ` · ${formatToolDuration(totalDuration)}` : ''} + {failed > 0 ? ` · ${t(`${failed} 项需注意`, `${failed} attention`)}` : ''} + + + {isRunning && runningEvent ? runningEvent.paramSummary : ''} + + +
+

{t('这里会展示为回答问题实际查阅的文档、章节和补充资料。', 'This shows the documents, sections, and supplementary sources actually read to answer your question.')}

+
+ {grouped.map((group, groupIndex) => { + const stageHasRunning = group.events.some((event) => event.status === 'running'); + const stageErrors = group.events.filter((event) => event.status === 'error').length; + const duration = group.events.reduce((total, event) => total + (event.durationMs ?? 0), 0); + const Icon = stageErrors > 0 ? AlertCircle : stageHasRunning ? CircleDot : CheckCircle2; + const label = group.round && ['planning', 'retrieval', 'verification', 'replanning'].includes(group.stage ?? '') + ? `${t('第', 'Round ')}${group.round}${t('轮 · ', ' · ')}${stageLabels(group.stage, language)}` + : stageLabels(group.stage, language); + return ( +
+
+ 0 ? 'text-destructive' : stageHasRunning ? 'animate-pulse text-primary' : 'text-emerald-500'}`} aria-hidden="true" /> + {label} + 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{stageErrors > 0 ? t('需注意', 'Needs attention') : stageHasRunning ? t('进行中', 'In progress') : t('已完成', 'Completed')}{duration > 0 ? ` · ${formatToolDuration(duration)}` : ''} +
+
    + {group.events.map((event) => { + const statusLabel = event.status === 'success' ? t('完成', 'Done') : event.status === 'running' ? t('进行中', 'Running') : event.status === 'error' ? t('失败', 'Failed') : t('准备中', 'Queued'); + const eventDuration = formatToolDuration(event.durationMs); + return ( +
  1. +
    + {event.paramSummary} + {statusLabel}{eventDuration ? ` · ${eventDuration}` : ''} +
    + {event.detail &&

    {event.detail}

    } +
  2. + ); + })} +
+
+ ); + })}
- 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{latest ? `${stageLabels(latest.stage, language)} · ${completed}/${events.length}` : `${events.length}`}{failed > 0 ? ` · ${t(`${failed} 项需注意`, `${failed} attention`)}` : ''} -
-
- {grouped.map((group, groupIndex) => { - const stageHasRunning = group.events.some((event) => event.status === 'running'); - const stageErrors = group.events.filter((event) => event.status === 'error').length; - const duration = group.events.reduce((total, event) => total + (event.durationMs ?? 0), 0); - const Icon = stageErrors > 0 ? AlertCircle : stageHasRunning ? CircleDot : CheckCircle2; - const label = group.round && ['planning', 'retrieval', 'verification', 'replanning'].includes(group.stage ?? '') - ? `${t('第', 'Round ')}${group.round}${t('轮 · ', ' · ')}${stageLabels(group.stage, language)}` - : stageLabels(group.stage, language); - return ( -
- - 0 ? 'text-destructive' : stageHasRunning ? 'animate-pulse text-primary' : 'text-emerald-500'}`} aria-hidden="true" /> - {label} - 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{stageErrors > 0 ? t('需注意', 'Needs attention') : stageHasRunning ? t('进行中', 'In progress') : t('已完成', 'Completed')}{duration > 0 ? ` · ${formatToolDuration(duration)}` : ''} - -
    - {group.events.map((event) => { - const statusLabel = event.status === 'success' ? t('完成', 'Done') : event.status === 'running' ? t('进行中', 'Running') : event.status === 'error' ? t('失败', 'Failed') : t('准备中', 'Queued'); - const eventDuration = formatToolDuration(event.durationMs); - return ( -
  1. -
    - {event.paramSummary} - {statusLabel}{eventDuration ? ` · ${eventDuration}` : ''} -
    - {event.detail &&

    {event.detail}

    } -
  2. - ); - })} -
-
- ); - })}
-
+ ); }; +/** 助手消息正文:行内引用渲染为 CitationBadge;按内容 + 证据 + 语言做 memo,避免流式期间全量重渲。 */ +const AssistantMessageBody = React.memo<{ content: string; evidenceIds: string[]; evidenceById: Record; language: 'zh' | 'en' }>(({ content, evidenceIds, evidenceById, language }) => { + const renderInlineCode = useCallback((text: string) => { + const evidences = evidenceIds + .map((id) => evidenceById[id]) + .filter((evidence): evidence is ToolEvidence => Boolean(evidence)); + if (evidences.length === 0) return null; + const evidence = resolveCitation(text, evidences); + if (!evidence?.path) return null; + const lineStart = evidence.lineStart ?? 1; + return ; + }, [evidenceIds, evidenceById, language]); + return ; +}); + const RepositoryChatSheet: React.FC = ({ isOpen, onClose, onCloseAutoFocus, repository, }) => { - const { language, setCurrentView } = useAppStore(useShallow((state) => ({ + const { language, setCurrentView, repositoryChatSettings, setRepositoryChatSettings } = useAppStore(useShallow((state) => ({ language: state.language, setCurrentView: state.setCurrentView, + repositoryChatSettings: state.repositoryChatSettings, + setRepositoryChatSettings: state.setRepositoryChatSettings, }))); const [showHistory, setShowHistory] = useState(false); const [draft, setDraft] = useState(''); + const [isPinnedToBottom, setIsPinnedToBottom] = useState(true); const { toast } = useDialog(); const messageRegionRef = useRef(null); const t = (zh: string, en: string) => language === 'zh' ? zh : en; @@ -142,6 +190,7 @@ const RepositoryChatSheet: React.FC = ({ send, stop, retry, + regenerate, } = useRepositoryChat({ repository, session: activeSession, @@ -166,8 +215,28 @@ const RepositoryChatSheet: React.FC = ({ sessionStorage.removeItem('gsm:repository-chat-return'); } } - messageRegionRef.current?.scrollTo({ top: messageRegionRef.current.scrollHeight }); - }, [isOpen, messages.length, repository]); + }, [isOpen, repository]); + + // 吸底滚动:流式输出期间自动跟随;用户向上滚动后暂停,可点按钮回到底部。 + useEffect(() => { + const element = messageRegionRef.current; + if (!element || !isPinnedToBottom) return; + element.scrollTo({ top: element.scrollHeight }); + }, [messages, toolEvents, isLoading, isPinnedToBottom]); + + const handleRegionScroll = useCallback(() => { + const element = messageRegionRef.current; + if (!element) return; + const nearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 80; + setIsPinnedToBottom(nearBottom); + }, []); + + const scrollToBottom = useCallback(() => { + const element = messageRegionRef.current; + if (!element) return; + setIsPinnedToBottom(true); + element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }); + }, []); const handleCreateSession = () => { if (isLoading || isSending) return; @@ -189,17 +258,27 @@ const RepositoryChatSheet: React.FC = ({ const question = draft.trim(); if (!question || !canChat || !activeSession || isSending) return; setDraft(''); + setIsPinnedToBottom(true); void send(question); }; - const handleCopyAnswer = async (content: string) => { - const result = await safeWriteText(content); + const handleCopyAnswer = async (message: RepositoryChatMessage) => { + const result = await safeWriteText(stripCitationsForCopy(message.content)); toast( result.success ? t('回答已复制', 'Answer copied') : (result.error || t('复制失败', 'Copy failed')), result.success ? 'success' : 'error' ); }; + const lastMessage = messages[messages.length - 1]; + const isCopyableMessage = (message: RepositoryChatMessage): boolean => ( + message.role === 'assistant' + && message.content.length > 0 + && message.status !== 'streaming' + && !/未能生成可与精确来源核验的总结性结果|无法完成可验证的判断|did not produce a source-verifiable summary|a verifiable determination cannot be made/i.test(message.content) + ); + const depth = depthMeta(repositoryChatSettings.taskDepth, language); + return ( !open && onClose()}> = ({ />
) : ( -
- {error ? ( -
-

{error}

-
- ) : isLoading ? ( -
-
- ) : !canChat ? ( -
-