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) {