feat(ThreadMemoryPanel): 新增会话记忆下拉面板并完成 i18n 接入

This commit is contained in:
肖应宇 2026-05-09 11:14:36 +08:00
parent fc9a30c784
commit 92b6bcc5fb
7 changed files with 223 additions and 145 deletions

View File

@ -27,7 +27,6 @@ import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadMemoryTestPanel } from "@/components/workspace/thread-memory-test-panel";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
@ -706,7 +705,6 @@ export default function ChatPage() {
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* {process.env.NODE_ENV !== "production" && <IframeTestPanel />} */}
{/* <ThreadMemoryTestPanel threadId={threadId} /> */}
</div>
</ThreadContext.Provider>
);

View File

@ -4,6 +4,7 @@ import type { ChatStatus } from "ai";
import { Tour } from "antd";
import {
CheckIcon,
BrainIcon,
GraduationCapIcon,
LightbulbIcon,
Loader2Icon,
@ -97,6 +98,7 @@ import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import { ScrollArea } from "../ui/scroll-area";
import { ModeHoverGuide } from "./mode-hover-guide";
import { ThreadMemoryPanel } from "./thread-memory-panel";
import { Tooltip } from "./tooltip";
@ -280,6 +282,7 @@ export function InputBox({
null,
);
const [isFocused, setIsFocused] = useState(false);
const [memoryPanelOpen, setMemoryPanelOpen] = useState(false);
const [references, setReferences] = useState<PromptInputReference[]>([]);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionOpen, setMentionOpen] = useState(false);
@ -293,7 +296,8 @@ export function InputBox({
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
// Welcome 态下禁用收缩,始终保持展开
const effectiveIsFocused = (showWelcomeStyle ?? false) || isFocused;
const effectiveIsFocused =
(showWelcomeStyle ?? false) || isFocused || memoryPanelOpen;
const shouldShowSuggestionList =
showWelcomeStyle && searchParams.get("mode") !== "skill";
@ -965,6 +969,7 @@ export function InputBox({
/>
</div>
)}
{/* {!showWelcomeStyle && (
<div className="shrink-0 h-full">
<ExitChattingButton
@ -976,6 +981,22 @@ export function InputBox({
<div ref={attachmentsButtonTourRef} className="shrink-0 h-full">
<AddAttachmentsButton />
</div>
{/* 记忆按钮 */}
<div className="shrink-0 h-full">
<DropdownMenu open={memoryPanelOpen} onOpenChange={setMemoryPanelOpen}>
<DropdownMenuTrigger asChild>
<WorkspaceToolButton
className="h-full"
disabled={!threadIdFromProps || threadIdFromProps === "new"}
>
<BrainIcon className="size-4" />
</WorkspaceToolButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto p-0">
<ThreadMemoryPanel threadId={threadIdFromProps} />
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="min-w-0 grow basis-0 h-full">
<IframeSkillDialogButton
skillButtonRef={skillButtonTourRef}

View File

@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
type ThreadMemoryPanelProps = {
threadId?: string;
};
export function ThreadMemoryPanel({ threadId }: ThreadMemoryPanelProps) {
const [memorySummary, setMemorySummary] = useState("");
const [memoryVersion, setMemoryVersion] = useState<number | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false);
const [savingSummary, setSavingSummary] = useState(false);
const [deletingMemory, setDeletingMemory] = useState(false);
const { t } = useI18n();
if (!threadId || threadId === "new") return null;
const handleLoadMemorySummary = async () => {
setLoadingSummary(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory-summary`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { summary: string; memoryVersion: number };
setMemorySummary(data.summary ?? "");
setMemoryVersion(data.memoryVersion ?? 0);
toast.success(t.threadMemoryPanel.toastLoadSuccess);
} catch {
toast.error(t.threadMemoryPanel.toastLoadFailed);
} finally {
setLoadingSummary(false);
}
};
const handleSaveMemorySummary = async () => {
if (memoryVersion == null) return;
setSavingSummary(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory-summary`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ summary: memorySummary, memoryVersion }),
},
);
if (res.status === 409) {
toast.error(t.threadMemoryPanel.toastConflict);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { memoryVersion?: number };
if (typeof data.memoryVersion === "number") setMemoryVersion(data.memoryVersion);
toast.success(t.threadMemoryPanel.toastSaveSuccess);
} catch {
toast.error(t.threadMemoryPanel.toastSaveFailed);
} finally {
setSavingSummary(false);
}
};
const handleDeleteMemory = async () => {
setDeletingMemory(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setMemorySummary("");
setMemoryVersion(0);
toast.success(t.threadMemoryPanel.toastDeleteSuccess);
} catch {
toast.error(t.threadMemoryPanel.toastDeleteFailed);
} finally {
setDeletingMemory(false);
}
};
return (
<div className="w-[380px] space-y-2 rounded-lg border border-ws-divider bg-ws-surface-elevated p-3 shadow-lg">
<div className="text-sm font-semibold">
<span className="hidden sm:inline">{t.threadMemoryPanel.title}</span>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
void handleLoadMemorySummary();
}}
disabled={loadingSummary}
>
{loadingSummary ? t.threadMemoryPanel.loading : t.threadMemoryPanel.load}
</Button>
<Button
size="sm"
onClick={() => {
void handleSaveMemorySummary();
}}
disabled={savingSummary || memoryVersion == null}
>
{savingSummary ? t.threadMemoryPanel.saving : t.threadMemoryPanel.save}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleDeleteMemory();
}}
disabled={deletingMemory}
>
{deletingMemory ? t.threadMemoryPanel.removing : t.threadMemoryPanel.remove}
</Button>
</div>
<div className="text-xs text-ws-text-subtle-strong">
{t.threadMemoryPanel.threadId}: {threadId.slice(0, 8)}... |{" "}
{t.threadMemoryPanel.version}:{" "}
{memoryVersion == null ? t.threadMemoryPanel.unavailableVersion : memoryVersion}
</div>
<Textarea
value={memorySummary}
onChange={(e) => setMemorySummary(e.target.value)}
placeholder={t.threadMemoryPanel.summaryPlaceholder}
className="min-h-32 bg-white/80"
/>
</div>
</div>
);
}

