mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(ai): limit editing to latest turn
Keep compression summaries before the in-flight question and let the Node runtime validate and atomically replace only the terminal question-answer round.
This commit is contained in:
1 parent
f35e41ca70
commit
cc240c85f5
15 files changed
+192
-138
No files matched your search
@@ -135,8 +135,8 @@ test('message round replacement route rolls back every edit when the replacement
|
||||
})
|
||||
|
||||
const chat = manager.createAIChat('session-1', 'Editable chat', 'general_cn')
|
||||
const firstTurn = manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
|
||||
manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
|
||||
manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
|
||||
const latestTurn = manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
|
||||
const messagesBeforeFailure = manager.getMessages(chat.id)
|
||||
|
||||
manager.executeAiSQL(`
|
||||
@@ -150,11 +150,10 @@ test('message round replacement route rolls back every edit when the replacement
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/_web/ai/chats/${chat.id}/replace-message-round`,
|
||||
url: `/_web/ai/chats/${chat.id}/replace-latest-message-round`,
|
||||
payload: {
|
||||
userMessageId: firstTurn.userMessage.id,
|
||||
userMessageId: latestTurn.userMessage.id,
|
||||
userContent: 'must roll back',
|
||||
oldAssistantMessageId: firstTurn.assistantMessage.id,
|
||||
assistantMessage: { content: 'must not persist' },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -75,15 +75,14 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: AiChatRouteCo
|
||||
Body: {
|
||||
userMessageId: string
|
||||
userContent: string
|
||||
oldAssistantMessageId?: string
|
||||
assistantMessage: {
|
||||
content: string
|
||||
contentBlocks?: ContentBlock[]
|
||||
tokenUsage?: TokenUsageData
|
||||
}
|
||||
}
|
||||
}>('/_web/ai/chats/:id/replace-message-round', async (request) => {
|
||||
return cm.replaceMessageRound(request.params.id, request.body)
|
||||
}>('/_web/ai/chats/:id/replace-latest-message-round', async (request) => {
|
||||
return cm.replaceLatestMessageRound(request.params.id, request.body)
|
||||
})
|
||||
|
||||
server.post<{
|
||||
|
||||
@@ -482,7 +482,7 @@ describe('AIChatManager message persistence and editing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an edited message round atomically', () => {
|
||||
it('replaces only the latest message round atomically', () => {
|
||||
const dir = createTempDir()
|
||||
try {
|
||||
const manager = createManager(dir)
|
||||
@@ -490,21 +490,29 @@ describe('AIChatManager message persistence and editing', () => {
|
||||
const firstTurn = manager.addMessagePair(chat.id, { content: 'question' }, { content: 'old answer' })
|
||||
const secondTurn = manager.addMessagePair(chat.id, { content: 'follow up' }, { content: 'follow answer' })
|
||||
|
||||
const replacement = manager.replaceMessageRound(chat.id, {
|
||||
userMessageId: firstTurn.userMessage.id,
|
||||
userContent: 'edited question',
|
||||
oldAssistantMessageId: firstTurn.assistantMessage.id,
|
||||
assert.throws(
|
||||
() =>
|
||||
manager.replaceLatestMessageRound(chat.id, {
|
||||
userMessageId: firstTurn.userMessage.id,
|
||||
userContent: 'must not edit history',
|
||||
assistantMessage: { content: 'must not persist' },
|
||||
}),
|
||||
/Only the latest user message can be edited/
|
||||
)
|
||||
|
||||
const replacement = manager.replaceLatestMessageRound(chat.id, {
|
||||
userMessageId: secondTurn.userMessage.id,
|
||||
userContent: 'edited follow up',
|
||||
assistantMessage: { content: 'new answer' },
|
||||
})
|
||||
|
||||
const replacedMessages = manager.getMessages(chat.id)
|
||||
assert.deepEqual(
|
||||
replacedMessages.map((message) => message.content),
|
||||
['edited question', 'new answer', 'follow up', 'follow answer']
|
||||
['question', 'old answer', 'edited follow up', 'new answer']
|
||||
)
|
||||
assert.equal(replacement.parentId, firstTurn.userMessage.id)
|
||||
assert.equal(replacedMessages[2]?.parentId, replacement.id)
|
||||
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, secondTurn.assistantMessage.id)
|
||||
assert.equal(replacement.parentId, secondTurn.userMessage.id)
|
||||
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, replacement.id)
|
||||
|
||||
const messagesBeforeFailure = manager.getMessages(chat.id)
|
||||
manager.executeAiSQL(`
|
||||
@@ -518,16 +526,15 @@ describe('AIChatManager message persistence and editing', () => {
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
manager.replaceMessageRound(chat.id, {
|
||||
userMessageId: firstTurn.userMessage.id,
|
||||
manager.replaceLatestMessageRound(chat.id, {
|
||||
userMessageId: secondTurn.userMessage.id,
|
||||
userContent: 'must roll back',
|
||||
oldAssistantMessageId: replacement.id,
|
||||
assistantMessage: { content: 'must not persist' },
|
||||
}),
|
||||
/injected replacement failure/
|
||||
)
|
||||
assert.deepEqual(manager.getMessages(chat.id), messagesBeforeFailure)
|
||||
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, secondTurn.assistantMessage.id)
|
||||
assert.equal(manager.getAIChat(chat.id)?.activeMessageId, replacement.id)
|
||||
manager.close()
|
||||
} finally {
|
||||
cleanup(dir)
|
||||
|
||||
@@ -700,38 +700,37 @@ export class AIChatManager {
|
||||
}))()
|
||||
}
|
||||
|
||||
/** 原子替换当前问答轮,避免新回复插入失败后留下已修改问题或已删除旧回复。 */
|
||||
replaceMessageRound(
|
||||
/** Atomically replaces the latest user/assistant round on the active path. */
|
||||
replaceLatestMessageRound(
|
||||
aiChatId: string,
|
||||
input: {
|
||||
userMessageId: string
|
||||
userContent: string
|
||||
oldAssistantMessageId?: string
|
||||
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
|
||||
}
|
||||
): AIMessage {
|
||||
const db = this.getDb()
|
||||
return db.transaction(() => {
|
||||
const userMessage = this.getMessageRow(input.userMessageId)
|
||||
if (!userMessage || userMessage.aiChatId !== aiChatId || userMessage.role !== 'user') {
|
||||
throw new Error('User message not found in AI chat')
|
||||
const activePath = this.getActivePathRows(aiChatId)
|
||||
const latestUserIndex = activePath.findLastIndex((message) => message.role === 'user')
|
||||
const userMessage = activePath[latestUserIndex]
|
||||
if (!userMessage || userMessage.id !== input.userMessageId) {
|
||||
throw new Error('Only the latest user message can be edited')
|
||||
}
|
||||
|
||||
if (input.oldAssistantMessageId) {
|
||||
const oldAssistantMessage = this.getMessageRow(input.oldAssistantMessageId)
|
||||
if (
|
||||
!oldAssistantMessage ||
|
||||
oldAssistantMessage.aiChatId !== aiChatId ||
|
||||
oldAssistantMessage.role !== 'assistant' ||
|
||||
oldAssistantMessage.parentId !== input.userMessageId
|
||||
) {
|
||||
throw new Error('Assistant message not found after user message')
|
||||
}
|
||||
const followingMessages = activePath.slice(latestUserIndex + 1)
|
||||
if (
|
||||
followingMessages.length > 1 ||
|
||||
(followingMessages.length === 1 &&
|
||||
(followingMessages[0]?.role !== 'assistant' || followingMessages[0].parentId !== input.userMessageId))
|
||||
) {
|
||||
throw new Error('Latest user message is not a replaceable round')
|
||||
}
|
||||
|
||||
const oldAssistantMessage = followingMessages[0]
|
||||
this.updateMessageContent(input.userMessageId, input.userContent)
|
||||
if (input.oldAssistantMessageId) {
|
||||
this.deleteAndRelinkMessage(aiChatId, input.oldAssistantMessageId)
|
||||
if (oldAssistantMessage) {
|
||||
this.deleteAndRelinkMessage(aiChatId, oldAssistantMessage.id)
|
||||
}
|
||||
|
||||
return this.insertMessageAfter(
|
||||
|
||||
@@ -204,6 +204,17 @@ const assistantBackupName = computed(() => {
|
||||
|
||||
// QA 对
|
||||
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
|
||||
const latestEditableUserMessageId = computed(() => {
|
||||
const lastUserIndex = messages.value.findLastIndex((message) => message.role === 'user')
|
||||
if (lastUserIndex < 0) return null
|
||||
|
||||
const followingMessages = messages.value.slice(lastUserIndex + 1)
|
||||
if (followingMessages.length === 0) return messages.value[lastUserIndex]?.id ?? null
|
||||
if (followingMessages.length === 1 && followingMessages[0]?.role === 'assistant') {
|
||||
return messages.value[lastUserIndex]?.id ?? null
|
||||
}
|
||||
return null
|
||||
})
|
||||
const progressiveHistory = useProgressiveChatHistory(qaPairs, currentAIChatId, chatScroll.messagesContainer)
|
||||
const { visiblePairs, hasOlderPairs, loadOlderPairs } = progressiveHistory
|
||||
|
||||
@@ -372,10 +383,8 @@ async function handleSend(payload: {
|
||||
conversationListRef.value?.refresh()
|
||||
}
|
||||
|
||||
async function handleEditMessage(payload: { messageId: string; content: string; overwriteSubsequent?: boolean }) {
|
||||
const result = await editMessageAndRegenerate(payload.messageId, payload.content, {
|
||||
overwriteSubsequent: payload.overwriteSubsequent,
|
||||
})
|
||||
async function handleEditMessage(payload: { messageId: string; content: string }) {
|
||||
const result = await editMessageAndRegenerate(payload.messageId, payload.content)
|
||||
if (!result.success) {
|
||||
if (result.reason === 'busy') {
|
||||
showRunningTaskToast()
|
||||
@@ -584,7 +593,7 @@ watch(
|
||||
:timestamp="pair.user.timestamp"
|
||||
:is-streaming="pair.user.isStreaming"
|
||||
:content-blocks="pair.user.contentBlocks"
|
||||
:editable="!isAIThinking"
|
||||
:editable="!isAIThinking && pair.user.id === latestEditableUserMessageId"
|
||||
@edit="handleEditMessage"
|
||||
/>
|
||||
<!-- AI 回复 -->
|
||||
|
||||
@@ -56,7 +56,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [payload: { messageId: string; content: string; overwriteSubsequent?: boolean }]
|
||||
edit: [payload: { messageId: string; content: string }]
|
||||
fork: [messageId: string]
|
||||
}>()
|
||||
|
||||
@@ -82,7 +82,6 @@ const editContent = ref(props.content)
|
||||
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const canEdit = computed(() => isUser.value && props.editable && !props.isStreaming && !!props.messageId)
|
||||
const canFork = computed(() => !isUser.value && !isSummary.value && !props.isStreaming && !!props.messageId)
|
||||
const overwriteSubsequent = ref(false)
|
||||
|
||||
// 创建 markdown-it 实例
|
||||
const md = new MarkdownIt({
|
||||
@@ -176,7 +175,6 @@ async function startEditing() {
|
||||
function cancelEditing() {
|
||||
isEditing.value = false
|
||||
editContent.value = props.content
|
||||
overwriteSubsequent.value = false
|
||||
}
|
||||
|
||||
function submitEditing() {
|
||||
@@ -187,8 +185,7 @@ function submitEditing() {
|
||||
return
|
||||
}
|
||||
isEditing.value = false
|
||||
emit('edit', { messageId: props.messageId, content, overwriteSubsequent: overwriteSubsequent.value })
|
||||
overwriteSubsequent.value = false
|
||||
emit('edit', { messageId: props.messageId, content })
|
||||
}
|
||||
|
||||
function getDisplayText(text: string): string {
|
||||
@@ -833,15 +830,7 @@ async function handleCopyMarkdown() {
|
||||
@keydown.ctrl.enter.prevent="submitEditing"
|
||||
@keydown.meta.enter.prevent="submitEditing"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<input
|
||||
v-model="overwriteSubsequent"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-gray-600"
|
||||
/>
|
||||
{{ t('ai.chat.message.edit.overwriteSubsequent') }}
|
||||
</label>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<div class="flex gap-2">
|
||||
<UButton size="xs" variant="ghost" color="gray" @click="cancelEditing">
|
||||
{{ t('common.cancel') }}
|
||||
|
||||
@@ -69,8 +69,8 @@ export function useAIChat(
|
||||
agentStatus: toRef(state, 'agentStatus'),
|
||||
selectedAssistantId: toRef(state, 'selectedAssistantId'),
|
||||
sendMessage: (content: string, options?: SendMessageOptions) => aiChatStore.sendMessage(chatKey, content, options),
|
||||
editMessageAndRegenerate: (messageId: string, content: string, options?: { overwriteSubsequent?: boolean }) =>
|
||||
aiChatStore.editMessageAndRegenerate(chatKey, messageId, content, options),
|
||||
editMessageAndRegenerate: (messageId: string, content: string) =>
|
||||
aiChatStore.editMessageAndRegenerate(chatKey, messageId, content),
|
||||
loadAIChat: (aiChatId: string) => aiChatStore.loadAIChat(chatKey, aiChatId),
|
||||
startNewAIChat: (welcomeMessage?: string) => aiChatStore.startNewAIChat(chatKey, welcomeMessage),
|
||||
loadMoreSourceMessages: () => aiChatStore.loadMoreSourceMessages(),
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"tooltip": "Edit and regenerate",
|
||||
"submit": "Send",
|
||||
"overwriteSubsequent": "Overwrite all subsequent messages"
|
||||
"submit": "Send"
|
||||
},
|
||||
"fork": {
|
||||
"tooltip": "Fork conversation from here"
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"tooltip": "編集して再生成",
|
||||
"submit": "送信",
|
||||
"overwriteSubsequent": "以降のすべての会話を上書き"
|
||||
"submit": "送信"
|
||||
},
|
||||
"fork": {
|
||||
"tooltip": "ここから会話をフォーク"
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"tooltip": "编辑并重新生成",
|
||||
"submit": "发送",
|
||||
"overwriteSubsequent": "覆盖后续所有对话"
|
||||
"submit": "发送"
|
||||
},
|
||||
"fork": {
|
||||
"tooltip": "从此处复刻对话"
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"tooltip": "編輯並重新生成",
|
||||
"submit": "送出",
|
||||
"overwriteSubsequent": "覆蓋後續所有對話"
|
||||
"submit": "送出"
|
||||
},
|
||||
"fork": {
|
||||
"tooltip": "從此處複製對話"
|
||||
|
||||
@@ -103,16 +103,15 @@ export class FetchAIAdapter implements AIAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async replaceMessageRound(
|
||||
async replaceLatestMessageRound(
|
||||
aiChatId: string,
|
||||
input: {
|
||||
userMessageId: string
|
||||
userContent: string
|
||||
oldAssistantMessageId?: string
|
||||
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
|
||||
}
|
||||
): Promise<AIMessage> {
|
||||
return post<AIMessage>(`/ai/chats/${aiChatId}/replace-message-round`, input)
|
||||
return post<AIMessage>(`/ai/chats/${aiChatId}/replace-latest-message-round`, input)
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
|
||||
@@ -146,12 +146,11 @@ export interface AIAdapter {
|
||||
userMessage: { content: string; entityRefs?: AIEntityRef[] },
|
||||
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
|
||||
): Promise<{ userMessage: AIMessage; assistantMessage: AIMessage }>
|
||||
replaceMessageRound(
|
||||
replaceLatestMessageRound(
|
||||
aiChatId: string,
|
||||
input: {
|
||||
userMessageId: string
|
||||
userContent: string
|
||||
oldAssistantMessageId?: string
|
||||
assistantMessage: { content: string; contentBlocks?: ContentBlock[]; tokenUsage?: TokenUsageData }
|
||||
}
|
||||
): Promise<AIMessage>
|
||||
|
||||
+12
-50
@@ -1349,7 +1349,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
content: chunk.compressionResult.summaryContent,
|
||||
timestamp: chunk.compressionResult.timestamp,
|
||||
}
|
||||
const insertIdx = Math.max(0, targetBuffer.messages.length - 1)
|
||||
// Compression is persisted before the in-flight user/assistant pair.
|
||||
const insertIdx = Math.max(0, targetBuffer.messages.length - 2)
|
||||
targetBuffer.messages.splice(insertIdx, 0, summaryMsg)
|
||||
aiMessageIndex++
|
||||
}
|
||||
@@ -1642,8 +1643,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
async function editMessageAndRegenerate(
|
||||
chatKey: string,
|
||||
messageId: string,
|
||||
newContent: string,
|
||||
options?: { overwriteSubsequent?: boolean }
|
||||
newContent: string
|
||||
): Promise<SendMessageResult> {
|
||||
const state = getSessionState(chatKey)
|
||||
const content = newContent.trim()
|
||||
@@ -1653,7 +1653,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
return { success: false, reason: 'busy', activeTask: activeTask.value }
|
||||
}
|
||||
|
||||
const overwriteAll = options?.overwriteSubsequent ?? false
|
||||
const targetBuffer = getOrCreateBuffer(state, state.currentAIChatId, state.selectedAssistantId)
|
||||
const editIndex = targetBuffer.messages.findIndex((message) => message.id === messageId)
|
||||
const originalMessage = targetBuffer.messages[editIndex]
|
||||
@@ -1664,42 +1663,18 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
return { success: false, reason: 'empty' }
|
||||
}
|
||||
|
||||
if (overwriteAll) {
|
||||
return editAndOverwriteAll(chatKey, state, targetBuffer, editIndex, originalMessage, content)
|
||||
}
|
||||
return editCurrentRoundOnly(chatKey, state, targetBuffer, editIndex, originalMessage, content)
|
||||
}
|
||||
|
||||
async function editAndOverwriteAll(
|
||||
chatKey: string,
|
||||
state: AIChatSessionState,
|
||||
targetBuffer: AIChatBuffer,
|
||||
editIndex: number,
|
||||
originalMessage: ChatMessage,
|
||||
content: string
|
||||
): Promise<SendMessageResult> {
|
||||
try {
|
||||
const hasConfig = await useLLMService().hasConfig()
|
||||
if (!hasConfig) {
|
||||
return { success: false, reason: 'no_config' }
|
||||
}
|
||||
if (state.isAborted) {
|
||||
return { success: false, reason: 'aborted' }
|
||||
}
|
||||
if (state.isAIThinking || activeTask.value) {
|
||||
return { success: false, reason: 'busy', activeTask: activeTask.value }
|
||||
}
|
||||
|
||||
await useAIService().deleteMessagesFrom(state.currentAIChatId!, originalMessage.id)
|
||||
targetBuffer.messages.splice(editIndex, targetBuffer.messages.length - editIndex)
|
||||
return sendMessage(chatKey, content)
|
||||
} catch (error) {
|
||||
console.error('[AI] edit and overwrite failed:', error)
|
||||
const latestUserIndex = targetBuffer.messages.findLastIndex((message) => message.role === 'user')
|
||||
const followingMessages = targetBuffer.messages.slice(editIndex + 1)
|
||||
const hasReplaceableTail =
|
||||
editIndex === latestUserIndex &&
|
||||
(followingMessages.length === 0 || (followingMessages.length === 1 && followingMessages[0]?.role === 'assistant'))
|
||||
if (!hasReplaceableTail) {
|
||||
return { success: false, reason: 'error' }
|
||||
}
|
||||
return editLatestRound(chatKey, state, targetBuffer, editIndex, originalMessage, content)
|
||||
}
|
||||
|
||||
async function editCurrentRoundOnly(
|
||||
async function editLatestRound(
|
||||
chatKey: string,
|
||||
state: AIChatSessionState,
|
||||
targetBuffer: AIChatBuffer,
|
||||
@@ -1727,9 +1702,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
|
||||
const oldAiResponse = targetBuffer.messages[editIndex + 1]
|
||||
const hasOldAiResponse = oldAiResponse?.role === 'assistant'
|
||||
const subsequentMessages = hasOldAiResponse
|
||||
? targetBuffer.messages.slice(editIndex + 2)
|
||||
: targetBuffer.messages.slice(editIndex + 1)
|
||||
|
||||
const aiPlaceholder: ChatMessage = {
|
||||
id: generateId('ai'),
|
||||
@@ -1759,7 +1731,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
if (hasOldAiResponse) {
|
||||
targetBuffer.messages.splice(editIndex + 1, 0, oldAiResponse)
|
||||
}
|
||||
targetBuffer.messages.push(...subsequentMessages)
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -2001,10 +1972,9 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
}
|
||||
|
||||
const serializableContentBlocks = toSerializableContentBlocks(targetBuffer.messages[aiMessageIndex].contentBlocks)
|
||||
const savedAiMsg = await useAIService().replaceMessageRound(state.currentAIChatId!, {
|
||||
const savedAiMsg = await useAIService().replaceLatestMessageRound(state.currentAIChatId!, {
|
||||
userMessageId: originalMessage.id,
|
||||
userContent: content,
|
||||
oldAssistantMessageId: hasOldAiResponse ? oldAiResponse.id : undefined,
|
||||
assistantMessage: {
|
||||
content: targetBuffer.messages[aiMessageIndex].content,
|
||||
contentBlocks: serializableContentBlocks,
|
||||
@@ -2023,14 +1993,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
content,
|
||||
}
|
||||
|
||||
const nextMsgIndex = aiMessageIndex + 1
|
||||
if (nextMsgIndex < targetBuffer.messages.length) {
|
||||
targetBuffer.messages[nextMsgIndex] = {
|
||||
...targetBuffer.messages[nextMsgIndex],
|
||||
parentId: savedAiMsg.id,
|
||||
}
|
||||
}
|
||||
|
||||
targetBuffer.sessionTokenUsage = toTokenUsage(await useAIService().getAIChatTokenUsage(state.currentAIChatId!))
|
||||
state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage }
|
||||
return { success: true }
|
||||
|
||||
@@ -10,7 +10,8 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
let singleMessageWrites = 0
|
||||
let messageRoundReplacements = 0
|
||||
let legacyEditWrites = 0
|
||||
let replacementInput: Parameters<AIAdapter['replaceMessageRound']>[1] | undefined
|
||||
let agentRuns = 0
|
||||
const replacementInputs: Array<Parameters<AIAdapter['replaceLatestMessageRound']>[1]> = []
|
||||
const persistenceError = new Error('injected persistence failure')
|
||||
const aiService = {
|
||||
createAIChat: async (sessionId: string, title: string, assistantId: string) => ({
|
||||
@@ -22,9 +23,31 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}),
|
||||
addMessagePair: async () => {
|
||||
addMessagePair: async (
|
||||
aiChatId: string,
|
||||
userMessage: { content: string },
|
||||
assistantMessage: { content: string }
|
||||
) => {
|
||||
messagePairWrites++
|
||||
throw persistenceError
|
||||
if (userMessage.content === 'atomic question') throw persistenceError
|
||||
return {
|
||||
userMessage: {
|
||||
id: 'compressed-user',
|
||||
aiChatId,
|
||||
role: 'user' as const,
|
||||
content: userMessage.content,
|
||||
timestamp: 3,
|
||||
parentId: 'summary-1',
|
||||
},
|
||||
assistantMessage: {
|
||||
id: 'compressed-assistant',
|
||||
aiChatId,
|
||||
role: 'assistant' as const,
|
||||
content: assistantMessage.content,
|
||||
timestamp: 4,
|
||||
parentId: 'compressed-user',
|
||||
},
|
||||
}
|
||||
},
|
||||
addMessage: async (aiChatId: string, role: 'user' | 'assistant', content: string) => {
|
||||
singleMessageWrites++
|
||||
@@ -41,13 +64,29 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
updatedAt: 1,
|
||||
}),
|
||||
getMessages: async (aiChatId: string) => [
|
||||
{ id: 'edit-user', aiChatId, role: 'user' as const, content: 'original question', timestamp: 1 },
|
||||
{ id: 'historical-user', aiChatId, role: 'user' as const, content: 'historical question', timestamp: 1 },
|
||||
{
|
||||
id: 'historical-assistant',
|
||||
aiChatId,
|
||||
role: 'assistant' as const,
|
||||
content: 'historical answer',
|
||||
timestamp: 2,
|
||||
parentId: 'historical-user',
|
||||
},
|
||||
{
|
||||
id: 'edit-user',
|
||||
aiChatId,
|
||||
role: 'user' as const,
|
||||
content: 'original question',
|
||||
timestamp: 3,
|
||||
parentId: 'historical-assistant',
|
||||
},
|
||||
{
|
||||
id: 'edit-assistant',
|
||||
aiChatId,
|
||||
role: 'assistant' as const,
|
||||
content: 'original answer',
|
||||
timestamp: 2,
|
||||
timestamp: 4,
|
||||
parentId: 'edit-user',
|
||||
},
|
||||
],
|
||||
@@ -58,9 +97,12 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}),
|
||||
replaceMessageRound: async (aiChatId: string, input: Parameters<AIAdapter['replaceMessageRound']>[1]) => {
|
||||
replaceLatestMessageRound: async (
|
||||
aiChatId: string,
|
||||
input: Parameters<AIAdapter['replaceLatestMessageRound']>[1]
|
||||
) => {
|
||||
messageRoundReplacements++
|
||||
replacementInput = input
|
||||
replacementInputs.push(input)
|
||||
return {
|
||||
id: 'replacement-assistant',
|
||||
aiChatId,
|
||||
@@ -130,7 +172,19 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
t.mock.module('@/services/ai-stream/service', {
|
||||
namedExports: {
|
||||
useAgentStreamService: () => ({
|
||||
runStream: (_params: unknown, onChunk?: (chunk: AgentStreamChunk) => void) => {
|
||||
runStream: (params: unknown, onChunk?: (chunk: AgentStreamChunk) => void) => {
|
||||
agentRuns++
|
||||
if ((params as { userMessage?: string }).userMessage === 'compressed question') {
|
||||
onChunk?.({
|
||||
type: 'compression_done',
|
||||
compressionResult: {
|
||||
summaryContent: 'compressed context',
|
||||
tokensBefore: 100,
|
||||
tokensAfter: 20,
|
||||
timestamp: 2,
|
||||
},
|
||||
})
|
||||
}
|
||||
onChunk?.({ type: 'content', content: 'model answer' })
|
||||
onChunk?.({ type: 'done', isFinished: true })
|
||||
return {
|
||||
@@ -172,16 +226,58 @@ test('uses atomic persistence operations for new and edited turns', async (t) =>
|
||||
})
|
||||
assert.equal(await store.loadAIChat(editChatKey, 'chat-edit'), true)
|
||||
|
||||
const historicalEditResult = await store.editMessageAndRegenerate(
|
||||
editChatKey,
|
||||
'historical-user',
|
||||
'must not edit history'
|
||||
)
|
||||
assert.deepEqual(historicalEditResult, { success: false, reason: 'error' })
|
||||
assert.equal(agentRuns, 1)
|
||||
assert.equal(messageRoundReplacements, 0)
|
||||
|
||||
const editResult = await store.editMessageAndRegenerate(editChatKey, 'edit-user', 'edited question')
|
||||
|
||||
assert.deepEqual(editResult, { success: true })
|
||||
assert.equal(messageRoundReplacements, 1)
|
||||
assert.equal(legacyEditWrites, 0)
|
||||
assert.ok(replacementInput)
|
||||
assert.equal(replacementInput.userMessageId, 'edit-user')
|
||||
assert.equal(replacementInput.userContent, 'edited question')
|
||||
assert.equal(replacementInput.oldAssistantMessageId, 'edit-assistant')
|
||||
assert.equal(replacementInput.assistantMessage.content, 'model answer')
|
||||
assert.equal(replacementInput.assistantMessage.contentBlocks?.length, 1)
|
||||
assert.equal(replacementInput.assistantMessage.contentBlocks?.[0]?.type, 'text')
|
||||
const editInput = replacementInputs[0]
|
||||
assert.ok(editInput)
|
||||
assert.equal(editInput.userMessageId, 'edit-user')
|
||||
assert.equal(editInput.userContent, 'edited question')
|
||||
assert.equal(editInput.assistantMessage.content, 'model answer')
|
||||
assert.equal(editInput.assistantMessage.contentBlocks?.length, 1)
|
||||
assert.equal(editInput.assistantMessage.contentBlocks?.[0]?.type, 'text')
|
||||
|
||||
const { chatKey: compressionChatKey, state: compressionState } = store.ensureSessionState({
|
||||
sessionId: 'session-3',
|
||||
sessionName: 'Compressed session',
|
||||
chatType: 'private',
|
||||
locale: 'zh-CN',
|
||||
})
|
||||
store.selectAssistantForSession(compressionChatKey, 'general_cn')
|
||||
|
||||
const compressedResult = await store.sendMessage(compressionChatKey, 'compressed question')
|
||||
|
||||
assert.deepEqual(compressedResult, { success: true })
|
||||
assert.deepEqual(
|
||||
compressionState.messages.map((message) => message.role),
|
||||
['summary', 'user', 'assistant']
|
||||
)
|
||||
assert.equal(compressionState.messages[1]?.id, 'compressed-user')
|
||||
assert.equal(compressionState.messages[2]?.id, 'compressed-assistant')
|
||||
|
||||
const compressedEditResult = await store.editMessageAndRegenerate(
|
||||
compressionChatKey,
|
||||
'compressed-user',
|
||||
'edited compressed question'
|
||||
)
|
||||
|
||||
assert.deepEqual(compressedEditResult, { success: true })
|
||||
assert.equal(messageRoundReplacements, 2)
|
||||
assert.equal(replacementInputs[1]?.userMessageId, 'compressed-user')
|
||||
assert.equal(replacementInputs[1]?.userContent, 'edited compressed question')
|
||||
assert.deepEqual(
|
||||
compressionState.messages.map((message) => message.role),
|
||||
['summary', 'user', 'assistant']
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user