diff --git a/app/api/canvas/auto-title/route.ts b/app/api/canvas/auto-title/route.ts new file mode 100644 index 0000000..cd3d39f --- /dev/null +++ b/app/api/canvas/auto-title/route.ts @@ -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: "新画布" }); + } +} diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index eecb9c2..74defe8 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -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) { - {isEditingTitle ? ( - 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 - /> - ) : ( - - )} + {/* 文件列表按钮 & 标题展示 */} + + + + + 查看文件列表 + - - - {/* Save Button (Moved to Left) */} - - - - - 保存 (Ctrl+S) - - - {isGenerating && ( + {(isGenerating || isSaving) && ( <>
- 生成中... + + {isGenerating ? "生成中..." : "保存中..."} +
)} @@ -630,6 +662,12 @@ export default function CanvasEditorPage({ params }: PageProps) { + + ); } diff --git a/components/canvas/CanvasFileList.tsx b/components/canvas/CanvasFileList.tsx new file mode 100644 index 0000000..15ac8c9 --- /dev/null +++ b/components/canvas/CanvasFileList.tsx @@ -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([]); + 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 ( + + + +
+ 已生成文件列表 + +
+ 画布列表 +
+ + + {isLoading ? ( +
+ +
+ ) : projects.length === 0 ? ( +
+ +

暂无画布历史

+
+ ) : ( +
+ {projects.map((project) => ( +
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" + )} + > + {/* 缩略图 */} +
+ {project.thumbnailUrl ? ( + {project.title} + ) : ( +
+ +
+ )} +
+ + {/* 信息 */} +
+

+ {project.title || "未命名画布"} +

+

+ {formatDistanceToNow(new Date(project.updatedAt), { + addSuffix: true, + locale: zhCN + })} +

+
+ + {/* 操作 */} + +
+ ))} + +
+ 到底了 +
+
+ )} +
+
+
+ ); +} diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index 1c6ed6a..4ad46dc 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -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="无限画布" >