diff --git a/prisma/migrations/20250815115018_add_documents_artifacts/migration.sql b/prisma/migrations/20250815115018_add_documents_artifacts/migration.sql new file mode 100644 index 00000000..d094e98c --- /dev/null +++ b/prisma/migrations/20250815115018_add_documents_artifacts/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 153dbc73..c82a95aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,7 @@ model User { videos Video[] userFeatures UserFeature[] workbenches Workbench[] + documents Document[] } enum UserRole { @@ -92,6 +93,7 @@ model Chat { messages Message[] stream Stream[] workbench Workbench? @relation(fields: [workbenchId], references: [id], onDelete: SetNull) + documents Document[] } model Message { @@ -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) } \ No newline at end of file diff --git a/src/app/(general)/_components/chat/messages/artifact-preview.tsx b/src/app/(general)/_components/chat/messages/artifact-preview.tsx new file mode 100644 index 00000000..d9b1e07c --- /dev/null +++ b/src/app/(general)/_components/chat/messages/artifact-preview.tsx @@ -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 = ({ + 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 ( +
+
+
+
+
+
+ ); + } + + 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 = { + 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 ( +
+ +
+ ); + } + + // For text and custom artifacts, use markdown rendering as-is + return ( +
+ +
+ ); + }; + + return ( +
+
+
+
{title}
+ + {kind} + +
+ +
+ +
+
+ + {isFullscreen && ( +
{ + if (e.target === e.currentTarget) { + setIsFullscreen(false); + } + }} + > +
e.stopPropagation()} + > +
+
+
{title}
+ + {kind} + +
+
+ + +
+
+
+
{renderContent()}
+
+
+
+ )} +
+ ); +}; diff --git a/src/app/(general)/_components/chat/messages/message-tool.tsx b/src/app/(general)/_components/chat/messages/message-tool.tsx index 9cfc7399..4215c916 100644 --- a/src/app/(general)/_components/chat/messages/message-tool.tsx +++ b/src/app/(general)/_components/chat/messages/message-tool.tsx @@ -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; @@ -32,6 +33,34 @@ const MessageToolComponent: React.FC = ({ 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 ( + + ); + } + return null; + } + + return null; + } + const [server, tool] = toolName.split("_"); if (!server || !tool) { @@ -194,10 +223,6 @@ const MessageToolComponent: React.FC = ({ toolInvocation }) => { height: completeOnFirstMount ? "auto" : 0, }} animate={{ opacity: 1, height: "auto" }} - // exit={{ - // opacity: 0, - // height: completeOnFirstMount ? "auto" : 0, - // }} transition={{ duration: 0.3, ease: "easeOut", @@ -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; } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2b858f04..f994db49 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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; @@ -221,20 +222,53 @@ export async function POST(request: Request) { }), ); - const tools = toolkitTools.reduce( - (acc, toolkitTools) => { - return { - ...acc, - ...toolkitTools, - }; - }, - {} as Record, - ); + const tools = { + ...toolkitTools.reduce( + (acc, toolkitTools) => { + return { + ...acc, + ...toolkitTools, + }; + }, + {} as Record, + ), + // 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 diff --git a/src/artifacts/code/client.tsx b/src/artifacts/code/client.tsx new file mode 100644 index 00000000..b170eced --- /dev/null +++ b/src/artifacts/code/client.tsx @@ -0,0 +1,195 @@ +import { Artifact } from "@/lib/artifacts/artifact"; +import type { ArtifactMetadata } from "@/lib/artifacts/types"; +import { Play, Copy, Download, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; + +interface CodeArtifactMetadata extends ArtifactMetadata { + language: string; + lineCount: number; + lastExecuted?: Date; + isExecuting: boolean; +} + +export const codeArtifact = new Artifact<"code", CodeArtifactMetadata>({ + kind: "code", + description: + "A code artifact for writing, editing, and executing code snippets.", + + initialize: async ({ documentId: _documentId, setMetadata }) => { + setMetadata({ + language: "javascript", + lineCount: 0, + isExecuting: false, + }); + }, + + onStreamPart: ({ streamPart, setMetadata, setArtifact }) => { + if (streamPart.type === "language-update") { + setMetadata((metadata) => ({ + ...metadata, + language: streamPart.content as string, + })); + } + + if (streamPart.type === "content-update") { + setArtifact((draftArtifact) => { + const newContent = + draftArtifact.content + (streamPart.content as string); + const lineCount = newContent.split("\n").length; + + setMetadata((metadata) => ({ + ...metadata, + lineCount, + })); + + return { + ...draftArtifact, + content: newContent, + status: "streaming", + }; + }); + } + }, + + content: ({ + mode, + status, + content, + isCurrentVersion, + currentVersionIndex, + onSaveContent, + getDocumentContentById, + isLoading, + metadata, + }) => { + if (isLoading) { + return ( +
+
+ + Loading code artifact... + +
+ ); + } + + if (mode === "diff") { + const oldContent = getDocumentContentById(currentVersionIndex - 1); + const newContent = getDocumentContentById(currentVersionIndex); + + return ( +
+

Code Comparison

+
+
+

+ Previous Version +

+
+
+                  {oldContent}
+                
+
+
+
+

+ Current Version +

+
+
+                  {newContent}
+                
+
+
+
+
+ ); + } + + const executeCode = async () => { + // TODO: Implement code execution using E2B or similar service + toast.info("Code execution coming soon!"); + }; + + return ( +
+
+
+ Language: {metadata.language} + Lines: {metadata.lineCount} + {metadata.lastExecuted && ( + + Last executed: {metadata.lastExecuted.toLocaleTimeString()} + + )} +
+ +
+ +
+
+ +
+
+ + Language: {metadata.language} + +
+ +