feat: comprehensive UI improvements - dark/light mode, markdown support, diff tool, and enhanced chat interface

This commit is contained in:
NicholaiVogel 2025-10-08 18:39:57 -06:00
parent 0e0d59f13f
commit ee74ad8485
16 changed files with 15826 additions and 98 deletions

View File

@ -0,0 +1,161 @@
# Agent Diff Tool Setup Guide
This guide explains how to configure your agent (n8n workflow) to use the diff tool functionality.
## How It Works
The chat interface now supports diff tool calls that get converted into beautiful, interactive diff displays. When the agent wants to show code changes, it can call the `show_diff` tool, which will be automatically converted to the proper markdown format.
## Agent Tool Call Format
Your agent should return tool calls in this JSON format:
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "function hello() {\n console.log('Hello, World!');\n}",
"newCode": "function hello(name = 'World') {\n console.log(`Hello, ${name}!`);\n}",
"title": "Updated hello function",
"language": "javascript"
}
}
```
## Parameters
- **oldCode** (required): The original code as a string with `\n` for line breaks
- **newCode** (required): The modified code as a string with `\n` for line breaks
- **title** (optional): A descriptive title for the diff (defaults to "Code Changes")
- **language** (optional): The programming language for syntax highlighting (defaults to "text")
## n8n Workflow Configuration
### Option 1: Direct Tool Call Response
In your n8n workflow, when you want to show a diff, return:
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "const x = 1;",
"newCode": "const x = 1;\nconst y = 2;",
"title": "Added new variable",
"language": "javascript"
}
}
```
### Option 2: Mixed Response with Tool Calls
You can also mix regular text with tool calls:
```json
{"type": "item", "content": "Here are the changes I made:\n\n"}
{"type": "tool_call", "name": "show_diff", "args": {"oldCode": "old code", "newCode": "new code", "title": "Changes"}}
{"type": "item", "content": "\n\nThese changes improve the functionality by..."}
```
### Option 3: Inline Tool Call
You can also return a single JSON object:
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "function example() {\n return 'old';\n}",
"newCode": "function example() {\n return 'new and improved';\n}",
"title": "Function improvement",
"language": "javascript"
}
}
```
## Example Use Cases
### 1. Code Refactoring
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "if (user) {\n return user.name;\n}",
"newCode": "if (user && user.name) {\n return user.name;\n} else {\n return 'Anonymous';\n}",
"title": "Added null safety check",
"language": "javascript"
}
}
```
### 2. Bug Fix
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "const result = a + b;",
"newCode": "const result = Number(a) + Number(b);",
"title": "Fixed type coercion bug",
"language": "javascript"
}
}
```
### 3. Feature Addition
```json
{
"type": "tool_call",
"name": "show_diff",
"args": {
"oldCode": "function calculate(x) {\n return x * 2;\n}",
"newCode": "function calculate(x, multiplier = 2) {\n return x * multiplier;\n}",
"title": "Added configurable multiplier",
"language": "javascript"
}
}
```
## n8n Node Configuration
In your n8n workflow, use a "Respond to Webhook" node with:
1. **Response Body**: Set to the JSON format above
2. **Response Headers**: Set `Content-Type` to `application/json`
3. **Response Code**: Set to `200`
## Testing
To test the diff tool:
1. Send a message to your agent asking for code changes
2. Configure your agent to return a `show_diff` tool call
3. The chat interface will automatically render the diff
## Error Handling
If the tool call is malformed, the API will return an error message instead of breaking. Make sure to:
- Include both `oldCode` and `newCode`
- Use proper JSON formatting
- Escape newlines as `\n`
- Use valid JSON structure
## Advanced Usage
You can also combine multiple tool calls in a single response by returning multiple JSON objects on separate lines:
```
{"type": "item", "content": "Here's the first change:\n\n"}
{"type": "tool_call", "name": "show_diff", "args": {...}}
{"type": "item", "content": "\n\nAnd here's the second change:\n\n"}
{"type": "tool_call", "name": "show_diff", "args": {...}}
```
This allows you to show multiple diffs in a single response.

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@opennextjs/cloudflare": "^1.9.1",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
@ -43,6 +44,7 @@
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"diff": "^8.0.2",
"embla-carousel-react": "8.5.1",
"geist": "^1.3.1",
"input-otp": "1.4.1",
@ -53,8 +55,11 @@
"react-day-picker": "9.8.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.60.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@ -69,6 +74,7 @@
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
"typescript": "^5",
"wrangler": "^4.42.0"
}
}

View File

@ -2,6 +2,29 @@ import { type NextRequest, NextResponse } from "next/server"
const WEBHOOK_URL = "https://n8n.biohazardvfx.com/webhook/d2ab4653-a107-412c-a905-ccd80e5b76cd"
// Helper function to convert diff tool call to markdown format
function convertToDiffTool(args: any): string {
try {
const { oldCode, newCode, title, language } = args
if (!oldCode || !newCode) {
return "Error: Missing oldCode or newCode in diff tool call"
}
const diffToolCall = {
oldCode: String(oldCode).replace(/\n/g, '\\n'),
newCode: String(newCode).replace(/\n/g, '\\n'),
title: title || "Code Changes",
language: language || "text"
}
return `\`\`\`diff-tool\n${JSON.stringify(diffToolCall, null, 2)}\n\`\`\``
} catch (error) {
console.error("[v0] Error converting diff tool:", error)
return "Error: Failed to process diff tool call"
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
@ -85,6 +108,12 @@ export async function POST(request: NextRequest) {
if (chunk.type === "item" && chunk.content) {
chunks.push(chunk.content)
}
// Handle diff tool calls
if (chunk.type === "tool_call" && chunk.name === "show_diff") {
const diffTool = convertToDiffTool(chunk.args)
chunks.push(diffTool)
}
} catch {
console.log("[v0] Failed to parse line:", line)
}
@ -101,6 +130,12 @@ export async function POST(request: NextRequest) {
const data = JSON.parse(responseText)
console.log("[v0] Parsed webhook data:", data)
// Check if this is a diff tool call
if (data.type === "tool_call" && data.name === "show_diff") {
const diffTool = convertToDiffTool(data.args)
return NextResponse.json({ response: diffTool })
}
// Extract the response from various possible fields
let responseMessage = data.response || data.message || data.output || data.text

View File

@ -4,39 +4,40 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
/* Warm light mode with cream/beige tones */
--background: oklch(0.97 0.01 60);
--foreground: oklch(0.2 0.02 30);
--card: oklch(0.99 0.005 60);
--card-foreground: oklch(0.2 0.02 30);
--popover: oklch(0.99 0.005 60);
--popover-foreground: oklch(0.2 0.02 30);
--primary: oklch(0.68 0.19 45);
--primary-foreground: oklch(0.99 0.005 60);
--secondary: oklch(0.94 0.01 60);
--secondary-foreground: oklch(0.2 0.02 30);
--muted: oklch(0.94 0.01 60);
--muted-foreground: oklch(0.5 0.015 40);
--accent: oklch(0.94 0.015 50);
--accent-foreground: oklch(0.2 0.02 30);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--destructive-foreground: oklch(0.99 0.005 60);
--border: oklch(0.88 0.015 55);
--input: oklch(0.88 0.015 55);
--ring: oklch(0.68 0.19 45);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: oklch(0.99 0.005 60);
--sidebar-foreground: oklch(0.2 0.02 30);
--sidebar-primary: oklch(0.68 0.19 45);
--sidebar-primary-foreground: oklch(0.99 0.005 60);
--sidebar-accent: oklch(0.94 0.015 50);
--sidebar-accent-foreground: oklch(0.2 0.02 30);
--sidebar-border: oklch(0.88 0.015 55);
--sidebar-ring: oklch(0.68 0.19 45);
}
.dark {
@ -118,19 +119,24 @@
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: #ff8c00 #000000;
}
body {
@apply bg-background text-foreground;
}
/* Light mode scrollbar */
* {
scrollbar-color: #ff8c00 oklch(0.94 0.01 60);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #000000;
background: oklch(0.94 0.01 60);
}
*::-webkit-scrollbar-thumb {
@ -141,5 +147,23 @@
*::-webkit-scrollbar-thumb:hover {
background: #ff9d1a;
}
/* Dark mode scrollbar */
.dark * {
scrollbar-color: #ff8c00 #000000;
}
.dark *::-webkit-scrollbar-track {
background: #000000;
}
.dark *::-webkit-scrollbar-thumb {
background: #ff8c00;
border-radius: 4px;
}
.dark *::-webkit-scrollbar-thumb:hover {
background: #ff9d1a;
}
}

View File

@ -4,12 +4,31 @@ import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { Analytics } from "@vercel/analytics/next"
import { Suspense } from "react"
import { ThemeProvider } from "@/components/theme-provider"
import "./globals.css"
export const metadata: Metadata = {
title: "AI Chat Interface",
description: "Chat with AI Agent",
generator: "v0.app",
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
description: "Get inspired and generate creative content with our AI-powered assistant. Perfect for brainstorming, content creation, and creative problem-solving.",
keywords: "AI, creative assistant, inspiration, brainstorming, content creation",
authors: [{ name: "Inspiration Repo Team" }],
creator: "Inspiration Repo",
publisher: "Inspiration Repo",
robots: "index, follow",
openGraph: {
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
description: "Get inspired and generate creative content with our AI-powered assistant.",
type: "website",
locale: "en_US",
siteName: "Inspiration Repo Agent",
},
twitter: {
card: "summary_large_image",
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
description: "Get inspired and generate creative content with our AI-powered assistant.",
},
viewport: "width=device-width, initial-scale=1",
canonical: "https://inspiration-repo-agent.com",
}
export default function RootLayout({
@ -18,10 +37,22 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" className="dark">
<html lang="en" suppressHydrationWarning>
<head>
<link rel="canonical" href="https://inspiration-repo-agent.com" />
<link rel="preload" href="/fonts/geist-sans.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href="/fonts/geist-mono.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
</head>
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable}`}>
<Suspense fallback={null}>{children}</Suspense>
<Analytics />
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Suspense fallback={null}>{children}</Suspense>
<Analytics />
</ThemeProvider>
</body>
</html>
)

View File

@ -1,9 +1,13 @@
import { ChatInterface } from "@/components/chat-interface"
import { Header } from "@/components/header"
export default function Home() {
return (
<main className="bg-black">
<ChatInterface />
</main>
<div className="flex h-screen flex-col bg-background">
<Header />
<main className="flex-1 overflow-hidden">
<ChatInterface />
</main>
</div>
)
}

View File

@ -0,0 +1,69 @@
# Diff Tool Usage Guide
The diff tool allows the AI model to display code differences in a beautiful, interactive format within chat messages.
## How to Use
The model can include diff displays in its responses by using the following markdown syntax:
```markdown
```diff-tool
{
"oldCode": "function hello() {\n console.log('Hello, World!');\n}",
"newCode": "function hello(name = 'World') {\n console.log(\`Hello, \${name}!\`);\n}",
"title": "Updated hello function",
"language": "javascript"
}
```
```
## Parameters
- **oldCode** (required): The original code as a string with `\n` for line breaks
- **newCode** (required): The modified code as a string with `\n` for line breaks
- **title** (optional): A descriptive title for the diff (defaults to "Code Changes")
- **language** (optional): The programming language for syntax highlighting (defaults to "text")
## Features
- **Interactive**: Click to expand/collapse the diff
- **Copy to Clipboard**: Copy button to copy the full diff
- **Line Numbers**: Shows line numbers for both old and new code
- **Color Coding**:
- Green for additions (+)
- Red for deletions (-)
- Neutral for unchanged lines
- **Syntax Highlighting**: Supports various programming languages
- **Responsive**: Works well on different screen sizes
## Example Usage Scenarios
1. **Code Refactoring**: Show before/after code improvements
2. **Bug Fixes**: Highlight what was changed to fix an issue
3. **Feature Additions**: Display new functionality being added
4. **Configuration Changes**: Show config file modifications
5. **API Changes**: Demonstrate API signature changes
## Best Practices
1. **Keep it focused**: Show only relevant changes, not entire files
2. **Add context**: Use descriptive titles and surrounding text
3. **Escape properly**: Make sure to escape quotes and newlines in JSON
4. **Reasonable size**: Avoid extremely large diffs that are hard to read
## Example Response Format
```markdown
Here are the changes I made to fix the bug:
```diff-tool
{
"oldCode": "if (user) {\n return user.name;\n}",
"newCode": "if (user && user.name) {\n return user.name;\n} else {\n return 'Anonymous';\n}",
"title": "Fixed null reference bug",
"language": "javascript"
}
```
The fix adds proper null checking and provides a fallback value.
```

View File

@ -5,7 +5,8 @@ import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Send, Bot, Loader2, SquarePen } from "lucide-react"
import { Send, Bot, Loader2, SquarePen, Sparkles, Paperclip, Mic } from "lucide-react"
import { MarkdownRenderer } from "./markdown-renderer"
interface Message {
id: string
@ -22,7 +23,7 @@ export function ChatInterface() {
const [isLoading, setIsLoading] = useState(false)
const [sessionId, setSessionId] = useState<string>("")
const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
// Try to get existing sessionID from localStorage
@ -43,8 +44,10 @@ export function ChatInterface() {
}
}, [messages, isLoading])
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault()
const sendMessage = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault()
}
if (!input.trim() || isLoading) return
@ -129,8 +132,15 @@ export function ChatInterface() {
inputRef.current?.focus()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
return (
<div className="flex h-screen w-full flex-col">
<div className="flex h-full w-full flex-col">
{messages.length > 0 && (
<div className="absolute right-4 top-4 z-10">
<Button
@ -148,46 +158,118 @@ export function ChatInterface() {
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-4 py-8 pb-32">
<div className="mx-auto max-w-3xl">
{messages.length === 0 ? (
<div className="flex h-full min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
<h1 className="text-2xl font-semibold text-white">Hello there!</h1>
<p className="text-base text-neutral-400">How can I help you today?</p>
<div className="flex h-full min-h-[60vh] flex-col items-center justify-center gap-8 text-center">
<div className="space-y-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-orange-500">
<Sparkles className="h-8 w-8 text-white" />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-white">Welcome to Inspiration Repo</h1>
<p className="text-lg text-neutral-400">Your AI-powered creative assistant is ready to help you brainstorm, create, and innovate.</p>
</div>
</div>
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
{[
"Help me brainstorm ideas for a new mobile app",
"Generate creative writing prompts for a fantasy novel",
"Suggest innovative marketing strategies for a startup",
"Create a list of unique product names for a tech company"
].map((prompt, index) => (
<button
key={index}
onClick={() => setInput(prompt)}
className="rounded-xl border border-neutral-800 bg-neutral-900/50 p-4 text-left text-sm text-neutral-300 transition-all hover:border-orange-500/50 hover:bg-neutral-800/50 hover:text-white"
>
{prompt}
</button>
))}
</div>
<div className="flex items-center gap-2 text-xs text-neutral-500">
<div className="h-1 w-1 rounded-full bg-green-500"></div>
<span>AI Assistant Online</span>
</div>
</div>
) : (
<div className="space-y-6">
<div className="space-y-8">
{messages.map((message) => (
<div key={message.id} className="flex gap-3">
<div key={message.id} className={`flex gap-4 ${message.role === "user" ? "flex-row-reverse" : ""}`}>
{message.role === "assistant" && (
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-neutral-800">
<Bot className="h-4 w-4 text-neutral-400" />
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500">
<Bot className="h-4 w-4 text-white" />
</div>
)}
{message.role === "user" && (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-500">
<div className="h-4 w-4 rounded-full bg-white"></div>
</div>
)}
<div className={`flex-1 ${message.role === "user" ? "flex justify-end" : ""}`}>
<div
className={`inline-block max-w-[85%] rounded-2xl px-4 py-2.5 ${
message.role === "user"
? "bg-primary text-white"
: message.isError
? "bg-red-500/10 text-red-400"
: "bg-neutral-800/50 text-neutral-100"
}`}
>
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{message.content}</p>
</div>
{message.hint && <p className="mt-1 text-xs text-neutral-500">{message.hint}</p>}
<div className="flex-1 space-y-2">
{message.role === "user" && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-neutral-400">You</span>
<span className="text-xs text-neutral-600">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
{message.role === "user" ? (
<div className="rounded-2xl px-5 py-4 shadow-sm bg-orange-500 text-white">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{message.content}</p>
</div>
) : message.isError ? (
<div className="rounded-2xl px-5 py-4 shadow-sm bg-red-500/10 text-red-400 border border-red-500/20">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{message.content}</p>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-neutral-400">AI Assistant</span>
<span className="text-xs text-neutral-600">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="text-neutral-100">
<MarkdownRenderer content={message.content} />
</div>
</div>
)}
{message.hint && (
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 px-3 py-2">
<p className="text-xs text-amber-400">{message.hint}</p>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-neutral-800">
<Bot className="h-4 w-4 text-neutral-400" />
<div className="flex gap-4">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="inline-block rounded-2xl bg-neutral-800/50 px-4 py-2.5">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
<p className="text-sm text-neutral-400">Thinking...</p>
<span className="text-xs font-medium text-neutral-400">AI Assistant</span>
<span className="text-xs text-neutral-600">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="text-neutral-100">
<div className="flex items-center gap-3">
<Loader2 className="h-4 w-4 animate-spin text-orange-500" />
<p className="text-sm text-neutral-400">Thinking...</p>
<div className="flex gap-1">
<div className="h-1 w-1 rounded-full bg-orange-500 animate-bounce"></div>
<div className="h-1 w-1 rounded-full bg-orange-500 animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="h-1 w-1 rounded-full bg-orange-500 animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
</div>
</div>
@ -199,23 +281,69 @@ export function ChatInterface() {
<div className="fixed bottom-6 left-1/2 z-20 w-full max-w-3xl -translate-x-1/2 px-4">
<form onSubmit={sendMessage} className="relative">
<div className="flex items-center gap-2 rounded-2xl border border-neutral-800 bg-neutral-900/95 p-2 shadow-2xl backdrop-blur-md">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a follow-up..."
disabled={isLoading}
className="flex-1 border-0 bg-transparent text-white placeholder:text-neutral-500 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
<Button
type="submit"
disabled={!input.trim() || isLoading}
size="icon"
className="h-9 w-9 flex-shrink-0 rounded-xl bg-primary text-white hover:bg-primary/90"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/95 p-3 shadow-2xl backdrop-blur-md">
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything or share your ideas... (Enter to send, Shift+Enter for new line)"
disabled={isLoading}
rows={1}
className="min-h-[20px] max-h-32 resize-none border-0 bg-transparent text-white placeholder:text-neutral-500 focus:outline-none focus:ring-0"
style={{
overflow: 'hidden',
height: 'auto'
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = Math.min(target.scrollHeight, 128) + 'px';
}}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-neutral-400 hover:text-white hover:bg-neutral-800"
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-neutral-400 hover:text-white hover:bg-neutral-800"
title="Voice input"
>
<Mic className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
{input.trim() && (
<span className="text-xs text-neutral-500">
{input.length}/2000
</span>
)}
<Button
type="submit"
disabled={!input.trim() || isLoading}
size="icon"
className="h-9 w-9 flex-shrink-0 rounded-xl bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,222 @@
"use client"
import { useState } from "react"
import { ChevronDown, ChevronRight, Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
interface DiffDisplayProps {
oldText: string
newText: string
title?: string
language?: string
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged'
content: string
oldLineNumber?: number
newLineNumber?: number
}
export function DiffDisplay({ oldText, newText, title = "Code Diff", language = "text" }: DiffDisplayProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [copied, setCopied] = useState(false)
// Simple diff algorithm - split into lines and compare
const generateDiff = (): DiffLine[] => {
const oldLines = oldText.split('\n')
const newLines = newText.split('\n')
const diff: DiffLine[] = []
let oldIndex = 0
let newIndex = 0
let oldLineNum = 1
let newLineNum = 1
while (oldIndex < oldLines.length || newIndex < newLines.length) {
const oldLine = oldLines[oldIndex]
const newLine = newLines[newIndex]
if (oldIndex >= oldLines.length) {
// Only new lines left
diff.push({
type: 'added',
content: newLine,
newLineNumber: newLineNum
})
newIndex++
newLineNum++
} else if (newIndex >= newLines.length) {
// Only old lines left
diff.push({
type: 'removed',
content: oldLine,
oldLineNumber: oldLineNum
})
oldIndex++
oldLineNum++
} else if (oldLine === newLine) {
// Lines are the same
diff.push({
type: 'unchanged',
content: oldLine,
oldLineNumber: oldLineNum,
newLineNumber: newLineNum
})
oldIndex++
newIndex++
oldLineNum++
newLineNum++
} else {
// Lines are different - check if it's an addition or removal
const oldLineNext = oldLines[oldIndex + 1]
const newLineNext = newLines[newIndex + 1]
if (oldLineNext === newLine) {
// Old line was removed
diff.push({
type: 'removed',
content: oldLine,
oldLineNumber: oldLineNum
})
oldIndex++
oldLineNum++
} else if (newLineNext === oldLine) {
// New line was added
diff.push({
type: 'added',
content: newLine,
newLineNumber: newLineNum
})
newIndex++
newLineNum++
} else {
// Both lines changed
diff.push({
type: 'removed',
content: oldLine,
oldLineNumber: oldLineNum
})
diff.push({
type: 'added',
content: newLine,
newLineNumber: newLineNum
})
oldIndex++
newIndex++
oldLineNum++
newLineNum++
}
}
}
return diff
}
const diff = generateDiff()
const hasChanges = diff.some(line => line.type !== 'unchanged')
const copyToClipboard = async () => {
const fullDiff = diff.map(line => {
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
const oldNum = line.oldLineNumber ? line.oldLineNumber.toString().padStart(3) : ' '
const newNum = line.newLineNumber ? line.newLineNumber.toString().padStart(3) : ' '
return `${prefix} ${oldNum}|${newNum} ${line.content}`
}).join('\n')
try {
await navigator.clipboard.writeText(fullDiff)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy diff:', err)
}
}
if (!hasChanges) {
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
<div className="flex items-center gap-2 text-sm text-neutral-400">
<Check className="h-4 w-4 text-green-500" />
<span>No changes detected</span>
</div>
</div>
)
}
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-neutral-800/50 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="h-6 w-6 p-0 text-neutral-400 hover:text-white"
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="text-sm font-medium text-white">{title}</span>
<span className="text-xs text-neutral-500">({language})</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={copyToClipboard}
className="h-6 w-6 p-0 text-neutral-400 hover:text-white"
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
{isExpanded && (
<div className="overflow-x-auto">
<div className="font-mono text-sm">
{diff.map((line, index) => (
<div
key={index}
className={`flex items-start gap-2 px-4 py-1 ${
line.type === 'added'
? 'bg-green-500/10 border-l-4 border-green-500'
: line.type === 'removed'
? 'bg-red-500/10 border-l-4 border-red-500'
: 'bg-transparent'
}`}
>
<div className="flex-shrink-0 w-12 text-xs text-neutral-500 select-none">
{line.oldLineNumber && line.newLineNumber ? (
`${line.oldLineNumber}|${line.newLineNumber}`
) : line.oldLineNumber ? (
`${line.oldLineNumber}| `
) : line.newLineNumber ? (
` |${line.newLineNumber}`
) : (
' | '
)}
</div>
<div className="flex-shrink-0 w-4 text-center text-xs select-none">
{line.type === 'added' ? (
<span className="text-green-500">+</span>
) : line.type === 'removed' ? (
<span className="text-red-500">-</span>
) : (
<span className="text-neutral-500"> </span>
)}
</div>
<div className={`flex-1 ${
line.type === 'added'
? 'text-green-400'
: line.type === 'removed'
? 'text-red-400'
: 'text-neutral-300'
}`}>
<code>{line.content || ' '}</code>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,36 @@
"use client"
import { DiffDisplay } from "./diff-display"
interface DiffToolProps {
oldCode: string
newCode: string
title?: string
language?: string
}
export function DiffTool({ oldCode, newCode, title, language }: DiffToolProps) {
return (
<div className="my-4">
<DiffDisplay
oldText={oldCode}
newText={newCode}
title={title || "Code Changes"}
language={language || "text"}
/>
</div>
)
}
// Helper function to create a diff tool call
export function createDiffToolCall(oldCode: string, newCode: string, title?: string, language?: string) {
return {
type: "diff_tool",
props: {
oldCode,
newCode,
title,
language
}
}
}

View File

@ -0,0 +1,34 @@
"use client"
import { Sparkles, Settings } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ModeToggle } from "@/components/mode-toggle"
export function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-md">
<div className="flex h-16 items-center justify-between px-4">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-orange-500">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Inspiration Repo</h1>
<p className="text-xs text-muted-foreground">AI Creative Assistant</p>
</div>
</div>
<div className="flex items-center space-x-2">
<ModeToggle />
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,188 @@
"use client"
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github-dark.css'
import { DiffTool } from './diff-tool'
interface MarkdownRendererProps {
content: string
className?: string
}
// Parse diff tool calls from markdown content
function parseDiffTools(content: string) {
const diffToolRegex = /```diff-tool\n([\s\S]*?)\n```/g
const tools: Array<{ match: string; props: any }> = []
let match
while ((match = diffToolRegex.exec(content)) !== null) {
try {
const props = JSON.parse(match[1])
tools.push({ match: match[0], props })
} catch (e) {
console.error('Failed to parse diff tool:', e)
}
}
return tools
}
export function MarkdownRenderer({ content, className = "" }: MarkdownRendererProps) {
// Parse diff tools from content
const diffTools = parseDiffTools(content)
let processedContent = content
// Replace diff tool calls with placeholders
diffTools.forEach((tool, index) => {
processedContent = processedContent.replace(tool.match, `__DIFF_TOOL_${index}__`)
})
return (
<div className={`prose prose-invert max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
// Custom component for diff tool placeholders
p: ({ children }) => {
const text = typeof children === 'string' ? children : children?.toString() || ''
const diffToolMatch = text.match(/^__DIFF_TOOL_(\d+)__$/)
if (diffToolMatch) {
const index = parseInt(diffToolMatch[1])
const tool = diffTools[index]
if (tool) {
return (
<DiffTool
oldCode={tool.props.oldCode}
newCode={tool.props.newCode}
title={tool.props.title}
language={tool.props.language}
/>
)
}
}
return (
<p className="text-sm leading-relaxed text-neutral-100 mb-2 last:mb-0">
{children}
</p>
)
},
// Custom styling for different elements
h1: ({ children }) => (
<h1 className="text-xl font-bold text-white mb-3 mt-4 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-white mb-2 mt-3 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold text-white mb-2 mt-3 first:mt-0">
{children}
</h3>
),
ul: ({ children }) => (
<ul className="list-disc list-inside text-sm text-neutral-100 mb-2 space-y-1">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-sm text-neutral-100 mb-2 space-y-1">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-sm text-neutral-100">
{children}
</li>
),
code: ({ children, className }) => {
const isInline = !className
if (isInline) {
return (
<code className="bg-neutral-800 text-orange-400 px-1.5 py-0.5 rounded text-xs font-mono">
{children}
</code>
)
}
return (
<code className={className}>
{children}
</code>
)
},
pre: ({ children }) => (
<pre className="bg-neutral-900 border border-neutral-800 rounded-lg p-4 overflow-x-auto mb-3">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-orange-500 pl-4 italic text-neutral-300 mb-3">
{children}
</blockquote>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 underline"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-white">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-neutral-200">
{children}
</em>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-3">
<table className="min-w-full border border-neutral-800 rounded-lg">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-neutral-800">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="bg-neutral-900">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="border-b border-neutral-800">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-sm font-semibold text-white">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-sm text-neutral-100">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@ -0,0 +1,41 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,12 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}