fix: message render bugs

commit c6cf790851
Author: lizhixuan <daoquqiexing@gmail.com>
Date:   Sat May 10 11:38:01 2025 +0800

    feat(MessageMenubar): add edit option to dropdown for single message editing

commit 87b106fad6
Author: lizhixuan <daoquqiexing@gmail.com>
Date:   Sat May 10 10:53:10 2025 +0800

    refactor(StreamProcessingService): comment out console.log for cleaner code

commit 7d0b8b33af
Author: lizhixuan <daoquqiexing@gmail.com>
Date:   Sat May 10 10:52:08 2025 +0800

    refactor(messageThunk): remove console.log statements for cleaner code

commit 7310eebebc
Author: kangfenmao <kangfenmao@qq.com>
Date:   Sat May 10 10:17:44 2025 +0800

    feat(i18n): add download success and failure messages in multiple languages

commit 42733d0fc8
Merge: 6364f4c0 ac0651a9
Author: kangfenmao <kangfenmao@qq.com>
Date:   Sat May 10 09:54:38 2025 +0800

    Merge branch 'main' into fix/next-release-bugs

commit 6364f4c006
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 22:19:35 2025 +0800

    fix: update styled component props to use dollar sign prefix for consistency

commit 34c49b84f6
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 22:02:06 2025 +0800

    fix: prevent default action in handleLinkClick for better link handling

commit 84bf76cc43
Merge: 572ffcc8 3697b31c
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 21:55:54 2025 +0800

    Merge branch 'main' into fix/next-release-bugs

commit 572ffcc8be
Merge: 9ba630b5 a6a8324c
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 21:48:13 2025 +0800

    Merge branch 'main' into fix/next-release-bugs

commit 9ba630b5e8
Merge: bf819a71 6d910755
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 21:27:08 2025 +0800

    Merge branch 'fix/next-release-bugs' of github.com:CherryHQ/cherry-studio into fix/next-release-bugs

commit bf819a7142
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 21:24:48 2025 +0800

    feat: enhance citation handling and add metadata support in citation blocks

commit 6d9107558e
Author: MyPrototypeWhat <daoquqiexing@gmail.com>
Date:   Fri May 9 19:47:24 2025 +0800

    fix: enhance logging and update async handling in StreamProcessingService and messageThunk

    - Enabled logging in `createStreamProcessor` for better debugging.
    - Added logging for updated messages in `updateExistingMessageAndBlocksInDB` and `saveUpdatesToDB`.
    - Updated `onTextComplete` and `onLLMWebSearchComplete` to handle asynchronous operations correctly.
    - Commented out unused `saveUpdatedBlockToDB` calls to prevent unnecessary database updates.

commit c402a1d21f
Author: MyPrototypeWhat <daoquqiexing@gmail.com>
Date:   Fri May 9 18:47:55 2025 +0800

    refactor: optimize block update logic and remove unused code

    - Updated `throttledBlockUpdate` to handle asynchronous updates directly.
    - Removed the unused `throttledBlockDbUpdate` function and its related logic.
    - Added cancellation for throttled updates on error and completion to improve performance and reliability.
    - Cleaned up commented-out code for better readability.

commit 6da1d08c9a
Author: MyPrototypeWhat <daoquqiexing@gmail.com>
Date:   Fri May 9 18:42:00 2025 +0800

    refactor: update message handling and state management

    - Simplified message editing logic by removing unnecessary success/error logging.
    - Added `updatedAt` timestamp to message updates for better tracking.
    - Refactored `editMessageBlocks` to accept message ID and updates directly.
    - Removed unused `getTopicLimit` function from `TopicManager`.
    - Updated message rendering to use `updatedAt` when available.
    - Enhanced type definitions to include `updatedAt` in message structure.

commit 30696e1ef1
Author: suyao <sy20010504@gmail.com>
Date:   Fri May 9 16:19:55 2025 +0800

    Remove Zhipu mode and text-only link handling

commit 5b95d20294
Author: kangfenmao <kangfenmao@qq.com>
Date:   Fri May 9 15:49:02 2025 +0800

    fix: update citation rendering logic in MainTextBlock component

    - Added a check to determine if the citation URL is a valid link.
    - Updated citation tag formatting to conditionally include the link based on the URL validity.

commit 28f1e486e6
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu May 8 18:31:14 2025 +0800

    style: update ChatNavigation and CitationsList components for improved UI consistency

    - Added header style to remove borders in ChatNavigation.
    - Enhanced CitationsList with new Skeleton loading state and improved layout for citation cards.
    - Refactored CitationLink to a div for better styling control and adjusted padding in OpenButton for a more polished appearance.
