Skip to content

Commit 5a477bd

Browse files
mrjasonroyclaude
andcommitted
feat: add LLM-powered file generator tool
Add a new file-generator tool that allows LLMs to create and save downloadable files. The tool supports various file formats including CSV, JSON, XML, code files, and more. Features: - Generate 1-5 files per invocation with custom content - Automatic MIME type inference from file extensions - Upload to S3/Vercel Blob storage with presigned URLs - Clean UI component with file metadata display - Download functionality with proper filenames - Supports multiple file formats (CSV, JSON, XML, YAML, code files, etc.) Implementation follows the existing image-manager tool pattern: - Server-side file generation and upload - Streaming of download URLs to client - Custom tool invocation UI component - Integration with existing file storage infrastructure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent d53818f commit 5a477bd

File tree

5 files changed

+304
-2
lines changed

5 files changed

+304
-2
lines changed

src/app/api/chat/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ import { getSession } from "auth/server";
4848
import { colorize } from "consola/utils";
4949
import { generateUUID } from "lib/utils";
5050
import { nanoBananaTool, openaiImageTool } from "lib/ai/tools/image";
51-
import { ImageToolName } from "lib/ai/tools";
51+
import { fileGeneratorTool } from "lib/ai/tools/file-generator";
52+
import { ImageToolName, FileGeneratorToolName } from "lib/ai/tools";
5253
import { buildCsvIngestionPreviewParts } from "@/lib/ai/ingest/csv-ingest";
5354
import { serverFileStorage } from "lib/file-storage";
5455

