feat: 删除提问时级联删除其回答,并移除回答上的删除按钮
- 删除按钮只保留在用户提问气泡上;AI 回答不再单独提供删除
- 后端 DELETE /messages/{id} 改为按整轮删除:从提问删到下一条提问之前,
返回实际删除的 deleted_ids,保持 checkpoint 合法(无孤立工具结果/回答)
- 前端隐藏后端返回的整批 id,删除后即时消失
- 更新删除相关中英文案
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5d12616211
commit
1f2b1e3b8a
7 files changed
+73
-46
No files matched your search
@@ -17,10 +17,11 @@ import { useDeleteMessage } from "@/core/threads/hooks";
|
|||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hover affordance that deletes a single message (a user question or an
|
* Hover affordance that deletes a conversation turn (a user question together
|
||||||
* assistant answer) after a confirmation step. The actual removal is persisted
|
* with its answer) after a confirmation step. The actual removal is persisted
|
||||||
* server-side; `onDeleted` lets the parent hide the bubble immediately without
|
* server-side; `onDeleted` receives the ids the server removed so the parent
|
||||||
* waiting for the stream cache to refresh.
|
* can hide those bubbles immediately without waiting for the stream cache to
|
||||||
|
* refresh.
|
||||||
*/
|
*/
|
||||||
export function DeleteMessageButton({
|
export function DeleteMessageButton({
|
||||||
threadId,
|
threadId,
|
||||||
@@ -30,7 +31,7 @@ export function DeleteMessageButton({
|
|||||||
}: {
|
}: {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
onDeleted?: () => void;
|
onDeleted?: (deletedIds: string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -39,8 +40,11 @@ export function DeleteMessageButton({
|
|||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteMessage.mutateAsync({ threadId, messageId });
|
const deletedIds = await deleteMessage.mutateAsync({
|
||||||
onDeleted?.();
|
threadId,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
onDeleted?.(deletedIds);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|||||||
@@ -147,18 +147,13 @@ export function MessageListItem({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (messageId: string) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
const messageMarkdown =
|
const messageMarkdown =
|
||||||
extractContentFromMessage(message) ??
|
extractContentFromMessage(message) ??
|
||||||
extractReasoningContentFromMessage(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 (
|
return (
|
||||||
<AIElementMessage
|
<AIElementMessage
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -200,13 +195,6 @@ export function MessageListItem({
|
|||||||
fileName="message"
|
fileName="message"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
|
||||||
<DeleteMessageButton
|
|
||||||
threadId={threadId}
|
|
||||||
messageId={message.id!}
|
|
||||||
onDeleted={() => onMessageDeleted?.(message.id!)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</MessageToolbar>
|
</MessageToolbar>
|
||||||
)}
|
)}
|
||||||
@@ -389,7 +377,7 @@ function MessageContent_({
|
|||||||
displaySources?: RuntimeDisplaySource[];
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
onEditResubmit?: (text: string) => void;
|
onEditResubmit?: (text: string) => void;
|
||||||
onMessageDeleted?: (messageId: string) => void;
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
|
|
||||||
@@ -521,6 +509,9 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isHuman) {
|
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 =
|
const canDelete =
|
||||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||||
return (
|
return (
|
||||||
@@ -536,7 +527,7 @@ function MessageContent_({
|
|||||||
<DeleteMessageButton
|
<DeleteMessageButton
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
messageId={message.id!}
|
messageId={message.id!}
|
||||||
onDeleted={() => onMessageDeleted?.(message.id!)}
|
onDeleted={(ids) => onMessageDeleted?.(ids)}
|
||||||
className="size-6"
|
className="size-6"
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ export function MessageList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHiddenMessageIds(new Set());
|
setHiddenMessageIds(new Set());
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
const handleMessageDeleted = useCallback((messageId: string) => {
|
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||||
|
if (deletedIds.length === 0) return;
|
||||||
setHiddenMessageIds((prev) => {
|
setHiddenMessageIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(messageId);
|
for (const id of deletedIds) next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ export const enUS: Translations = {
|
|||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "How can I assist you today?",
|
placeholder: "How can I assist you today?",
|
||||||
editQuestion: "Edit question",
|
editQuestion: "Edit question",
|
||||||
deleteMessage: "Delete message",
|
deleteMessage: "Delete question and answer",
|
||||||
deleteMessageConfirmTitle: "Delete this message?",
|
deleteMessageConfirmTitle: "Delete this question and its answer?",
|
||||||
deleteMessageConfirmDescription: "This action cannot be undone.",
|
deleteMessageConfirmDescription:
|
||||||
|
"The question and its answer will both be removed. This cannot be undone.",
|
||||||
createSkillPrompt:
|
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?",
|
"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",
|
addAttachments: "Add attachments",
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ export const zhCN: Translations = {
|
|||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "今天我能为你做些什么?",
|
placeholder: "今天我能为你做些什么?",
|
||||||
editQuestion: "编辑问题",
|
editQuestion: "编辑问题",
|
||||||
deleteMessage: "删除消息",
|
deleteMessage: "删除提问及回答",
|
||||||
deleteMessageConfirmTitle: "删除这条消息?",
|
deleteMessageConfirmTitle: "删除该提问及其回答?",
|
||||||
deleteMessageConfirmDescription: "删除后将无法恢复。",
|
deleteMessageConfirmDescription: "该提问及对应的回答都会被删除,且无法恢复。",
|
||||||
createSkillPrompt:
|
createSkillPrompt:
|
||||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||||
addAttachments: "添加附件",
|
addAttachments: "添加附件",
|
||||||
|
|||||||
@@ -805,11 +805,13 @@ export function useDeleteThread() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a single message (a user question or an assistant answer) from a
|
* Delete a conversation turn (a user question together with its answer) from a
|
||||||
* thread. Hits the Gateway endpoint that filters the message out of the latest
|
* thread. Hits the Gateway endpoint that removes the question and every
|
||||||
* checkpoint. The live `useStream` cache is not auto-refreshed by this call —
|
* following message up to the next question from the latest checkpoint, and
|
||||||
* callers hide the deleted id locally (see `MessageList`) so the removal shows
|
* returns the ids that were actually removed. The live `useStream` cache is not
|
||||||
* immediately; a reload then reads the persisted, already-trimmed state.
|
* 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() {
|
export function useDeleteMessage() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -819,7 +821,7 @@ export function useDeleteMessage() {
|
|||||||
}: {
|
}: {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}) => {
|
}): Promise<string[]> => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||||
threadId,
|
threadId,
|
||||||
@@ -835,6 +837,12 @@ export function useDeleteMessage() {
|
|||||||
.catch(() => ({ detail: "删除消息失败。" }));
|
.catch(() => ({ detail: "删除消息失败。" }));
|
||||||
throw new Error(error.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];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1012,13 +1012,16 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
|
|||||||
@router.delete("/{thread_id}/messages/{message_id}")
|
@router.delete("/{thread_id}/messages/{message_id}")
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@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]:
|
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
|
"""Delete a conversation turn from a thread's latest checkpoint.
|
||||||
thread's latest checkpoint.
|
|
||||||
|
|
||||||
Filters the message whose ``id`` matches ``message_id`` out of the
|
``message_id`` identifies a user question. The whole turn is removed: the
|
||||||
``messages`` channel and writes a fresh checkpoint. The remaining messages
|
question plus every following message (its answer and any tool/intermediate
|
||||||
keep their original ``BaseMessage`` objects (mirroring
|
messages) up to — but not including — the next user question. Removing a
|
||||||
:func:`update_thread_state`), so the graph can still resume the thread.
|
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)
|
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):
|
if not isinstance(messages, list):
|
||||||
raise HTTPException(status_code=404, detail="Thread has no messages")
|
raise HTTPException(status_code=404, detail="Thread has no messages")
|
||||||
|
|
||||||
filtered = [m for m in messages if _message_id(m) != message_id]
|
target_index = next(
|
||||||
if len(filtered) == len(messages):
|
(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")
|
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
|
channel_values["messages"] = filtered
|
||||||
checkpoint["channel_values"] = channel_values
|
checkpoint["channel_values"] = channel_values
|
||||||
metadata["updated_at"] = now_iso()
|
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")
|
raise HTTPException(status_code=500, detail="Failed to delete message")
|
||||||
|
|
||||||
logger.info(
|
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),
|
sanitize_log_param(message_id),
|
||||||
|
len(messages) - len(filtered),
|
||||||
sanitize_log_param(thread_id),
|
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])
|
@router.post("/{thread_id}/history", response_model=list[HistoryEntry])
|
||||||
|
|||||||
Reference in new issue
Block a user