This commit is contained in:
kangfenmao 2025-05-10 19:15:46 +08:00
parent 14d013243c
commit b962126d98
23 changed files with 308 additions and 243 deletions

View File

@ -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<Message> & Pick<Message, 'id'> = {
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<Message> & Pick<Message, 'id'> = {
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<MessageBlock>[]) => {
async (messageId: string, updates: Partial<MessageBlock>) => {
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<Message> & Pick<Message, 'id'> = {
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]
)

View File

@ -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)
},

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -147,9 +147,9 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
</StickyWrapper>
<CodeContent
ref={codeContentRef}
isShowLineNumbers={codeShowLineNumbers}
isUnwrapped={isUnwrapped}
isCodeWrappable={codeWrappable}
$isShowLineNumbers={codeShowLineNumbers}
$isUnwrapped={isUnwrapped}
$isCodeWrappable={codeWrappable}
// dangerouslySetInnerHTML={{ __html: html }}
style={{
padding: '1px',
@ -272,7 +272,7 @@ const CodeBlockWrapper = styled.div`
position: relative;
`
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
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;

View File

@ -12,14 +12,14 @@ import CitationsList from '../CitationsList'
function CitationBlock({ block }: { block: CitationMessageBlock }) {
const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id))
const hasCitations = useMemo(() => {
const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI
const hasCitations = useMemo(() => {
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 <Spinner text="message.searching" />
@ -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 ? (
<>
<CitationsList citations={formattedCitations} />
<SearchEntryPoint

View File

@ -1,8 +1,9 @@
import { GroundingSupport } from '@google/genai'
import { useSettings } from '@renderer/hooks/useSettings'
import { getModelUniqId } from '@renderer/services/ModelService'
import type { RootState } from '@renderer/store'
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
import type { Model } from '@renderer/types'
import { type Model, WebSearchSource } from '@renderer/types'
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
import { Flex } from 'antd'
import React, { useMemo } from 'react'
@ -47,8 +48,75 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
return content
}
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))
// Handle[<sup>N</sup>](url)
const preFormattedRegex = new RegExp(`\\[<sup>${citationNum}</sup>\\]\\(.*?\\)`, 'g')
const citationTag = `[<sup data-citation='${citationJson}'>${citationNum}</sup>](${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 ? `[<sup>${citationNum + 1}</sup>](${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(`\\[<sup>${citationNum}</sup>\\]\\(${citation.url}\\)`, 'g')
const fullCitationTag = `[<sup data-citation='${citationJson}'>${citationNum}</sup>](${citation.url})`
content = content.replace(basicCitationRegex, fullCitationTag)
})
break
}
default: {
// FIXME性能问题需要优化
// Replace all citation numbers in the content with formatted citations
// Replace all citation numbers and pre-formatted links with formatted citations
formattedCitations.forEach((citation) => {
const citationNum = citation.number
const supData = {
@ -59,13 +127,17 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
}
const isLink = citation.url.startsWith('http')
const citationJson = encodeHTML(JSON.stringify(supData))
// Handle both plain references [N] and pre-formatted links [<sup>N</sup>](url)
const plainRefRegex = new RegExp(`\\[${citationNum}\\]`, 'g')
const supTag = `<sup data-citation='${citationJson}'>${citationNum}</sup>`
const citationTag = isLink ? `[${supTag}](${citation.url})` : supTag
// Replace all occurrences of [citationNum] with the formatted citation
const regex = new RegExp(`\\[${citationNum}\\]`, 'g')
content = content.replace(regex, citationTag)
content = content.replace(plainRefRegex, citationTag)
})
}
}
return content
}, [block.content, block.citationReferences, citationBlockId, formattedCitations])

View File

