feat: implement auto-save, auto-title, and canvas file list sidebar

- Remove manual rename and save buttons from canvas header
- Add auto-save logic with debounce
- Add AI auto-titling and thumbnail generation based on canvas content
- Add CanvasFileList sidebar for managing canvases
- Direct entry to new canvas from top navigation
- Add new API endpoint for auto-titling
This commit is contained in:
Your Name 2026-01-24 22:50:34 +08:00
parent 7704a799f8
commit 9385b6f6d6
4 changed files with 352 additions and 47 deletions

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
import { getSystemSetting } from "@/lib/db/queries";
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { imageUrl } = await request.json();
if (!imageUrl) {
return NextResponse.json({ error: "Image URL is required" }, { status: 400 });
}
// 获取 AI 配置
const aiProviderConfig = await getSystemSetting({ key: "ai_provider_config" });
const modelsConfig = await getSystemSetting({ key: "ai_models_config" });
// 默认使用通义千问 (Alibaba) 配置,如果未配置则尝试其他
// 这里为了简化,我们假设系统中配置了支持视觉的模型。
// 在实际生产中,最好在前端指定一个确定的视觉模型ID传过来。
// 构造 Prompt
const prompt = "请用2-4个字概括这张图片的主体内容,作为文件标题。直接返回标题,不要包含任何标点符号或其他文字。";
// 这里我们尝试使用环境变量中的配置,或者从数据库读取的配置初始化模型
// 为了确保稳定性,这里建议使用一个支持视觉的兜底模型配置
// 假设系统中有配置好的 'qwen-vl-max' 或类似模型
// 临时方案:直接使用 fetch 调用通义千问 API (如果使用了兼容 OpenAI 的接口)
// 或者使用项目现有的 provider 逻辑。
// 由于我看不到完整的 provider 实现细节,我将使用项目中已有的 /api/chat 逻辑的简化版
// 但为了更直接,我们这里模拟一个简单的视觉模型调用,或者利用 ai-sdk 的标准特性
// ⚠️ 注意:这里需要确保您的环境变量中有 ALIBABA_API_KEY 或者对应的配置
// 如果项目中统一使用了 provider 管理,最好复用。
// 为了不破坏现有架构,我们这里简化处理:
// 如果您有特定的 vision model ID,请替换这里。
// 这里的 fallback 逻辑需要根据您的实际 .env 配置来调整。
const apiKey = process.env.ALIBABA_API_KEY || process.env.OPENAI_API_KEY;
const baseURL = process.env.ALIBABA_BASE_URL || "https://dashscope.aliyuncs.com/compatible-mode/v1";
if (!apiKey) {
return NextResponse.json({ title: "未命名画布" }, { status: 200 });
}
const openai = createOpenAI({
apiKey: apiKey,
baseURL: baseURL,
});
const { text } = await generateText({
model: openai("qwen-vl-max"), // 尝试使用 qwen-vl-max
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image', image: imageUrl },
],
},
],
});
return NextResponse.json({ title: text.trim() });
} catch (error) {
console.error("Auto-title error:", error);
// 失败时返回默认标题,不报错中断流程
return NextResponse.json({ title: "新画布" });
}
}

View File

