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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "ArtifactKind" AS ENUM ('text', 'code', 'custom');

-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"kind" "ArtifactKind" NOT NULL DEFAULT 'text',
"userId" TEXT NOT NULL,
"chatId" TEXT,

CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE SET NULL ON UPDATE CASCADE;
21 changes: 21 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ model User {
videos Video[]
userFeatures UserFeature[]
workbenches Workbench[]
documents Document[]
}

enum UserRole {
Expand Down Expand Up @@ -92,6 +93,7 @@ model Chat {
messages Message[]
stream Stream[]
workbench Workbench? @relation(fields: [workbenchId], references: [id], onDelete: SetNull)
documents Document[]
}

model Message {
Expand Down Expand Up @@ -190,4 +192,23 @@ model Tool {
toolkit Toolkit @relation(fields: [toolkitId], references: [id], onDelete: Cascade)

@@id([id, toolkitId])
}

enum ArtifactKind {
text
code
custom
}

model Document {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
content String? @db.Text
kind ArtifactKind @default(text)
userId String
chatId String? // Optional association with a chat
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chat Chat? @relation(fields: [chatId], references: [id], onDelete: SetNull)
}
187 changes: 187 additions & 0 deletions src/app/(general)/_components/chat/messages/artifact-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Copy, Expand, X } from "lucide-react";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { LLMMarkdown } from "./utils/llm-markdown";
import { CodeBlock } from "@/components/ui/code-block";
import type { BundledLanguage } from "@/components/ui/code/shiki.bundle";

interface Props {
documentId: string;
title: string;
kind: "text" | "code" | "custom";
_description?: string;
}

export const ArtifactPreview: React.FC<Props> = ({
documentId,
title,
kind,
_description,
}) => {
const [isFullscreen, setIsFullscreen] = useState(false);

const { data: document, isLoading } = api.documents.get.useQuery(
{ id: documentId },
{ enabled: !!documentId },
);

const handleCopy = async () => {
if (document?.content) {
await navigator.clipboard.writeText(document.content);
toast.success("Content copied to clipboard");
}
};

if (isLoading) {
return (
<div className="bg-background w-full rounded-lg border p-4">
<div className="animate-pulse">
<div className="bg-muted mb-2 h-4 w-1/3 rounded"></div>
<div className="bg-muted h-20 rounded"></div>
</div>
</div>
);
}

const renderContent = () => {
if (!document?.content) return null;

const content = document.content;

// For code artifacts, use CodeBlock directly with language detection
if (kind === "code") {
// Try to detect language from the first line if it has a language comment
let language: BundledLanguage = "javascript"; // default

const firstLine = content.split("\n")[0]?.toLowerCase() ?? "";
if (
firstLine.includes("// language:") ||
firstLine.includes("# language:")
) {
const match = /(?:\/\/|#)\s*language:\s*(\w+)/.exec(firstLine);
if (match?.[1]) {
const detectedLang = match[1];
// Map common language names to valid BundledLanguage types
const langMap: Record<string, BundledLanguage> = {
go: "go",
golang: "go",
javascript: "javascript",
js: "javascript",
typescript: "typescript",
ts: "typescript",
python: "python",
py: "python",
rust: "rust",
rs: "rust",
java: "java",
cpp: "cpp",
c: "c",
html: "html",
css: "css",
json: "json",
yaml: "yaml",
yml: "yaml",
sql: "sql",
};
language = langMap[detectedLang] ?? "javascript";
}
}

return (
<div className="h-full w-full overflow-auto">
<CodeBlock
value={content}
language={language}
showLineNumbers={true}
allowCopy={true}
/>
</div>
);
}

// For text and custom artifacts, use markdown rendering as-is
return (
<div className="h-full w-full overflow-auto">
<LLMMarkdown llmOutput={content} isStreamFinished={true} />
</div>
);
};

return (
<div className="w-full space-y-2">
<div className="bg-background flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center space-x-3">
<div className="text-sm font-medium">{title}</div>
<Badge variant="secondary" className="text-xs">
{kind}
</Badge>
</div>

<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(true)}
className="h-8 w-8 p-0"
title="View artifact"
>
<Expand className="h-4 w-4" />
</Button>
</div>
</div>

{isFullscreen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) {
setIsFullscreen(false);
}
}}
>
<div
className="bg-background flex h-full max-h-[90vh] w-full max-w-6xl flex-col rounded-lg border shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-background flex flex-shrink-0 items-center justify-between border-b p-4">
<div className="flex items-center space-x-3">
<div className="text-lg font-semibold">{title}</div>
<Badge variant="secondary" className="text-xs">
{kind}
</Badge>
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 w-8 p-0"
title="Copy content"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(false)}
className="h-8 w-8 p-0"
title="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="bg-background flex-1 overflow-hidden p-4">
<div className="h-full overflow-y-auto">{renderContent()}</div>
</div>
</div>
</div>
)}
</div>
);
};
38 changes: 29 additions & 9 deletions src/app/(general)/_components/chat/messages/message-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AnimatePresence, motion } from "motion/react";
import React from "react";
import type z from "zod";
import { useChatContext } from "@/app/(general)/_contexts/chat-context";
import { ArtifactPreview } from "./artifact-preview";

