From 1f2b1e3b8ae8966e66a396f9ec163b75baadb9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 4 Jun 2026 14:34:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E6=8F=90=E9=97=AE?= =?UTF-8?q?=E6=97=B6=E7=BA=A7=E8=81=94=E5=88=A0=E9=99=A4=E5=85=B6=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=EF=BC=8C=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除按钮只保留在用户提问气泡上;AI 回答不再单独提供删除 - 后端 DELETE /messages/{id} 改为按整轮删除:从提问删到下一条提问之前, 返回实际删除的 deleted_ids,保持 checkpoint 合法(无孤立工具结果/回答) - 前端隐藏后端返回的整批 id,删除后即时消失 - 更新删除相关中英文案 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../messages/delete-message-button.tsx | 18 ++++---- .../workspace/messages/message-list-item.tsx | 21 +++------- .../workspace/messages/message-list.tsx | 5 ++- frontend-web/src/core/i18n/locales/en-US.ts | 7 ++-- frontend-web/src/core/i18n/locales/zh-CN.ts | 6 +-- frontend-web/src/core/threads/hooks.ts | 20 ++++++--- .../backend/app/gateway/routers/threads.py | 42 ++++++++++++++----- 7 files changed, 73 insertions(+), 46 deletions(-) diff --git a/frontend-web/src/components/workspace/messages/delete-message-button.tsx b/frontend-web/src/components/workspace/messages/delete-message-button.tsx index ea81bff5..f4b789bd 100644 --- a/frontend-web/src/components/workspace/messages/delete-message-button.tsx +++ b/frontend-web/src/components/workspace/messages/delete-message-button.tsx @@ -17,10 +17,11 @@ import { useDeleteMessage } from "@/core/threads/hooks"; import { Tooltip } from "../tooltip"; /** - * Hover affordance that deletes a single message (a user question or an - * assistant answer) after a confirmation step. The actual removal is persisted - * server-side; `onDeleted` lets the parent hide the bubble immediately without - * waiting for the stream cache to refresh. + * Hover affordance that deletes a conversation turn (a user question together + * with its answer) after a confirmation step. The actual removal is persisted + * server-side; `onDeleted` receives the ids the server removed so the parent + * can hide those bubbles immediately without waiting for the stream cache to + * refresh. */ export function DeleteMessageButton({ threadId, @@ -30,7 +31,7 @@ export function DeleteMessageButton({ }: { threadId: string; messageId: string; - onDeleted?: () => void; + onDeleted?: (deletedIds: string[]) => void; className?: string; }) { const { t } = useI18n(); @@ -39,8 +40,11 @@ export function DeleteMessageButton({ const handleConfirm = async () => { try { - await deleteMessage.mutateAsync({ threadId, messageId }); - onDeleted?.(); + const deletedIds = await deleteMessage.mutateAsync({ + threadId, + messageId, + }); + onDeleted?.(deletedIds); setOpen(false); } catch (error) { toast.error( diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 1be1d34e..a1242072 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -147,18 +147,13 @@ export function MessageListItem({ displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; - onMessageDeleted?: (messageId: string) => void; + onMessageDeleted?: (deletedIds: string[]) => void; }) { const isHuman = message.type === 'human'; const messageMarkdown = extractContentFromMessage(message) ?? extractReasoningContentFromMessage(message) ?? ''; - // Deletion is offered only for messages that already exist server-side - // (real, non-optimistic ids). Optimistic placeholders (`opt-*`) have no - // checkpoint entry to remove. - const canDelete = - !!onMessageDeleted && !!message.id && !message.id.startsWith('opt-'); return ( )} - {canDelete && ( - onMessageDeleted?.(message.id!)} - /> - )} )} @@ -389,7 +377,7 @@ function MessageContent_({ displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; - onMessageDeleted?: (messageId: string) => void; + onMessageDeleted?: (deletedIds: string[]) => void; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -521,6 +509,9 @@ function MessageContent_({ } if (isHuman) { + // Only the user's own question carries a delete affordance, and only once + // it exists server-side (real, non-optimistic id). Deleting it cascades to + // its answer on the backend. const canDelete = !!onMessageDeleted && !!message.id && !message.id.startsWith('opt-'); return ( @@ -536,7 +527,7 @@ function MessageContent_({ onMessageDeleted?.(message.id!)} + onDeleted={(ids) => onMessageDeleted?.(ids)} className="size-6" /> ) : null diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 4ecefac0..e76de423 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -104,10 +104,11 @@ export function MessageList({ useEffect(() => { setHiddenMessageIds(new Set()); }, [threadId]); - const handleMessageDeleted = useCallback((messageId: string) => { + const handleMessageDeleted = useCallback((deletedIds: string[]) => { + if (deletedIds.length === 0) return; setHiddenMessageIds((prev) => { const next = new Set(prev); - next.add(messageId); + for (const id of deletedIds) next.add(id); return next; }); }, []); diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index b6e750e2..cc3edbbc 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -82,9 +82,10 @@ export const enUS: Translations = { inputBox: { placeholder: "How can I assist you today?", editQuestion: "Edit question", - deleteMessage: "Delete message", - deleteMessageConfirmTitle: "Delete this message?", - deleteMessageConfirmDescription: "This action cannot be undone.", + deleteMessage: "Delete question and answer", + deleteMessageConfirmTitle: "Delete this question and its answer?", + deleteMessageConfirmDescription: + "The question and its answer will both be removed. This cannot be undone.", createSkillPrompt: "We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?", addAttachments: "Add attachments", diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index b884439e..3e27a7c1 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -82,9 +82,9 @@ export const zhCN: Translations = { inputBox: { placeholder: "今天我能为你做些什么?", editQuestion: "编辑问题", - deleteMessage: "删除消息", - deleteMessageConfirmTitle: "删除这条消息?", - deleteMessageConfirmDescription: "删除后将无法恢复。", + deleteMessage: "删除提问及回答", + deleteMessageConfirmTitle: "删除该提问及其回答?", + deleteMessageConfirmDescription: "该提问及对应的回答都会被删除,且无法恢复。", createSkillPrompt: "我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。", addAttachments: "添加附件", diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 573a1b27..06c82d70 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -805,11 +805,13 @@ export function useDeleteThread() { } /** - * Delete a single message (a user question or an assistant answer) from a - * thread. Hits the Gateway endpoint that filters the message out of the latest - * checkpoint. The live `useStream` cache is not auto-refreshed by this call — - * callers hide the deleted id locally (see `MessageList`) so the removal shows - * immediately; a reload then reads the persisted, already-trimmed state. + * Delete a conversation turn (a user question together with its answer) from a + * thread. Hits the Gateway endpoint that removes the question and every + * following message up to the next question from the latest checkpoint, and + * returns the ids that were actually removed. The live `useStream` cache is not + * auto-refreshed by this call — callers hide the returned ids locally (see + * `MessageList`) so the removal shows immediately; a reload then reads the + * persisted, already-trimmed state. */ export function useDeleteMessage() { return useMutation({ @@ -819,7 +821,7 @@ export function useDeleteMessage() { }: { threadId: string; messageId: string; - }) => { + }): Promise => { const response = await apiFetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent( threadId, @@ -835,6 +837,12 @@ export function useDeleteMessage() { .catch(() => ({ detail: "删除消息失败。" })); throw new Error(error.detail ?? "删除消息失败。"); } + + const body = (await response.json().catch(() => ({}))) as { + deleted_ids?: string[]; + }; + // Fall back to the requested id so the caller can always hide something. + return body.deleted_ids?.length ? body.deleted_ids : [messageId]; }, }); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/threads.py b/offline-backend-20260512/backend/app/gateway/routers/threads.py index 3007388a..f1089335 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/threads.py +++ b/offline-backend-20260512/backend/app/gateway/routers/threads.py @@ -1012,13 +1012,16 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re @router.delete("/{thread_id}/messages/{message_id}") @require_permission("threads", "write", owner_check=True, require_existing=True) async def delete_thread_message(thread_id: str, message_id: str, request: Request) -> dict[str, Any]: - """Delete a single message (a user question or an assistant answer) from a - thread's latest checkpoint. + """Delete a conversation turn from a thread's latest checkpoint. - Filters the message whose ``id`` matches ``message_id`` out of the - ``messages`` channel and writes a fresh checkpoint. The remaining messages - keep their original ``BaseMessage`` objects (mirroring - :func:`update_thread_state`), so the graph can still resume the thread. + ``message_id`` identifies a user question. The whole turn is removed: the + question plus every following message (its answer and any tool/intermediate + messages) up to — but not including — the next user question. Removing a + complete turn keeps the remaining checkpoint valid (no orphaned tool + results / dangling answers) and lets the graph still resume the thread. + + If ``message_id`` points at a non-human message, only that single message is + removed. """ checkpointer = get_checkpointer(request) @@ -1045,10 +1048,28 @@ async def delete_thread_message(thread_id: str, message_id: str, request: Reques if not isinstance(messages, list): raise HTTPException(status_code=404, detail="Thread has no messages") - filtered = [m for m in messages if _message_id(m) != message_id] - if len(filtered) == len(messages): + target_index = next( + (i for i, m in enumerate(messages) if _message_id(m) == message_id), + None, + ) + if target_index is None: raise HTTPException(status_code=404, detail=f"Message {message_id} not found") + # Delete the whole turn: from the question to just before the next question. + end = len(messages) + if _message_type(messages[target_index]) == "human": + for j in range(target_index + 1, len(messages)): + if _message_type(messages[j]) == "human": + end = j + break + else: + end = target_index + 1 + + deleted_ids = [ + mid for m in messages[target_index:end] if (mid := _message_id(m)) + ] + filtered = messages[:target_index] + messages[end:] + channel_values["messages"] = filtered checkpoint["channel_values"] = channel_values metadata["updated_at"] = now_iso() @@ -1070,11 +1091,12 @@ async def delete_thread_message(thread_id: str, message_id: str, request: Reques raise HTTPException(status_code=500, detail="Failed to delete message") logger.info( - "Deleted message %s from thread %s", + "Deleted turn starting at message %s (%d message(s)) from thread %s", sanitize_log_param(message_id), + len(messages) - len(filtered), sanitize_log_param(thread_id), ) - return {"ok": True, "deleted": message_id, "remaining": len(filtered)} + return {"ok": True, "deleted_ids": deleted_ids, "remaining": len(filtered)} @router.post("/{thread_id}/history", response_model=list[HistoryEntry])