@@ -283,9 +284,13 @@ export async function POST(request: Request) {
283284
: openaiImageTool,
284285
}
285286
: {};
287+
const FILE_GENERATOR_TOOL: Record<string, Tool> = {
288+
[FileGeneratorToolName]: fileGeneratorTool,
289+
};
286290
const vercelAITooles = safe({
287291
...MCP_TOOLS,
288292
...WORKFLOW_TOOLS,
293+
...FILE_GENERATOR_TOOL,
289294
})
290295
.map((t) => {
291296
const bindingTools =

src/components/message-parts.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ import {
5151
VercelAIWorkflowToolStreamingResultTag,
5252
} from "app-types/workflow";
5353
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
54-
import { DefaultToolName, ImageToolName } from "lib/ai/tools";
54+
import {
55+
DefaultToolName,
56+
ImageToolName,
57+
FileGeneratorToolName,
58+
} from "lib/ai/tools";
5559
import {
5660
Shortcut,
5761
getShortcutKeyList,
@@ -726,6 +730,17 @@ const ImageGeneratorToolInvocation = dynamic(
726730
},
727731
);
728732

733+
const FileGeneratorToolInvocation = dynamic(
734+
() =>
735+
import("./tool-invocation/file-generator").then(
736+
(mod) => mod.FileGeneratorToolInvocation,
737+
),
738+
{
739+
ssr: false,
740+
loading,
741+
},
742+
);
743+
729744
// Local shortcuts for tool invocation approval/rejection
730745
const approveToolInvocationShortcut: Shortcut = {
731746
description: "approveToolInvocation",
@@ -880,6 +895,10 @@ export const ToolMessagePart = memo(
880895
return <ImageGeneratorToolInvocation part={part} />;
881896
}
882897

898+
if (toolName === FileGeneratorToolName) {
899+
return <FileGeneratorToolInvocation part={part} />;
900+
}
901+
883902
if (toolName === DefaultToolName.JavascriptExecution) {
884903
return (
885904
<CodeExecutor
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"use client";
2+
3+
import { ToolUIPart } from "ai";
4+
import equal from "lib/equal";
5+
import { FileIcon, Download } from "lucide-react";
6+
import { memo, useMemo } from "react";
7+
import { TextShimmer } from "ui/text-shimmer";
8+
import { Button } from "ui/button";
9+
import { Badge } from "ui/badge";
10+
11+
interface FileGeneratorToolInvocationProps {
12+
part: ToolUIPart;
13+
}
14+
15+
interface FileGeneratorResult {
16+
files: {
17+
url: string;
18+
filename: string;
19+
mimeType: string;
20+
size: number;
21+
}[];
22+
description?: string;
23+
}
24+
25+
function formatFileSize(bytes: number): string {
26+
if (bytes < 1024) return `${bytes} B`;
27+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
28+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
29+
}
30+
31+
function PureFileGeneratorToolInvocation({
32+
part,
33+
}: FileGeneratorToolInvocationProps) {
34+
const isGenerating = useMemo(() => {
35+
return !part.state.startsWith("output");
36+
}, [part.state]);
37+
38+
const result = useMemo(() => {
39+
if (!part.state.startsWith("output")) return null;
40+
return part.output as FileGeneratorResult;
41+
}, [part.state, part.output]);
42+
43+
const files = useMemo(() => {
44+
return result?.files || [];
45+
}, [result]);
46+
47+
const hasError = useMemo(() => {
48+
return (
49+
part.state === "output-error" ||
50+
(part.state === "output-available" && result?.files.length === 0)
51+
);
52+
}, [part.state, result]);
53+
54+
// Loading state
55+
if (isGenerating) {
56+
return (
57+
<div className="flex flex-col gap-3">
58+
<TextShimmer>Generating file...</TextShimmer>
59+
<div className="bg-muted/30 border border-border/50 rounded-lg p-6 flex items-center justify-center">
60+
<div className="flex items-center gap-3 text-muted-foreground">
61+
<FileIcon className="size-5 animate-pulse" />
62+
<span className="text-sm">Creating your file...</span>
63+
</div>
64+
</div>
65+
</div>
66+
);
67+
}
68+
69+
return (
70+
<div className="flex flex-col gap-4">
71+
<div className="flex items-center gap-2">
72+
{!hasError && <FileIcon className="size-4" />}
73+
<span className="text-sm font-semibold">
74+
{hasError
75+
? "File generation failed"
76+
: files.length === 1
77+
? "File generated"
78+
: `${files.length} files generated`}
79+
</span>
80+
</div>
81+
82+
{hasError ? (
83+
<div className="bg-card text-muted-foreground p-6 rounded-lg text-xs border border-border/20">
84+
{part.errorText ?? "Failed to generate file. Please try again."}
85+
</div>
86+
) : (
87+
<div className="flex flex-col gap-3">
88+
{result?.description && (
89+
<p className="text-sm text-muted-foreground">
90+
{result.description}
91+
</p>
92+
)}
93+
<div className="flex flex-col gap-2">
94+
{files.map((file, index) => {
95+
const fileExtension =
96+
file.filename.split(".").pop()?.toUpperCase() || "FILE";
97+
98+
return (
99+
<div
100+
key={index}
101+
className="bg-muted/60 border border-border/80 rounded-xl p-4 hover:border-primary/50 transition-all shadow-sm hover:shadow-md group"
102+
>
103+
<div className="flex items-center gap-4">
104+
<div className="flex-shrink-0 rounded-lg bg-muted p-3">
105+
<FileIcon className="size-6 text-muted-foreground" />
106+
</div>
107+
<div className="flex-1 min-w-0 space-y-1">
108+
<p
109+
className="text-sm font-medium line-clamp-1"
110+
title={file.filename}
111+
>
112+
{file.filename}
113+
</p>
114+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
115+
<Badge
116+
variant="outline"
117+
className="uppercase tracking-wide px-2 py-0.5"
118+
>
119+
{fileExtension}
120+
</Badge>
121+
<span>{formatFileSize(file.size)}</span>
122+
{file.mimeType && (
123+
<span
124+
className="truncate max-w-[10rem]"
125+
title={file.mimeType}
126+
>
127+
{file.mimeType}
128+
</span>
129+
)}
130+
</div>
131+
</div>
132+
<Button
133+
asChild
134+
size="sm"
135+
variant="outline"
136+
className="flex-shrink-0 hover:bg-primary hover:text-primary-foreground transition-colors"
137+
>
138+
<a href={file.url} download={file.filename}>
139+
<Download className="size-4 mr-2" />
140+
Download
141+
</a>
142+
</Button>
143+
</div>
144+
</div>
145+
);
146+
})}
147+
</div>
148+
</div>
149+
)}
150+
</div>
151+
);
152+
}
153+
154+
export const FileGeneratorToolInvocation = memo(
155+
PureFileGeneratorToolInvocation,
156+
(prev, next) => {
157+
return equal(prev.part, next.part);
158+
},
159+
);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { tool as createTool } from "ai";
2+
import { serverFileStorage } from "lib/file-storage";
3+
import z from "zod";
4+
import { FileGeneratorToolName } from "..";
5+
import logger from "logger";
6+
7+
export type FileGeneratorToolResult = {
8+
files: {
9+
url: string;
10+
filename: string;
11+
mimeType: string;
12+
size: number;
13+
}[];
14+
description?: string;
15+
};
16+
17+
export const fileGeneratorTool = createTool({
18+
name: FileGeneratorToolName,
19+
description: `Create and save files with specified content. Use this tool when the user requests downloadable files such as:
20+
- Data files: CSV, JSON, XML, YAML
21+
- Documents: Markdown, text files, configuration files
22+
- Code files: Python, JavaScript, HTML, CSS, etc.
23+
- Structured data exports
24+
25+
The tool will generate the file, upload it to storage, and provide a download link. Do not use this for images (use image-manager instead) or for simple text responses that don't need to be downloaded.`,
26+
inputSchema: z.object({
27+
files: z
28+
.array(
29+
z.object({
30+
filename: z
31+
.string()
32+
.describe(
33+
"The name of the file including extension (e.g., data.csv, script.py)",
34+
),
35+
content: z.string().describe("The complete content of the file"),
36+
mimeType: z
37+
.string()
38+
.optional()
39+
.describe(
40+
"MIME type (e.g., 'text/csv', 'application/json', 'text/plain'). If not provided, will be inferred from filename.",
41+
),
42+
}),
43+
)
44+
.min(1)
45+
.max(5)
46+
.describe("Array of files to generate (1-5 files)"),
47+
description: z
48+
.string()
49+
.optional()
50+
.describe(
51+
"Optional description of what the files contain or how to use them",
52+
),
53+
}),
54+
execute: async ({ files, description }) => {
55+
try {
56+
const uploadedFiles = await Promise.all(
57+
files.map(async (file) => {
58+
// Convert content to Buffer
59+
const buffer = Buffer.from(file.content, "utf-8");
60+
61+
// Infer MIME type from filename if not provided
62+
let mimeType = file.mimeType;
63+
if (!mimeType) {
64+
const extension = file.filename.split(".").pop()?.toLowerCase();
65+
const mimeTypeMap: Record<string, string> = {
66+
csv: "text/csv",
67+
json: "application/json",
68+
xml: "application/xml",
69+
yaml: "text/yaml",
70+
yml: "text/yaml",
71+
txt: "text/plain",
72+
md: "text/markdown",
73+
html: "text/html",
74+
css: "text/css",
75+
js: "text/javascript",
76+
ts: "text/typescript",
77+
py: "text/x-python",
78+
sh: "application/x-sh",
79+
sql: "application/sql",
80+
env: "text/plain",
81+
log: "text/plain",
82+
};
83+
mimeType = mimeTypeMap[extension || ""] || "text/plain";
84+
}
85+
86+
// Upload to storage (same pattern as image tool)
87+
const uploaded = await serverFileStorage.upload(buffer, {
88+
filename: file.filename,
89+
contentType: mimeType,
90+
});
91+
92+
// Get presigned URL for private buckets if available
93+
const downloadUrl = serverFileStorage.getDownloadUrl
94+
? await serverFileStorage.getDownloadUrl(uploaded.key)
95+
: uploaded.sourceUrl;
96+
97+
return {
98+
url: downloadUrl || uploaded.sourceUrl,
99+
filename: uploaded.metadata.filename || file.filename,
100+
mimeType: uploaded.metadata.contentType || mimeType,
101+
size: uploaded.metadata.size || buffer.length,
102+
};
103+
}),
104+
);
105+
106+
return {
107+
files: uploadedFiles,
108+
description,
109+
};
110+
} catch (e) {
111+
logger.error(e);
112+
throw new Error(
113+
"File generation was successful, but file upload failed. Please check your file upload configuration and try again.",
114+
);
115+
}
116+
},
117+
});

src/lib/ai/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ export enum DefaultToolName {
2020
export const SequentialThinkingToolName = "sequential-thinking";
2121

2222
export const ImageToolName = "image-manager";
23+
24+
export const FileGeneratorToolName = "file-generator";

0 commit comments

Comments
 (0)