create-supyagent-app 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/templates/base/src/components/chat-input.tsx +160 -32
- package/templates/base/src/components/chat-message.tsx +28 -0
- package/templates/base/src/components/supyagent/tool-renderers.tsx +6 -0
- package/templates/base/src/components/supyagent/tools/audio.tsx +92 -0
- package/templates/base/src/components/supyagent/tools/image.tsx +67 -0
- package/templates/base/src/components/supyagent/tools/video.tsx +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-supyagent-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Create a supyagent-powered chatbot app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"dist",
|
|
11
11
|
"templates"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "tsup",
|
|
15
|
-
"clean": "rm -rf dist"
|
|
16
|
-
},
|
|
17
13
|
"dependencies": {
|
|
18
14
|
"@clack/prompts": "^0.8.0",
|
|
19
15
|
"nypm": "^0.4.0",
|
|
@@ -23,5 +19,9 @@
|
|
|
23
19
|
"tsup": "^8.3.0",
|
|
24
20
|
"typescript": "^5.7.0"
|
|
25
21
|
},
|
|
26
|
-
"license": "MIT"
|
|
27
|
-
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"clean": "rm -rf dist"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { ArrowUp, Square } from "lucide-react";
|
|
3
|
+
import { ArrowUp, Square, Paperclip, X } from "lucide-react";
|
|
4
4
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
5
|
-
import type { FormEvent, KeyboardEvent } from "react";
|
|
5
|
+
import type { FormEvent, KeyboardEvent, DragEvent, ClipboardEvent } from "react";
|
|
6
6
|
|
|
7
7
|
interface ChatInputProps {
|
|
8
|
-
sendMessage: (message: {
|
|
8
|
+
sendMessage: (message: {
|
|
9
|
+
text: string;
|
|
10
|
+
files?: Array<{ type: "file"; url: string; mediaType: string; filename?: string }>;
|
|
11
|
+
}) => Promise<void>;
|
|
9
12
|
isLoading: boolean;
|
|
10
13
|
stop: () => void;
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
function readFileAsDataURL(file: File): Promise<string> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const reader = new FileReader();
|
|
19
|
+
reader.onload = () => resolve(reader.result as string);
|
|
20
|
+
reader.onerror = reject;
|
|
21
|
+
reader.readAsDataURL(file);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
export function ChatInput({ sendMessage, isLoading, stop }: ChatInputProps) {
|
|
14
26
|
const [input, setInput] = useState("");
|
|
27
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
28
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
15
29
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
30
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
16
31
|
|
|
17
32
|
const adjustHeight = useCallback(() => {
|
|
18
33
|
const textarea = textareaRef.current;
|
|
@@ -25,12 +40,43 @@ export function ChatInput({ sendMessage, isLoading, stop }: ChatInputProps) {
|
|
|
25
40
|
adjustHeight();
|
|
26
41
|
}, [input, adjustHeight]);
|
|
27
42
|
|
|
28
|
-
const
|
|
43
|
+
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
|
44
|
+
const imageFiles = Array.from(newFiles).filter((f) =>
|
|
45
|
+
f.type.startsWith("image/")
|
|
46
|
+
);
|
|
47
|
+
if (imageFiles.length > 0) {
|
|
48
|
+
setFiles((prev) => [...prev, ...imageFiles]);
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const removeFile = useCallback((index: number) => {
|
|
53
|
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
29
57
|
e.preventDefault();
|
|
30
|
-
if (!input.trim() || isLoading) return;
|
|
31
|
-
|
|
58
|
+
if ((!input.trim() && files.length === 0) || isLoading) return;
|
|
59
|
+
|
|
60
|
+
let fileUIParts:
|
|
61
|
+
| Array<{ type: "file"; url: string; mediaType: string; filename?: string }>
|
|
62
|
+
| undefined;
|
|
63
|
+
if (files.length > 0) {
|
|
64
|
+
fileUIParts = await Promise.all(
|
|
65
|
+
files.map(async (f) => ({
|
|
66
|
+
type: "file" as const,
|
|
67
|
+
url: await readFileAsDataURL(f),
|
|
68
|
+
mediaType: f.type,
|
|
69
|
+
filename: f.name,
|
|
70
|
+
}))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sendMessage({
|
|
75
|
+
text: input,
|
|
76
|
+
...(fileUIParts ? { files: fileUIParts } : {}),
|
|
77
|
+
});
|
|
32
78
|
setInput("");
|
|
33
|
-
|
|
79
|
+
setFiles([]);
|
|
34
80
|
if (textareaRef.current) {
|
|
35
81
|
textareaRef.current.style.height = "auto";
|
|
36
82
|
}
|
|
@@ -39,44 +85,126 @@ export function ChatInput({ sendMessage, isLoading, stop }: ChatInputProps) {
|
|
|
39
85
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
40
86
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
41
87
|
e.preventDefault();
|
|
42
|
-
if (input.trim() && !isLoading) {
|
|
88
|
+
if ((input.trim() || files.length > 0) && !isLoading) {
|
|
43
89
|
handleSubmit(e as unknown as FormEvent);
|
|
44
90
|
}
|
|
45
91
|
}
|
|
46
92
|
};
|
|
47
93
|
|
|
94
|
+
const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
95
|
+
const items = e.clipboardData?.files;
|
|
96
|
+
if (items && items.length > 0) {
|
|
97
|
+
addFiles(items);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleDragOver = (e: DragEvent<HTMLFormElement>) => {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setIsDragging(true);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleDragLeave = (e: DragEvent<HTMLFormElement>) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
setIsDragging(false);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleDrop = (e: DragEvent<HTMLFormElement>) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
setIsDragging(false);
|
|
114
|
+
if (e.dataTransfer.files.length > 0) {
|
|
115
|
+
addFiles(e.dataTransfer.files);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const hasContent = input.trim() || files.length > 0;
|
|
120
|
+
|
|
48
121
|
return (
|
|
49
122
|
<form
|
|
50
123
|
onSubmit={handleSubmit}
|
|
51
|
-
|
|
124
|
+
onDragOver={handleDragOver}
|
|
125
|
+
onDragLeave={handleDragLeave}
|
|
126
|
+
onDrop={handleDrop}
|
|
127
|
+
className={`relative flex flex-col rounded-xl border bg-card transition-colors ${
|
|
128
|
+
isDragging
|
|
129
|
+
? "border-ring ring-1 ring-ring"
|
|
130
|
+
: "border-border focus-within:border-ring focus-within:ring-1 focus-within:ring-ring"
|
|
131
|
+
}`}
|
|
52
132
|
>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
133
|
+
{/* File previews */}
|
|
134
|
+
{files.length > 0 && (
|
|
135
|
+
<div className="flex gap-2 px-3 pt-3 pb-0 flex-wrap">
|
|
136
|
+
{files.map((file, i) => (
|
|
137
|
+
<div key={`${file.name}-${i}`} className="relative group">
|
|
138
|
+
<img
|
|
139
|
+
src={URL.createObjectURL(file)}
|
|
140
|
+
alt={file.name}
|
|
141
|
+
className="h-16 w-16 rounded-lg object-cover border border-border"
|
|
142
|
+
/>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => removeFile(i)}
|
|
146
|
+
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-muted border border-border flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-accent"
|
|
147
|
+
>
|
|
148
|
+
<X className="h-3 w-3 text-muted-foreground" />
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="flex items-end">
|
|
156
|
+
{/* Paperclip button */}
|
|
157
|
+
<div className="p-2">
|
|
64
158
|
<button
|
|
65
159
|
type="button"
|
|
66
|
-
onClick={
|
|
67
|
-
className="flex h-8 w-8 items-center justify-center rounded-lg
|
|
160
|
+
onClick={() => fileInputRef.current?.click()}
|
|
161
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
162
|
+
title="Attach image"
|
|
68
163
|
>
|
|
69
|
-
<
|
|
164
|
+
<Paperclip className="h-4 w-4" />
|
|
70
165
|
</button>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
type="
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
166
|
+
<input
|
|
167
|
+
ref={fileInputRef}
|
|
168
|
+
type="file"
|
|
169
|
+
accept="image/*"
|
|
170
|
+
multiple
|
|
171
|
+
className="hidden"
|
|
172
|
+
onChange={(e) => {
|
|
173
|
+
if (e.target.files) addFiles(e.target.files);
|
|
174
|
+
e.target.value = "";
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<textarea
|
|
180
|
+
ref={textareaRef}
|
|
181
|
+
value={input}
|
|
182
|
+
onChange={(e) => setInput(e.target.value)}
|
|
183
|
+
onKeyDown={handleKeyDown}
|
|
184
|
+
onPaste={handlePaste}
|
|
185
|
+
placeholder="Send a message..."
|
|
186
|
+
rows={1}
|
|
187
|
+
className="flex-1 resize-none bg-transparent py-3 text-sm text-foreground placeholder-muted-foreground outline-none max-h-[200px]"
|
|
188
|
+
/>
|
|
189
|
+
<div className="p-2">
|
|
190
|
+
{isLoading ? (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={stop}
|
|
194
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
195
|
+
>
|
|
196
|
+
<Square className="h-3.5 w-3.5" />
|
|
197
|
+
</button>
|
|
198
|
+
) : (
|
|
199
|
+
<button
|
|
200
|
+
type="submit"
|
|
201
|
+
disabled={!hasContent}
|
|
202
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground disabled:opacity-30 disabled:cursor-not-allowed hover:bg-primary/90 transition-colors"
|
|
203
|
+
>
|
|
204
|
+
<ArrowUp className="h-4 w-4" />
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
80
208
|
</div>
|
|
81
209
|
</form>
|
|
82
210
|
);
|
|
@@ -72,6 +72,34 @@ export function ChatMessage({ message, addToolApprovalResponse }: ChatMessagePro
|
|
|
72
72
|
return <MarkdownContent key={i} text={(part as any).text} />;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
if (part.type === "file") {
|
|
76
|
+
const filePart = part as { type: "file"; mediaType?: string; url: string; filename?: string };
|
|
77
|
+
if (filePart.mediaType?.startsWith("image/")) {
|
|
78
|
+
return (
|
|
79
|
+
<img
|
|
80
|
+
key={i}
|
|
81
|
+
src={filePart.url}
|
|
82
|
+
alt={filePart.filename || "Image"}
|
|
83
|
+
className="rounded-lg max-w-full max-h-96 object-contain"
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (filePart.mediaType?.startsWith("audio/")) {
|
|
88
|
+
return <audio key={i} controls src={filePart.url} className="w-full max-w-md" />;
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<a
|
|
92
|
+
key={i}
|
|
93
|
+
href={filePart.url}
|
|
94
|
+
target="_blank"
|
|
95
|
+
rel="noopener noreferrer"
|
|
96
|
+
className="text-xs text-primary hover:underline"
|
|
97
|
+
>
|
|
98
|
+
{filePart.filename || "Download file"}
|
|
99
|
+
</a>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
if (isToolUIPart(part)) {
|
|
76
104
|
return <ToolMessage key={i} part={part} addToolApprovalResponse={addToolApprovalResponse} />;
|
|
77
105
|
}
|
|
@@ -26,6 +26,9 @@ import { CalendlyRenderer } from "./tools/calendly";
|
|
|
26
26
|
import { TwilioRenderer } from "./tools/twilio";
|
|
27
27
|
import { LinkedInRenderer } from "./tools/linkedin";
|
|
28
28
|
import { BashRenderer } from "./tools/bash";
|
|
29
|
+
import { ImageRenderer } from "./tools/image";
|
|
30
|
+
import { AudioRenderer } from "./tools/audio";
|
|
31
|
+
import { VideoRenderer } from "./tools/video";
|
|
29
32
|
import { GenericRenderer } from "./tools/generic";
|
|
30
33
|
|
|
31
34
|
export interface ToolRendererProps {
|
|
@@ -64,6 +67,9 @@ const renderers: Record<string, ComponentType<ToolRendererProps>> = {
|
|
|
64
67
|
twilio: TwilioRenderer,
|
|
65
68
|
linkedin: LinkedInRenderer,
|
|
66
69
|
bash: BashRenderer,
|
|
70
|
+
image: ImageRenderer,
|
|
71
|
+
audio: AudioRenderer,
|
|
72
|
+
video: VideoRenderer,
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
export function getToolRenderer(formatterType: string): ComponentType<ToolRendererProps> {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ExternalLink, Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface AudioRendererProps {
|
|
5
|
+
data: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function AudioRenderer({ data }: AudioRendererProps) {
|
|
9
|
+
if (typeof data !== "object" || data === null) {
|
|
10
|
+
return (
|
|
11
|
+
<p className="text-sm text-muted-foreground italic">No data returned</p>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const d = data as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
if (d.error) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
|
|
20
|
+
<p className="text-sm text-destructive">{String(d.error)}</p>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (d.status === "processing" || (d.poll_url && !d.audio_url)) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
|
28
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
29
|
+
<span className="text-sm text-muted-foreground">Processing audio...</span>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TTS result — audio player
|
|
35
|
+
if (typeof d.audio_url === "string") {
|
|
36
|
+
return (
|
|
37
|
+
<div className="space-y-2">
|
|
38
|
+
<audio controls className="w-full max-w-md">
|
|
39
|
+
<source src={d.audio_url} />
|
|
40
|
+
</audio>
|
|
41
|
+
<a
|
|
42
|
+
href={d.audio_url}
|
|
43
|
+
target="_blank"
|
|
44
|
+
rel="noopener noreferrer"
|
|
45
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
46
|
+
>
|
|
47
|
+
<ExternalLink className="h-3 w-3" />
|
|
48
|
+
Download audio
|
|
49
|
+
</a>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// STT result — transcription text
|
|
55
|
+
if (d.result && typeof d.result === "object") {
|
|
56
|
+
const result = d.result as Record<string, unknown>;
|
|
57
|
+
if (typeof result.text === "string") {
|
|
58
|
+
return (
|
|
59
|
+
<div className="space-y-2">
|
|
60
|
+
<div className="rounded-lg border border-border bg-card p-3">
|
|
61
|
+
<p className="text-sm text-foreground whitespace-pre-wrap">{result.text}</p>
|
|
62
|
+
</div>
|
|
63
|
+
{result.tokens_used != null && (
|
|
64
|
+
<p className="text-xs text-muted-foreground">
|
|
65
|
+
Tokens used: {String(result.tokens_used)}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// OCR-like text result at top level
|
|
74
|
+
if (typeof d.text === "string") {
|
|
75
|
+
return (
|
|
76
|
+
<div className="rounded-lg border border-border bg-card p-3">
|
|
77
|
+
<p className="text-sm text-foreground whitespace-pre-wrap">{d.text}</p>
|
|
78
|
+
{d.tokens_used != null && (
|
|
79
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
80
|
+
Tokens used: {String(d.tokens_used)}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
89
|
+
{JSON.stringify(data, null, 2)}
|
|
90
|
+
</pre>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ExternalLink, Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface ImageRendererProps {
|
|
5
|
+
data: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ImageRenderer({ data }: ImageRendererProps) {
|
|
9
|
+
if (typeof data !== "object" || data === null) {
|
|
10
|
+
return (
|
|
11
|
+
<p className="text-sm text-muted-foreground italic">No data returned</p>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const d = data as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
if (d.error) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
|
|
20
|
+
<p className="text-sm text-destructive">{String(d.error)}</p>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (d.status === "processing" || (d.poll_url && !d.image_url)) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
|
28
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
29
|
+
<span className="text-sm text-muted-foreground">Generating image...</span>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof d.image_url === "string") {
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-2">
|
|
37
|
+
<a
|
|
38
|
+
href={d.image_url}
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
className="block"
|
|
42
|
+
>
|
|
43
|
+
<img
|
|
44
|
+
src={d.image_url}
|
|
45
|
+
alt="Generated image"
|
|
46
|
+
className="rounded-lg max-w-full max-h-96 object-contain border border-border"
|
|
47
|
+
/>
|
|
48
|
+
</a>
|
|
49
|
+
<a
|
|
50
|
+
href={d.image_url}
|
|
51
|
+
target="_blank"
|
|
52
|
+
rel="noopener noreferrer"
|
|
53
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
54
|
+
>
|
|
55
|
+
<ExternalLink className="h-3 w-3" />
|
|
56
|
+
Open full size
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
64
|
+
{JSON.stringify(data, null, 2)}
|
|
65
|
+
</pre>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ExternalLink, Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface VideoRendererProps {
|
|
5
|
+
data: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function VideoRenderer({ data }: VideoRendererProps) {
|
|
9
|
+
if (typeof data !== "object" || data === null) {
|
|
10
|
+
return (
|
|
11
|
+
<p className="text-sm text-muted-foreground italic">No data returned</p>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const d = data as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
if (d.error) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
|
|
20
|
+
<p className="text-sm text-destructive">{String(d.error)}</p>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (d.status === "processing" || (d.poll_url && !d.answer && !d.result)) {
|
|
26
|
+
const eta = typeof d.estimated_time_seconds === "number"
|
|
27
|
+
? ` (~${d.estimated_time_seconds}s)`
|
|
28
|
+
: "";
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
|
31
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
32
|
+
<span className="text-sm text-muted-foreground">Processing video...{eta}</span>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Video understanding — text answer
|
|
38
|
+
if (typeof d.answer === "string") {
|
|
39
|
+
return (
|
|
40
|
+
<div className="rounded-lg border border-border bg-card p-3">
|
|
41
|
+
<p className="text-sm text-foreground whitespace-pre-wrap">{d.answer}</p>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Video generation — playable result
|
|
47
|
+
if (d.result && typeof d.result === "object") {
|
|
48
|
+
const result = d.result as Record<string, unknown>;
|
|
49
|
+
const videoUrl = typeof result.output === "string"
|
|
50
|
+
? result.output
|
|
51
|
+
: typeof result.url === "string"
|
|
52
|
+
? result.url
|
|
53
|
+
: typeof result.video_url === "string"
|
|
54
|
+
? result.video_url
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
if (videoUrl) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="space-y-2">
|
|
60
|
+
<video
|
|
61
|
+
controls
|
|
62
|
+
className="rounded-lg max-w-full max-h-96 border border-border"
|
|
63
|
+
>
|
|
64
|
+
<source src={videoUrl} />
|
|
65
|
+
</video>
|
|
66
|
+
<a
|
|
67
|
+
href={videoUrl}
|
|
68
|
+
target="_blank"
|
|
69
|
+
rel="noopener noreferrer"
|
|
70
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
71
|
+
>
|
|
72
|
+
<ExternalLink className="h-3 w-3" />
|
|
73
|
+
Open video
|
|
74
|
+
</a>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<pre className="rounded-lg border border-border bg-background p-3 text-xs text-foreground overflow-x-auto max-h-96 overflow-y-auto">
|
|
82
|
+
{JSON.stringify(data, null, 2)}
|
|
83
|
+
</pre>
|
|
84
|
+
);
|
|
85
|
+
}
|