From ac2d36bf9c9773148672b998380d3beda7af4261 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 00:09:14 +0800 Subject: [PATCH] fix(chat): preserve retry attachments and per-answer context snapshots --- backend/app/contracts.py | 1 + backend/app/database/migrations.py | 1 + backend/app/routes.py | 3 ++ backend/app/services/chat_history.py | 6 ++- backend/tests/test_chat_versions.py | 37 +++++++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 4 ++ .../development/聊天按需检索与Markdown工具.md | 8 ++++ frontend/src/contracts/index.ts | 1 + frontend/src/stores/chat.spec.ts | 35 ++++++++++++++++++ frontend/src/stores/chat.ts | 15 ++++++-- 10 files changed, 106 insertions(+), 5 deletions(-) diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 35db143..facf82f 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -312,6 +312,7 @@ class ConversationListResponse(Contract): class ChatMessage(Contract): + context_captured: bool = False attachments: list[str] = Field(default_factory=list) workspace_context: WorkspaceContext | None = None activity: list[dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/database/migrations.py b/backend/app/database/migrations.py index b870c3b..817ab25 100644 --- a/backend/app/database/migrations.py +++ b/backend/app/database/migrations.py @@ -171,6 +171,7 @@ MIGRATIONS: list[str] = [ """ALTER TABLE chat_conversations ADD COLUMN active_response_id TEXT;""", """ALTER TABLE chat_messages ADD COLUMN workspace_context_json TEXT;""", """ALTER TABLE chat_messages ADD COLUMN attachments_json TEXT NOT NULL DEFAULT '[]';""", + """ALTER TABLE chat_messages ADD COLUMN context_captured INTEGER NOT NULL DEFAULT 0;""", ] diff --git a/backend/app/routes.py b/backend/app/routes.py index 77f106a..fb9077f 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -506,6 +506,9 @@ async def chat(request: ChatRequest) -> StreamingResponse: usage=usage, activity=activity, parent_message_id=user_message_id, + workspace_context=request.workspace_context.model_dump() if request.workspace_context else None, + attachments=request.attachments, + context_captured=True, ) return StreamingResponse(stream(), media_type="text/event-stream") diff --git a/backend/app/services/chat_history.py b/backend/app/services/chat_history.py index 1708f2c..4e6c185 100644 --- a/backend/app/services/chat_history.py +++ b/backend/app/services/chat_history.py @@ -39,6 +39,7 @@ def _message(row) -> ChatMessage: thinking=row["thinking"], activity=json.loads(row['activity_json']), attachments=json.loads(row['attachments_json']), + context_captured=bool(row['context_captured']), workspace_context=json.loads(row['workspace_context_json']) if row['workspace_context_json'] else None, citations=citations, tool_calls=json.loads(row["tool_calls_json"]), @@ -130,6 +131,7 @@ def append_message( parent_message_id: str | None = None, workspace_context: dict | None = None, attachments: list[str] | None = None, + context_captured: bool = False, ) -> None: now = _now().isoformat() clean_title = (title or "").strip() or content[:30].strip() or "New conversation" @@ -139,7 +141,7 @@ def append_message( _append_message_in_transaction( conn, conversation_id, message_id=message_id, role=role, content=content, title=clean_title, thinking=thinking, citations=citations, tool_calls=tool_calls, - usage=usage, now=now, activity=activity, parent_message_id=parent_message_id, workspace_context=workspace_context, attachments=attachments, + usage=usage, now=now, activity=activity, parent_message_id=parent_message_id, workspace_context=workspace_context, attachments=attachments, context_captured=context_captured, ) conn.execute("COMMIT") except BaseException: @@ -165,6 +167,7 @@ def _append_message_in_transaction( parent_message_id: str | None = None, workspace_context: dict | None = None, attachments: list[str] | None = None, + context_captured: bool = False, ) -> None: conversation = conn.execute( "SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,) @@ -215,6 +218,7 @@ def _append_message_in_transaction( conn.execute('UPDATE chat_messages SET parent_message_id=?, activity_json=? WHERE message_id=?', (parent, json.dumps(activity or [], ensure_ascii=False), message_id)) conn.execute('UPDATE chat_messages SET workspace_context_json=? WHERE message_id=?', (json.dumps(workspace_context, ensure_ascii=False) if workspace_context is not None else None, message_id)) conn.execute('UPDATE chat_messages SET attachments_json=? WHERE message_id=?', (json.dumps(attachments or []),message_id)) + conn.execute('UPDATE chat_messages SET context_captured=? WHERE message_id=?', (int(context_captured), message_id)) # A late stream may be persisted, but must not steal the selected branch. response_id = conn.execute('SELECT active_response_id FROM chat_conversations WHERE conversation_id=?', (conversation_id,)).fetchone()[0] if active_leaf == parent and (role != 'assistant' or response_id is None or response_id == message_id): diff --git a/backend/tests/test_chat_versions.py b/backend/tests/test_chat_versions.py index 9d2461a..d95e79c 100644 --- a/backend/tests/test_chat_versions.py +++ b/backend/tests/test_chat_versions.py @@ -50,3 +50,40 @@ def test_workspace_snapshots_and_agent_links_survive_history_reload(): assert total == 2 assert messages[0].workspace_context.model_dump() == snapshot assert messages[1].tool_calls == calls + + +def test_regeneration_persists_context_per_answer_without_rewriting_original(monkeypatch): + import asyncio + from types import SimpleNamespace + from app.contracts import ChatRequest, Message, ModelEvent, ModelEventType + from app.routes import chat, utc_now + received=[] + class Adapter: + async def stream(self, request): + received.append(request) + yield ModelEvent(event=ModelEventType.text_delta, sequence=0, data={'text':'answer'}, timestamp=utc_now()) + yield ModelEvent(event=ModelEventType.done, sequence=1, data={}, timestamp=utc_now()) + monkeypatch.setattr('app.routes.provider_or_404',lambda _:SimpleNamespace(adapter=Adapter())) + # Keep attachment parsing out of this persistence test; the route must save raw IDs. + async def prepare(request, provider): + return request.model_copy(update={'attachments':[]}) + monkeypatch.setattr('app.services.chat_attachments.prepare',prepare) + async def scenario(): + history.create('Snapshots','snapshots') + for index,context in enumerate([{'file_path':'a.md','content':'A'},{'file_path':'b.md','content':'B'},None]): + req=ChatRequest(provider_id='test',model='test',use_rag=False,conversation_id='snapshots', + user_message_id='su',assistant_message_id=f'sa{index}',retry_message_id=f'sa{index-1}' if index else None, + messages=[Message(role='user',content='explain')],workspace_context=context,attachments=[f'file{index}.md']) + response=await chat(req) + _=[chunk async for chunk in response.body_iterator] + for index,path in enumerate(['a.md','b.md',None]): + history.select_version('snapshots',f'sa{index}') + messages,_=history.list_messages('snapshots',100,0) + assert messages[0].workspace_context.file_path=='a.md' + answer=messages[-1] + assert answer.context_captured + assert (answer.workspace_context.file_path if answer.workspace_context else None)==path + assert answer.attachments==[f'file{index}.md'] + assert 'b.md' in received[1].system + assert received[2].system is None + asyncio.run(scenario()) diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md index c4837cb..76fe8c5 100644 --- a/docs/contracts/第二阶段接口契约-开发版.md +++ b/docs/contracts/第二阶段接口契约-开发版.md @@ -1609,3 +1609,7 @@ CUDA 组件:`GET /api/local-models/runtime-components/cuda` 返回 status、st `/api/media/attachments` 新增允许 DOCX、PPTX、PPT、PNG、JPG/JPEG、WebP 后缀。聊天通过 `ChatRequest.attachments` 提交最多 8 个持久化附件 ID,并通过 `ChatMessage.attachments` 恢复记录。`image_fallback_tools` 最多两个注册工具名,服务端固定 MCP 优先、Plugin 次之,不接受任意命令或远程下载 URL。 内部模型 `Message.images` 使用有大小限制的 PNG/JPEG/WebP base64 data URI,Provider 适配器转换为各自原生协议。文档和音频提取为参考文本后才交给普通聊天,清除已解析的二进制附件标记,使文本上下文检测仍可工作。附件失败返回 `CHAT_ATTACHMENT_FAILED`,进度与截断提示使用 `ContextStatus`,不将失败附件当作已读取内容。 + +#### 回答版本的上下文快照(2026-09-07) + +`ChatMessage.context_captured` 为布尔值,旧记录默认 false。新 assistant 消息保存本次请求的 `workspace_context` 和 `attachments`,并设置 context_captured 为 true;此时 null 文件上下文和空附件列表都是明确快照。重新生成不覆盖原 user 消息的快照。客户端恢复旧记录时仅在 context_captured 为 false 时回退到对应父用户消息。 diff --git a/docs/development/聊天按需检索与Markdown工具.md b/docs/development/聊天按需检索与Markdown工具.md index 62a6048..d70d653 100644 --- a/docs/development/聊天按需检索与Markdown工具.md +++ b/docs/development/聊天按需检索与Markdown工具.md @@ -93,3 +93,11 @@ AI 消息提供“重新生成”,用户消息提供“编辑”及“保存 内置 `chat-operator` Skill 与 `chat-policy` Plugin 随 Host 注册。Plugin 的 `chat-policy.plan` 使用宿主白名单 handler 校验任务与预算,生成读取、执行和核验步骤,不执行任意插件代码。聊天委托创建运行前调用检查;启用的 Skill 加入聊天系统提示词并作为委托运行的 Skill,权限继续由 Agent 管理。用户禁用扩展后不会自动重新启用。 新增验证:`test_chat_attachments.py` 覆盖 Office/Markdown 文本抽取、旧 PPT 文本记录、截断、音频任务路由、原生视觉优先及 MCP 超时后 Plugin 降级;浮窗测试覆盖尺寸记忆和重置。浏览器实测折叠设置、上传入口和拖动缩放。未使用真实外部模型或 MCP 服务进行付费调用验收。 + +### 重试上下文与历史版本一致性(2026-09-07) + +编辑旧用户消息使用该消息的附件;重新生成回答使用该回答版本实际使用的附件和文件快照。旧版未记录回答快照时才回退到它的父用户消息,不读取会话末尾其他轮次的附件。重试不会消耗输入区尚未发送的新附件。 + +新回答将 `workspace_context`、`attachments` 和 `context_captured=true` 一起持久化。原用户消息保持不变,因此同一问题的不同回答可以各自恢复生成时的文件内容;工作区浮窗显式传入当前文件时,以本次文件为准。`context_captured=true` 且 `workspace_context=null` 表示该版本明确未附带文件,继续对话或重试时不能回退到原用户消息的旧文件。数据库迁移为既有消息设置 false,保持旧记录可恢复。 + +回归覆盖:两轮使用不同附件后编辑/重试第一轮、保留待发送附件、切换工作区文件后生成新版本、历史回读后继续重试、明确清空文件上下文、原版本快照保持不变。 diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index bfe61a7..2eaa4f7 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -71,6 +71,7 @@ export interface Conversation { export interface WorkspaceContext { file_path: string; content: string } export interface ChatMessage { + context_captured?: boolean attachments?: string[] workspace_context?: WorkspaceContext activity?: Array<{ type: 'thinking'; text: string } | { type: 'tool'; tool_call_id: string }> diff --git a/frontend/src/stores/chat.spec.ts b/frontend/src/stores/chat.spec.ts index 80fa84e..f6a8329 100644 --- a/frontend/src/stores/chat.spec.ts +++ b/frontend/src/stores/chat.spec.ts @@ -356,3 +356,38 @@ it('uploads attachments and includes their durable IDs in an attachment-only mes expect(store.pendingAttachments).toEqual([]) upload.mockRestore() }) + +it.each(['user', 'assistant'] as const)('retries older %s messages with their attachments, preserving pending uploads', async role => { + const s=useChatStore(); s.selectedProviderId='real'; s.selectedModel='model' + s.pendingAttachments=[{attachment_id:'first.md',name:'first.md'}] + await s.sendMessage('first'); vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + const old=s.messages[role === 'user' ? 0 : 1]!.message_id + s.pendingAttachments=[{attachment_id:'later.md',name:'later.md'}] + await s.sendMessage('later'); vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + s.pendingAttachments=[{attachment_id:'draft.md',name:'draft.md'}] + await s.retryMessage(old, role === 'user' ? 'edited first' : undefined) + expect(vi.mocked(streamChat).mock.calls.at(-1)![0].attachments).toEqual(['first.md']) + expect(s.pendingAttachments.map(a=>a.attachment_id)).toEqual(['draft.md']) +}) + +it('restores each answer context after history reload, including explicitly absent workspace context', async () => { + const s=useChatStore(); s.selectedProviderId='real'; s.selectedModel='model' + const first={file_path:'a.md',content:'A'}; const second={file_path:'b.md',content:'B'} + await s.sendMessage('explain',undefined,first); vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + const original=s.messages[1]!.message_id + await s.retryMessage(original,undefined,second); vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + expect(s.messages[0]!.workspace_context).toEqual(first) + expect(s.messages[1]!.workspace_context).toEqual(second) + vi.mocked(listConversationMessages).mockResolvedValue({items:JSON.parse(JSON.stringify(s.messages)),page:{total:2,limit:500,offset:0}}) + await s.setActiveConversation(s.activeConversationId!) + await s.retryMessage(s.messages[1]!.message_id) + expect(vi.mocked(streamChat).mock.calls.at(-1)![0].workspace_context).toEqual(second) + vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + await s.retryMessage(s.messages[1]!.message_id,undefined,null) + vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.() + // API serializes absent captured context as null; do not fall back to the original user snapshot. + vi.mocked(listConversationMessages).mockResolvedValue({items:JSON.parse(JSON.stringify(s.messages)),page:{total:2,limit:500,offset:0}}) + await s.setActiveConversation(s.activeConversationId!) + await s.sendMessage('continue') + expect(vi.mocked(streamChat).mock.calls.at(-1)![0].workspace_context).toBeUndefined() +}) diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 8c514cc..3ce47ce 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -181,9 +181,15 @@ export const useChatStore = defineStore('chat', () => { async function sendMessage(text: string, retryMessageId?: string, workspaceContext?: WorkspaceContext | null) { const content = text.trim() || (pendingAttachments.value.length ? '请分析附件内容' : '') if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return - const context = workspaceContext === undefined ? [...messages.value].reverse().find(m => m.role === 'user')?.workspace_context : workspaceContext + const targetIndex = retryMessageId ? messages.value.findIndex(m => m.message_id === retryMessageId) : messages.value.length - 1 + if (retryMessageId && targetIndex < 0) return + const target = messages.value[targetIndex] + const source = target?.role === 'assistant' && !target.context_captured + ? messages.value[targetIndex - 1] : target + const context = workspaceContext === undefined ? source?.workspace_context : workspaceContext const snapshot = context ? { ...context } : undefined - const attachments = pendingAttachments.value.length ? pendingAttachments.value.map(a=>a.attachment_id) : ([...messages.value].reverse().find(m=>m.role==='user')?.attachments ?? []) + const attachments = !retryMessageId && pendingAttachments.value.length + ? pendingAttachments.value.map(a => a.attachment_id) : [...(source?.attachments ?? [])] const version = ++streamVersion isPreparing.value = true historyError.value = '' @@ -217,6 +223,7 @@ export const useChatStore = defineStore('chat', () => { const aiMsg = reactive({ message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '', created_at: new Date().toISOString(), citations: [], tool_calls: [], activity: [], + context_captured: true, workspace_context: snapshot, attachments: [...attachments], }) if (retryTarget) { messages.value = messages.value.slice(0, retryIndex) @@ -226,7 +233,7 @@ export const useChatStore = defineStore('chat', () => { if (!regenerate) messages.value.push(userMsg) messages.value.push(aiMsg) inputText.value = '' - pendingAttachments.value = [] + if (!retryMessageId) pendingAttachments.value = [] isStreaming.value = true conversation.updated_at = new Date().toISOString() conversation.message_count = messages.value.length @@ -322,7 +329,7 @@ export const useChatStore = defineStore('chat', () => { const message = messages.value[index] if (!message) return const text = message.role === 'user' ? editedText : messages.value[index - 1]?.content - if (text?.trim()) await sendMessage(text, messageId, workspaceContext !== undefined ? workspaceContext : (message.role === 'user' ? message.workspace_context : messages.value[index - 1]?.workspace_context)) + if (text?.trim()) await sendMessage(text, messageId, workspaceContext) } async function switchVersion(messageId: string) {