interface Props {
toolInvocation: ToolInvocation;
Expand All @@ -32,6 +33,34 @@ const MessageToolComponent: React.FC<Props> = ({ toolInvocation }) => {

const { toolName } = toolInvocation;

if (toolName === "create_artifact") {
if (toolInvocation.state === "result" && toolInvocation.result) {
const result = toolInvocation.result as {
success?: boolean;
documentId?: string;
error?: string;
};
if (result.success && result.documentId) {
const args = toolInvocation.args as {
title?: string;
kind?: "text" | "code" | "custom";
description?: string;
};
return (
<ArtifactPreview
documentId={result.documentId}
title={args.title ?? "Untitled Artifact"}
kind={args.kind ?? "text"}
_description={args.description}
/>
);
}
return null;
}

return null;
}

const [server, tool] = toolName.split("_");

if (!server || !tool) {
Expand Down Expand Up @@ -194,10 +223,6 @@ const MessageToolComponent: React.FC<Props> = ({ toolInvocation }) => {
height: completeOnFirstMount ? "auto" : 0,
}}
animate={{ opacity: 1, height: "auto" }}
// exit={{
// opacity: 0,
// height: completeOnFirstMount ? "auto" : 0,
// }}
transition={{
duration: 0.3,
ease: "easeOut",
Expand All @@ -221,21 +246,16 @@ const areEqual = (prevProps: Props, nextProps: Props): boolean => {
const { toolInvocation: prev } = prevProps;
const { toolInvocation: next } = nextProps;

// Compare all relevant fields of toolInvocation
if (prev.toolCallId !== next.toolCallId) return false;
if (prev.toolName !== next.toolName) return false;
if (prev.state !== next.state) return false;

// Deep compare args object
if (JSON.stringify(prev.args) !== JSON.stringify(next.args)) return false;

// Deep compare result object (only exists when state is "result")
if (prev.state === "result" && next.state === "result") {
// Both have result property, compare them
if (JSON.stringify(prev.result) !== JSON.stringify(next.result))
return false;
} else if (prev.state === "result" || next.state === "result") {
// Only one has result property, they're different
return false;
}

Expand Down
54 changes: 44 additions & 10 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { Chat } from "@prisma/client";
import { openai } from "@ai-sdk/openai";
import { getServerToolkit } from "@/toolkits/toolkits/server";
import { languageModels } from "@/ai/language";
import { createArtifactTool } from "@/lib/artifacts/tool";

export const maxDuration = 60;

Expand Down Expand Up @@ -221,20 +222,53 @@ export async function POST(request: Request) {
}),
);

const tools = toolkitTools.reduce(
(acc, toolkitTools) => {
return {
...acc,
...toolkitTools,
};
},
{} as Record<string, Tool>,
);
const tools = {
...toolkitTools.reduce(
(acc, toolkitTools) => {
return {
...acc,
...toolkitTools,
};
},
{} as Record<string, Tool>,
),
// Add the artifact creation tool
create_artifact: createArtifactTool(id), // Pass the chat ID
};

const isOpenAi = selectedChatModel.startsWith("openai");

// Build comprehensive system prompt
const baseSystemPrompt = `You are a helpful assistant. The current date and time is ${new Date().toLocaleString()}. Whenever you are asked to write code, you must include a language with \`\`\``;
const baseSystemPrompt = `You are a helpful assistant. The current date and time is ${new Date().toLocaleString()}. Whenever you are asked to write code, you must include a language with \`\`\`.

## Artifacts

You have access to a special artifact creation system. Use the create_artifact tool when users ask for:

**Text Artifacts** - For substantial written content:
- Essays, articles, or long-form writing
- Email drafts or formal documents
- Creative writing, stories, or scripts
- Documentation or guides

**Code Artifacts** - For programming content:
- Complete code examples or scripts
- Functions or algorithms
- Configuration files
- Code tutorials with examples

**Custom Artifacts** - For structured or specialized content:
- Templates or forms
- Structured data or lists
- Custom formats or layouts

**When NOT to use artifacts:**
- Simple questions or short responses
- Brief explanations or conversational replies
- Single sentences or paragraphs
- Quick code snippets (under 10 lines)

When you create an artifact, it will be displayed in a special workspace interface alongside our conversation, making it easy for the user to view, edit, and work with the content.`;

const toolkitInstructions =
toolkitSystemPrompts.length > 0
Expand Down
Loading