feat: 重置会话时新增checkbox,清除当前会话的memory

This commit is contained in:
肖应宇 2026-06-02 10:21:33 +08:00
parent dd98337a92
commit 63563ce6a3
6 changed files with 121 additions and 23 deletions

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Ticker } from "@tombcato/smart-ticker"; import { Ticker } from "@tombcato/smart-ticker";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { FilesIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -23,20 +23,20 @@ import {
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats"; import { useThreadChat } from "@/components/workspace/chats";
// import { DevTodoList } from "@/components/workspace/dev-todo-list"; // import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils"; import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { clearThreadMemoryOnExit } from "@/core/threads/exit-thread-memory";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@ -60,8 +60,6 @@ export default function ChatPage() {
setArtifacts, setArtifacts,
select: selectArtifact, select: selectArtifact,
selectedArtifact, selectedArtifact,
deselect: deselectArtifact,
setFullscreen: setArtifactsFullscreen,
fullscreen, fullscreen,
} = useArtifacts(); } = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } = const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
@ -303,6 +301,8 @@ export default function ChatPage() {
const todoListCollapsed = true; const todoListCollapsed = true;
const [showExitDialog, setShowExitDialog] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false);
const [clearMemoryOnExit, setClearMemoryOnExit] = useState(false);
const [isConfirmingExit, setIsConfirmingExit] = useState(false);
const isStreaming = isUploading || thread.isLoading; const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: Parameters<typeof sendMessage>[1]) => { async (message: Parameters<typeof sendMessage>[1]) => {
@ -627,7 +627,16 @@ export default function ChatPage() {
</div> </div>
{/* 退出确认对话框 */} {/* 退出确认对话框 */}
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}> <DevDialog
open={showExitDialog}
onOpenChange={(open) => {
setShowExitDialog(open);
if (!open) {
setClearMemoryOnExit(false);
setIsConfirmingExit(false);
}
}}
>
<DevDialogContent> <DevDialogContent>
<DevDialogHeader> <DevDialogHeader>
<DevDialogTitle>{t.chatPage.exitDialogTitle}</DevDialogTitle> <DevDialogTitle>{t.chatPage.exitDialogTitle}</DevDialogTitle>
@ -635,11 +644,22 @@ export default function ChatPage() {
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{t.chatPage.exitDialogDescription} {t.chatPage.exitDialogDescription}
</p> </p>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ws-fg-primary">
<input
type="checkbox"
className="h-4 w-4 rounded border-ws-divider accent-ws-interactive-primary"
checked={clearMemoryOnExit}
onChange={(e) => setClearMemoryOnExit(e.target.checked)}
disabled={isConfirmingExit}
/>
<span>{t.chatPage.exitDialogClearMemory}</span>
</label>
<DevDialogFooter> <DevDialogFooter>
<Button <Button
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground" className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost" variant="ghost"
onClick={() => setShowExitDialog(false)} onClick={() => setShowExitDialog(false)}
disabled={isConfirmingExit}
> >
{t.common.cancel} {t.common.cancel}
</Button> </Button>
@ -647,25 +667,31 @@ export default function ChatPage() {
className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground" className="w-full bg-ws-surface-subtle hover:bg-ws-interactive-primary hover:text-primary-foreground"
variant="ghost" variant="ghost"
onClick={async () => { onClick={async () => {
// 如果正在生成,先终止再退出 setIsConfirmingExit(true);
if (thread.isLoading) { try {
await handleStop(); if (thread.isLoading) {
await handleStop();
}
await clearThreadMemoryOnExit({
backendBaseURL: getBackendBaseURL(),
threadId: safeThreadId,
shouldClearMemory: clearMemoryOnExit,
});
setShowExitDialog(false);
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
router.replace(`/workspace/chats/new?thread_id=${threadId}`);
} catch {
toast.error(t.threadMemoryPanel.toastDeleteFailed);
} finally {
setIsConfirmingExit(false);
} }
setShowExitDialog(false);
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
// 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams();
if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId);
}
// /workspace/chats/${threadId}?is_chatting=false
router.replace(
`/workspace/chats/new?thread_id=${threadId}`,
);
}} }}
disabled={isConfirmingExit}
> >
{t.chatPage.exitDialogConfirm} {t.chatPage.exitDialogConfirm}
</Button> </Button>

View File

@ -298,6 +298,7 @@ export const enUS: Translations = {
exitDialogTitle: "Notice", exitDialogTitle: "Notice",
exitDialogDescription: exitDialogDescription:
"Chat history is automatically deleted every seven days. You will return to the welcome page now. Continue?", "Chat history is automatically deleted every seven days. You will return to the welcome page now. Continue?",
exitDialogClearMemory: "Also clear memory for this thread",
exitDialogConfirm: "Confirm", exitDialogConfirm: "Confirm",
selectedSkillLoadFailed: "Failed to load skill", selectedSkillLoadFailed: "Failed to load skill",
unknownErrorRetry: "An unknown error occurred. Please try again later.", unknownErrorRetry: "An unknown error occurred. Please try again later.",

View File

@ -227,6 +227,7 @@ export interface Translations {
noArtifactSelectedDescription: string; noArtifactSelectedDescription: string;
exitDialogTitle: string; exitDialogTitle: string;
exitDialogDescription: string; exitDialogDescription: string;
exitDialogClearMemory: string;
exitDialogConfirm: string; exitDialogConfirm: string;
selectedSkillLoadFailed: string; selectedSkillLoadFailed: string;
unknownErrorRetry: string; unknownErrorRetry: string;

View File

@ -323,6 +323,7 @@ export const zhCN: Translations = {
exitDialogTitle: "提示", exitDialogTitle: "提示",
exitDialogDescription: exitDialogDescription:
"每七天自动删除。现在将返回欢迎页且清空聊天消息,是否继续?", "每七天自动删除。现在将返回欢迎页且清空聊天消息,是否继续?",
exitDialogClearMemory: "同时清除当前会话的记忆",
exitDialogConfirm: "确定", exitDialogConfirm: "确定",
selectedSkillLoadFailed: "技能加载失败", selectedSkillLoadFailed: "技能加载失败",
unknownErrorRetry: "发生了未知错误,请稍后重试。", unknownErrorRetry: "发生了未知错误,请稍后重试。",

View File

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
const { clearThreadMemoryOnExit } = await import(
new URL("./exit-thread-memory.ts", import.meta.url).href
);
void test("clears thread memory when checkbox is enabled", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = (async (input, init) => {
calls.push({ input, init });
return new Response(null, { status: 204 });
}) as typeof fetch;
await clearThreadMemoryOnExit({
backendBaseURL: "http://localhost:3000",
threadId: "thread-123",
shouldClearMemory: true,
});
assert.equal(calls.length, 1);
assert.equal(
calls[0]?.input,
"http://localhost:3000/api/threads/thread-123/memory",
);
assert.equal(calls[0]?.init?.method, "DELETE");
});
void test("skips clearing thread memory when checkbox is disabled", async () => {
let called = false;
globalThis.fetch = (async () => {
called = true;
return new Response(null, { status: 204 });
}) as typeof fetch;
await clearThreadMemoryOnExit({
backendBaseURL: "http://localhost:3000",
threadId: "thread-123",
shouldClearMemory: false,
});
assert.equal(called, false);
});

View File

@ -0,0 +1,24 @@
type ClearThreadMemoryOnExitParams = {
backendBaseURL?: string;
threadId?: string;
shouldClearMemory: boolean;
};
export async function clearThreadMemoryOnExit({
backendBaseURL = "",
threadId,
shouldClearMemory,
}: ClearThreadMemoryOnExitParams) {
if (!threadId || !shouldClearMemory) {
return;
}
const res = await fetch(
`${backendBaseURL}/api/threads/${encodeURIComponent(threadId)}/memory`,
{ method: "DELETE" },
);
if (!res.ok) {
throw new Error(`Failed to clear thread memory: HTTP ${res.status}`);
}
}