View File

@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getBackendBaseURL } from "@/core/config";
type ThreadMemoryTestPanelProps = {
threadId?: string;
};
export function ThreadMemoryTestPanel({ threadId }: ThreadMemoryTestPanelProps) {
const [memorySummary, setMemorySummary] = useState("");
const [memoryVersion, setMemoryVersion] = useState<number | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false);
const [savingSummary, setSavingSummary] = useState(false);
const [deletingMemory, setDeletingMemory] = useState(false);
const [open, setOpen] = useState(true);
if (!threadId || threadId === "new") return null;
const handleLoadMemorySummary = async () => {
setLoadingSummary(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory-summary`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { summary: string; memoryVersion: number };
setMemorySummary(data.summary ?? "");
setMemoryVersion(data.memoryVersion ?? 0);
toast.success("已加载会话记忆");
} catch {
toast.error("加载会话记忆失败");
} finally {
setLoadingSummary(false);
}
};
const handleSaveMemorySummary = async () => {
if (memoryVersion == null) return;
setSavingSummary(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory-summary`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ summary: memorySummary, memoryVersion }),
},
);
if (res.status === 409) {
toast.error("记忆已更新,请先重新加载再保存");
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { memoryVersion?: number };
if (typeof data.memoryVersion === "number") setMemoryVersion(data.memoryVersion);
toast.success("会话记忆已保存");
} catch {
toast.error("保存会话记忆失败");
} finally {
setSavingSummary(false);
}
};
const handleDeleteMemory = async () => {
setDeletingMemory(true);
try {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/memory`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setMemorySummary("");
setMemoryVersion(0);
toast.success("当前会话记忆已删除");
} catch {
toast.error("删除会话记忆失败");
} finally {
setDeletingMemory(false);
}
};
return (
<div className="fixed right-4 bottom-4 z-50 w-[380px] rounded-lg border border-ws-divider bg-ws-surface-elevated p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold">Thread Memory TestPanel</div>
<Button size="sm" variant="ghost" onClick={() => setOpen((v) => !v)}>
{open ? "收起" : "展开"}
</Button>
</div>
{open && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
void handleLoadMemorySummary();
}}
disabled={loadingSummary}
>
{loadingSummary ? "加载中..." : "查看记忆"}
</Button>
<Button
size="sm"
onClick={() => {
void handleSaveMemorySummary();
}}
disabled={savingSummary || memoryVersion == null}
>
{savingSummary ? "保存中..." : "保存记忆"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
void handleDeleteMemory();
}}
disabled={deletingMemory}
>
{deletingMemory ? "删除中..." : "删除记忆"}
</Button>
</div>
<div className="text-xs text-ws-text-subtle-strong">
threadId: {threadId.slice(0, 8)}... | version:{" "}
{memoryVersion == null ? "-" : memoryVersion}
</div>
<Textarea
value={memorySummary}
onChange={(e) => setMemorySummary(e.target.value)}
placeholder="这里显示会话记忆总结,可编辑后保存"
className="min-h-32 bg-white/80"
/>
</div>
)}
</div>
);
}

View File

@ -264,6 +264,27 @@ export const enUS: Translations = {
scrollToBottom: "Scroll to bottom",
},
threadMemoryPanel: {
title: "Thread Memory",
load: "Load memory",
loading: "Loading...",
save: "Save memory",
saving: "Saving...",
remove: "Delete memory",
removing: "Deleting...",
threadId: "Thread ID",
version: "Version",
unavailableVersion: "-",
summaryPlaceholder: "Thread memory summary is shown here. Edit it and save.",
toastLoadSuccess: "Thread memory loaded",
toastLoadFailed: "Failed to load thread memory",
toastConflict: "Memory changed. Please reload before saving.",
toastSaveSuccess: "Thread memory saved",
toastSaveFailed: "Failed to save thread memory",
toastDeleteSuccess: "Thread memory deleted",
toastDeleteFailed: "Failed to delete thread memory",
},
// Workspace Chat Page
chatPage: {
defaultSlogan: "Let's study and work together",

View File

@ -194,6 +194,27 @@ export interface Translations {
scrollToBottom: string;
};
threadMemoryPanel: {
title: string;
load: string;
loading: string;
save: string;
saving: string;
remove: string;
removing: string;
threadId: string;
version: string;
unavailableVersion: string;
summaryPlaceholder: string;
toastLoadSuccess: string;
toastLoadFailed: string;
toastConflict: string;
toastSaveSuccess: string;
toastSaveFailed: string;
toastDeleteSuccess: string;
toastDeleteFailed: string;
};
// Workspace Chat Page
chatPage: {
defaultSlogan: string;

View File

@ -252,6 +252,27 @@ export const zhCN: Translations = {
scrollToBottom: "滚动到底部",
},
threadMemoryPanel: {
title: "会话记忆",
load: "查看记忆",
loading: "加载中...",
save: "保存记忆",
saving: "保存中...",
remove: "删除记忆",
removing: "删除中...",
threadId: "threadId",
version: "版本",
unavailableVersion: "-",
summaryPlaceholder: "这里显示会话记忆总结,可编辑后保存",
toastLoadSuccess: "已加载会话记忆",
toastLoadFailed: "加载会话记忆失败",
toastConflict: "记忆已更新,请先重新加载再保存",
toastSaveSuccess: "会话记忆已保存",
toastSaveFailed: "保存会话记忆失败",
toastDeleteSuccess: "当前会话记忆已删除",
toastDeleteFailed: "删除会话记忆失败",
},
// Workspace Chat Page
chatPage: {
defaultSlogan: "来,一起学习工作吧",