diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 40cda0213f..8cc82063b3 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -79,7 +79,7 @@ export function useMessageOperations(topic: Topic) { ) /** - * 编辑消息。(目前仅更新 Redux state)。 / Edits a message. (Currently only updates Redux state). + * 编辑消息。 / Edits a message. * 使用 newMessagesActions.updateMessage. */ const editMessage = useCallback( @@ -92,17 +92,12 @@ export function useMessageOperations(topic: Topic) { const messageUpdates: Partial & Pick = { id: messageId, + updatedAt: new Date().toISOString(), ...updates } // Call the thunk with topic.id and only message updates - const success = await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [])) - - if (success) { - console.log(`[useMessageOperations] Successfully edited message ${messageId} properties.`) - } else { - console.error(`[useMessageOperations] Failed to edit message ${messageId} properties.`) - } + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [])) }, [dispatch, topic.id] ) @@ -133,9 +128,16 @@ export function useMessageOperations(topic: Topic) { const files = findFileBlocks(message).map((block) => block.file) const usage = await estimateUserPromptUsage({ content: editedContent, files }) + const messageUpdates: Partial & Pick = { + id: message.id, + updatedAt: new Date().toISOString(), + usage + } - await dispatch(updateMessageAndBlocksThunk(topic.id, { id: message.id, usage }, [])) - + await dispatch( + newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates }) + ) + // 对于message的修改会在下面的thunk中保存 await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant)) }, [dispatch, topic.id] @@ -313,29 +315,23 @@ export function useMessageOperations(topic: Topic) { * Uses the generalized thunk for persistence. */ const editMessageBlocks = useCallback( - // messageId?: string - async (blockUpdatesListRaw: Partial[]) => { + async (messageId: string, updates: Partial) => { if (!topic?.id) { console.error('[editMessageBlocks] Topic prop is not valid.') return } - if (!blockUpdatesListRaw || blockUpdatesListRaw.length === 0) { - console.warn('[editMessageBlocks] Received empty block updates list.') - return + + const blockUpdatesListProcessed = { + updatedAt: new Date().toISOString(), + ...updates } - const blockUpdatesListProcessed = blockUpdatesListRaw.map((update) => ({ - ...update, + const messageUpdates: Partial & Pick = { + id: messageId, updatedAt: new Date().toISOString() - })) - - const success = await dispatch(updateMessageAndBlocksThunk(topic.id, null, blockUpdatesListProcessed)) - - if (success) { - // console.log(`[useMessageOperations] Successfully processed block updates for message ${messageId}.`) - } else { - // console.error(`[useMessageOperations] Failed to process block updates for message ${messageId}.`) } + + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed])) }, [dispatch, topic.id] ) diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index 2e6d4eb724..06c5fd89ee 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -107,14 +107,6 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) => // Convert class to object with functions since class only has static methods // 只有静态方法,没必要用class,可以export {} export const TopicManager = { - async getTopicLimit(limit: number) { - return await db.topics - .orderBy('updatedAt') // 按 updatedAt 排序(默认升序) - .reverse() // 逆序(变成降序) - .limit(limit) // 取前 10 条 - .toArray() - }, - async getTopic(id: string) { return await db.topics.get(id) }, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 3288ca7221..5f9560ca9c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -620,7 +620,9 @@ "error.siyuan.no_config": "Siyuan Note API address or token is not configured", "success.siyuan.export": "Successfully exported to Siyuan Note", "warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!", - "warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!" + "warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!", + "download.success": "Download successfully", + "download.failed": "Download failed" }, "minapp": { "popup": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 25cf32bd5d..57f935b7a4 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -620,7 +620,9 @@ "success.siyuan.export": "思源ノートへのエクスポートに成功しました", "warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!", "warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!", - "error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません" + "error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません", + "download.success": "ダウンロードに成功しました", + "download.failed": "ダウンロードに失敗しました" }, "minapp": { "popup": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index dfc2eff725..34711f720f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -620,7 +620,9 @@ "error.siyuan.no_config": "Не настроен API адрес или токен Siyuan", "success.siyuan.export": "Успешный экспорт в Siyuan", "warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!", - "warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!" + "warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!", + "download.success": "Скачано успешно", + "download.failed": "Скачивание не удалось" }, "minapp": { "popup": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 39d6670744..01af59cb75 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -620,7 +620,9 @@ "error.siyuan.no_config": "未配置思源笔记API地址或令牌", "success.siyuan.export": "导出到思源笔记成功", "warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!", - "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!" + "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", + "download.success": "下载成功", + "download.failed": "下载失败" }, "minapp": { "popup": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4b0ca3b964..23dee80e7a 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -620,7 +620,9 @@ "error.siyuan.no_config": "未配置思源筆記API地址或令牌", "success.siyuan.export": "導出到思源筆記成功", "warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!", - "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!" + "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!", + "download.success": "下載成功", + "download.failed": "下載失敗" }, "minapp": { "popup": { diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 2b25e1ff38..2772a2dfe3 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -147,9 +147,9 @@ const CodeBlock: React.FC = ({ children, className }) => { ` +const CodeContent = styled.div<{ $isShowLineNumbers: boolean; $isUnwrapped: boolean; $isCodeWrappable: boolean }>` transition: opacity 0.3s ease; .shiki { padding: 1em; @@ -285,13 +285,13 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolea .line { display: block; min-height: 1.3rem; - padding-left: ${(props) => (props.isShowLineNumbers ? '2rem' : '0')}; + padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')}; } } } ${(props) => - props.isShowLineNumbers && + props.$isShowLineNumbers && ` code { counter-reset: step; @@ -311,8 +311,8 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolea `} ${(props) => - props.isCodeWrappable && - !props.isUnwrapped && + props.$isCodeWrappable && + !props.$isUnwrapped && ` code .line * { word-wrap: break-word; diff --git a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx index c4a13c380b..9e613b288b 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx @@ -12,14 +12,14 @@ import CitationsList from '../CitationsList' function CitationBlock({ block }: { block: CitationMessageBlock }) { const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id)) + const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI const hasCitations = useMemo(() => { - const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI return ( (formattedCitations && formattedCitations.length > 0) || hasGeminiBlock || (block.knowledge && block.knowledge.length > 0) ) - }, [formattedCitations, block.response, block.knowledge]) + }, [formattedCitations, block.knowledge, hasGeminiBlock]) if (block.status === MessageBlockStatus.PROCESSING) { return @@ -29,12 +29,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) { return null } - const isGemini = block.response?.source === WebSearchSource.GEMINI - return ( <> {block.status === MessageBlockStatus.SUCCESS && - (isGemini ? ( + (hasGeminiBlock ? ( <> = ({ block, citationBlockId, role, mentions return content } - // FIXME:性能问题,需要优化 - // Replace all citation numbers in the content with formatted citations - formattedCitations.forEach((citation) => { - const citationNum = citation.number - const supData = { - id: citationNum, - url: citation.url, - title: citation.title || citation.hostname || '', - content: citation.content?.substring(0, 200) - } - const isLink = citation.url.startsWith('http') - const citationJson = encodeHTML(JSON.stringify(supData)) - const supTag = `${citationNum}` - const citationTag = isLink ? `[${supTag}](${citation.url})` : supTag + switch (block.citationReferences[0].citationBlockSource) { + case WebSearchSource.OPENAI_COMPATIBLE: + case WebSearchSource.OPENAI: { + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) - // Replace all occurrences of [citationNum] with the formatted citation - const regex = new RegExp(`\\[${citationNum}\\]`, 'g') - content = content.replace(regex, citationTag) - }) + // Handle[N](url) + const preFormattedRegex = new RegExp(`\\[${citationNum}\\]\\(.*?\\)`, 'g') + + const citationTag = `[${citationNum}](${citation.url})` + + content = content.replace(preFormattedRegex, citationTag) + }) + break + } + case WebSearchSource.GEMINI: { + // First pass: Add basic citation marks using metadata + let processedContent = content + const firstCitation = formattedCitations[0] + if (firstCitation?.metadata) { + firstCitation.metadata.forEach((support: GroundingSupport) => { + const citationNums = support.groundingChunkIndices! + + if (support.segment) { + const text = support.segment.text! + // 生成引用标记 + const basicTag = citationNums + .map((citationNum) => { + const citation = formattedCitations.find((c) => c.number === citationNum + 1) + return citation ? `[${citationNum + 1}](${citation.url})` : '' + }) + .join('') + + // 在文本后面添加引用标记,而不是替换 + if (text && basicTag) { + processedContent = processedContent.replace(text, `${text}${basicTag}`) + } + } + }) + content = processedContent + } + // Second pass: Replace basic citations with full citation data + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) + + // Replace basic citation with full citation including data + const basicCitationRegex = new RegExp(`\\[${citationNum}\\]\\(${citation.url}\\)`, 'g') + const fullCitationTag = `[${citationNum}](${citation.url})` + content = content.replace(basicCitationRegex, fullCitationTag) + }) + break + } + default: { + // FIXME:性能问题,需要优化 + // Replace all citation numbers and pre-formatted links with formatted citations + formattedCitations.forEach((citation) => { + const citationNum = citation.number + const supData = { + id: citationNum, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const isLink = citation.url.startsWith('http') + const citationJson = encodeHTML(JSON.stringify(supData)) + + // Handle both plain references [N] and pre-formatted links [N](url) + const plainRefRegex = new RegExp(`\\[${citationNum}\\]`, 'g') + + const supTag = `${citationNum}` + const citationTag = isLink ? `[${supTag}](${citation.url})` : supTag + + content = content.replace(plainRefRegex, citationTag) + }) + } + } return content }, [block.content, block.citationReferences, citationBlockId, formattedCitations]) diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index f56d8d7e47..d674db4e18 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -16,6 +16,7 @@ export interface Citation { content?: string showFavicon?: boolean type?: string + metadata?: Record } interface CitationsListProps { @@ -207,9 +208,7 @@ const WebSearchCard = styled.div` flex-direction: column; width: 100%; padding: 12px; - margin-bottom: 8px; - border-radius: 8px; - border: 1px solid var(--color-border); + border-radius: var(--list-item-border-radius); background-color: var(--color-background); transition: all 0.3s ease; ` diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 268b28ac99..cf25fd36f1 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -102,7 +102,7 @@ const MessageHeader: FC = memo(({ assistant, model, message }) => { {username} - {dayjs(message.createdAt).format('MM/DD HH:mm')} + {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} diff --git a/src/renderer/src/pages/home/Messages/MessageImage.tsx b/src/renderer/src/pages/home/Messages/MessageImage.tsx index 138a8f171c..7198066259 100644 --- a/src/renderer/src/pages/home/Messages/MessageImage.tsx +++ b/src/renderer/src/pages/home/Messages/MessageImage.tsx @@ -13,6 +13,7 @@ import { Image as AntdImage, Space } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' + interface Props { block: ImageMessageBlock } @@ -87,40 +88,42 @@ const MessageImage: FC = ({ block }) => { } } + const renderToolbar = + (currentImage: string, currentIndex: number) => + ( + _: any, + { + transform: { scale }, + actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset } + }: any + ) => ( + + + + + + + + + onCopy(block.metadata?.generateImageResponse?.type!, currentImage)} /> + onDownload(currentImage, currentIndex)} /> + + ) + const images = block.metadata?.generateImageResponse?.images?.length ? block.metadata?.generateImageResponse?.images - : // TODO 加file是否合适? - block?.file?.path + : block?.file?.path ? [`file://${block?.file?.path}`] : [] + return ( {images.map((image, index) => ( ( - - - - - - - - - onCopy(block.metadata?.generateImageResponse?.type!, image)} /> - onDownload(image, index)} /> - - ) - }} + style={{ maxWidth: 500, maxHeight: 500 }} + preview={{ toolbarRender: renderToolbar(image, index) }} /> ))} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 918bd9cf9d..45d50050ff 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -26,6 +26,7 @@ import { findImageBlocks, findMainTextBlocks, getMainTextContent } from '@render import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' +import { FilePenLine } from 'lucide-react' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -164,7 +165,7 @@ const MessageMenubar: FC = (props) => { if (resendMessage) { resendUserMessageWithEdit(message, editedText, assistant) } else { - editMessageBlocks([{ ...findMainTextBlocks(message)[0], content: editedText }]) + editMessageBlocks(message.id, { id: findMainTextBlocks(message)[0].id, content: editedText }) } // // 更新消息内容,保留图片信息 // await editMessage(message.id, { @@ -221,6 +222,10 @@ const MessageMenubar: FC = (props) => { [isTranslating, message, getTranslationUpdater, mainTextContent] ) + const isEditable = useMemo(() => { + return findMainTextBlocks(message).length === 1 + }, [message]) + const dropdownItems = useMemo( () => [ { @@ -232,12 +237,16 @@ const MessageMenubar: FC = (props) => { window.api.file.save(fileName, mainTextContent) } }, - // { - // label: t('common.edit'), - // key: 'edit', - // icon: , - // onClick: onEdit - // }, + ...(isEditable + ? [ + { + label: t('common.edit'), + key: 'edit', + icon: , + onClick: onEdit + } + ] + : []), { label: t('chat.message.new.branch'), key: 'new-branch', @@ -338,7 +347,7 @@ const MessageMenubar: FC = (props) => { ].filter(Boolean) } ], - [message, messageContainerRef, mainTextContent, onNewBranch, t, topic.name, exportMenuOptions] + [message, messageContainerRef, onEdit, mainTextContent, onNewBranch, t, topic.name, exportMenuOptions] ) const onRegenerate = async (e: React.MouseEvent | undefined) => { diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts index eb1e97169f..cfb617d46a 100644 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts @@ -361,7 +361,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { const model = assistant.model || defaultModel const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId + const isEnabledBultinWebSearch = assistant.enableWebSearch messages = addImageFileToContents(messages) const enableReasoning = ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && @@ -747,7 +747,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { } } if ( - isEnabledWebSearch && + isEnabledBultinWebSearch && isZhipuModel(model) && finishReason === 'stop' && originalFinishRawChunk?.web_search @@ -761,7 +761,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { } as LLMWebSearchCompleteChunk) } if ( - isEnabledWebSearch && + isEnabledBultinWebSearch && isHunyuanSearchModel(model) && originalFinishRawChunk?.search_info?.search_results ) { diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 32341d020d..7d70e05aa5 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -309,7 +309,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const defaultModel = getDefaultModel() const model = assistant.model || defaultModel const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId + const isEnabledBuiltinWebSearch = assistant.enableWebSearch // 退回到 OpenAI 兼容模式 if (isOpenAIWebSearch(model)) { const systemMessage = { role: 'system', content: assistant.prompt || '' } @@ -363,7 +363,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason if (delta?.content) { - if (delta?.annotations) { + if (isOpenAIWebSearch(model)) { delta.content = convertLinks(delta.content || '', isFirstChunk) } if (isFirstChunk) { @@ -409,7 +409,10 @@ export abstract class BaseOpenAiProvider extends BaseProvider { return } let tools: OpenAI.Responses.Tool[] = [] - if (isEnabledWebSearch) { + const toolChoices: OpenAI.Responses.ToolChoiceTypes = { + type: 'web_search_preview' + } + if (isEnabledBuiltinWebSearch) { tools.push({ type: 'web_search_preview' }) @@ -660,17 +663,22 @@ export abstract class BaseOpenAiProvider extends BaseProvider { thinking_millsec: new Date().getTime() - time_first_token_millsec }) break - case 'response.output_text.delta': + case 'response.output_text.delta': { + let delta = chunk.delta + if (isEnabledBuiltinWebSearch) { + delta = convertLinks(delta) + } onChunk({ type: ChunkType.TEXT_DELTA, - text: chunk.delta + text: delta }) - content += chunk.delta + content += delta break + } case 'response.output_text.done': onChunk({ type: ChunkType.TEXT_COMPLETE, - text: chunk.text + text: content }) break case 'response.function_call_arguments.done': { @@ -773,6 +781,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { max_output_tokens: maxTokens, stream: streamOutput, tools: tools.length > 0 ? tools : undefined, + tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, service_tier: this.getServiceTier(model), ...this.getResponseReasoningEffort(assistant, model), ...this.getCustomParameters(assistant) diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 202f8371aa..33c00200da 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -85,19 +85,22 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita if (!block) return [] let formattedCitations: Citation[] = [] - // 1. Handle Web Search Responses (Non-Gemini) + // 1. Handle Web Search Responses if (block.response) { switch (block.response.source) { - case WebSearchSource.GEMINI: + case WebSearchSource.GEMINI: { + const groundingMetadata = block.response.results as GroundingMetadata formattedCitations = - (block.response?.results as GroundingMetadata)?.groundingChunks?.map((chunk, index) => ({ + groundingMetadata?.groundingChunks?.map((chunk, index) => ({ number: index + 1, url: chunk?.web?.uri || '', title: chunk?.web?.title, - showFavicon: false, + showFavicon: true, + metadata: groundingMetadata.groundingSupports, type: 'websearch' })) || [] break + } case WebSearchSource.OPENAI: formattedCitations = (block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => { diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index eadca50d73..5dd8d5984d 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -91,6 +91,7 @@ const updateExistingMessageAndBlocksInDB = async ( const newMessages = [...topic.messages] // Apply the updates passed in updatedMessage Object.assign(newMessages[messageIndex], updatedMessage) + // console.log('updateExistingMessageAndBlocksInDB', updatedMessage) await db.topics.update(updatedMessage.topicId, { messages: newMessages }) } else { console.error(`[updateExistingMsg] Message ${updatedMessage.id} not found in topic ${updatedMessage.topicId}`) @@ -106,44 +107,46 @@ const updateExistingMessageAndBlocksInDB = async ( } // 更新单个块的逻辑,用于更新消息中的单个块 -const throttledBlockUpdate = throttle((id, blockUpdate) => { - const state = store.getState() - const block = state.messageBlocks.entities[id] +const throttledBlockUpdate = throttle(async (id, blockUpdate) => { + // const state = store.getState() + // const block = state.messageBlocks.entities[id] // throttle是异步函数,可能会在complete事件触发后才执行 - if ( - blockUpdate.status === MessageBlockStatus.STREAMING && - (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) - ) - return + // if ( + // blockUpdate.status === MessageBlockStatus.STREAMING && + // (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) + // ) + // return store.dispatch(updateOneBlock({ id, changes: blockUpdate })) + await db.message_blocks.update(id, blockUpdate) }, 150) -// 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks) -export const throttledBlockDbUpdate = throttle( - async (blockId: string, blockChanges: Partial) => { - // Check if blockId is valid before attempting update - if (!blockId) { - console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.') - return - } - const state = store.getState() - const block = state.messageBlocks.entities[blockId] - // throttle是异步函数,可能会在complete事件触发后才执行 - if ( - blockChanges.status === MessageBlockStatus.STREAMING && - (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) - ) - return - try { - await db.message_blocks.update(blockId, blockChanges) - } catch (error) { - console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error) - } - }, - 300, // 可以调整节流间隔 - { leading: false, trailing: true } -) +const cancelThrottledBlockUpdate = throttledBlockUpdate.cancel + +// // 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks) +// export const throttledBlockDbUpdate = throttle( +// async (blockId: string, blockChanges: Partial) => { +// // Check if blockId is valid before attempting update +// if (!blockId) { +// console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.') +// return +// } +// const state = store.getState() +// const block = state.messageBlocks.entities[blockId] +// // throttle是异步函数,可能会在complete事件触发后才执行 +// if ( +// blockChanges.status === MessageBlockStatus.STREAMING && +// (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR) +// ) +// return +// try { +// } catch (error) { +// console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error) +// } +// }, +// 300, // 可以调整节流间隔 +// { leading: false, trailing: true } +// ) // 新增: 通用的、非节流的函数,用于保存消息和块的更新到数据库 const saveUpdatesToDB = async ( @@ -279,12 +282,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const currentState = getState() const updatedMessage = currentState.messages.entities[assistantMsgId] if (updatedMessage) { - await saveUpdatesToDB( - assistantMsgId, - topicId, - { blocks: updatedMessage.blocks, status: updatedMessage.status }, - [newBlock] - ) + await saveUpdatesToDB(assistantMsgId, topicId, { blocks: updatedMessage.blocks }, [newBlock]) } else { console.error(`[handleBlockTransition] Failed to get updated message ${assistantMsgId} from state for DB save.`) } @@ -338,7 +336,7 @@ const fetchAndProcessAssistantResponseImpl = async ( status: MessageBlockStatus.STREAMING } throttledBlockUpdate(lastBlockId, blockChanges) - throttledBlockDbUpdate(lastBlockId, blockChanges) + // throttledBlockDbUpdate(lastBlockId, blockChanges) } else { const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { status: MessageBlockStatus.STREAMING, @@ -349,7 +347,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } } }, - onTextComplete: (finalText) => { + onTextComplete: async (finalText) => { if (lastBlockType === MessageBlockType.MAIN_TEXT && lastBlockId) { const changes = { content: finalText, @@ -366,8 +364,8 @@ const fetchAndProcessAssistantResponseImpl = async ( { response: { source: WebSearchSource.OPENROUTER, results: extractedUrls } }, { status: MessageBlockStatus.SUCCESS } ) - handleBlockTransition(citationBlock, MessageBlockType.CITATION) - saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) + await handleBlockTransition(citationBlock, MessageBlockType.CITATION) + // saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) } } } else { @@ -396,7 +394,7 @@ const fetchAndProcessAssistantResponseImpl = async ( thinking_millsec: thinking_millsec } throttledBlockUpdate(lastBlockId, blockChanges) - throttledBlockDbUpdate(lastBlockId, blockChanges) + // throttledBlockDbUpdate(lastBlockId, blockChanges) } else { const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, { status: MessageBlockStatus.STREAMING, @@ -477,10 +475,9 @@ const fetchAndProcessAssistantResponseImpl = async ( const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING }) citationBlockId = citationBlock.id handleBlockTransition(citationBlock, MessageBlockType.CITATION) - saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) + // saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) }, onExternalToolComplete: (externalToolResult: ExternalToolResult) => { - console.warn('onExternalToolComplete received.', externalToolResult) if (citationBlockId) { const changes: Partial = { response: externalToolResult.webSearch, @@ -497,9 +494,9 @@ const fetchAndProcessAssistantResponseImpl = async ( const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING }) citationBlockId = citationBlock.id handleBlockTransition(citationBlock, MessageBlockType.CITATION) - saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) + // saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState) }, - onLLMWebSearchComplete(llmWebSearchResult) { + onLLMWebSearchComplete: async (llmWebSearchResult) => { if (citationBlockId) { const changes: Partial = { response: llmWebSearchResult, @@ -515,16 +512,21 @@ const fetchAndProcessAssistantResponseImpl = async ( ) citationBlockId = citationBlock.id handleBlockTransition(citationBlock, MessageBlockType.CITATION) - if (mainTextBlockId) { - const state = getState() - const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] - if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { - const currentRefs = existingMainTextBlock.citationReferences || [] - if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) { - const mainTextChanges = { citationReferences: [...currentRefs, { citationBlockId }] } - dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) - saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + } + if (mainTextBlockId) { + const state = getState() + const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] + if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { + const currentRefs = existingMainTextBlock.citationReferences || [] + if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) { + const mainTextChanges = { + citationReferences: [ + ...currentRefs, + { citationBlockId, citationBlockSource: llmWebSearchResult.source } + ] } + dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) + saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) } } } @@ -550,6 +552,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } }, onError: async (error) => { + cancelThrottledBlockUpdate() console.dir(error, { depth: null }) const isErrorTypeAbort = isAbortError(error) let pauseErrorLanguagePlaceholder = '' @@ -591,6 +594,8 @@ const fetchAndProcessAssistantResponseImpl = async ( }) }, onComplete: async (status: AssistantMessageStatus, response?: Response) => { + cancelThrottledBlockUpdate() + const finalStateOnComplete = getState() const finalAssistantMsg = finalStateOnComplete.messages.entities[assistantMsgId] @@ -629,7 +634,6 @@ const fetchAndProcessAssistantResponseImpl = async ( updates: messageUpdates }) ) - saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, []) EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status }) @@ -877,6 +881,7 @@ export const resendMessageThunk = const blockIdsToDelete = [...(originalMsg.blocks || [])] const resetMsg = resetAssistantMessage(originalMsg, { status: AssistantMessageStatus.PENDING, + updatedAt: new Date().toISOString(), ...(assistantMessagesToReset.length === 1 ? { model: assistant.model } : {}) }) @@ -979,7 +984,8 @@ export const regenerateAssistantResponseThunk = // 5. Reset the message entity in Redux const resetAssistantMsg = resetAssistantMessage(messageToResetEntity, { - status: AssistantMessageStatus.PENDING + status: AssistantMessageStatus.PENDING, + updatedAt: new Date().toISOString() }) dispatch( newMessagesActions.updateMessage({ @@ -1096,7 +1102,7 @@ export const initiateTranslationThunk = export const updateTranslationBlockThunk = (blockId: string, accumulatedText: string, isComplete: boolean = false) => async (dispatch: AppDispatch) => { - console.log(`[updateTranslationBlockThunk] 更新翻译块 ${blockId}, isComplete: ${isComplete}`) + // console.log(`[updateTranslationBlockThunk] 更新翻译块 ${blockId}, isComplete: ${isComplete}`) try { const status = isComplete ? MessageBlockStatus.SUCCESS : MessageBlockStatus.STREAMING const changes: Partial = { @@ -1109,7 +1115,7 @@ export const updateTranslationBlockThunk = // 更新数据库 await db.message_blocks.update(blockId, changes) - console.log(`[updateTranslationBlockThunk] Successfully updated translation block ${blockId}.`) + // console.log(`[updateTranslationBlockThunk] Successfully updated translation block ${blockId}.`) } catch (error) { console.error(`[updateTranslationBlockThunk] Failed to update translation block ${blockId}:`, error) } diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index dd453dcbc5..6005d75db8 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -11,7 +11,8 @@ import type { Model, Topic, Usage, - WebSearchResponse + WebSearchResponse, + WebSearchSource } from '.' // MessageBlock 类型枚举 - 根据实际API返回特性优化 @@ -63,6 +64,7 @@ export interface MainTextMessageBlock extends BaseMessageBlock { // Citation references citationReferences?: { citationBlockId?: string + citationBlockSource?: WebSearchSource }[] } @@ -161,7 +163,7 @@ export type Message = { assistantId: string topicId: string createdAt: string - // updatedAt?: string + updatedAt?: string status: UserMessageStatus | AssistantMessageStatus // 消息元数据 diff --git a/src/renderer/src/utils/__tests__/linkConverter.test.ts b/src/renderer/src/utils/__tests__/linkConverter.test.ts index 48d4614095..eaecc3ca1f 100644 --- a/src/renderer/src/utils/__tests__/linkConverter.test.ts +++ b/src/renderer/src/utils/__tests__/linkConverter.test.ts @@ -99,12 +99,6 @@ describe('linkConverter', () => { expect(result).toBe('这里有链接 [1](https://example.com)') }) - it('should preserve non-domain link text', () => { - const input = '点击[这里](https://example.com)查看更多' - const result = convertLinks(input, true) - expect(result).toBe('点击这里[1](https://example.com)查看更多') - }) - it('should use the same counter for duplicate URLs', () => { const input = '第一个链接 [example.com](https://example.com) 和第二个相同链接 [subdomain.example.com](https://example.com)' @@ -113,24 +107,6 @@ describe('linkConverter', () => { '第一个链接 [1](https://example.com) 和第二个相同链接 [1](https://example.com)' ) }) - - it('should correctly convert links in Zhipu mode', () => { - const input = '这里是引用 [ref_1]' - const result = convertLinks(input, true, true) - expect(result).toBe('这里是引用 [1]()') - }) - - it('should handle incomplete links in chunked input', () => { - // 第一个块包含未完成的链接 - const chunk1 = '这是链接 [' - const result1 = convertLinks(chunk1, true) - expect(result1).toBe('这是链接 ') - - // 第二个块完成链接 - const chunk2 = 'example.com](https://example.com)' - const result2 = convertLinks(chunk2, false) - expect(result2).toBe('[1](https://example.com)') - }) }) describe('convertLinksToOpenRouter', () => { diff --git a/src/renderer/src/utils/fetch.ts b/src/renderer/src/utils/fetch.ts index ff88973f31..115aa34171 100644 --- a/src/renderer/src/utils/fetch.ts +++ b/src/renderer/src/utils/fetch.ts @@ -126,3 +126,20 @@ export async function fetchWebContent( } } } + +export async function fetchRedirectUrl(url: string) { + try { + const response = await fetch(url, { + method: 'HEAD', + redirect: 'follow', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }) + return response.url + } catch (e) { + console.error(`Failed to fetch redirect url: ${e}`) + return url + } +} diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index 9cdf13205e..ac45cf96a3 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -113,14 +113,13 @@ export function convertLinksToHunyuan(text: string, webSearch: any[], resetCount * Converts Markdown links in the text to numbered links based on the rules: * 1. ([host](url)) -> [cnt](url) * 2. [host](url) -> [cnt](url) - * 3. [anytext except host](url) -> anytext[cnt](url) + * 3. [any text except url](url)-> any text [cnt](url) * * @param text The current chunk of text to process * @param resetCounter Whether to reset the counter and buffer - * @param isZhipu Whether to use Zhipu format * @returns Processed text with complete links converted */ -export function convertLinks(text: string, resetCounter = false, isZhipu = false): string { +export function convertLinks(text: string, resetCounter = false): string { if (resetCounter) { linkCounter = 1 buffer = '' @@ -132,34 +131,6 @@ export function convertLinks(text: string, resetCounter = false, isZhipu = false // Find the safe point - the position after which we might have incomplete patterns let safePoint = buffer.length - if (isZhipu) { - // Handle Zhipu mode - find safe point for [ref_N] patterns - let safePoint = buffer.length - - // Check from the end for potentially incomplete [ref_N] patterns - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - // Check if it's a complete [ref_N] pattern - const match = /^\[ref_\d+\]/.exec(substring) - - if (!match) { - // Potentially incomplete [ref_N] pattern - safePoint = i - break - } - } - } - - // Process the safe part of the buffer - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Replace all complete [ref_N] patterns - return safeBuffer.replace(/\[ref_(\d+)\]/g, (_, num) => { - return `[${num}]()` - }) - } // Check for potentially incomplete patterns from the end for (let i = buffer.length - 1; i >= 0; i--) { @@ -239,10 +210,12 @@ export function convertLinks(text: string, resetCounter = false, isZhipu = false urlToCounterMap.set(url, counter) } - if (isHost(linkText)) { - result += `[${counter}](${url})` + // Rule 3: If the link text is not a URL/host, keep the text and add the numbered link + if (!isHost(linkText)) { + result += `${linkText} [${counter}](${url})` } else { - result += `${linkText}[${counter}](${url})` + // Rule 2: If the link text is a URL/host, replace with numbered link + result += `[${counter}](${url})` } position += match[0].length @@ -351,7 +324,7 @@ export function extractUrlsFromMarkdown(text: string): string[] { // 匹配所有Markdown链接格式 const linkPattern = /\[(?:[^[\]]*)\]\(([^()]+)\)/g - let match + let match: RegExpExecArray | null while ((match = linkPattern.exec(text)) !== null) { const url = match[1].trim() diff --git a/src/renderer/src/utils/messageUtils/create.ts b/src/renderer/src/utils/messageUtils/create.ts index 12a19958a2..fdd90c3fde 100644 --- a/src/renderer/src/utils/messageUtils/create.ts +++ b/src/renderer/src/utils/messageUtils/create.ts @@ -384,7 +384,7 @@ export function resetMessage( */ export const resetAssistantMessage = ( originalMessage: Message, - updates?: Partial> // Primarily allow updating status + updates?: Partial> // Primarily allow updating status ): Message => { // Ensure we are only resetting assistant messages if (originalMessage.role !== 'assistant') {