mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-23 10:00:08 +08:00
fix: message render bugs
commitc6cf790851Author: lizhixuan <daoquqiexing@gmail.com> Date: Sat May 10 11:38:01 2025 +0800 feat(MessageMenubar): add edit option to dropdown for single message editing commit87b106fad6Author: lizhixuan <daoquqiexing@gmail.com> Date: Sat May 10 10:53:10 2025 +0800 refactor(StreamProcessingService): comment out console.log for cleaner code commit7d0b8b33afAuthor: lizhixuan <daoquqiexing@gmail.com> Date: Sat May 10 10:52:08 2025 +0800 refactor(messageThunk): remove console.log statements for cleaner code commit7310eebebcAuthor: kangfenmao <kangfenmao@qq.com> Date: Sat May 10 10:17:44 2025 +0800 feat(i18n): add download success and failure messages in multiple languages commit42733d0fc8Merge:6364f4c0ac0651a9Author: kangfenmao <kangfenmao@qq.com> Date: Sat May 10 09:54:38 2025 +0800 Merge branch 'main' into fix/next-release-bugs commit6364f4c006Author: 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 commit34c49b84f6Author: suyao <sy20010504@gmail.com> Date: Fri May 9 22:02:06 2025 +0800 fix: prevent default action in handleLinkClick for better link handling commit84bf76cc43Merge:572ffcc83697b31cAuthor: suyao <sy20010504@gmail.com> Date: Fri May 9 21:55:54 2025 +0800 Merge branch 'main' into fix/next-release-bugs commit572ffcc8beMerge:9ba630b5a6a8324cAuthor: suyao <sy20010504@gmail.com> Date: Fri May 9 21:48:13 2025 +0800 Merge branch 'main' into fix/next-release-bugs commit9ba630b5e8Merge:bf819a716d910755Author: 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 commitbf819a7142Author: suyao <sy20010504@gmail.com> Date: Fri May 9 21:24:48 2025 +0800 feat: enhance citation handling and add metadata support in citation blocks commit6d9107558eAuthor: 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. commitc402a1d21fAuthor: 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. commit6da1d08c9aAuthor: 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. commit30696e1ef1Author: suyao <sy20010504@gmail.com> Date: Fri May 9 16:19:55 2025 +0800 Remove Zhipu mode and text-only link handling commit5b95d20294Author: 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. commit28f1e486e6Author: 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:
parent
14d013243c
commit
b962126d98
@ -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]
|
||||
)
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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;
|
||||
`
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
// 消息元数据
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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') {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user