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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-supyagent-app",
3
- "version": "0.1.21",
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: { text: string }) => Promise<void>;
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 handleSubmit = (e: FormEvent) => {
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
- sendMessage({ text: input });
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
- // Reset height
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
- className="relative flex items-end rounded-xl border border-border bg-card focus-within:border-ring focus-within:ring-1 focus-within:ring-ring transition-colors"
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
- <textarea
54
- ref={textareaRef}
55
- value={input}
56
- onChange={(e) => setInput(e.target.value)}
57
- onKeyDown={handleKeyDown}
58
- placeholder="Send a message..."
59
- rows={1}
60
- className="flex-1 resize-none bg-transparent px-4 py-3 text-sm text-foreground placeholder-muted-foreground outline-none max-h-[200px]"
61
- />
62
- <div className="p-2">
63
- {isLoading ? (
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={stop}
67
- 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"
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
- <Square className="h-3.5 w-3.5" />
164
+ <Paperclip className="h-4 w-4" />
70
165
  </button>
71
- ) : (
72
- <button
73
- type="submit"
74
- disabled={!input.trim()}
75
- 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"
76
- >
77
- <ArrowUp className="h-4 w-4" />
78
- </button>
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
+ }