diff --git a/backend/routes/messages.py b/backend/routes/messages.py index b67474a..a87d30e 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -73,3 +73,33 @@ def delete_message(conv_id, msg_id): db.session.delete(msg) db.session.commit() return ok(message="deleted") + + +@bp.route("/api/conversations//regenerate/", methods=["POST"]) +def regenerate_message(conv_id, msg_id): + """Regenerate an assistant message""" + conv = db.session.get(Conversation, conv_id) + if not conv: + return err(404, "conversation not found") + + # 获取要重新生成的消息 + msg = db.session.get(Message, msg_id) + if not msg or msg.conversation_id != conv_id: + return err(404, "message not found") + + if msg.role != "assistant": + return err(400, "can only regenerate assistant messages") + + # 删除该消息及其后面的所有消息 + Message.query.filter( + Message.conversation_id == conv_id, + Message.created_at >= msg.created_at + ).delete(synchronize_session=False) + db.session.commit() + + # 获取工具启用状态 + d = request.json or {} + tools_enabled = d.get("tools_enabled", True) + + # 流式重新生成 + return _chat_service.stream_response(conv, tools_enabled) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a18f825..e830a8d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,6 +25,7 @@ :tools-enabled="toolsEnabled" @send-message="sendMessage" @delete-message="deleteMessage" + @regenerate-message="regenerateMessage" @toggle-settings="showSettings = true" @load-more-messages="loadMoreMessages" @toggle-tools="updateToolsEnabled" @@ -359,6 +360,99 @@ async function deleteMessage(msgId) { } } +// -- Regenerate message -- +async function regenerateMessage(msgId) { + if (!currentConvId.value || streaming.value) return + + const convId = currentConvId.value + + // 找到要重新生成的消息索引 + const msgIndex = messages.value.findIndex(m => m.id === msgId) + if (msgIndex === -1) return + + // 移除该消息及其后面的所有消息 + messages.value = messages.value.slice(0, msgIndex) + + streaming.value = true + streamContent.value = '' + streamThinking.value = '' + streamToolCalls.value = [] + streamProcessSteps.value = [] + + currentStreamPromise = messageApi.regenerate(convId, msgId, { + toolsEnabled: toolsEnabled.value, + onThinkingStart() { + if (currentConvId.value === convId) { + streamThinking.value = '' + } + }, + onThinking(text) { + if (currentConvId.value === convId) { + streamThinking.value += text + } + }, + onMessage(text) { + if (currentConvId.value === convId) { + streamContent.value += text + } + }, + onToolCalls(calls) { + if (currentConvId.value === convId) { + streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null }))) + } + }, + onToolResult(result) { + if (currentConvId.value === convId) { + const call = streamToolCalls.value.find(c => c.id === result.id) + if (call) call.result = result.content + } + }, + onProcessStep(step) { + const idx = step.index + if (currentConvId.value === convId) { + const newSteps = [...streamProcessSteps.value] + while (newSteps.length <= idx) { + newSteps.push(null) + } + newSteps[idx] = step + streamProcessSteps.value = newSteps + } + }, + async onDone(data) { + if (currentConvId.value === convId) { + streaming.value = false + currentStreamPromise = null + messages.value.push({ + id: data.message_id, + conversation_id: convId, + role: 'assistant', + content: streamContent.value, + token_count: data.token_count, + thinking_content: streamThinking.value || null, + tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null, + process_steps: streamProcessSteps.value.filter(Boolean), + created_at: new Date().toISOString(), + }) + streamContent.value = '' + streamThinking.value = '' + streamToolCalls.value = [] + streamProcessSteps.value = [] + } + }, + onError(msg) { + if (currentConvId.value === convId) { + streaming.value = false + currentStreamPromise = null + streamContent.value = '' + streamThinking.value = '' + streamToolCalls.value = [] + streamProcessSteps.value = [] + console.error('Regenerate error:', msg) + } + }, + }) +} + // -- Delete conversation -- async function deleteConversation(id) { try { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 5e5749e..2223e99 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -172,4 +172,71 @@ export const messageApi = { delete(convId, msgId) { return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' }) }, + + regenerate(convId, msgId, { toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) { + const controller = new AbortController() + + const promise = (async () => { + try { + const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tools_enabled: toolsEnabled }), + signal: controller.signal, + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message || `HTTP ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + let currentEvent = '' + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7).trim() + } else if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) + if (currentEvent === 'thinking_start' && onThinkingStart) { + onThinkingStart() + } else if (currentEvent === 'thinking' && onThinking) { + onThinking(data.content) + } else if (currentEvent === 'message' && onMessage) { + onMessage(data.content) + } else if (currentEvent === 'tool_calls' && onToolCalls) { + onToolCalls(data.calls) + } else if (currentEvent === 'tool_result' && onToolResult) { + onToolResult(data) + } else if (currentEvent === 'process_step' && onProcessStep) { + onProcessStep(data) + } else if (currentEvent === 'done' && onDone) { + onDone(data) + } else if (currentEvent === 'error' && onError) { + onError(data.content) + } + } + } + } + } catch (e) { + if (e.name !== 'AbortError' && onError) { + onError(e.message) + } + } + })() + + promise.abort = () => controller.abort() + + return promise + }, } diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index 66c46d8..f3e36b2 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -44,6 +44,7 @@ :created-at="msg.created_at" :deletable="msg.role === 'user'" @delete="$emit('deleteMessage', msg.id)" + @regenerate="$emit('regenerateMessage', msg.id)" />
@@ -98,7 +99,7 @@ const props = defineProps({ toolsEnabled: { type: Boolean, default: true }, }) -defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools']) +defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools']) const scrollContainer = ref(null) const inputRef = ref(null) @@ -142,13 +143,12 @@ defineExpose({ scrollToBottom })