Compare commits

...

2 Commits

Author SHA1 Message Date
suyao
f9024eb07a fix: simplify finish reason warning condition 2025-11-27 05:33:21 +08:00
suyao
504ce9d936 feat: add support for message continuation and finish reason handling 2025-11-27 05:29:21 +08:00
19 changed files with 494 additions and 11 deletions

View File

@@ -339,12 +339,14 @@ export class AiSdkToChunkAdapter {
reasoning_content: final.reasoningContent || ''
}
// Pass finishReason in BLOCK_COMPLETE for message-level tracking
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
...baseResponse,
usage: { ...usage },
metrics: metrics ? { ...metrics } : undefined
metrics: metrics ? { ...metrics } : undefined,
finishReason: chunk.finishReason
}
})
this.onChunk({

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} citations",
"citations": "References",
"continue_generation": {
"prompt": "[CONTINUE EXACTLY FROM CUTOFF POINT]\n\nYour previous response was cut off mid-generation. Continue IMMEDIATELY from where you stopped - do NOT repeat, summarize, or restart. Your next word should be the exact continuation.\n\nYour response ended with: \"{{truncatedContent}}\"\n\nContinue now (first word must follow directly from the above):"
},
"copied": "Copied!",
"copy": {
"failed": "Copy failed",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "Content was blocked by safety filter",
"continue": "Continue generating",
"error": "An error occurred during generation",
"length": "Maximum output length limit reached",
"other": "Generation terminated",
"unknown": "Generation terminated for unknown reason"
},
"rate": {
"limit": "Too many requests. Please wait {{seconds}} seconds before trying again."
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} 个引用内容",
"citations": "引用内容",
"continue_generation": {
"prompt": "[从截断处精确继续]\n\n你之前的回复在生成过程中被截断了。请立即从停止的地方继续——不要重复、总结或重新开始。你的下一个字必须是精确的接续。\n\n你的回复结尾是\"{{truncatedContent}}\"\n\n现在继续第一个字必须直接接上面的内容"
},
"copied": "已复制",
"copy": {
"failed": "复制失败",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "内容被安全过滤器拦截",
"continue": "继续生成",
"error": "生成过程中发生错误",
"length": "已达到最大输出长度限制",
"other": "生成已终止",
"unknown": "生成因未知原因终止"
},
"rate": {
"limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} 個引用內容",
"citations": "引用內容",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "已複製!",
"copy": {
"failed": "複製失敗",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} Zitate",
"citations": "Zitate",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Kopiert",
"copy": {
"failed": "Kopieren fehlgeschlagen",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Zu viele Anfragen. Bitte warten Sie {{seconds}} Sekunden, bevor Sie es erneut versuchen"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} αναφορές",
"citations": "Περιεχόμενα αναφοράς",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Αντιγράφηκε",
"copy": {
"failed": "Η αντιγραφή απέτυχε",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Υπερβολική συχνότητα στείλατε παρακαλώ περιμένετε {{seconds}} δευτερόλεπτα και προσπαθήστε ξανά"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} contenido citado",
"citations": "Citas",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Copiado",
"copy": {
"failed": "Copia fallida",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Envío demasiado frecuente, espere {{seconds}} segundos antes de intentarlo de nuevo"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} éléments cités",
"citations": "Citations",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Copié",
"copy": {
"failed": "La copie a échoué",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Vous envoyez trop souvent, veuillez attendre {{seconds}} secondes avant de réessayer"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}}個の引用内容",
"citations": "引用内容",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "コピーしました!",
"copy": {
"failed": "コピーに失敗しました",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} conteúdo(s) citado(s)",
"citations": "Citações",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Copiado",
"copy": {
"failed": "Cópia falhou",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Envio muito frequente, aguarde {{seconds}} segundos antes de tentar novamente"
}

View File

@@ -1730,6 +1730,9 @@
},
"citation": "{{count}} цитат",
"citations": "Содержание цитат",
"continue_generation": {
"prompt": "[to be translated]:Please continue your previous response exactly from where you left off. Do not repeat any content that was already generated. Continue directly from:\n\n{{truncatedContent}}"
},
"copied": "Скопировано!",
"copy": {
"failed": "Не удалось скопировать",
@@ -1941,6 +1944,14 @@
}
},
"warning": {
"finish_reason": {
"content-filter": "[to be translated]:Content was blocked by safety filter",
"continue": "[to be translated]:Continue generating",
"error": "[to be translated]:An error occurred during generation",
"length": "[to be translated]:Maximum output length limit reached",
"other": "[to be translated]:Generation terminated",
"unknown": "[to be translated]:Generation terminated for unknown reason"
},
"rate": {
"limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова."
}

View File

@@ -0,0 +1,64 @@
import type { FinishReason } from 'ai'
import { Alert as AntdAlert, Button } from 'antd'
import { Play } from 'lucide-react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
finishReason: FinishReason
onContinue?: () => void
onDismiss?: () => void
}
/**
* Displays a warning banner when message generation was truncated or filtered
* Only shows for non-normal finish reasons (not 'stop' or 'tool-calls')
*/
const FinishReasonWarning: React.FC<Props> = ({ finishReason, onContinue, onDismiss }) => {
const { t } = useTranslation()
// Don't show warning for normal finish reasons
if (finishReason === 'stop' || finishReason === 'tool-calls') {
return null
}
const getWarningMessage = () => {
const i18nKey = `message.warning.finish_reason.${finishReason}`
return t(i18nKey)
}
// Only show continue button for 'length' reason (max tokens reached)
const showContinueButton = finishReason === 'length' && onContinue
return (
<Alert
message={getWarningMessage()}
type="warning"
showIcon
closable={!!onDismiss}
onClose={onDismiss}
action={
showContinueButton && (
<Button
size="small"
type="text"
icon={<Play size={14} />}
onClick={onContinue}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('message.warning.finish_reason.continue')}
</Button>
)
}
/>
)
}
const Alert = styled(AntdAlert)`
margin: 0.5rem 0 !important;
padding: 8px 12px;
font-size: 12px;
align-items: center;
`
export default React.memo(FinishReasonWarning)

View File

@@ -13,7 +13,7 @@ import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import type { Assistant, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import type { Message as MessageType, MessageBlock } from '@renderer/types/newMessage'
import { classNames, cn } from '@renderer/utils'
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
import { Divider } from 'antd'
@@ -30,7 +30,7 @@ import MessageMenubar from './MessageMenubar'
import MessageOutline from './MessageOutline'
interface Props {
message: Message
message: MessageType
topic: Topic
assistant?: Assistant
index?: number
@@ -39,7 +39,7 @@ interface Props {
style?: React.CSSProperties
isGrouped?: boolean
isStreaming?: boolean
onSetMessages?: Dispatch<SetStateAction<Message[]>>
onSetMessages?: Dispatch<SetStateAction<MessageType[]>>
onUpdateUseful?: (msgId: string) => void
isGroupContextMessage?: boolean
}
@@ -116,6 +116,26 @@ const MessageItem: FC<Props> = ({
stopEditing()
}, [stopEditing])
// Handle continue generation when max tokens reached
const handleContinueGeneration = useCallback(
async (msg: MessageType) => {
if (!assistant) return
// Clear the finishReason first, then trigger continue generation
await editMessage(msg.id, { finishReason: undefined })
// Emit event to trigger continue generation
EventEmitter.emit(EVENT_NAMES.CONTINUE_GENERATION, { message: msg, assistant, topic })
},
[assistant, editMessage, topic]
)
// Handle dismiss warning (just clear finishReason)
const handleDismissWarning = useCallback(
async (msg: MessageType) => {
await editMessage(msg.id, { finishReason: undefined })
},
[editMessage]
)
const isLastMessage = index === 0 || !!isGrouped
const isAssistantMessage = message.role === 'assistant'
const isProcessing = isMessageProcessing(message)
@@ -223,7 +243,11 @@ const MessageItem: FC<Props> = ({
overflowY: 'visible'
}}>
<MessageErrorBoundary>
<MessageContent message={message} />
<MessageContent
message={message}
onContinueGeneration={handleContinueGeneration}
onDismissWarning={handleDismissWarning}
/>
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (

View File

@@ -6,11 +6,29 @@ import React from 'react'
import styled from 'styled-components'
import MessageBlockRenderer from './Blocks'
import FinishReasonWarning from './FinishReasonWarning'
interface Props {
message: Message
onContinueGeneration?: (message: Message) => void
onDismissWarning?: (message: Message) => void
}
const MessageContent: React.FC<Props> = ({ message }) => {
const MessageContent: React.FC<Props> = ({ message, onContinueGeneration, onDismissWarning }) => {
// Check if we should show finish reason warning
const showFinishReasonWarning =
message.role === 'assistant' &&
message.finishReason &&
!['stop', 'tool-calls', 'error'].includes(message.finishReason)
const handleContinue = () => {
onContinueGeneration?.(message)
}
const handleDismiss = () => {
onDismissWarning?.(message)
}
return (
<>
{!isEmpty(message.mentions) && (
@@ -21,6 +39,13 @@ const MessageContent: React.FC<Props> = ({ message }) => {
</Flex>
)}
<MessageBlockRenderer blocks={message.blocks} message={message} />
{showFinishReasonWarning && (
<FinishReasonWarning
finishReason={message.finishReason!}
onContinue={onContinueGeneration ? handleContinue : undefined}
onDismiss={onDismissWarning ? handleDismiss : undefined}
/>
)}
</>
)
}

View File

@@ -18,7 +18,11 @@ import { estimateHistoryTokens } from '@renderer/services/TokenService'
import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk'
import {
continueGenerationThunk,
saveMessageAndBlocksToDB,
updateMessageAndBlocksThunk
} from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import type { MessageBlock } from '@renderer/types/newMessage'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
@@ -233,6 +237,18 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
window.toast.error(t('code_block.edit.save.failed.label'))
}
}
),
EventEmitter.on(
EVENT_NAMES.CONTINUE_GENERATION,
async (data: { message: Message; assistant: Assistant; topic: Topic }) => {
const { message, assistant: msgAssistant, topic: msgTopic } = data
// Only handle if it's for the current topic
if (msgTopic.id !== topic.id) {
return
}
await dispatch(continueGenerationThunk(topic.id, message, msgAssistant))
scrollToBottom()
}
)
]

View File

@@ -28,5 +28,6 @@ export const EVENT_NAMES = {
RESEND_MESSAGE: 'RESEND_MESSAGE',
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK',
CHANGE_TOPIC: 'CHANGE_TOPIC'
CHANGE_TOPIC: 'CHANGE_TOPIC',
CONTINUE_GENERATION: 'CONTINUE_GENERATION'
}

View File

@@ -194,7 +194,12 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
}
}
const messageUpdates = { status, metrics: response?.metrics, usage: response?.usage }
const messageUpdates = {
status,
metrics: response?.metrics,
usage: response?.usage,
finishReason: response?.finishReason
}
dispatch(
newMessagesActions.updateMessage({
topicId,

View File

@@ -14,9 +14,15 @@ import store from '@renderer/store'
import { updateTopicUpdatedAt } from '@renderer/store/assistants'
import { type ApiServerConfig, type Assistant, type FileMetadata, type Model, type Topic } from '@renderer/types'
import type { AgentSessionEntity, GetAgentSessionResponse } from '@renderer/types/agent'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { FileMessageBlock, ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import {
AssistantMessageStatus,
MessageBlockStatus,
MessageBlockType,
UserMessageStatus
} from '@renderer/types/newMessage'
import { uuid } from '@renderer/utils'
import { addAbortController } from '@renderer/utils/abortController'
import {
@@ -26,6 +32,7 @@ import {
} from '@renderer/utils/agentSession'
import {
createAssistantMessage,
createMainTextBlock,
createTranslationBlock,
resetAssistantMessage
} from '@renderer/utils/messageUtils/create'
@@ -1494,6 +1501,230 @@ export const appendAssistantResponseThunk =
}
}
/**
* Thunk to continue generation from where an assistant message was truncated.
* Appends new content to the original truncated message instead of creating new messages.
* This avoids issues with APIs that don't support consecutive assistant messages.
* @param topicId - The topic ID.
* @param truncatedAssistantMessage - The assistant message that was truncated (finishReason: 'length').
* @param assistant - The assistant configuration.
*/
export const continueGenerationThunk =
(topicId: Topic['id'], truncatedAssistantMessage: Message, assistant: Assistant) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
try {
const state = getState()
// Verify the truncated message exists
if (!state.messages.entities[truncatedAssistantMessage.id]) {
logger.error(`[continueGenerationThunk] Truncated message ${truncatedAssistantMessage.id} not found.`)
return
}
// Get the content of the truncated message to include in the continuation prompt
const truncatedContent = getMainTextContent(truncatedAssistantMessage)
// Create a continuation prompt that asks the AI to continue strictly from where it left off
// Use only the last 150 chars to minimize repetition - just enough for context
const continuationPrompt = t('message.continue_generation.prompt', {
truncatedContent: truncatedContent.slice(-150)
})
// Update the truncated message status to PROCESSING to indicate continuation
const messageUpdates = {
status: AssistantMessageStatus.PROCESSING,
updatedAt: new Date().toISOString()
}
dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: truncatedAssistantMessage.id,
updates: messageUpdates
})
)
dispatch(updateTopicUpdatedAt({ topicId }))
// Queue the generation with continuation context
const queue = getTopicQueue(topicId)
const assistantConfig = {
...assistant,
model: truncatedAssistantMessage.model || assistant.model
}
queue.add(async () => {
await fetchAndProcessContinuationImpl(
dispatch,
getState,
topicId,
assistantConfig,
truncatedAssistantMessage,
continuationPrompt
)
})
} catch (error) {
logger.error(`[continueGenerationThunk] Error continuing generation:`, error as Error)
} finally {
finishTopicLoading(topicId)
}
}
/**
* Implementation for continuing generation on a truncated message.
* Similar to fetchAndProcessAssistantResponseImpl but:
* 1. Finds the existing main text block to append content to
* 2. Uses a continuation prompt to ask the AI to continue
* 3. Wraps the chunk processor to prepend existing content to new text
*/
const fetchAndProcessContinuationImpl = async (
dispatch: AppDispatch,
getState: () => RootState,
topicId: string,
origAssistant: Assistant,
truncatedMessage: Message,
continuationPrompt: string
) => {
const topic = origAssistant.topics.find((t) => t.id === topicId)
const assistant = topic?.prompt
? { ...origAssistant, prompt: `${origAssistant.prompt}\n${topic.prompt}` }
: origAssistant
const assistantMsgId = truncatedMessage.id
let callbacks: StreamProcessorCallbacks = {}
// Create a virtual user message with the continuation prompt
// We need to temporarily add the block to store so getMainTextContent can read it
const virtualUserMessageId = uuid()
const virtualTextBlock = createMainTextBlock(virtualUserMessageId, continuationPrompt, {
status: MessageBlockStatus.SUCCESS
})
try {
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true }))
// Find the existing main text block content to prepend
const state = getState()
const existingMainTextBlockId = truncatedMessage.blocks.find((blockId) => {
const block = state.messageBlocks.entities[blockId]
return block?.type === MessageBlockType.MAIN_TEXT
})
const existingContent = existingMainTextBlockId
? (state.messageBlocks.entities[existingMainTextBlockId] as any)?.content || ''
: ''
// Create BlockManager instance
const blockManager = new BlockManager({
dispatch,
getState,
saveUpdatedBlockToDB,
saveUpdatesToDB,
assistantMsgId,
topicId,
throttledBlockUpdate,
cancelThrottledBlockUpdate
})
const allMessagesForTopic = selectMessagesForTopic(getState(), topicId)
// Find the original user message that triggered this assistant response
const userMessageId = truncatedMessage.askId
const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessageId)
let messagesForContext: Message[] = []
if (userMessageIndex === -1) {
logger.error(
`[fetchAndProcessContinuationImpl] Triggering user message ${userMessageId} not found. Falling back.`
)
const assistantMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === assistantMsgId)
messagesForContext = (
assistantMessageIndex !== -1 ? allMessagesForTopic.slice(0, assistantMessageIndex) : allMessagesForTopic
).filter((m) => m && !m.status?.includes('ing'))
} else {
// Include messages up to the user message
const contextSlice = allMessagesForTopic.slice(0, userMessageIndex + 1)
messagesForContext = contextSlice.filter((m) => m && !m.status?.includes('ing'))
}
// Add the truncated assistant message content to context
const truncatedAssistantForContext: Message = {
...truncatedMessage,
status: AssistantMessageStatus.SUCCESS // Treat as completed for context
}
const virtualContinueMessage: Message = {
id: virtualUserMessageId,
role: 'user',
topicId,
assistantId: assistant.id,
createdAt: new Date().toISOString(),
status: UserMessageStatus.SUCCESS,
blocks: [virtualTextBlock.id],
model: assistant.model,
modelId: assistant.model?.id
}
// Temporarily add the block to store (will be removed in finally block)
dispatch(upsertOneBlock(virtualTextBlock))
// Build the final context: original context + truncated assistant + virtual user message
messagesForContext = [...messagesForContext, truncatedAssistantForContext, virtualContinueMessage]
// Create standard callbacks (no modification needed)
callbacks = createCallbacks({
blockManager,
dispatch,
getState,
topicId,
assistantMsgId,
saveUpdatesToDB,
assistant
})
const baseStreamProcessor = createStreamProcessor(callbacks)
// Wrap the stream processor to prepend existing content to text chunks
const wrappedStreamProcessor = (chunk: Chunk) => {
if (chunk.type === ChunkType.TEXT_DELTA || chunk.type === ChunkType.TEXT_COMPLETE) {
// Prepend existing content to the new text
return baseStreamProcessor({
...chunk,
text: existingContent + chunk.text
})
}
return baseStreamProcessor(chunk)
}
const abortController = new AbortController()
addAbortController(userMessageId!, () => abortController.abort())
await transformMessagesAndFetch(
{
messages: messagesForContext,
assistant,
topicId,
options: {
signal: abortController.signal,
timeout: 30000,
headers: defaultAppHeaders()
}
},
wrappedStreamProcessor
)
} catch (error: any) {
logger.error('Error in fetchAndProcessContinuationImpl:', error)
endSpan({
topicId,
error: error,
modelName: assistant.model?.name
})
try {
callbacks.onError?.(error)
} catch (callbackError) {
logger.error('Error in onError callback:', callbackError as Error)
}
} finally {
// Always clean up the temporary virtual block
dispatch(removeManyBlocks([virtualTextBlock.id]))
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
}
}
/**
* Clones messages from a source topic up to a specified index into a *pre-existing* new topic.
* Generates new unique IDs for all cloned messages and blocks.

View File

@@ -1,5 +1,5 @@
import type { CompletionUsage } from '@cherrystudio/openai/resources'
import type { ProviderMetadata } from 'ai'
import type { FinishReason, ProviderMetadata } from 'ai'
import type {
Assistant,
@@ -221,6 +221,10 @@ export type Message = {
// raw data
// TODO: add this providerMetadata to MessageBlock to save raw provider data for each block
providerMetadata?: ProviderMetadata
// Finish reason from AI SDK (e.g., 'stop', 'length', 'content-filter', etc.)
// Used to show warnings when generation was truncated or filtered
finishReason?: FinishReason
}
export interface Response {
@@ -232,6 +236,7 @@ export interface Response {
mcpToolResponse?: MCPToolResponse[]
generateImage?: GenerateImageResponse
error?: ResponseError
finishReason?: FinishReason
}
export type ResponseError = Record<string, any>