diff --git a/apps/electron/package.json b/apps/electron/package.json index 12a2e0b4e..3c303afdb 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@proma/electron", - "version": "0.17.39", + "version": "0.17.40", "description": "Proma next gen ai software with general agents - Electron App", "main": "dist/main.cjs", "author": { diff --git a/apps/electron/src/renderer/components/ai-elements/file-path-chip.tsx b/apps/electron/src/renderer/components/ai-elements/file-path-chip.tsx index db2ea6a2b..e88041243 100644 --- a/apps/electron/src/renderer/components/ai-elements/file-path-chip.tsx +++ b/apps/electron/src/renderer/components/ai-elements/file-path-chip.tsx @@ -264,6 +264,15 @@ export function isAbsoluteFilePath(text: string): boolean { return false } +/** + * 判断 Markdown inline code 当前是否可以转换为文件 chip。 + * 流式期间必须保留原始代码文本,等语法和内容稳定后再缩成 chip,避免视觉回退。 + */ +export function shouldRenderFilePathChip(text: string, isStreaming: boolean, hasBasePaths: boolean): boolean { + if (isStreaming) return false + return isAbsoluteFilePath(text) || (hasBasePaths && isRelativeFilePath(text)) +} + /** * 检测文本是否为相对文件路径(需要 basePath 才有意义) * diff --git a/apps/electron/src/renderer/components/ai-elements/message.tsx b/apps/electron/src/renderer/components/ai-elements/message.tsx index 9b25b9b6f..b7987907a 100644 --- a/apps/electron/src/renderer/components/ai-elements/message.tsx +++ b/apps/electron/src/renderer/components/ai-elements/message.tsx @@ -40,7 +40,7 @@ import { import { LoadingIndicator } from '@/components/ui/loading-indicator' import { CodeBlock, MermaidBlock } from '@proma/ui' import { detectLanguage } from '@proma/core' -import { FilePathChip, isAbsoluteFilePath, isImageFilePath, isRelativeFilePath } from './file-path-chip' +import { FilePathChip, isAbsoluteFilePath, isImageFilePath, isRelativeFilePath, shouldRenderFilePathChip } from './file-path-chip' import { buildAgentHistoryQuoteLabel, parseAgentHistoryQuoteMention } from '@/lib/quoted-selection' import { useAgentBrowserLink } from '@/components/browser/AgentBrowserLinkProvider' import type { HTMLAttributes, ComponentProps, ReactNode } from 'react' @@ -543,6 +543,15 @@ const MarkdownLink = React.memo(function MarkdownLink({ ...linkProps }: React.AnchorHTMLAttributes): React.ReactElement { const agentBrowserLink = useAgentBrowserLink() + const isStreamingMarkdown = React.useContext(MarkdownStreamingContext) + // 流式期间保持原始链接文本,避免闭合链接后立刻缩成更短的文件 chip。 + if (isStreamingMarkdown && href) { + const mentionMatch = MENTION_URL_RE.exec(href) + const filePath = safeDecode(href) + if (mentionMatch || isAbsoluteFilePath(filePath)) { + return {linkChildren} + } + } // mention:// 协议 → 渲染为 MentionChip if (href) { const mentionMatch = MENTION_URL_RE.exec(href) @@ -645,6 +654,7 @@ const MarkdownInlineCode = React.memo(function MarkdownInlineCode({ }: React.HTMLAttributes & { basePath?: string; basePaths?: string[] }): React.ReactElement { // 兜底:从 context 读附加 basePaths(避免穿透 SDKMessageRenderer / ContentBlock 等中间层) const ctxBasePaths = React.useContext(BasePathsContext) + const isStreamingMarkdown = React.useContext(MarkdownStreamingContext) // 本轮「文件名 → 绝对路径」映射:命中时把内联裸文件名补全为绝对路径 const turnFileMap = React.useContext(TurnFileMapContext) if (codeClassName) { @@ -654,34 +664,39 @@ const MarkdownInlineCode = React.memo(function MarkdownInlineCode({ const text = typeof codeChildren === 'string' ? codeChildren : '' if (text) { - // 合并 basePath(主 cwd)+ basePaths(props 或 context 提供的附加目录)作为候选 - const merged: string[] = [] - if (basePath) merged.push(basePath) - const allExtra = basePaths || ctxBasePaths - if (allExtra) { - for (const p of allExtra) { - if (p && !merged.includes(p)) merged.push(p) + // 流式期间不把 Markdown inline code 转换成文件 chip,避免同一段文本在 + // 反引号闭合后由完整路径缩成文件名,造成视觉上的内容回退。 + const hasBasePaths = Boolean(basePath || (basePaths || ctxBasePaths)?.some(Boolean)) + if (shouldRenderFilePathChip(text, isStreamingMarkdown, hasBasePaths)) { + // 合并 basePath(主 cwd)+ basePaths(props 或 context 提供的附加目录)作为候选 + const merged: string[] = [] + if (basePath) merged.push(basePath) + const allExtra = basePaths || ctxBasePaths + if (allExtra) { + for (const p of allExtra) { + if (p && !merged.includes(p)) merged.push(p) + } } - } - if (isAbsoluteFilePath(text)) { - return 0 ? merged : undefined} /> - } - if (merged.length > 0 && isRelativeFilePath(text)) { - // 命中本轮实际触及文件的映射时,用绝对路径替换裸文件名(保留行号后缀), - // 使内联引用与 footer chip 走同一条可靠解析;未命中则维持原样降级。 - const trimmed = text.trim() - if (turnFileMap && turnFileMap.size > 0) { - const lineColMatch = trimmed.match(/^(.+?)(:\d+(?::\d+)?)$/) - const hasLineCol = !!lineColMatch && !lineColMatch[1]!.endsWith(':') - const pathPart = hasLineCol ? lineColMatch![1]! : trimmed - const suffix = hasLineCol ? lineColMatch![2]! : '' - const baseName = pathPart.split(/[\\/]/).pop() || pathPart - const abs = turnFileMap.get(baseName) - if (abs) { - return + if (isAbsoluteFilePath(text)) { + return 0 ? merged : undefined} /> + } + if (merged.length > 0 && isRelativeFilePath(text)) { + // 命中本轮实际触及文件的映射时,用绝对路径替换裸文件名(保留行号后缀), + // 使内联引用与 footer chip 走同一条可靠解析;未命中则维持原样降级。 + const trimmed = text.trim() + if (turnFileMap && turnFileMap.size > 0) { + const lineColMatch = trimmed.match(/^(.+?)(:\d+(?::\d+)?)$/) + const hasLineCol = !!lineColMatch && !lineColMatch[1]!.endsWith(':') + const pathPart = hasLineCol ? lineColMatch![1]! : trimmed + const suffix = hasLineCol ? lineColMatch![2]! : '' + const baseName = pathPart.split(/[\\/]/).pop() || pathPart + const abs = turnFileMap.get(baseName) + if (abs) { + return + } } + return } - return } } diff --git a/apps/electron/src/renderer/components/chat/ChatMessages.tsx b/apps/electron/src/renderer/components/chat/ChatMessages.tsx index 50396554b..578e11efe 100644 --- a/apps/electron/src/renderer/components/chat/ChatMessages.tsx +++ b/apps/electron/src/renderer/components/chat/ChatMessages.tsx @@ -26,6 +26,7 @@ import { MessageContent, MessageLoading, MessageResponse, + MarkdownStreamingContext, StreamingIndicator, } from '@/components/ai-elements/message' import { @@ -343,6 +344,7 @@ export function ChatMessages({ streaming={streaming} streamingContent={smoothContent} streamingReasoning={smoothReasoning} + markdownStreaming={streaming || smoothContent !== streamingContent} startedAt={startedAt} contextDividers={contextDividers} onDeleteDivider={onDeleteDivider} @@ -429,7 +431,9 @@ export function ChatMessages({ {/* 流式内容(经过平滑处理) */} {smoothContent ? ( <> - {smoothContent} + + {smoothContent} + {streaming && } ) : ( diff --git a/apps/electron/src/renderer/components/chat/ParallelChatMessages.tsx b/apps/electron/src/renderer/components/chat/ParallelChatMessages.tsx index 78b0493c0..c535ad641 100644 --- a/apps/electron/src/renderer/components/chat/ParallelChatMessages.tsx +++ b/apps/electron/src/renderer/components/chat/ParallelChatMessages.tsx @@ -21,6 +21,7 @@ import { MessageContent, MessageLoading, MessageResponse, + MarkdownStreamingContext, StreamingIndicator, } from '@/components/ai-elements/message' import { @@ -56,6 +57,8 @@ interface ParallelChatMessagesProps { onSubmitInlineEdit?: (message: ChatMessage, payload: InlineEditSubmitPayload) => Promise onCancelInlineEdit?: () => void inlineEditingMessageId?: string | null + /** Markdown 解析是否仍处于流式排空阶段(与 UI streaming 状态分离) */ + markdownStreaming?: boolean /** 是否正在加载更多历史消息 */ loadingMore?: boolean } @@ -139,6 +142,8 @@ interface MessageColumnProps { streaming?: boolean streamingContent?: string streamingReasoning?: string + /** Markdown 解析是否仍处于流式排空阶段 */ + markdownStreaming?: boolean startedAt?: number } @@ -157,6 +162,7 @@ function MessageColumn({ streamingContent = '', streamingReasoning = '', startedAt, + markdownStreaming = streaming, }: MessageColumnProps): React.ReactElement { const streamingModel = useAtomValue(streamingModelAtom) const channels = useAtomValue(channelsAtom) @@ -224,7 +230,9 @@ function MessageColumn({ )} {streamingContent ? ( <> - {streamingContent} + + {streamingContent} + {streaming && } ) : ( @@ -254,6 +262,7 @@ export function ParallelChatMessages({ onCancelInlineEdit, inlineEditingMessageId, loadingMore = false, + markdownStreaming = streaming, }: ParallelChatMessagesProps): React.ReactElement { // 分段消息 const segments = useMemo( @@ -326,6 +335,7 @@ export function ParallelChatMessages({ streamingContent={streamingContent} streamingReasoning={streamingReasoning} startedAt={startedAt} + markdownStreaming={markdownStreaming} /> @@ -397,6 +407,7 @@ export function ParallelChatMessages({ streamingContent={index === segments.length - 1 ? streamingContent : ''} streamingReasoning={index === segments.length - 1 ? streamingReasoning : ''} startedAt={index === segments.length - 1 ? startedAt : undefined} + markdownStreaming={index === segments.length - 1 ? markdownStreaming : false} />