From 6b385792e4669bb41095ec080997b70088342674 Mon Sep 17 00:00:00 2001 From: MiaoMint <44718819+MiaoMint@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:34:25 +0800 Subject: [PATCH 1/2] feat: implement asset file download functionality --- binding/app/service.go | 3 ++ binding/database/service.go | 52 +++++++++++++++++++++++ frontend/src/components/asset-library.tsx | 28 ++++++++++-- frontend/wailsjs/go/database/Service.d.ts | 2 + frontend/wailsjs/go/database/Service.js | 4 ++ main.go | 10 ++--- 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/binding/app/service.go b/binding/app/service.go index f2a458a..33dfb07 100644 --- a/binding/app/service.go +++ b/binding/app/service.go @@ -1,6 +1,7 @@ package app import ( + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,8 @@ import ( "visionflow/database" ) +var WailsContext *context.Context + type Service struct { WailsJSON string } diff --git a/binding/database/service.go b/binding/database/service.go index c0b7abf..54319d0 100644 --- a/binding/database/service.go +++ b/binding/database/service.go @@ -4,12 +4,16 @@ import ( "crypto/md5" "encoding/hex" "fmt" + "io" "os" "path/filepath" "time" + "visionflow/binding/app" db "visionflow/database" "visionflow/service/fileserver" "visionflow/storage" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) type Service struct { @@ -142,3 +146,51 @@ func (s *Service) CreateAssetFromFile(name string, data []byte) (*db.Asset, erro createdAsset.URL = fileserver.GetFileUrl(createdAsset.Path) return createdAsset, nil } + +func (s *Service) DownloadAssetFile(filename string) error { + // 1. Resolve source path + assetsDir, err := storage.GetAssetsDir() + if err != nil { + return fmt.Errorf("failed to get assets directory: %w", err) + } + sourcePath := filepath.Join(assetsDir, filename) + + // Check if source file exists + if _, err := os.Stat(sourcePath); os.IsNotExist(err) { + return fmt.Errorf("source file does not exist: %s", filename) + } + + // 2. Open Save Dialog + destPath, err := runtime.SaveFileDialog(*app.WailsContext, runtime.SaveDialogOptions{ + DefaultFilename: filename, + Title: "Save Asset File", + }) + if err != nil { + return err + } + + // User cancelled + if destPath == "" { + return nil + } + + // 3. Copy File efficiently + source, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer source.Close() + + destination, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer destination.Close() + + _, err = io.Copy(destination, source) + if err != nil { + return fmt.Errorf("failed to save file: %w", err) + } + + return nil +} diff --git a/frontend/src/components/asset-library.tsx b/frontend/src/components/asset-library.tsx index 7816ffb..5011b2e 100644 --- a/frontend/src/components/asset-library.tsx +++ b/frontend/src/components/asset-library.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef } from "react"; import { database } from "../../wailsjs/go/models"; -import { ListAssets, DeleteAsset } from "../../wailsjs/go/database/Service"; +import { ListAssets, DeleteAsset, DownloadAssetFile } from "../../wailsjs/go/database/Service"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Trash2, FileImage, FileVideo, FileAudio } from "lucide-react"; +import { Trash2, FileImage, FileVideo, FileAudio, Download } from "lucide-react"; import { toast } from "sonner"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -53,7 +53,16 @@ export function AssetLibrary() { } }; - + const handleSave = async (e: React.MouseEvent, asset: database.Asset) => { + e.stopPropagation(); + try { + await DownloadAssetFile(asset.path); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; return (
@@ -89,6 +98,14 @@ export function AssetLibrary() {
+ + + +
{nodeData.processing ? (
@@ -46,7 +89,9 @@ export const AudioNode = memo((props: NodeProps) => { ) : nodeData.audioUrl ? (
diff --git a/frontend/src/components/nodes/image-node.tsx b/frontend/src/components/nodes/image-node.tsx index 0cfef75..ad4dd33 100644 --- a/frontend/src/components/nodes/image-node.tsx +++ b/frontend/src/components/nodes/image-node.tsx @@ -1,18 +1,28 @@ import { memo } from "react"; -import { type NodeProps, useReactFlow } from "@xyflow/react"; -import { Image as ImageIcon } from "lucide-react"; +import { + type NodeProps, + NodeToolbar, + Position, + useReactFlow, +} from "@xyflow/react"; +import { Download, Image as ImageIcon } from "lucide-react"; import type { ImageNodeData } from "./types"; import { GenerateImage } from "../../../wailsjs/go/ai/Service"; +import { DownloadAssetFile } from "../../../wailsjs/go/database/Service"; import { Skeleton } from "@/components/ui/skeleton"; import { BaseNode } from "./base-node"; import { useNodeRun } from "../../hooks/use-node-run"; import { useLingui } from "@lingui/react"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { ButtonGroup } from "../ui/button-group"; +import { Button } from "../ui/button"; +import { toast } from "sonner"; export const ImageNode = memo((props: NodeProps) => { const { id, data } = props; const nodeData = data as unknown as ImageNodeData; + const isSelected = props.selected; const { updateNodeData } = useReactFlow(); const { _ } = useLingui(); @@ -28,6 +38,20 @@ export const ImageNode = memo((props: NodeProps) => { }, }); + const handleSave = async () => { + if (nodeData.imageUrl == null) { + toast.error(_(msg`No image to save`)); + return; + } + try { + await DownloadAssetFile(nodeData.imageUrl.split("/").pop() || ""); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; + return ( { minWidth={200} minHeight={200} > + + + + + +
{nodeData.processing ? (
diff --git a/frontend/src/components/nodes/video-node.tsx b/frontend/src/components/nodes/video-node.tsx index 0068ac5..104e2bd 100644 --- a/frontend/src/components/nodes/video-node.tsx +++ b/frontend/src/components/nodes/video-node.tsx @@ -1,6 +1,11 @@ import { memo, useRef, useState } from "react"; -import { type NodeProps, useReactFlow } from "@xyflow/react"; -import { Video, Play, Pause } from "lucide-react"; +import { + type NodeProps, + NodeToolbar, + Position, + useReactFlow, +} from "@xyflow/react"; +import { Video, Play, Pause, Download } from "lucide-react"; import type { VideoNodeData } from "./types"; import { GenerateVideo } from "../../../wailsjs/go/ai/Service"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,9 +15,13 @@ import { useNodeRun } from "../../hooks/use-node-run"; import { useLingui } from "@lingui/react"; import { msg } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; +import { ButtonGroup } from "../ui/button-group"; +import { toast } from "sonner"; +import { DownloadAssetFile } from "../../../wailsjs/go/database/Service"; export const VideoNode = memo((props: NodeProps) => { const { id, data } = props; + const isSelected = props.selected; const nodeData = data as unknown as VideoNodeData; const { updateNodeData } = useReactFlow(); const videoRef = useRef(null); @@ -47,6 +56,20 @@ export const VideoNode = memo((props: NodeProps) => { setIsPlaying(false); }; + const handleSave = async () => { + if (nodeData.videoUrl == null) { + toast.error(_(msg`No video to save`)); + return; + } + try { + await DownloadAssetFile(nodeData.videoUrl.split("/").pop() || ""); + toast.success(_(msg`Asset saved`)); + } catch (err) { + console.error("Failed to save asset:", err); + toast.error(_(msg`Failed to save`)); + } + }; + return ( { minWidth={200} minHeight={200} > + + + + + +
{nodeData.processing ? (
@@ -71,7 +112,9 @@ export const VideoNode = memo((props: NodeProps) => { onEnded={onVideoEnded} onClick={togglePlay} /> -
+
) : ( -
No video yet
+
+ No video yet +
)}