Skip to content

Commit 7e675ed

Browse files
committed
feat: single node execution (#14)
* feat: Implement local storage for AI-generated media and integrate AI generation into media nodes. * feat: display destructive ring for nodes in an error state * feat: implement custom video player controls for video node and add OpenAI Sora video generation to backend.
1 parent b559402 commit 7e675ed

File tree

14 files changed

+599
-116
lines changed

14 files changed

+599
-116
lines changed

binding/ai/service.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"firebringer/database"
88
aiservice "firebringer/service/ai"
9+
"firebringer/storage"
910
)
1011

1112
// Service provides AI methods for the frontend
@@ -135,9 +136,9 @@ func (s *Service) GenerateImage(req ImageRequest) (*AIResponse, error) {
135136
return nil, err
136137
}
137138

138-
content := resp.URL
139-
if content == "" {
140-
content = resp.B64JSON
139+
content, err := s.processContent(resp.Data, resp.B64JSON, resp.URL, "image", ".png")
140+
if err != nil {
141+
return nil, err
141142
}
142143

143144
return &AIResponse{
@@ -166,10 +167,9 @@ func (s *Service) GenerateVideo(req VideoRequest) (*AIResponse, error) {
166167
return nil, err
167168
}
168169

169-
content := resp.URL
170-
if content == "" && len(resp.Data) > 0 {
171-
content = "Video data (base64 or bytes)" // Improve this for frontend to handle bytes
172-
// potentially convert bytes to base64 if it is bytes
170+
content, err := s.processContent(resp.Data, "", resp.URL, "video", ".mp4")
171+
if err != nil {
172+
return nil, err
173173
}
174174

175175
return &AIResponse{
@@ -201,11 +201,9 @@ func (s *Service) GenerateAudio(req AudioRequest) (*AIResponse, error) {
201201
// Usually audio is returned as bytes.
202202
// We might want to base64 encode it for the frontend or return a Blob URL if we could.
203203
// For now, let's assume valid JSON marshalling or handle it in specific response type
204-
content := ""
205-
if len(resp.Data) > 0 {
206-
// Simple indicator, actual data in Raw or handled by frontend from specific field?
207-
// Actually AIResponse.Raw is interface{}, so it will marshal the []byte as base64 string automatically in JSON.
208-
content = "Audio generated"
204+
content, err := s.processContent(resp.Data, "", "", "audio", ".mp3")
205+
if err != nil {
206+
return nil, err
209207
}
210208

211209
return &AIResponse{
@@ -254,3 +252,24 @@ func (s *Service) ListModels(providerId *int) ([]aiservice.Model, error) {
254252

255253
return client.ListModels(s.ctx)
256254
}
255+
256+
func (s *Service) processContent(data []byte, b64 string, url string, prefix string, ext string) (string, error) {
257+
var filename string
258+
var err error
259+
260+
if len(data) > 0 {
261+
filename, err = storage.SaveGeneratedContent(data, prefix, ext)
262+
} else if b64 != "" {
263+
filename, err = storage.SaveBase64Content(b64, prefix, ext)
264+
} else if url != "" {
265+
filename, err = storage.SaveURLContent(url, prefix, ext)
266+
} else {
267+
return "", nil
268+
}
269+
270+
if err != nil {
271+
return "", fmt.Errorf("failed to save %s: %w", prefix, err)
272+
}
273+
274+
return fmt.Sprintf("http://localhost:34116/%s", filename), nil
275+
}

frontend/src/components/ai/model-selector.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function ModelSelector({
144144
placeholder="搜索模型..."
145145
className="h-9 border-b"
146146
/>
147-
<CommandList className="max-h-full flex-1 min-h-0">
147+
<CommandList className="max-h-full flex-1 min-h-0 w-[300px]">
148148
{loadingModels ? (
149149
<div className="flex justify-center p-8">
150150
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -166,15 +166,37 @@ export function ModelSelector({
166166
onSelect={() => handleModelSelect(model.id)}
167167
className="text-xs"
168168
>
169-
<Check
170-
className={cn(
171-
"mr-2 h-4 w-4",
172-
modelId === model.id
173-
? "opacity-100"
174-
: "opacity-0"
175-
)}
176-
/>
177-
{model.id}
169+
<div className="flex flex-col gap-1 w-full">
170+
<div className="flex items-center">
171+
<Check
172+
className={cn(
173+
"mr-2 h-4 w-4 shrink-0",
174+
modelId === model.id
175+
? "opacity-100"
176+
: "opacity-0"
177+
)}
178+
/>
179+
<span className="truncate">{model.id}</span>
180+
</div>
181+
<div className="flex flex-wrap gap-1 pl-6">
182+
{model.input?.map((i) => (
183+
<span
184+
key={`in-${i}`}
185+
className="px-1 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 text-[10px]"
186+
>
187+
In: {i}
188+
</span>
189+
))}
190+
{model.output?.map((o) => (
191+
<span
192+
key={`out-${o}`}
193+
className="px-1 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 text-[10px]"
194+
>
195+
Out: {o}
196+
</span>
197+
))}
198+
</div>
199+
</div>
178200
</CommandItem>
179201
))}
180202
</CommandGroup>

frontend/src/components/canvas-view.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
type Node,
1313
type Edge,
1414
SelectionMode,
15-
useOnSelectionChange,
1615
ReactFlowProvider,
1716
} from "@xyflow/react";
1817
import "@xyflow/react/dist/style.css";
@@ -22,7 +21,6 @@ import { Textarea } from "@/components/ui/textarea";
2221
import {
2322
ArrowLeft,
2423
MessageSquare,
25-
Plus,
2624
FileText,
2725
Image,
2826
Video,
@@ -192,6 +190,8 @@ function CanvasEditor({ projectId, projectName, onBack }: CanvasViewProps) {
192190
panOnDrag={[1, 2]}
193191
selectionOnDrag
194192
selectionMode={SelectionMode.Partial}
193+
panOnScroll
194+
zoomOnScroll={false}
195195
>
196196
<Controls />
197197
<MiniMap />

frontend/src/components/nodes/audio-node.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,70 @@
11
import { memo } from "react";
22
import { Handle, Position, type NodeProps, useReactFlow } from "@xyflow/react";
33
import { Card } from "@/components/ui/card";
4-
import { Music, Loader2 } from "lucide-react";
4+
import { Music } from "lucide-react";
55
import type { AudioNodeData } from "./types";
66
import { NodeParametersPanel } from "./node-parameters-panel";
7+
import { GenerateAudio } from "../../../wailsjs/go/ai/Service";
8+
import { Spinner } from "@/components/ui/spinner";
9+
import { Skeleton } from "@/components/ui/skeleton";
710

811
export const AudioNode = memo(({ id, data, selected }: NodeProps) => {
912
const nodeData = data as unknown as AudioNodeData;
1013
const { updateNodeData } = useReactFlow();
1114

15+
const handleRun = async () => {
16+
if (!nodeData.providerId || !nodeData.modelId) {
17+
updateNodeData(id, { error: "Please select a provider and model" });
18+
return;
19+
}
20+
if (!nodeData.prompt) {
21+
updateNodeData(id, { error: "Please enter a prompt" });
22+
return;
23+
}
24+
25+
updateNodeData(id, { processing: true, error: undefined, audioUrl: undefined });
26+
27+
try {
28+
const response = await GenerateAudio({
29+
prompt: nodeData.prompt,
30+
model: nodeData.modelId,
31+
providerId: nodeData.providerId,
32+
});
33+
console.log("GenerateAudio response:", response);
34+
updateNodeData(id, { processing: false, audioUrl: response.content });
35+
} catch (err: any) {
36+
console.error("GenerateAudio error:", err);
37+
updateNodeData(id, { processing: false, error: err.toString() });
38+
}
39+
};
40+
1241
return (
1342
<div className="relative">
1443
<div className="p-3 flex items-center gap-2 absolute -top-10 left-0 right-0">
15-
<Music className="h-4 w-4 text-green-500" />
44+
{nodeData.processing ? (
45+
<Spinner className="h-4 w-4 mr-1" />
46+
) : (
47+
<Music className="h-4 w-4 text-green-500" />
48+
)}
1649
<input
1750
value={nodeData.label}
1851
onChange={(evt) => updateNodeData(id, { label: evt.target.value })}
1952
className="font-semibold text-sm bg-transparent border-none outline-none focus:ring-0 p-0 w-full"
2053
/>
21-
{nodeData.processing && (
22-
<Loader2 className="h-3 w-3 animate-spin ml-auto" />
23-
)}
2454
</div>
2555

2656
<Card
27-
className={`max-w-75 min-w-52 py-0! gap-0 ${selected ? "ring-2 ring-primary" : ""
57+
className={`max-w-75 min-w-52 py-0! gap-0 ${nodeData.error ? "ring-2 ring-destructive" : selected ? "ring-2 ring-primary" : ""
2858
} ${nodeData.processing ? "opacity-70" : ""}`}
2959
>
3060
<Handle type="target" position={Position.Left} />
3161

32-
33-
34-
<div className="p-4 flex items-center justify-center bg-muted/20">
35-
{nodeData.audioUrl ? (
62+
<div className="p-4 flex items-center justify-center bg-muted/20 min-h-16">
63+
{nodeData.processing ? (
64+
<div className="w-full space-y-2">
65+
<Skeleton className="h-8 w-full" />
66+
</div>
67+
) : nodeData.audioUrl ? (
3668
<audio src={nodeData.audioUrl} controls className="w-full" />
3769
) : (
3870
<div className="text-xs text-muted-foreground italic">暂无音频</div>
@@ -47,6 +79,7 @@ export const AudioNode = memo(({ id, data, selected }: NodeProps) => {
4779
nodeId={id}
4880
nodeData={nodeData}
4981
promptPlaceholder="输入音频处理提示词..."
82+
onRun={handleRun}
5083
/>
5184
)}
5285
</div>

frontend/src/components/nodes/image-node.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,70 @@
11
import { memo } from "react";
22
import { Handle, Position, type NodeProps, useReactFlow } from "@xyflow/react";
33
import { Card } from "@/components/ui/card";
4-
import { Image as ImageIcon, Loader2 } from "lucide-react";
4+
import { Image as ImageIcon } from "lucide-react";
55
import type { ImageNodeData } from "./types";
66
import { NodeParametersPanel } from "./node-parameters-panel";
7+
import { GenerateImage } from "../../../wailsjs/go/ai/Service";
8+
import { Spinner } from "@/components/ui/spinner";
9+
import { Skeleton } from "@/components/ui/skeleton";
710

811
export const ImageNode = memo(({ id, data, selected }: NodeProps) => {
912
const nodeData = data as unknown as ImageNodeData;
1013
const { updateNodeData } = useReactFlow();
1114

15+
const handleRun = async () => {
16+
if (!nodeData.providerId || !nodeData.modelId) {
17+
updateNodeData(id, { error: "Please select a provider and model" });
18+
return;
19+
}
20+
if (!nodeData.prompt) {
21+
updateNodeData(id, { error: "Please enter a prompt" });
22+
return;
23+
}
24+
25+
updateNodeData(id, { processing: true, error: undefined, imageUrl: undefined });
26+
27+
try {
28+
const response = await GenerateImage({
29+
prompt: nodeData.prompt,
30+
model: nodeData.modelId,
31+
providerId: nodeData.providerId,
32+
});
33+
console.log("GenerateImage response:", response);
34+
updateNodeData(id, { processing: false, imageUrl: response.content });
35+
} catch (err: any) {
36+
console.error("GenerateImage error:", err);
37+
updateNodeData(id, { processing: false, error: err.toString() });
38+
}
39+
};
40+
1241
return (
1342
<div className="relative">
1443
<div className="p-3 flex items-center gap-2 absolute -top-10 left-0 right-0">
15-
<ImageIcon className="h-4 w-4 text-blue-500" />
44+
{nodeData.processing ? (
45+
<Spinner className="h-4 w-4 mr-1" />
46+
) : (
47+
<ImageIcon className="h-4 w-4 text-blue-500" />
48+
)}
1649
<input
1750
value={nodeData.label}
1851
onChange={(evt) => updateNodeData(id, { label: evt.target.value })}
1952
className="font-semibold text-sm bg-transparent border-none outline-none focus:ring-0 p-0 w-full"
2053
/>
21-
{nodeData.processing && (
22-
<Loader2 className="h-3 w-3 animate-spin ml-auto" />
23-
)}
2454
</div>
2555

2656
<Card
27-
className={`max-w-75 min-w-52 py-0! gap-0 ${selected ? "ring-2 ring-primary" : ""
57+
className={`max-w-120 min-w-52 py-0! gap-0 ${nodeData.error ? "ring-2 ring-destructive" : selected ? "ring-2 ring-primary" : ""
2858
} ${nodeData.processing ? "opacity-70" : ""}`}
2959
>
3060
<Handle type="target" position={Position.Left} />
3161

3262
<div className="p-0 overflow-hidden bg-muted/20 min-h-37.5 flex items-center justify-center">
33-
{nodeData.imageUrl ? (
63+
{nodeData.processing ? (
64+
<div className="w-full h-37.5 p-4 space-y-2 flex flex-col justify-center">
65+
<Skeleton className="h-32 w-full mx-auto" />
66+
</div>
67+
) : nodeData.imageUrl ? (
3468
<img
3569
src={nodeData.imageUrl}
3670
alt="Preview"
@@ -51,6 +85,7 @@ export const ImageNode = memo(({ id, data, selected }: NodeProps) => {
5185
nodeId={id}
5286
nodeData={nodeData}
5387
promptPlaceholder="输入图片处理提示词..."
88+
onRun={handleRun}
5489
/>
5590
)}
5691
</div>

0 commit comments

Comments
 (0)