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.
|
* 使用 newMessagesActions.updateMessage.
|
||||||
*/
|
*/
|
||||||
const editMessage = useCallback(
|
const editMessage = useCallback(
|
||||||
@ -92,17 +92,12 @@ export function useMessageOperations(topic: Topic) {
|
|||||||
|
|
||||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||||
id: messageId,
|
id: messageId,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
...updates
|
...updates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the thunk with topic.id and only message updates
|
// Call the thunk with topic.id and only message updates
|
||||||
const success = await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, []))
|
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.`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[dispatch, topic.id]
|
[dispatch, topic.id]
|
||||||
)
|
)
|
||||||
@ -133,9 +128,16 @@ export function useMessageOperations(topic: Topic) {
|
|||||||
const files = findFileBlocks(message).map((block) => block.file)
|
const files = findFileBlocks(message).map((block) => block.file)
|
||||||
|
|
||||||
const usage = await estimateUserPromptUsage({ content: editedContent, files })
|
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))
|
await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant))
|
||||||
},
|
},
|
||||||
[dispatch, topic.id]
|
[dispatch, topic.id]
|
||||||
@ -313,29 +315,23 @@ export function useMessageOperations(topic: Topic) {
|
|||||||
* Uses the generalized thunk for persistence.
|
* Uses the generalized thunk for persistence.
|
||||||
*/
|
*/
|
||||||
const editMessageBlocks = useCallback(
|
const editMessageBlocks = useCallback(
|
||||||
// messageId?: string
|
async (messageId: string, updates: Partial<MessageBlock>) => {
|
||||||
async (blockUpdatesListRaw: Partial<MessageBlock>[]) => {
|
|
||||||
if (!topic?.id) {
|
if (!topic?.id) {
|
||||||
console.error('[editMessageBlocks] Topic prop is not valid.')
|
console.error('[editMessageBlocks] Topic prop is not valid.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!blockUpdatesListRaw || blockUpdatesListRaw.length === 0) {
|
|
||||||
console.warn('[editMessageBlocks] Received empty block updates list.')
|
const blockUpdatesListProcessed = {
|
||||||
return
|
updatedAt: new Date().toISOString(),
|
||||||
|
...updates
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockUpdatesListProcessed = blockUpdatesListRaw.map((update) => ({
|
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||||
...update,
|
id: messageId,
|
||||||
updatedAt: new Date().toISOString()
|
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]
|
[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
|
// Convert class to object with functions since class only has static methods
|
||||||
// 只有静态方法,没必要用class,可以export {}
|
// 只有静态方法,没必要用class,可以export {}
|
||||||
export const TopicManager = {
|
export const TopicManager = {
|
||||||
async getTopicLimit(limit: number) {
|
|
||||||
return await db.topics
|
|
||||||
.orderBy('updatedAt') // 按 updatedAt 排序(默认升序)
|
|
||||||
.reverse() // 逆序(变成降序)
|
|
||||||
.limit(limit) // 取前 10 条
|
|
||||||
.toArray()
|
|
||||||
},
|
|
||||||
|
|
||||||
async getTopic(id: string) {
|
async getTopic(id: string) {
|
||||||
return await db.topics.get(id)
|
return await db.topics.get(id)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -620,7 +620,9 @@
|
|||||||
"error.siyuan.no_config": "Siyuan Note API address or token is not configured",
|
"error.siyuan.no_config": "Siyuan Note API address or token is not configured",
|
||||||
"success.siyuan.export": "Successfully exported to Siyuan Note",
|
"success.siyuan.export": "Successfully exported to Siyuan Note",
|
||||||
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
|
"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": {
|
"minapp": {
|
||||||
"popup": {
|
"popup": {
|
||||||
|
|||||||
@ -620,7 +620,9 @@
|
|||||||
"success.siyuan.export": "思源ノートへのエクスポートに成功しました",
|
"success.siyuan.export": "思源ノートへのエクスポートに成功しました",
|
||||||
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
|
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
|
||||||
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
|
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
|
||||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
|
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません",
|
||||||
|
"download.success": "ダウンロードに成功しました",
|
||||||
|
"download.failed": "ダウンロードに失敗しました"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
"popup": {
|
"popup": {
|
||||||
|
|||||||
@ -620,7 +620,9 @@
|
|||||||
"error.siyuan.no_config": "Не настроен API адрес или токен Siyuan",
|
"error.siyuan.no_config": "Не настроен API адрес или токен Siyuan",
|
||||||
"success.siyuan.export": "Успешный экспорт в Siyuan",
|
"success.siyuan.export": "Успешный экспорт в Siyuan",
|
||||||
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
|
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
|
||||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
|
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!",
|
||||||
|
"download.success": "Скачано успешно",
|
||||||
|
"download.failed": "Скачивание не удалось"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
"popup": {
|
"popup": {
|
||||||
|
|||||||
@ -620,7 +620,9 @@
|
|||||||
"error.siyuan.no_config": "未配置思源笔记API地址或令牌",
|
"error.siyuan.no_config": "未配置思源笔记API地址或令牌",
|
||||||
"success.siyuan.export": "导出到思源笔记成功",
|
"success.siyuan.export": "导出到思源笔记成功",
|
||||||
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
|
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
|
||||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
|
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!",
|
||||||
|
"download.success": "下载成功",
|
||||||
|
"download.failed": "下载失败"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
"popup": {
|
"popup": {
|
||||||
|
|||||||
@ -620,7 +620,9 @@
|
|||||||
"error.siyuan.no_config": "未配置思源筆記API地址或令牌",
|
"error.siyuan.no_config": "未配置思源筆記API地址或令牌",
|
||||||
"success.siyuan.export": "導出到思源筆記成功",
|
"success.siyuan.export": "導出到思源筆記成功",
|
||||||
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
|
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
|
||||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
|
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!",
|
||||||
|
"download.success": "下載成功",
|
||||||
|
"download.failed": "下載失敗"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
"popup": {
|
"popup": {
|
||||||
|
|||||||
@ -147,9 +147,9 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
|||||||
</StickyWrapper>
|
</StickyWrapper>
|
||||||
<CodeContent
|
<CodeContent
|
||||||
ref={codeContentRef}
|
ref={codeContentRef}
|
||||||
isShowLineNumbers={codeShowLineNumbers}
|
$isShowLineNumbers={codeShowLineNumbers}
|
||||||
isUnwrapped={isUnwrapped}
|
$isUnwrapped={isUnwrapped}
|
||||||
isCodeWrappable={codeWrappable}
|
$isCodeWrappable={codeWrappable}
|
||||||
// dangerouslySetInnerHTML={{ __html: html }}
|
// dangerouslySetInnerHTML={{ __html: html }}
|
||||||
style={{
|
style={{
|
||||||
padding: '1px',
|
padding: '1px',
|
||||||
@ -272,7 +272,7 @@ const CodeBlockWrapper = styled.div`
|
|||||||
position: relative;
|
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;
|
transition: opacity 0.3s ease;
|
||||||
.shiki {
|
.shiki {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
@ -285,13 +285,13 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolea
|
|||||||
.line {
|
.line {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 1.3rem;
|
min-height: 1.3rem;
|
||||||
padding-left: ${(props) => (props.isShowLineNumbers ? '2rem' : '0')};
|
padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.isShowLineNumbers &&
|
props.$isShowLineNumbers &&
|
||||||
`
|
`
|
||||||
code {
|
code {
|
||||||
counter-reset: step;
|
counter-reset: step;
|
||||||
@ -311,8 +311,8 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolea
|
|||||||
`}
|
`}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.isCodeWrappable &&
|
props.$isCodeWrappable &&
|
||||||
!props.isUnwrapped &&
|
!props.$isUnwrapped &&
|
||||||
`
|
`
|
||||||
code .line * {
|
code .line * {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|||||||
@ -12,14 +12,14 @@ import CitationsList from '../CitationsList'
|
|||||||
|
|
||||||
function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||||
const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id))
|
const formattedCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, block.id))
|
||||||
|
const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI
|
||||||
const hasCitations = useMemo(() => {
|
const hasCitations = useMemo(() => {
|
||||||
const hasGeminiBlock = block.response?.source === WebSearchSource.GEMINI
|
|
||||||
return (
|
return (
|
||||||
(formattedCitations && formattedCitations.length > 0) ||
|
(formattedCitations && formattedCitations.length > 0) ||
|
||||||
hasGeminiBlock ||
|
hasGeminiBlock ||
|
||||||
(block.knowledge && block.knowledge.length > 0)
|
(block.knowledge && block.knowledge.length > 0)
|
||||||
)
|
)
|
||||||
}, [formattedCitations, block.response, block.knowledge])
|
}, [formattedCitations, block.knowledge, hasGeminiBlock])
|
||||||
|
|
||||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
if (block.status === MessageBlockStatus.PROCESSING) {
|
||||||
return <Spinner text="message.searching" />
|
return <Spinner text="message.searching" />
|
||||||
@ -29,12 +29,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGemini = block.response?.source === WebSearchSource.GEMINI
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{block.status === MessageBlockStatus.SUCCESS &&
|
{block.status === MessageBlockStatus.SUCCESS &&
|
||||||
(isGemini ? (
|
(hasGeminiBlock ? (
|
||||||
<>
|
<>
|
||||||
<CitationsList citations={formattedCitations} />
|
<CitationsList citations={formattedCitations} />
|
||||||
<SearchEntryPoint
|
<SearchEntryPoint
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
|
import { GroundingSupport } from '@google/genai'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import type { RootState } from '@renderer/store'
|
import type { RootState } from '@renderer/store'
|
||||||
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
|
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 type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
|
||||||
import { Flex } from 'antd'
|
import { Flex } from 'antd'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
@ -47,25 +48,96 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME:性能问题,需要优化
|
switch (block.citationReferences[0].citationBlockSource) {
|
||||||
// Replace all citation numbers in the content with formatted citations
|
case WebSearchSource.OPENAI_COMPATIBLE:
|
||||||
formattedCitations.forEach((citation) => {
|
case WebSearchSource.OPENAI: {
|
||||||
const citationNum = citation.number
|
formattedCitations.forEach((citation) => {
|
||||||
const supData = {
|
const citationNum = citation.number
|
||||||
id: citationNum,
|
const supData = {
|
||||||
url: citation.url,
|
id: citationNum,
|
||||||
title: citation.title || citation.hostname || '',
|
url: citation.url,
|
||||||
content: citation.content?.substring(0, 200)
|
title: citation.title || citation.hostname || '',
|
||||||
}
|
content: citation.content?.substring(0, 200)
|
||||||
const isLink = citation.url.startsWith('http')
|
}
|
||||||
const citationJson = encodeHTML(JSON.stringify(supData))
|
const citationJson = encodeHTML(JSON.stringify(supData))
|
||||||
const supTag = `<sup data-citation='${citationJson}'>${citationNum}</sup>`
|
|
||||||
const citationTag = isLink ? `[${supTag}](${citation.url})` : supTag
|
|
||||||
|
|
||||||
// Replace all occurrences of [citationNum] with the formatted citation
|
// Handle[<sup>N</sup>](url)
|
||||||
const regex = new RegExp(`\\[${citationNum}\\]`, 'g')
|
const preFormattedRegex = new RegExp(`\\[<sup>${citationNum}</sup>\\]\\(.*?\\)`, 'g')
|
||||||
content = content.replace(regex, citationTag)
|
|
||||||
})
|
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 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 [<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
|
||||||
|
|
||||||
|
content = content.replace(plainRefRegex, citationTag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return content
|
return content
|
||||||
}, [block.content, block.citationReferences, citationBlockId, formattedCitations])
|
}, [block.content, block.citationReferences, citationBlockId, formattedCitations])
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface Citation {
|
|||||||
content?: string
|
content?: string
|
||||||
showFavicon?: boolean
|
showFavicon?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
|
metadata?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CitationsListProps {
|
interface CitationsListProps {
|
||||||
@ -207,9 +208,7 @@ const WebSearchCard = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 8px;
|
border-radius: var(--list-item-border-radius);
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
`
|
`
|
||||||
|
|||||||
@ -102,7 +102,7 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
|||||||
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
|
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
|
||||||
{username}
|
{username}
|
||||||
</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>
|
</UserWrap>
|
||||||
</AvatarWrapper>
|
</AvatarWrapper>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Image as AntdImage, Space } from 'antd'
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
block: ImageMessageBlock
|
block: ImageMessageBlock
|
||||||
}
|
}
|
||||||
@ -87,40 +88,42 @@ const MessageImage: FC<Props> = ({ block }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<SwapOutlined onClick={onFlipX} />
|
||||||
|
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||||
|
<RotateRightOutlined onClick={onRotateRight} />
|
||||||
|
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||||
|
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||||
|
<UndoOutlined onClick={onReset} />
|
||||||
|
<CopyOutlined onClick={() => onCopy(block.metadata?.generateImageResponse?.type!, currentImage)} />
|
||||||
|
<DownloadOutlined onClick={() => onDownload(currentImage, currentIndex)} />
|
||||||
|
</ToobarWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
const images = block.metadata?.generateImageResponse?.images?.length
|
const images = block.metadata?.generateImageResponse?.images?.length
|
||||||
? block.metadata?.generateImageResponse?.images
|
? block.metadata?.generateImageResponse?.images
|
||||||
: // TODO 加file是否合适?
|
: block?.file?.path
|
||||||
block?.file?.path
|
|
||||||
? [`file://${block?.file?.path}`]
|
? [`file://${block?.file?.path}`]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container style={{ marginBottom: 8 }}>
|
<Container style={{ marginBottom: 8 }}>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
key={`image-${index}`}
|
key={`image-${index}`}
|
||||||
height={300}
|
style={{ maxWidth: 500, maxHeight: 500 }}
|
||||||
preview={{
|
preview={{ toolbarRender: renderToolbar(image, index) }}
|
||||||
toolbarRender: (
|
|
||||||
_,
|
|
||||||
{
|
|
||||||
transform: { scale },
|
|
||||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
|
||||||
}
|
|
||||||
) => (
|
|
||||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
|
||||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
|
||||||
<SwapOutlined onClick={onFlipX} />
|
|
||||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
|
||||||
<RotateRightOutlined onClick={onRotateRight} />
|
|
||||||
<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)} />
|
|
||||||
</ToobarWrapper>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { findImageBlocks, findMainTextBlocks, getMainTextContent } from '@render
|
|||||||
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
|
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react'
|
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 { FC, memo, useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
@ -164,7 +165,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
if (resendMessage) {
|
if (resendMessage) {
|
||||||
resendUserMessageWithEdit(message, editedText, assistant)
|
resendUserMessageWithEdit(message, editedText, assistant)
|
||||||
} else {
|
} else {
|
||||||
editMessageBlocks([{ ...findMainTextBlocks(message)[0], content: editedText }])
|
editMessageBlocks(message.id, { id: findMainTextBlocks(message)[0].id, content: editedText })
|
||||||
}
|
}
|
||||||
// // 更新消息内容,保留图片信息
|
// // 更新消息内容,保留图片信息
|
||||||
// await editMessage(message.id, {
|
// await editMessage(message.id, {
|
||||||
@ -221,6 +222,10 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
[isTranslating, message, getTranslationUpdater, mainTextContent]
|
[isTranslating, message, getTranslationUpdater, mainTextContent]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isEditable = useMemo(() => {
|
||||||
|
return findMainTextBlocks(message).length === 1
|
||||||
|
}, [message])
|
||||||
|
|
||||||
const dropdownItems = useMemo(
|
const dropdownItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@ -232,12 +237,16 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
window.api.file.save(fileName, mainTextContent)
|
window.api.file.save(fileName, mainTextContent)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// {
|
...(isEditable
|
||||||
// label: t('common.edit'),
|
? [
|
||||||
// key: 'edit',
|
{
|
||||||
// icon: <FilePenLine size={16} />,
|
label: t('common.edit'),
|
||||||
// onClick: onEdit
|
key: 'edit',
|
||||||
// },
|
icon: <FilePenLine size={16} />,
|
||||||
|
onClick: onEdit
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: t('chat.message.new.branch'),
|
label: t('chat.message.new.branch'),
|
||||||
key: 'new-branch',
|
key: 'new-branch',
|
||||||
@ -338,7 +347,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
].filter(Boolean)
|
].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) => {
|
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||||
|
|||||||
@ -361,7 +361,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider {
|
|||||||
const model = assistant.model || defaultModel
|
const model = assistant.model || defaultModel
|
||||||
|
|
||||||
const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant)
|
const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant)
|
||||||
const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId
|
const isEnabledBultinWebSearch = assistant.enableWebSearch
|
||||||
messages = addImageFileToContents(messages)
|
messages = addImageFileToContents(messages)
|
||||||
const enableReasoning =
|
const enableReasoning =
|
||||||
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
||||||
@ -747,7 +747,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isEnabledWebSearch &&
|
isEnabledBultinWebSearch &&
|
||||||
isZhipuModel(model) &&
|
isZhipuModel(model) &&
|
||||||
finishReason === 'stop' &&
|
finishReason === 'stop' &&
|
||||||
originalFinishRawChunk?.web_search
|
originalFinishRawChunk?.web_search
|
||||||
@ -761,7 +761,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider {
|
|||||||
} as LLMWebSearchCompleteChunk)
|
} as LLMWebSearchCompleteChunk)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isEnabledWebSearch &&
|
isEnabledBultinWebSearch &&
|
||||||
isHunyuanSearchModel(model) &&
|
isHunyuanSearchModel(model) &&
|
||||||
originalFinishRawChunk?.search_info?.search_results
|
originalFinishRawChunk?.search_info?.search_results
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -309,7 +309,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider {
|
|||||||
const defaultModel = getDefaultModel()
|
const defaultModel = getDefaultModel()
|
||||||
const model = assistant.model || defaultModel
|
const model = assistant.model || defaultModel
|
||||||
const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant)
|
const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant)
|
||||||
const isEnabledWebSearch = assistant.enableWebSearch || !!assistant.webSearchProviderId
|
const isEnabledBuiltinWebSearch = assistant.enableWebSearch
|
||||||
// 退回到 OpenAI 兼容模式
|
// 退回到 OpenAI 兼容模式
|
||||||
if (isOpenAIWebSearch(model)) {
|
if (isOpenAIWebSearch(model)) {
|
||||||
const systemMessage = { role: 'system', content: assistant.prompt || '' }
|
const systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||||
@ -363,7 +363,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider {
|
|||||||
const delta = chunk.choices[0]?.delta
|
const delta = chunk.choices[0]?.delta
|
||||||
const finishReason = chunk.choices[0]?.finish_reason
|
const finishReason = chunk.choices[0]?.finish_reason
|
||||||
if (delta?.content) {
|
if (delta?.content) {
|
||||||
if (delta?.annotations) {
|
if (isOpenAIWebSearch(model)) {
|
||||||
delta.content = convertLinks(delta.content || '', isFirstChunk)
|
delta.content = convertLinks(delta.content || '', isFirstChunk)
|
||||||
}
|
}
|
||||||
if (isFirstChunk) {
|
if (isFirstChunk) {
|
||||||
@ -409,7 +409,10 @@ export abstract class BaseOpenAiProvider extends BaseProvider {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let tools: OpenAI.Responses.Tool[] = []
|
let tools: OpenAI.Responses.Tool[] = []
|
||||||
if (isEnabledWebSearch) {
|
const toolChoices: OpenAI.Responses.ToolChoiceTypes = {
|
||||||
|
type: 'web_search_preview'
|
||||||
|
}
|
||||||
|
if (isEnabledBuiltinWebSearch) {
|
||||||
tools.push({
|
tools.push({
|
||||||
type: 'web_search_preview'
|
type: 'web_search_preview'
|
||||||
})
|
})
|
||||||
@ -660,17 +663,22 @@ export abstract class BaseOpenAiProvider extends BaseProvider {
|
|||||||
thinking_millsec: new Date().getTime() - time_first_token_millsec
|
thinking_millsec: new Date().getTime() - time_first_token_millsec
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'response.output_text.delta':
|
case 'response.output_text.delta': {
|
||||||
|
let delta = chunk.delta
|
||||||
|
if (isEnabledBuiltinWebSearch) {
|
||||||
|
delta = convertLinks(delta)
|
||||||
|
}
|
||||||
onChunk({
|
onChunk({
|
||||||
type: ChunkType.TEXT_DELTA,
|
type: ChunkType.TEXT_DELTA,
|
||||||
text: chunk.delta
|
text: delta
|
||||||
})
|
})
|
||||||
content += chunk.delta
|
content += delta
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'response.output_text.done':
|
case 'response.output_text.done':
|
||||||
onChunk({
|
onChunk({
|
||||||
type: ChunkType.TEXT_COMPLETE,
|
type: ChunkType.TEXT_COMPLETE,
|
||||||
text: chunk.text
|
text: content
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'response.function_call_arguments.done': {
|
case 'response.function_call_arguments.done': {
|
||||||
@ -773,6 +781,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider {
|
|||||||
max_output_tokens: maxTokens,
|
max_output_tokens: maxTokens,
|
||||||
stream: streamOutput,
|
stream: streamOutput,
|
||||||
tools: tools.length > 0 ? tools : undefined,
|
tools: tools.length > 0 ? tools : undefined,
|
||||||
|
tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined,
|
||||||
service_tier: this.getServiceTier(model),
|
service_tier: this.getServiceTier(model),
|
||||||
...this.getResponseReasoningEffort(assistant, model),
|
...this.getResponseReasoningEffort(assistant, model),
|
||||||
...this.getCustomParameters(assistant)
|
...this.getCustomParameters(assistant)
|
||||||
|
|||||||
@ -85,19 +85,22 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita
|
|||||||
if (!block) return []
|
if (!block) return []
|
||||||
|
|
||||||
let formattedCitations: Citation[] = []
|
let formattedCitations: Citation[] = []
|
||||||
// 1. Handle Web Search Responses (Non-Gemini)
|
// 1. Handle Web Search Responses
|
||||||
if (block.response) {
|
if (block.response) {
|
||||||
switch (block.response.source) {
|
switch (block.response.source) {
|
||||||
case WebSearchSource.GEMINI:
|
case WebSearchSource.GEMINI: {
|
||||||
|
const groundingMetadata = block.response.results as GroundingMetadata
|
||||||
formattedCitations =
|
formattedCitations =
|
||||||
(block.response?.results as GroundingMetadata)?.groundingChunks?.map((chunk, index) => ({
|
groundingMetadata?.groundingChunks?.map((chunk, index) => ({
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
url: chunk?.web?.uri || '',
|
url: chunk?.web?.uri || '',
|
||||||
title: chunk?.web?.title,
|
title: chunk?.web?.title,
|
||||||
showFavicon: false,
|
showFavicon: true,
|
||||||
|
metadata: groundingMetadata.groundingSupports,
|
||||||
type: 'websearch'
|
type: 'websearch'
|
||||||
})) || []
|
})) || []
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case WebSearchSource.OPENAI:
|
case WebSearchSource.OPENAI:
|
||||||
formattedCitations =
|
formattedCitations =
|
||||||
(block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => {
|
(block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => {
|
||||||
|
|||||||
@ -91,6 +91,7 @@ const updateExistingMessageAndBlocksInDB = async (
|
|||||||
const newMessages = [...topic.messages]
|
const newMessages = [...topic.messages]
|
||||||
// Apply the updates passed in updatedMessage
|
// Apply the updates passed in updatedMessage
|
||||||
Object.assign(newMessages[messageIndex], updatedMessage)
|
Object.assign(newMessages[messageIndex], updatedMessage)
|
||||||
|
// console.log('updateExistingMessageAndBlocksInDB', updatedMessage)
|
||||||
await db.topics.update(updatedMessage.topicId, { messages: newMessages })
|
await db.topics.update(updatedMessage.topicId, { messages: newMessages })
|
||||||
} else {
|
} else {
|
||||||
console.error(`[updateExistingMsg] Message ${updatedMessage.id} not found in topic ${updatedMessage.topicId}`)
|
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 throttledBlockUpdate = throttle(async (id, blockUpdate) => {
|
||||||
const state = store.getState()
|
// const state = store.getState()
|
||||||
const block = state.messageBlocks.entities[id]
|
// const block = state.messageBlocks.entities[id]
|
||||||
// throttle是异步函数,可能会在complete事件触发后才执行
|
// throttle是异步函数,可能会在complete事件触发后才执行
|
||||||
if (
|
// if (
|
||||||
blockUpdate.status === MessageBlockStatus.STREAMING &&
|
// blockUpdate.status === MessageBlockStatus.STREAMING &&
|
||||||
(block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR)
|
// (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR)
|
||||||
)
|
// )
|
||||||
return
|
// return
|
||||||
|
|
||||||
store.dispatch(updateOneBlock({ id, changes: blockUpdate }))
|
store.dispatch(updateOneBlock({ id, changes: blockUpdate }))
|
||||||
|
await db.message_blocks.update(id, blockUpdate)
|
||||||
}, 150)
|
}, 150)
|
||||||
|
|
||||||
// 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks)
|
const cancelThrottledBlockUpdate = throttledBlockUpdate.cancel
|
||||||
export const throttledBlockDbUpdate = throttle(
|
|
||||||
async (blockId: string, blockChanges: Partial<MessageBlock>) => {
|
// // 修改: 节流更新单个块的内容/状态到数据库 (仅用于 Text/Thinking Chunks)
|
||||||
// Check if blockId is valid before attempting update
|
// export const throttledBlockDbUpdate = throttle(
|
||||||
if (!blockId) {
|
// async (blockId: string, blockChanges: Partial<MessageBlock>) => {
|
||||||
console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.')
|
// // Check if blockId is valid before attempting update
|
||||||
return
|
// if (!blockId) {
|
||||||
}
|
// console.warn('[DB Throttle Block Update] Attempted to update with null/undefined blockId. Skipping.')
|
||||||
const state = store.getState()
|
// return
|
||||||
const block = state.messageBlocks.entities[blockId]
|
// }
|
||||||
// throttle是异步函数,可能会在complete事件触发后才执行
|
// const state = store.getState()
|
||||||
if (
|
// const block = state.messageBlocks.entities[blockId]
|
||||||
blockChanges.status === MessageBlockStatus.STREAMING &&
|
// // throttle是异步函数,可能会在complete事件触发后才执行
|
||||||
(block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR)
|
// if (
|
||||||
)
|
// blockChanges.status === MessageBlockStatus.STREAMING &&
|
||||||
return
|
// (block?.status === MessageBlockStatus.SUCCESS || block?.status === MessageBlockStatus.ERROR)
|
||||||
try {
|
// )
|
||||||
await db.message_blocks.update(blockId, blockChanges)
|
// return
|
||||||
} catch (error) {
|
// try {
|
||||||
console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error)
|
// } catch (error) {
|
||||||
}
|
// console.error(`[DB Throttle Block Update] Failed for block ${blockId}:`, error)
|
||||||
},
|
// }
|
||||||
300, // 可以调整节流间隔
|
// },
|
||||||
{ leading: false, trailing: true }
|
// 300, // 可以调整节流间隔
|
||||||
)
|
// { leading: false, trailing: true }
|
||||||
|
// )
|
||||||
|
|
||||||
// 新增: 通用的、非节流的函数,用于保存消息和块的更新到数据库
|
// 新增: 通用的、非节流的函数,用于保存消息和块的更新到数据库
|
||||||
const saveUpdatesToDB = async (
|
const saveUpdatesToDB = async (
|
||||||
@ -279,12 +282,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
const currentState = getState()
|
const currentState = getState()
|
||||||
const updatedMessage = currentState.messages.entities[assistantMsgId]
|
const updatedMessage = currentState.messages.entities[assistantMsgId]
|
||||||
if (updatedMessage) {
|
if (updatedMessage) {
|
||||||
await saveUpdatesToDB(
|
await saveUpdatesToDB(assistantMsgId, topicId, { blocks: updatedMessage.blocks }, [newBlock])
|
||||||
assistantMsgId,
|
|
||||||
topicId,
|
|
||||||
{ blocks: updatedMessage.blocks, status: updatedMessage.status },
|
|
||||||
[newBlock]
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`[handleBlockTransition] Failed to get updated message ${assistantMsgId} from state for DB save.`)
|
console.error(`[handleBlockTransition] Failed to get updated message ${assistantMsgId} from state for DB save.`)
|
||||||
}
|
}
|
||||||
@ -338,7 +336,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
status: MessageBlockStatus.STREAMING
|
status: MessageBlockStatus.STREAMING
|
||||||
}
|
}
|
||||||
throttledBlockUpdate(lastBlockId, blockChanges)
|
throttledBlockUpdate(lastBlockId, blockChanges)
|
||||||
throttledBlockDbUpdate(lastBlockId, blockChanges)
|
// throttledBlockDbUpdate(lastBlockId, blockChanges)
|
||||||
} else {
|
} else {
|
||||||
const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, {
|
const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, {
|
||||||
status: MessageBlockStatus.STREAMING,
|
status: MessageBlockStatus.STREAMING,
|
||||||
@ -349,7 +347,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTextComplete: (finalText) => {
|
onTextComplete: async (finalText) => {
|
||||||
if (lastBlockType === MessageBlockType.MAIN_TEXT && lastBlockId) {
|
if (lastBlockType === MessageBlockType.MAIN_TEXT && lastBlockId) {
|
||||||
const changes = {
|
const changes = {
|
||||||
content: finalText,
|
content: finalText,
|
||||||
@ -366,8 +364,8 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
{ response: { source: WebSearchSource.OPENROUTER, results: extractedUrls } },
|
{ response: { source: WebSearchSource.OPENROUTER, results: extractedUrls } },
|
||||||
{ status: MessageBlockStatus.SUCCESS }
|
{ status: MessageBlockStatus.SUCCESS }
|
||||||
)
|
)
|
||||||
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
await handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
||||||
saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
// saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -396,7 +394,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
thinking_millsec: thinking_millsec
|
thinking_millsec: thinking_millsec
|
||||||
}
|
}
|
||||||
throttledBlockUpdate(lastBlockId, blockChanges)
|
throttledBlockUpdate(lastBlockId, blockChanges)
|
||||||
throttledBlockDbUpdate(lastBlockId, blockChanges)
|
// throttledBlockDbUpdate(lastBlockId, blockChanges)
|
||||||
} else {
|
} else {
|
||||||
const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, {
|
const newBlock = createThinkingBlock(assistantMsgId, accumulatedThinking, {
|
||||||
status: MessageBlockStatus.STREAMING,
|
status: MessageBlockStatus.STREAMING,
|
||||||
@ -477,10 +475,9 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING })
|
const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING })
|
||||||
citationBlockId = citationBlock.id
|
citationBlockId = citationBlock.id
|
||||||
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
||||||
saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
// saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
||||||
},
|
},
|
||||||
onExternalToolComplete: (externalToolResult: ExternalToolResult) => {
|
onExternalToolComplete: (externalToolResult: ExternalToolResult) => {
|
||||||
console.warn('onExternalToolComplete received.', externalToolResult)
|
|
||||||
if (citationBlockId) {
|
if (citationBlockId) {
|
||||||
const changes: Partial<CitationMessageBlock> = {
|
const changes: Partial<CitationMessageBlock> = {
|
||||||
response: externalToolResult.webSearch,
|
response: externalToolResult.webSearch,
|
||||||
@ -497,9 +494,9 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING })
|
const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING })
|
||||||
citationBlockId = citationBlock.id
|
citationBlockId = citationBlock.id
|
||||||
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
||||||
saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
// saveUpdatedBlockToDB(citationBlock.id, assistantMsgId, topicId, getState)
|
||||||
},
|
},
|
||||||
onLLMWebSearchComplete(llmWebSearchResult) {
|
onLLMWebSearchComplete: async (llmWebSearchResult) => {
|
||||||
if (citationBlockId) {
|
if (citationBlockId) {
|
||||||
const changes: Partial<CitationMessageBlock> = {
|
const changes: Partial<CitationMessageBlock> = {
|
||||||
response: llmWebSearchResult,
|
response: llmWebSearchResult,
|
||||||
@ -515,16 +512,21 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
)
|
)
|
||||||
citationBlockId = citationBlock.id
|
citationBlockId = citationBlock.id
|
||||||
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
handleBlockTransition(citationBlock, MessageBlockType.CITATION)
|
||||||
if (mainTextBlockId) {
|
}
|
||||||
const state = getState()
|
if (mainTextBlockId) {
|
||||||
const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId]
|
const state = getState()
|
||||||
if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) {
|
const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId]
|
||||||
const currentRefs = existingMainTextBlock.citationReferences || []
|
if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) {
|
||||||
if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) {
|
const currentRefs = existingMainTextBlock.citationReferences || []
|
||||||
const mainTextChanges = { citationReferences: [...currentRefs, { citationBlockId }] }
|
if (!currentRefs.some((ref) => ref.citationBlockId === citationBlockId)) {
|
||||||
dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges }))
|
const mainTextChanges = {
|
||||||
saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState)
|
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) => {
|
onError: async (error) => {
|
||||||
|
cancelThrottledBlockUpdate()
|
||||||
console.dir(error, { depth: null })
|
console.dir(error, { depth: null })
|
||||||
const isErrorTypeAbort = isAbortError(error)
|
const isErrorTypeAbort = isAbortError(error)
|
||||||
let pauseErrorLanguagePlaceholder = ''
|
let pauseErrorLanguagePlaceholder = ''
|
||||||
@ -591,6 +594,8 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onComplete: async (status: AssistantMessageStatus, response?: Response) => {
|
onComplete: async (status: AssistantMessageStatus, response?: Response) => {
|
||||||
|
cancelThrottledBlockUpdate()
|
||||||
|
|
||||||
const finalStateOnComplete = getState()
|
const finalStateOnComplete = getState()
|
||||||
const finalAssistantMsg = finalStateOnComplete.messages.entities[assistantMsgId]
|
const finalAssistantMsg = finalStateOnComplete.messages.entities[assistantMsgId]
|
||||||
|
|
||||||
@ -629,7 +634,6 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
updates: messageUpdates
|
updates: messageUpdates
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, [])
|
saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, [])
|
||||||
|
|
||||||
EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status })
|
EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status })
|
||||||
@ -877,6 +881,7 @@ export const resendMessageThunk =
|
|||||||
const blockIdsToDelete = [...(originalMsg.blocks || [])]
|
const blockIdsToDelete = [...(originalMsg.blocks || [])]
|
||||||
const resetMsg = resetAssistantMessage(originalMsg, {
|
const resetMsg = resetAssistantMessage(originalMsg, {
|
||||||
status: AssistantMessageStatus.PENDING,
|
status: AssistantMessageStatus.PENDING,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
...(assistantMessagesToReset.length === 1 ? { model: assistant.model } : {})
|
...(assistantMessagesToReset.length === 1 ? { model: assistant.model } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -979,7 +984,8 @@ export const regenerateAssistantResponseThunk =
|
|||||||
|
|
||||||
// 5. Reset the message entity in Redux
|
// 5. Reset the message entity in Redux
|
||||||
const resetAssistantMsg = resetAssistantMessage(messageToResetEntity, {
|
const resetAssistantMsg = resetAssistantMessage(messageToResetEntity, {
|
||||||
status: AssistantMessageStatus.PENDING
|
status: AssistantMessageStatus.PENDING,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
dispatch(
|
dispatch(
|
||||||
newMessagesActions.updateMessage({
|
newMessagesActions.updateMessage({
|
||||||
@ -1096,7 +1102,7 @@ export const initiateTranslationThunk =
|
|||||||
export const updateTranslationBlockThunk =
|
export const updateTranslationBlockThunk =
|
||||||
(blockId: string, accumulatedText: string, isComplete: boolean = false) =>
|
(blockId: string, accumulatedText: string, isComplete: boolean = false) =>
|
||||||
async (dispatch: AppDispatch) => {
|
async (dispatch: AppDispatch) => {
|
||||||
console.log(`[updateTranslationBlockThunk] 更新翻译块 ${blockId}, isComplete: ${isComplete}`)
|
// console.log(`[updateTranslationBlockThunk] 更新翻译块 ${blockId}, isComplete: ${isComplete}`)
|
||||||
try {
|
try {
|
||||||
const status = isComplete ? MessageBlockStatus.SUCCESS : MessageBlockStatus.STREAMING
|
const status = isComplete ? MessageBlockStatus.SUCCESS : MessageBlockStatus.STREAMING
|
||||||
const changes: Partial<MessageBlock> = {
|
const changes: Partial<MessageBlock> = {
|
||||||
@ -1109,7 +1115,7 @@ export const updateTranslationBlockThunk =
|
|||||||
|
|
||||||
// 更新数据库
|
// 更新数据库
|
||||||
await db.message_blocks.update(blockId, changes)
|
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) {
|
} catch (error) {
|
||||||
console.error(`[updateTranslationBlockThunk] Failed to update translation block ${blockId}:`, error)
|
console.error(`[updateTranslationBlockThunk] Failed to update translation block ${blockId}:`, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,8 @@ import type {
|
|||||||
Model,
|
Model,
|
||||||
Topic,
|
Topic,
|
||||||
Usage,
|
Usage,
|
||||||
WebSearchResponse
|
WebSearchResponse,
|
||||||
|
WebSearchSource
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
// MessageBlock 类型枚举 - 根据实际API返回特性优化
|
// MessageBlock 类型枚举 - 根据实际API返回特性优化
|
||||||
@ -63,6 +64,7 @@ export interface MainTextMessageBlock extends BaseMessageBlock {
|
|||||||
// Citation references
|
// Citation references
|
||||||
citationReferences?: {
|
citationReferences?: {
|
||||||
citationBlockId?: string
|
citationBlockId?: string
|
||||||
|
citationBlockSource?: WebSearchSource
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +163,7 @@ export type Message = {
|
|||||||
assistantId: string
|
assistantId: string
|
||||||
topicId: string
|
topicId: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
// updatedAt?: string
|
updatedAt?: string
|
||||||
status: UserMessageStatus | AssistantMessageStatus
|
status: UserMessageStatus | AssistantMessageStatus
|
||||||
|
|
||||||
// 消息元数据
|
// 消息元数据
|
||||||
|
|||||||
@ -99,12 +99,6 @@ describe('linkConverter', () => {
|
|||||||
expect(result).toBe('这里有链接 [<sup>1</sup>](https://example.com)')
|
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', () => {
|
it('should use the same counter for duplicate URLs', () => {
|
||||||
const input =
|
const input =
|
||||||
'第一个链接 [example.com](https://example.com) 和第二个相同链接 [subdomain.example.com](https://example.com)'
|
'第一个链接 [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)'
|
'第一个链接 [<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', () => {
|
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:
|
* Converts Markdown links in the text to numbered links based on the rules:
|
||||||
* 1. ([host](url)) -> [cnt](url)
|
* 1. ([host](url)) -> [cnt](url)
|
||||||
* 2. [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 text The current chunk of text to process
|
||||||
* @param resetCounter Whether to reset the counter and buffer
|
* @param resetCounter Whether to reset the counter and buffer
|
||||||
* @param isZhipu Whether to use Zhipu format
|
|
||||||
* @returns Processed text with complete links converted
|
* @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) {
|
if (resetCounter) {
|
||||||
linkCounter = 1
|
linkCounter = 1
|
||||||
buffer = ''
|
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
|
// Find the safe point - the position after which we might have incomplete patterns
|
||||||
let safePoint = buffer.length
|
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
|
// Check for potentially incomplete patterns from the end
|
||||||
for (let i = buffer.length - 1; i >= 0; i--) {
|
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)
|
urlToCounterMap.set(url, counter)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHost(linkText)) {
|
// Rule 3: If the link text is not a URL/host, keep the text and add the numbered link
|
||||||
result += `[<sup>${counter}</sup>](${url})`
|
if (!isHost(linkText)) {
|
||||||
|
result += `${linkText} [<sup>${counter}</sup>](${url})`
|
||||||
} else {
|
} else {
|
||||||
result += `${linkText}[<sup>${counter}</sup>](${url})`
|
// Rule 2: If the link text is a URL/host, replace with numbered link
|
||||||
|
result += `[<sup>${counter}</sup>](${url})`
|
||||||
}
|
}
|
||||||
|
|
||||||
position += match[0].length
|
position += match[0].length
|
||||||
@ -351,7 +324,7 @@ export function extractUrlsFromMarkdown(text: string): string[] {
|
|||||||
|
|
||||||
// 匹配所有Markdown链接格式
|
// 匹配所有Markdown链接格式
|
||||||
const linkPattern = /\[(?:[^[\]]*)\]\(([^()]+)\)/g
|
const linkPattern = /\[(?:[^[\]]*)\]\(([^()]+)\)/g
|
||||||
let match
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
while ((match = linkPattern.exec(text)) !== null) {
|
while ((match = linkPattern.exec(text)) !== null) {
|
||||||
const url = match[1].trim()
|
const url = match[1].trim()
|
||||||
|
|||||||
@ -384,7 +384,7 @@ export function resetMessage(
|
|||||||
*/
|
*/
|
||||||
export const resetAssistantMessage = (
|
export const resetAssistantMessage = (
|
||||||
originalMessage: Message,
|
originalMessage: Message,
|
||||||
updates?: Partial<Pick<Message, 'status'>> // Primarily allow updating status
|
updates?: Partial<Pick<Message, 'status' | 'updatedAt'>> // Primarily allow updating status
|
||||||
): Message => {
|
): Message => {
|
||||||
// Ensure we are only resetting assistant messages
|
// Ensure we are only resetting assistant messages
|
||||||
if (originalMessage.role !== 'assistant') {
|
if (originalMessage.role !== 'assistant') {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user