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:
parent
7704a799f8
commit
9385b6f6d6
79
app/api/canvas/auto-title/route.ts
Normal file
79
app/api/canvas/auto-title/route.ts
Normal 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: "新画布" });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
185
components/canvas/CanvasFileList.tsx
Normal file
185
components/canvas/CanvasFileList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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="无限画布"
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user