feat: add global claw chat history
This commit is contained in:
parent
6bc43ffc3f
commit
ebfc7589a9
42
app/api/canvas/clawd-chat/history/route.ts
Normal file
42
app/api/canvas/clawd-chat/history/route.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user