@ -62,9 +62,10 @@ import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileList } from "@/components/canvas/CanvasFileList";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { MessageSquare } from "lucide-react";
import { MessageSquare, FolderOpen } from "lucide-react";
interface PageProps {
params: Promise<{ id: string }>;
@ -115,6 +116,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [isFileListOpen, setIsFileListOpen] = useState(false);
// 生成配置
const [prompt, setPrompt] = useState("");
@ -148,7 +150,60 @@ export default function CanvasEditorPage({ params }: PageProps) {
loadProject();
}, [id]);
// 保存画布
// 自动保存逻辑
useEffect(() => {
if (isLoading) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
const canvasData = getCanvasState();
try {
await canvasService.saveCanvasState(id, canvasData);
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [nodes, id, isLoading, getCanvasState]);
// 智能命名与缩略图逻辑
useEffect(() => {
const autoTitleAndThumbnail = async () => {
// 如果标题是默认值,且有图片节点
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
const imageNode = nodes.find(n => n.type === 'image' && n.data?.src);
if (imageNode?.data?.src) {
try {
// 1. 设置缩略图
await canvasService.updateProject(id, { thumbnailUrl: imageNode.data.src });
// 2. 智能命名
const res = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: imageNode.data.src })
});
const data = await res.json();
if (data.title) {
setProjectTitle(data.title);
await canvasService.updateProject(id, { title: data.title });
toast.success(`画布已自动命名为:${data.title}`);
}
} catch (error) {
console.error("Auto title failed:", error);
}
}
}
};
// 使用防抖,避免频繁触发
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
return () => clearTimeout(timer);
}, [nodes, projectTitle, id, setProjectTitle]);
// 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => {
setIsSaving(true);
const canvasData = getCanvasState();
@ -381,55 +436,32 @@ export default function CanvasEditorPage({ params }: PageProps) {
<Separator orientation="vertical" className="h-5" />
{isEditingTitle ? (
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleUpdateTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') handleUpdateTitle();
if (e.key === 'Escape') setIsEditingTitle(false);
}}
className="h-8 w-48 text-sm bg-transparent border-none focus-visible:ring-0 px-2"
autoFocus
/>
) : (
<button
className="text-sm font-medium px-3 h-8 hover:bg-muted rounded-lg transition-colors"
onClick={() => {
setEditTitle(projectTitle);
setIsEditingTitle(true);
}}
>
{projectTitle}
</button>
)}
{/* 文件列表按钮 & 标题展示 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFileListOpen(true)}
className="h-8 gap-2 px-2 font-normal hover:bg-muted rounded-lg"
>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium max-w-[200px] truncate">
{projectTitle}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>查看文件列表</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* Save Button (Moved to Left) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleSave}
disabled={isSaving}
className="rounded-lg h-8"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="ml-2 hidden sm:inline">保存</span>
</Button>
</TooltipTrigger>
<TooltipContent>保存 (Ctrl+S)</TooltipContent>
</Tooltip>
{isGenerating && (
{(isGenerating || isSaving) && (
<>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">生成中...</span>
<span className="hidden sm:inline">
{isGenerating ? "生成中..." : "保存中..."}
</span>
</div>
</>
)}
@ -630,6 +662,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
</div>
<CanvasFileList
open={isFileListOpen}
onOpenChange={setIsFileListOpen}
currentId={id}
/>
</TooltipProvider>
);
}

View File

@ -0,0 +1,185 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { Trash2, FileImage, Loader2, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { canvasService } from "@/services/canvas";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { CanvasProject } from "@/types/canvas";
interface CanvasFileListProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentId: string;
}
export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileListProps) {
const router = useRouter();
const [projects, setProjects] = useState<CanvasProject[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 加载项目列表
const loadProjects = async () => {
setIsLoading(true);
const result = await canvasService.getProjects();
if (result.success && result.data) {
// 按更新时间降序排序
const sorted = result.data.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
setProjects(sorted);
}
setIsLoading(false);
};
useEffect(() => {
if (open) {
loadProjects();
}
}, [open]);
// 创建新画布
const handleCreateNew = async () => {
// 生成一个新 ID 并跳转,具体的创建逻辑由页面组件处理
const newId = crypto.randomUUID();
router.push(`/canvas/${newId}`);
onOpenChange(false);
};
// 切换画布
const handleSwitch = (id: string) => {
if (id === currentId) {
onOpenChange(false);
return;
}
router.push(`/canvas/${id}`);
onOpenChange(false);
};
// 删除画布
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (!confirm("确定要删除这个画布吗?")) return;
const result = await canvasService.deleteProject(id);
if (result.success) {
setProjects(prev => prev.filter(p => p.id !== id));
toast.success("删除成功");
// 如果删除的是当前画布,跳转到列表第一个或首页
if (id === currentId) {
const next = projects.find(p => p.id !== id);
if (next) {
router.push(`/canvas/${next.id}`);
} else {
router.push('/canvas'); // 或者 handleCreateNew()
}
}
} else {
toast.error("删除失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/95 backdrop-blur-md">
<DialogHeader className="p-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle>已生成文件列表</DialogTitle>
<Button size="sm" onClick={handleCreateNew} className="h-8">
<Plus className="h-4 w-4 mr-1" />
新建画布
</Button>
</div>
<DialogDescription className="hidden">画布列表</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 p-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>暂无画布历史</p>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<div
key={project.id}
onClick={() => handleSwitch(project.id)}
className={cn(
"flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border",
"hover:bg-muted/50 hover:shadow-sm",
project.id === currentId
? "bg-primary/5 border-primary/20 ring-1 ring-primary/10"
: "bg-card border-transparent"
)}
>
{/* 缩略图 */}
<div className="h-12 w-12 rounded-lg bg-muted/50 overflow-hidden shrink-0 border">
{project.thumbnailUrl ? (
<img
src={project.thumbnailUrl}
alt={project.title}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<FileImage className="h-5 w-5 text-muted-foreground/40" />
</div>
)}
</div>
{/* 信息 */}
<div className="flex-1 min-w-0">
<h4 className={cn(
"font-medium text-sm truncate",
project.id === currentId && "text-primary"
)}>
{project.title || "未命名画布"}
</h4>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(project.updatedAt), {
addSuffix: true,
locale: zhCN
})}
</p>
</div>
{/* 操作 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
onClick={(e) => handleDelete(e, project.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<div className="text-center py-4 text-xs text-muted-foreground/50">
到底了
</div>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@ -259,7 +259,10 @@ export function TopNavbar({
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
onClick={() => router.push("/canvas")}
onClick={() => {
const newId = crypto.randomUUID();
router.push(`/canvas/${newId}`);
}}
type="button"
title="无限画布"
>