feat(ThreadMemoryPanel): 新增会话记忆下拉面板并完成 i18n 接入
This commit is contained in:
parent
fc9a30c784
commit
92b6bcc5fb
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
138
frontend/src/components/workspace/thread-memory-panel.tsx
Normal file
138
frontend/src/components/workspace/thread-memory-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "来,一起学习工作吧",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user