Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@
"visualization": "Data Visualization",
"webSearch": "Search the Web",
"http": "HTTP Request",
"code": "Code Execution"
"code": "Code Execution",
"fileGenerator": "File Generator"
}
},
"VoiceChat": {
Expand Down
1 change: 1 addition & 0 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export async function POST(request: Request) {
const vercelAITooles = safe({
...MCP_TOOLS,
...WORKFLOW_TOOLS,
...IMAGE_TOOL,
})
.map((t) => {
const bindingTools =
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/storage/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { isFileStorageConfigured } from "@/lib/file-storage/is-storage-configured";
import { NextResponse } from "next/server";

export async function GET() {
const configured = isFileStorageConfigured();
return NextResponse.json({ configured });
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { Toaster } from "ui/sonner";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { FileStorageInitializer } from "@/components/file-storage-initializer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
Expand Down Expand Up @@ -45,6 +46,7 @@ export default async function RootLayout({
>
<ThemeStyleProvider>
<NextIntlClientProvider>
<FileStorageInitializer />
<div id="root">
{children}
<Toaster richColors />
Expand Down
2 changes: 2 additions & 0 deletions src/app/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const initialState: AppState = {
allowedAppDefaultToolkit: [
AppDefaultToolkit.Code,
AppDefaultToolkit.Visualization,
AppDefaultToolkit.WebSearch,
// FileGenerator will be added dynamically if storage is configured
],
toolPresets: [],
chatModel: undefined,
Expand Down
50 changes: 50 additions & 0 deletions src/components/file-storage-initializer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { appStore } from "@/app/store";
import { AppDefaultToolkit } from "@/lib/ai/tools";
import { useEffect } from "react";
import { useShallow } from "zustand/shallow";

/**
* Initializes file-generator tool if file storage is configured.
* This runs once on app mount and adds FileGenerator to the default toolkit.
*/
export function FileStorageInitializer() {
const [allowedAppDefaultToolkit, appStoreMutate] = appStore(
useShallow((state) => [state.allowedAppDefaultToolkit, state.mutate]),
);

useEffect(() => {
// Only run once on mount
const checkStorageAndInit = async () => {
try {
const response = await fetch("/api/storage/config");
const data = await response.json();

if (data.configured) {
// Check if FileGenerator is already in the list
const hasFileGenerator = allowedAppDefaultToolkit?.includes(
AppDefaultToolkit.FileGenerator,
);

if (!hasFileGenerator) {
// Add FileGenerator to the default toolkit
appStoreMutate((prev) => ({
allowedAppDefaultToolkit: [
...(prev.allowedAppDefaultToolkit || []),
AppDefaultToolkit.FileGenerator,
],
}));
}
}
} catch (error) {
console.error("Failed to check file storage config:", error);
}
};

checkStorageAndInit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run once on mount

return null; // This component doesn't render anything
}
21 changes: 20 additions & 1 deletion src/components/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ import {
VercelAIWorkflowToolStreamingResultTag,
} from "app-types/workflow";
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
import { DefaultToolName, ImageToolName } from "lib/ai/tools";
import {
DefaultToolName,
ImageToolName,
FileGeneratorToolName,
} from "lib/ai/tools";
import {
Shortcut,
getShortcutKeyList,
Expand Down Expand Up @@ -726,6 +730,17 @@ const ImageGeneratorToolInvocation = dynamic(
},
);

const FileGeneratorToolInvocation = dynamic(
() =>
import("./tool-invocation/file-generator").then(
(mod) => mod.FileGeneratorToolInvocation,
),
{
ssr: false,
loading,
},
);

// Local shortcuts for tool invocation approval/rejection
const approveToolInvocationShortcut: Shortcut = {
description: "approveToolInvocation",
Expand Down Expand Up @@ -880,6 +895,10 @@ export const ToolMessagePart = memo(
return <ImageGeneratorToolInvocation part={part} />;
}

if (toolName === FileGeneratorToolName) {
return <FileGeneratorToolInvocation part={part} />;
}

if (toolName === DefaultToolName.JavascriptExecution) {
return (
<CodeExecutor
Expand Down
159 changes: 159 additions & 0 deletions src/components/tool-invocation/file-generator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"use client";

import { ToolUIPart } from "ai";
import equal from "lib/equal";
import { FileIcon, Download } from "lucide-react";
import { memo, useMemo } from "react";
import { TextShimmer } from "ui/text-shimmer";
import { Button } from "ui/button";
import { Badge } from "ui/badge";

interface FileGeneratorToolInvocationProps {
part: ToolUIPart;
}

interface FileGeneratorResult {
files: {
url: string;
filename: string;
mimeType: string;
size: number;
}[];
description?: string;
}

function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function PureFileGeneratorToolInvocation({
part,
}: FileGeneratorToolInvocationProps) {
const isGenerating = useMemo(() => {
return !part.state.startsWith("output");
}, [part.state]);

const result = useMemo(() => {
if (!part.state.startsWith("output")) return null;
return part.output as FileGeneratorResult;
}, [part.state, part.output]);

const files = useMemo(() => {
return result?.files || [];
}, [result]);

const hasError = useMemo(() => {
return (
part.state === "output-error" ||
(part.state === "output-available" && result?.files.length === 0)
);
}, [part.state, result]);

// Loading state
if (isGenerating) {
return (
<div className="flex flex-col gap-3">
<TextShimmer>Generating file...</TextShimmer>
<div className="bg-muted/30 border border-border/50 rounded-lg p-6 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<FileIcon className="size-5 animate-pulse" />
<span className="text-sm">Creating your file...</span>
</div>
</div>
</div>
);
}

return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{!hasError && <FileIcon className="size-4" />}
<span className="text-sm font-semibold">
{hasError
? "File generation failed"
: files.length === 1
? "File generated"
: `${files.length} files generated`}
</span>
</div>

{hasError ? (
<div className="bg-card text-muted-foreground p-6 rounded-lg text-xs border border-border/20">
{part.errorText ?? "Failed to generate file. Please try again."}
</div>
) : (
<div className="flex flex-col gap-3">
{result?.description && (
<p className="text-sm text-muted-foreground">
{result.description}
</p>
)}
<div className="flex flex-col gap-2">
{files.map((file, index) => {
const fileExtension =
file.filename.split(".").pop()?.toUpperCase() || "FILE";

return (
<div
key={index}
className="bg-muted/60 border border-border/80 rounded-xl p-4 hover:border-primary/50 transition-all shadow-sm hover:shadow-md group"
>
<div className="flex items-center gap-4">
<div className="flex-shrink-0 rounded-lg bg-muted p-3">
<FileIcon className="size-6 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<p
className="text-sm font-medium line-clamp-1"
title={file.filename}
>
{file.filename}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="uppercase tracking-wide px-2 py-0.5"
>
{fileExtension}
</Badge>
<span>{formatFileSize(file.size)}</span>
{file.mimeType && (
<span
className="truncate max-w-[10rem]"
title={file.mimeType}
>
{file.mimeType}
</span>
)}
</div>
</div>
<Button
asChild
size="sm"
variant="outline"
className="flex-shrink-0 hover:bg-primary hover:text-primary-foreground transition-colors"
>
<a href={file.url} download={file.filename}>
<Download className="size-4 mr-2" />
Download
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

export const FileGeneratorToolInvocation = memo(
PureFileGeneratorToolInvocation,
(prev, next) => {
return equal(prev.part, next.part);
},
);
4 changes: 4 additions & 0 deletions src/components/tool-select-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ChartColumn,
ChevronRight,
CodeIcon,
FileIcon,
GlobeIcon,
HardDriveUploadIcon,
ImagesIcon,
Expand Down Expand Up @@ -887,6 +888,9 @@ function AppDefaultToolKitSelector() {
case AppDefaultToolkit.Code:
icon = CodeIcon;
break;
case AppDefaultToolkit.FileGenerator:
icon = FileIcon;
break;
}
return {
label,
Expand Down
Loading
Loading