feat: add global claw chat history

This commit is contained in:
134 2026-07-07 22:26:04 +08:00
parent 6bc43ffc3f
commit ebfc7589a9
3 changed files with 341 additions and 60 deletions

View File

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getClawdChatsByUserId } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(request: Request) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const limit = Math.max(
1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
);
const chats = await getClawdChatsByUserId({
userId: session.user.id,
limit,
});
return NextResponse.json(
{
chats: chats.map((chat) => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt?.toISOString?.() || chat.createdAt,
updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt,
messageCount: Number(chat.messageCount || 0),
})),
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
}

View File

@ -1971,6 +1971,14 @@ interface CanvasChatSession {
clawdSkillTagPromptApplied?: boolean;
}
interface ClawdHistoryItem {
id: string;
title: string;
createdAt: string | Date;
updatedAt: string | Date;
messageCount: number;
}
const CANVAS_CHAT_QUERY_PARAM = "chat";
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
@ -2194,6 +2202,7 @@ export function CanvasChatPanel({
const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const [isClawdHistoryMenuOpen, setIsClawdHistoryMenuOpen] = useState(false);
const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false);
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
@ -2239,6 +2248,7 @@ export function CanvasChatPanel({
);
const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
@ -2963,6 +2973,17 @@ export function CanvasChatPanel({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isSessionMenuOpen]);
useEffect(() => {
if (!isClawdHistoryMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!clawdHistoryMenuRef.current?.contains(event.target as Node)) {
setIsClawdHistoryMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isClawdHistoryMenuOpen]);
useEffect(() => {
const sessionStorageKey = getSessionStorageKey(canvasId, chatScope);
if (loadedChatSessionStorageKey !== sessionStorageKey) return;
@ -4545,6 +4566,19 @@ export function CanvasChatPanel({
activeChatSession,
activeComposerMode
);
const clawdHistorySWRKey =
isClawdMode && isClawdHistoryMenuOpen
? "/api/canvas/clawd-chat/history?limit=80"
: null;
const {
data: clawdHistoryData,
isLoading: isClawdHistoryLoading,
mutate: mutateClawdHistory,
} = useSWR<{ chats: ClawdHistoryItem[] }>(clawdHistorySWRKey, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 5000,
});
useEffect(() => {
if (activeChatSessionMode !== "clawd") return;
if (!isClawdModeEnabled) return;
@ -10785,6 +10819,7 @@ export function CanvasChatPanel({
if (rawChatId) {
void mutate(buildHistoryMessagesUrl(rawChatId, { cacheBust: true }));
}
void mutateClawdHistory();
hasHydratedHistoryRef.current = true;
} catch (error) {
console.error("[CanvasChat] Claw request failed:", error);
@ -11203,6 +11238,7 @@ export function CanvasChatPanel({
shouldBlockDuplicateSend,
status,
mutate,
mutateClawdHistory,
markSessionClawdSkillTagPromptApplied,
updateChatSessions,
buildHistoryMessagesUrl,
@ -12598,6 +12634,70 @@ export function CanvasChatPanel({
toast.success("已切换聊天窗口");
}, [activeChatId, releaseSendGuard, replaceChatSessionQuery]);
const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => {
const sessionId = normalizeCanvasChatSessionId(historyItem.id);
if (!sessionId) return;
const updatedAt = Date.parse(String(historyItem.updatedAt || "")) || Date.now();
const createdAt = Date.parse(String(historyItem.createdAt || "")) || updatedAt;
const title = String(historyItem.title || "").trim() || `${CLAW_MODE_LABEL} 对话`;
updateChatSessions((prevSessions) => {
const currentIndex = prevSessions.findIndex(
(session) => session.id === sessionId
);
if (currentIndex < 0) {
return [
{
id: sessionId,
title,
mode: "clawd",
createdAt,
updatedAt,
},
...prevSessions,
];
}
const nextSessions = [...prevSessions];
nextSessions[currentIndex] = {
...nextSessions[currentIndex],
title,
mode: "clawd",
updatedAt: Math.max(
Number(nextSessions[currentIndex]?.updatedAt) || 0,
updatedAt
),
};
nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
return nextSessions;
});
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
setActiveChatId(sessionId);
setComposerModeOverride("clawd");
setIsDetailGenerationMode(false);
replaceChatSessionQuery(sessionId);
setIsClawdHistoryMenuOpen(false);
toast.success("已打开 Claw 历史对话");
},
[activeChatId, releaseSendGuard, replaceChatSessionQuery, updateChatSessions]
);
const formatClawdHistoryTime = useCallback((value: string | Date) => {
const timestamp = Date.parse(String(value || ""));
if (!Number.isFinite(timestamp)) return "";
return new Date(timestamp).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}, []);
const activeSessionTitle = useMemo(() => {
const current = chatSessions.find((session) => session.id === activeChatId);
return String(current?.title || "").trim();
@ -12726,72 +12826,173 @@ export function CanvasChatPanel({
>
<Plus className="h-4 w-4" />
</Button>
<div className="relative" ref={sessionMenuRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full",
isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10",
isSessionMenuOpen && (isVideoWorkspace ? "bg-background" : "bg-muted dark:bg-white/10")
)}
onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录"
>
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div
{isClawdMode ? (
<div className="relative" ref={clawdHistoryMenuRef}>
<Button
variant="ghost"
className={cn(
"absolute right-0 top-10 w-64 p-2 shadow-xl",
"h-8 rounded-full px-2.5 text-xs font-semibold",
isVideoWorkspace
? "rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl"
: "rounded-xl border bg-white dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-100"
? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10",
isClawdHistoryMenuOpen &&
(isVideoWorkspace ? "bg-background" : "bg-muted dark:bg-white/10")
)}
onClick={() => {
setIsSessionMenuOpen(false);
setIsClawdHistoryMenuOpen((open) => {
if (!open) {
void mutateClawdHistory();
}
return !open;
});
}}
title="Claw 对话历史"
>
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{currentModeChatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
currentModeChatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={session.id}
type="button"
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
isActive
? isVideoWorkspace
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-primary/10 text-primary"
: isVideoWorkspace
? "hover:bg-background"
: "hover:bg-muted dark:hover:bg-white/10"
)}
onClick={() => switchToSession(session.id)}
>
<div className="text-sm font-medium truncate">
{session.title || `对话 ${index + 1}`}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
最近更新 {timeLabel}
</div>
</button>
);
})
<History className="h-4 w-4" />
<span>Claw</span>
</Button>
{isClawdHistoryMenuOpen && (
<div
className={cn(
"absolute right-0 top-10 w-72 p-2 shadow-xl",
isVideoWorkspace
? "rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl"
: "rounded-xl border bg-white dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-100"
)}
>
<div className="flex items-center justify-between gap-2 px-2 py-1">
<div className="text-xs font-medium text-muted-foreground">
Claw 对话历史
</div>
<button
type="button"
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground dark:hover:bg-white/10"
onClick={() => void mutateClawdHistory()}
title="刷新 Claw 历史"
>
<RefreshCw
className={cn(
"h-3.5 w-3.5",
isClawdHistoryLoading && "animate-spin"
)}
/>
</button>
</div>
<div className="max-h-72 space-y-1 overflow-y-auto">
{isClawdHistoryLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载 Claw 历史
</div>
) : (clawdHistoryData?.chats || []).length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
暂无 Claw 历史
</div>
) : (
(clawdHistoryData?.chats || []).map((item) => {
const sessionId = normalizeCanvasChatSessionId(item.id);
const isActive = sessionId === activeChatId;
const timeLabel = formatClawdHistoryTime(item.updatedAt);
return (
<button
key={item.id}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left transition-colors",
isActive
? isVideoWorkspace
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-primary/10 text-primary"
: isVideoWorkspace
? "hover:bg-background"
: "hover:bg-muted dark:hover:bg-white/10"
)}
onClick={() => switchToClawdHistorySession(item)}
>
<div className="truncate text-sm font-medium">
{item.title || `${CLAW_MODE_LABEL} 对话`}
</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>{timeLabel ? `最近更新 ${timeLabel}` : "最近更新"}</span>
<span>{Math.max(0, Number(item.messageCount || 0))} 条</span>
</div>
</button>
);
})
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="relative" ref={sessionMenuRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full",
isVideoWorkspace
? "border border-border/60 bg-background/72 hover:bg-background"
: "text-zinc-600 hover:bg-muted dark:text-zinc-100 dark:hover:bg-white/10",
isSessionMenuOpen && (isVideoWorkspace ? "bg-background" : "bg-muted dark:bg-white/10")
)}
onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录"
>
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div
className={cn(
"absolute right-0 top-10 w-64 p-2 shadow-xl",
isVideoWorkspace
? "rounded-2xl border border-border/60 bg-background/95 backdrop-blur-xl"
: "rounded-xl border bg-white dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-100"
)}
>
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{currentModeChatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
currentModeChatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={session.id}
type="button"
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
isActive
? isVideoWorkspace
? "border border-primary/20 bg-primary/10 text-primary"
: "bg-primary/10 text-primary"
: isVideoWorkspace
? "hover:bg-background"
: "hover:bg-muted dark:hover:bg-white/10"
)}
onClick={() => switchToSession(session.id)}
>
<div className="text-sm font-medium truncate">
{session.title || `对话 ${index + 1}`}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
最近更新 {timeLabel}
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
)}
<Button
variant="ghost"
size="icon"

View File

@ -186,6 +186,44 @@ export async function saveChat({
}
}
export async function getClawdChatsByUserId({
userId,
limit = 50,
}: {
userId: string;
limit?: number;
}) {
const safeLimit = Math.max(1, Math.min(100, Math.round(limit) || 50));
const latestMessageAt = sql<Date>`max(${message.createdAt})`;
try {
return await db
.select({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: latestMessageAt,
messageCount: sql<number>`count(${message.id})::int`,
})
.from(chat)
.innerJoin(message, eq(message.chatId, chat.id))
.where(
and(
eq(chat.userId, userId),
sql`coalesce(${message.videoMetadata}->>'composerMode', '') = 'clawd'`
)
)
.groupBy(chat.id, chat.title, chat.createdAt)
.orderBy(desc(latestMessageAt))
.limit(safeLimit);
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get Claw chats by user id"
);
}
}
export async function deleteChatById({ id }: { id: string }) {
try {
await db.delete(vote).where(eq(vote.chatId, id));