@ -16,6 +16,7 @@ export interface Citation {
content?: string
showFavicon?: boolean
type?: string
metadata?: Record<string, any>
}
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;
`

View File

@ -102,7 +102,7 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
{username}
</UserName>
<MessageTime>{dayjs(message.createdAt).format('MM/DD HH:mm')}</MessageTime>
<MessageTime>{dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')}</MessageTime>
</UserWrap>
</AvatarWrapper>
</Container>

View File

@ -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,26 +88,14 @@ const MessageImage: FC<Props> = ({ block }) => {
}
}
const images = block.metadata?.generateImageResponse?.images?.length
? block.metadata?.generateImageResponse?.images
: // TODO 加file是否合适
block?.file?.path
? [`file://${block?.file?.path}`]
: []
return (
<Container style={{ marginBottom: 8 }}>
{images.map((image, index) => (
<Image
src={image}
key={`image-${index}`}
height={300}
preview={{
toolbarRender: (
_,
const renderToolbar =
(currentImage: string, currentIndex: number) =>
(
_: any,
{
transform: { scale },
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
}
}: any
) => (
<ToobarWrapper size={12} className="toolbar-wrapper">
<SwapOutlined rotate={90} onClick={onFlipY} />
@ -116,11 +105,25 @@ const MessageImage: FC<Props> = ({ block }) => {
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<UndoOutlined onClick={onReset} />
<CopyOutlined onClick={() => onCopy(block.metadata?.generateImageResponse?.type!, image)} />
<DownloadOutlined onClick={() => onDownload(image, index)} />
<CopyOutlined onClick={() => onCopy(block.metadata?.generateImageResponse?.type!, currentImage)} />
<DownloadOutlined onClick={() => onDownload(currentImage, currentIndex)} />
</ToobarWrapper>
)
}}
const images = block.metadata?.generateImageResponse?.images?.length
? block.metadata?.generateImageResponse?.images
: block?.file?.path
? [`file://${block?.file?.path}`]
: []
return (
<Container style={{ marginBottom: 8 }}>
{images.map((image, index) => (
<Image
src={image}
key={`image-${index}`}
style={{ maxWidth: 500, maxHeight: 500 }}
preview={{ toolbarRender: renderToolbar(image, index) }}
/>
))}
</Container>

View File

@ -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> = (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> = (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> = (props) => {
window.api.file.save(fileName, mainTextContent)
}
},
// {
// label: t('common.edit'),
// key: 'edit',
// icon: <FilePenLine size={16} />,
// onClick: onEdit
// },
...(isEditable
? [
{
label: t('common.edit'),
key: 'edit',
icon: <FilePenLine size={16} />,
onClick: onEdit
}
]
: []),
{
label: t('chat.message.new.branch'),
key: 'new-branch',
@ -338,7 +347,7 @@ const MessageMenubar: FC<Props> = (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) => {

View File

@ -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
) {

View File

@ -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)

View File

@ -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) => {

View File

@ -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<MessageBlock>) => {
// 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<MessageBlock>) => {
// // 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<CitationMessageBlock> = {
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<CitationMessageBlock> = {
response: llmWebSearchResult,
@ -515,19 +512,24 @@ 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 }] }
const mainTextChanges = {
citationReferences: [
...currentRefs,
{ citationBlockId, citationBlockSource: llmWebSearchResult.source }
]
}
dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges }))
saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState)
}
}
}
}
},
onImageCreated: () => {
const imageBlock = createImageBlock(assistantMsgId, {
@ -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<MessageBlock> = {
@ -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)
}

View File

@ -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
// 消息元数据

View File

@ -99,12 +99,6 @@ describe('linkConverter', () => {
expect(result).toBe('这里有链接 [<sup>1</sup>](https://example.com)')
})
it('should preserve non-domain link text', () => {
const input = '点击[这里](https://example.com)查看更多'
const result = convertLinks(input, true)
expect(result).toBe('点击这里[<sup>1</sup>](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', () => {
'第一个链接 [<sup>1</sup>](https://example.com) 和第二个相同链接 [<sup>1</sup>](https://example.com)'
)
})
it('should correctly convert links in Zhipu mode', () => {
const input = '这里是引用 [ref_1]'
const result = convertLinks(input, true, true)
expect(result).toBe('这里是引用 [<sup>1</sup>]()')
})
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('[<sup>1</sup>](https://example.com)')
})
})
describe('convertLinksToOpenRouter', () => {

View File

@ -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
}
}

View File

@ -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 `[<sup>${num}</sup>]()`
})
}
// 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 += `[<sup>${counter}</sup>](${url})`
} else {
// Rule 3: If the link text is not a URL/host, keep the text and add the numbered link
if (!isHost(linkText)) {
result += `${linkText} [<sup>${counter}</sup>](${url})`
} else {
// Rule 2: If the link text is a URL/host, replace with numbered link
result += `[<sup>${counter}</sup>](${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()

View File

@ -384,7 +384,7 @@ export function resetMessage(
*/
export const resetAssistantMessage = (
originalMessage: Message,
updates?: Partial<Pick<Message, 'status'>> // Primarily allow updating status
updates?: Partial<Pick<Message, 'status' | 'updatedAt'>> // Primarily allow updating status
): Message => {
// Ensure we are only resetting assistant messages
if (originalMessage.role !== 'assistant') {