mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-22 05:34:45 +08:00
Citations: - Add citations parsing utilities for extracting source references from AI responses - Render inline citations as hover card badges in message content - Display citation cards with title, URL, and description on hover - Add citation badge rendering in artifact markdown preview - Update prompt to guide AI to output citations in correct format Thread Management: - Add rename functionality for chat threads with dialog UI - Add share functionality to copy thread link to clipboard - Share links use Vercel URL for production accessibility - Add useRenameThread hook for thread title updates i18n: - Add translations for rename, share, cancel, save, and linkCopied Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
|
import { useCallback, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarMenu,
|
|
SidebarMenuAction,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
} from "@/components/ui/sidebar";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import {
|
|
useDeleteThread,
|
|
useRenameThread,
|
|
useThreads,
|
|
} from "@/core/threads/hooks";
|
|
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
|
import { env } from "@/env";
|
|
|
|
export function RecentChatList() {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
|
const { data: threads = [] } = useThreads();
|
|
const { mutate: deleteThread } = useDeleteThread();
|
|
const { mutate: renameThread } = useRenameThread();
|
|
|
|
// Rename dialog state
|
|
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
|
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
|
|
const handleDelete = useCallback(
|
|
(threadId: string) => {
|
|
deleteThread({ threadId });
|
|
if (threadId === threadIdFromPath) {
|
|
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
|
let nextThreadId = "new";
|
|
if (threadIndex > -1) {
|
|
if (threads[threadIndex + 1]) {
|
|
nextThreadId = threads[threadIndex + 1]!.thread_id;
|
|
} else if (threads[threadIndex - 1]) {
|
|
nextThreadId = threads[threadIndex - 1]!.thread_id;
|
|
}
|
|
}
|
|
void router.push(`/workspace/chats/${nextThreadId}`);
|
|
}
|
|
},
|
|
[deleteThread, router, threadIdFromPath, threads],
|
|
);
|
|
|
|
const handleRenameClick = useCallback(
|
|
(threadId: string, currentTitle: string) => {
|
|
setRenameThreadId(threadId);
|
|
setRenameValue(currentTitle);
|
|
setRenameDialogOpen(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleRenameSubmit = useCallback(() => {
|
|
if (renameThreadId && renameValue.trim()) {
|
|
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
|
|
setRenameDialogOpen(false);
|
|
setRenameThreadId(null);
|
|
setRenameValue("");
|
|
}
|
|
}, [renameThread, renameThreadId, renameValue]);
|
|
|
|
const handleShare = useCallback(
|
|
async (threadId: string) => {
|
|
// Always use Vercel URL for sharing so others can access
|
|
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
|
|
const isLocalhost =
|
|
window.location.hostname === "localhost" ||
|
|
window.location.hostname === "127.0.0.1";
|
|
// On localhost: use Vercel URL; On production: use current origin
|
|
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
|
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
toast.success(t.clipboard.linkCopied);
|
|
} catch {
|
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
if (threads.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<>
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>
|
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
|
? t.sidebar.recentChats
|
|
: t.sidebar.demoChats}
|
|
</SidebarGroupLabel>
|
|
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
|
<SidebarMenu>
|
|
<div className="flex w-full flex-col gap-1">
|
|
{threads.map((thread) => {
|
|
const isActive = pathOfThread(thread.thread_id) === pathname;
|
|
return (
|
|
<SidebarMenuItem
|
|
key={thread.thread_id}
|
|
className="group/side-menu-item"
|
|
>
|
|
<SidebarMenuButton isActive={isActive} asChild>
|
|
<div>
|
|
<Link
|
|
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
|
href={pathOfThread(thread.thread_id)}
|
|
>
|
|
{titleOfThread(thread)}
|
|
</Link>
|
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuAction
|
|
showOnHover
|
|
className="bg-background/50 hover:bg-background"
|
|
>
|
|
<MoreHorizontal />
|
|
<span className="sr-only">{t.common.more}</span>
|
|
</SidebarMenuAction>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-48 rounded-lg"
|
|
side={"right"}
|
|
align={"start"}
|
|
>
|
|
<DropdownMenuItem
|
|
onSelect={() =>
|
|
handleRenameClick(
|
|
thread.thread_id,
|
|
titleOfThread(thread),
|
|
)
|
|
}
|
|
>
|
|
<Pencil className="text-muted-foreground" />
|
|
<span>{t.common.rename}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => handleShare(thread.thread_id)}
|
|
>
|
|
<Share2 className="text-muted-foreground" />
|
|
<span>{t.common.share}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() => handleDelete(thread.thread_id)}
|
|
>
|
|
<Trash2 className="text-muted-foreground" />
|
|
<span>{t.common.delete}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
{/* Rename Dialog */}
|
|
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{t.common.rename}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<Input
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
placeholder={t.common.rename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleRenameSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setRenameDialogOpen(false)}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|