Implement multi-agent chat platform with shadcn Dialog agent selector

Key features:
- Multi-agent support with dynamic agent configuration via environment variables
- Floating shadcn Dialog for elegant agent selection
- Per-agent chat sessions and message history with localStorage persistence
- Image upload support with base64 encoding
- Dynamic webhook routing based on selected agent
- Refactored components: ChatInterface, Header, and new AgentSelector
- New API endpoints: GET /api/agents and updated POST /api/chat
- Centralized TypeScript types in src/lib/types.ts
- Comprehensive CLAUDE.md documentation

Agent Configuration:
- Set environment variables: AGENT_N_URL, AGENT_N_NAME, AGENT_N_DESCRIPTION
- Agent selector automatically discovers agents on first visit
- Seamless switching between agents without page reload

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nicholai 2025-11-13 13:39:17 -07:00
parent 207c0fbc98
commit 6189c87bb2
9 changed files with 867 additions and 41 deletions

234
CLAUDE.md Normal file
View File

@ -0,0 +1,234 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Multi-Agent Chat Interface** - A Next.js-based AI chat platform that supports multiple agents configured via environment variables. Users select an agent from a menu on first visit, then chat with that agent. The application integrates with n8n workflows via webhooks, features markdown rendering with syntax highlighting, image uploads, and an interactive diff tool for code changes.
## Development Commands
```bash
npm run dev # Start Next.js development server (http://localhost:3000)
npm run build # Create production build
npm start # Run production server
npm run lint # Run ESLint checks
```
**Note:** No testing framework is currently configured. Tests should be added when needed.
## Technology Stack
- **Frontend:** Next.js 15.5.4 (App Router), React 19, TypeScript 5
- **Styling:** Tailwind CSS 4.1.9 with PostCSS
- **UI Components:** shadcn/ui (Radix UI primitives), Lucide icons
- **Forms & Validation:** React Hook Form + Zod
- **Markdown:** react-markdown with remark-gfm and rehype-highlight for code syntax
- **Diffs:** Custom pipeline using `diff` library with Highlight.js for colored output
- **Deployment:** Cloudflare Workers/Pages via OpenNextJS + Wrangler
## Project Structure
```
src/
├── app/
│ ├── api/
│ │ ├── agents/route.ts # GET endpoint - returns available agents from env vars
│ │ └── chat/route.ts # POST endpoint - routes to selected agent's webhook
│ ├── layout.tsx # Root layout with theme provider
│ ├── page.tsx # Home page - conditionally renders AgentSelector or ChatInterface
│ └── globals.css # Tailwind global styles
├── components/
│ ├── agent-selector.tsx # Agent selection menu (card-based UI)
│ ├── chat-interface.tsx # Main chat UI component (client-side, per-agent)
│ ├── markdown-renderer.tsx # Parses markdown + extracts diff-tool blocks
│ ├── diff-display.tsx # Renders diffs with syntax highlighting
│ ├── diff-tool.tsx # Diff tool wrapper component
│ ├── header.tsx # App header with agent name and switch button
│ ├── mode-toggle.tsx # Dark/light theme toggle
│ ├── theme-provider.tsx # Theme context setup
│ ├── DIFF_TOOL_USAGE.md # In-component documentation
│ └── ui/ # shadcn/ui component library
└── lib/
├── types.ts # TypeScript types and interfaces (Agent, Message, ChatRequest, etc.)
└── utils.ts # Utility functions (cn() for classname merging)
```
## Architecture & Data Flow
### Agent Selection Flow
```
User visits site
page.tsx checks localStorage for selected agent
If no agent: Show AgentSelector
│ - Fetches agents from GET /api/agents
│ - Displays agent cards with name + description
│ - On selection: saves agent to localStorage and shows ChatInterface
If agent exists: Show ChatInterface with that agent
```
### Multi-Agent API Pattern
**GET /api/agents**
- Reads environment variables `AGENT_1_URL`, `AGENT_1_NAME`, `AGENT_1_DESCRIPTION`, `AGENT_2_URL`, etc.
- Returns array of available agents: `{ agents: Agent[] }`
**POST /api/chat**
- **Request:** `{ message, timestamp, sessionId, agentId, images? }`
- **Processing:**
1. Validates agentId is provided
2. Looks up webhook URL using `getAgentWebhookUrl(agentId)`
3. Proxies request to agent's specific n8n webhook
4. Forwards images (base64) if provided
- **Response Format:** Newline-delimited JSON with two message types:
- `"item"` - Text content rendered directly
- `"tool_call"` - Structured tools like `{ name: "show_diff", input: {...} }`
### Diff Tool Pipeline
```
n8n webhook response (tool_call: show_diff)
/api/chat/route.ts (converts to markdown code block format)
MarkdownRenderer (regex extracts diff-tool code blocks)
DiffDisplay (renders diffs with Highlight.js syntax highlighting)
```
### Client-Side Architecture
- **State Management:** React hooks (useState, useRef) + localStorage for persistence
- **Agent Persistence:** Selected agent stored in `selected-agent` and `selected-agent-id`
- **Session ID:** Per-agent format `chat-session-{agentId}` stored as `chat-session-{agentId}`
- **Message Storage:** Per-agent messages stored as `chat-messages-{agentId}` in localStorage
- **Image Handling:** Images converted to base64 and included in message payload
- **Auto-scroll:** Maintains scroll position at latest message
### Markdown Processing Details
- Custom regex parser in `MarkdownRenderer` extracts ` ```diff-tool ... ``` ` code blocks
- Replaces diff-tool blocks with placeholders during markdown rendering
- After markdown is rendered, dynamically inserts `<DiffDisplay>` components
- Supports remark-gfm (GitHub-flavored markdown) and rehype-highlight for syntax coloring
## Key Files & Responsibilities
| File | Purpose |
|------|---------|
| `src/lib/types.ts` | Centralized TypeScript types: Agent, Message, ChatRequest, ChatResponse, etc. |
| `src/app/api/agents/route.ts` | GET endpoint - parses env vars and returns available agents |
| `src/app/api/chat/route.ts` | POST endpoint - looks up agent webhook URL and proxies requests |
| `src/app/page.tsx` | Home page - manages agent selection state, conditionally renders AgentSelector or ChatInterface |
| `src/components/agent-selector.tsx` | Agent selection UI - fetches agents, displays cards, handles selection |
| `src/components/chat-interface.tsx` | Main chat UI - handles per-agent messages, image uploads, streaming |
| `src/components/header.tsx` | App header - displays agent name and "Switch Agent" button |
| `src/components/markdown-renderer.tsx` | Parses markdown and extracts diff-tool blocks for custom rendering |
| `src/components/diff-display.tsx` | Renders side-by-side diffs with syntax highlighting using Highlight.js |
| `AGENT_DIFF_TOOL_SETUP.md` | Comprehensive guide for configuring n8n agents to output diff tools |
| `src/components/DIFF_TOOL_USAGE.md` | In-component documentation for the diff tool feature |
## Theme & Styling
- **System:** Tailwind CSS 4 with CSS custom properties and OKLch color space
- **Colors:** Warm light mode (cream/beige) and pure black dark mode
- **Implementation:** `next-themes` with "light" and "dark" variants
- **Toggle:** Mode toggle button in header
- **Global Styles:** `src/app/globals.css` contains theme definitions and Tailwind imports
## Configuration Files
- `tsconfig.json` - TypeScript strict mode, bundler module resolution, `@/*` alias to `src/`
- `next.config.ts` - Next.js configuration
- `open-next.config.ts` - Cloudflare-specific Next.js configuration
- `wrangler.jsonc` - Wrangler (Cloudflare CLI) configuration
- `eslint.config.mjs` - ESLint with Next.js core-web-vitals and TypeScript support
- `components.json` - shadcn/ui component library configuration
- `postcss.config.mjs` - Tailwind CSS plugin configuration
## n8n Webhook Integration
The chat endpoint routes to an n8n workflow via webhook. The workflow should:
1. Accept message input and context from the client
2. Return newline-delimited JSON with messages in one of two formats:
- `{ "type": "item", "content": "text content" }`
- `{ "type": "tool_call", "name": "show_diff", "input": { "before": "...", "after": "...", "language": "..." } }`
**Note:** See `AGENT_DIFF_TOOL_SETUP.md` for detailed instructions on configuring n8n agents to use the diff tool feature.
## Common Tasks
### Configuring Agents via Environment Variables
**Local Development (.env.local):**
```
AGENT_1_URL=https://n8n.example.com/webhook/agent-1-uuid
AGENT_1_NAME=Creative Writer
AGENT_1_DESCRIPTION=An AI assistant for creative writing and brainstorming
AGENT_2_URL=https://n8n.example.com/webhook/agent-2-uuid
AGENT_2_NAME=Code Reviewer
AGENT_2_DESCRIPTION=An AI assistant for code review and refactoring
```
**Cloudflare Deployment:**
- Go to Cloudflare dashboard → Workers & Pages → your-project → Settings → Environment variables
- Add the same AGENT_* variables above
- Deploy to apply changes
### Adding a New Agent
1. Add three environment variables:
- `AGENT_N_URL` - webhook URL for the agent
- `AGENT_N_NAME` - display name
- `AGENT_N_DESCRIPTION` - short description
2. On next page reload, new agent appears in AgentSelector
3. No code changes needed
### Modifying Chat Messages or Display
- **Chat UI:** `src/components/chat-interface.tsx`
- **Rendering:** `src/components/markdown-renderer.tsx`
- **State:** Message list stored in component state, persisted to localStorage per agent
- **Per-agent persistence:** Messages saved as `chat-messages-{agentId}`
### Changing Theme Colors
- Edit CSS custom properties in `src/app/globals.css`
- Uses OKLch color space (perceptually uniform)
- Dark mode variant defined with `@custom-variant dark`
### Adding New Tool Types (beyond show_diff)
- **Step 1:** Handle in `/api/chat/route.ts` - convert tool_call to markdown format
- **Step 2:** Add detection logic in `markdown-renderer.tsx` to extract new code block type
- **Step 3:** Create new component similar to `DiffDisplay` and render dynamically
### Switching Between Agents
- Users click "Switch Agent" button in header
- Returns to AgentSelector menu
- Previously selected agents/messages are preserved in localStorage per agent
- No data loss when switching
## Notes for Future Development
### Multi-Agent Features
- **Agent Switching:** Currently requires page reload to agent selector. Could add inline dropdown in header.
- **Agent Management:** UI for adding/editing agents without env vars could improve UX
- **Agent Verification:** Add health checks to verify agents' webhook URLs are reachable
- **Agent Categories:** Could group agents by category/type for better organization
### Image Upload Enhancements
- **Image Storage:** Currently base64 in memory; consider Cloudflare R2 for large images
- **Image Preview:** Add full-screen image viewer for uploaded images
- **Multi-file Upload:** Support multiple file types beyond images
### Performance & Scaling
- **Message Virtualization:** Chat interface uses `useRef` for scroll - consider virtualizing long message lists
- **Storage Size:** localStorage has 5-10MB limit; consider IndexedDB for longer conversations
- **Streaming:** Current implementation reads entire response before parsing; consider streaming HTML as it arrives
### Testing & Quality
- **Testing:** Consider adding Jest or Vitest for component and API testing
- **Error Handling:** Enhance with retry logic for failed webhook calls and better error messages
- **Logging:** Add structured logging for debugging multi-agent interactions
### Accessibility & UX
- **Keyboard Navigation:** Verify keyboard navigation on agent selector cards and custom diff tool
- **ARIA Labels:** Add aria-labels for screen readers on interactive elements
- **Mobile Responsive:** Test agent selector and chat interface on mobile devices

View File

@ -0,0 +1,66 @@
import { type NextRequest, NextResponse } from "next/server"
import type { Agent, AgentsResponse } from "@/lib/types"
/**
* GET /api/agents
* Returns list of available agents configured via environment variables
*
* Expected environment variables format:
* - AGENT_1_URL, AGENT_1_NAME, AGENT_1_DESCRIPTION
* - AGENT_2_URL, AGENT_2_NAME, AGENT_2_DESCRIPTION
* - etc.
*/
export async function GET(request: NextRequest): Promise<NextResponse<AgentsResponse>> {
try {
const agents: Agent[] = []
// Parse agent configurations from environment variables
// Look for AGENT_N_URL, AGENT_N_NAME, AGENT_N_DESCRIPTION patterns
let agentIndex = 1
while (true) {
const urlKey = `AGENT_${agentIndex}_URL`
const nameKey = `AGENT_${agentIndex}_NAME`
const descriptionKey = `AGENT_${agentIndex}_DESCRIPTION`
const webhookUrl = process.env[urlKey]
const name = process.env[nameKey]
const description = process.env[descriptionKey]
// Stop if we don't find a URL for this index
if (!webhookUrl) {
break
}
// Require at least URL and name
if (!name) {
console.warn(`[agents] Agent ${agentIndex} missing name, skipping`)
agentIndex++
continue
}
agents.push({
id: `agent-${agentIndex}`,
name,
description: description || "",
webhookUrl,
})
agentIndex++
}
if (agents.length === 0) {
console.warn("[agents] No agents configured in environment variables")
}
console.log(`[agents] Loaded ${agents.length} agents`)
return NextResponse.json({ agents })
} catch (error) {
console.error("[agents] Error loading agents:", error)
return NextResponse.json(
{ agents: [], error: "Failed to load agents" },
{ status: 500 },
)
}
}

View File

@ -1,6 +1,29 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
import type { ChatRequest, ChatResponse } from "@/lib/types"
const WEBHOOK_URL = "https://n8n.biohazardvfx.com/webhook/d2ab4653-a107-412c-a905-ccd80e5b76cd" /**
* Get webhook URL for a specific agent from environment variables
* Format: AGENT_{agentIndex}_URL
*/
function getAgentWebhookUrl(agentId: string): string | null {
// Extract agent index from agentId (format: "agent-1", "agent-2", etc.)
const match = agentId.match(/agent-(\d+)/)
if (!match) {
console.error("[chat] Invalid agentId format:", agentId)
return null
}
const agentIndex = match[1]
const urlKey = `AGENT_${agentIndex}_URL`
const webhookUrl = process.env[urlKey]
if (!webhookUrl) {
console.error(`[chat] No webhook URL configured for ${urlKey}`)
return null
}
return webhookUrl
}
// Helper function to convert diff tool call to markdown format // Helper function to convert diff tool call to markdown format
function convertToDiffTool(args: any): string { function convertToDiffTool(args: any): string {
@ -25,28 +48,36 @@ function convertToDiffTool(args: any): string {
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest): Promise<NextResponse<ChatResponse>> {
try { try {
const body = await request.json() const body = await request.json()
if (typeof body !== "object" || body === null) { if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 }) return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
} }
const { message, timestamp, sessionId } = body as {
message?: string const { message, timestamp, sessionId, agentId, images } = body as ChatRequest
timestamp?: string
sessionId?: string // Validate required fields
}
if (!message || typeof message !== "string") { if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 }) return NextResponse.json({ error: "Message is required" }, { status: 400 })
} }
if (!message) { if (!agentId || typeof agentId !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 }) return NextResponse.json({ error: "Agent ID is required" }, { status: 400 })
} }
console.log("[v0] Sending to webhook:", { message, timestamp, sessionId }) // Get webhook URL for the selected agent
const webhookUrl = getAgentWebhookUrl(agentId)
if (!webhookUrl) {
return NextResponse.json(
{ error: `Agent ${agentId} is not properly configured` },
{ status: 400 },
)
}
const response = await fetch(WEBHOOK_URL, { console.log("[chat] Sending to webhook:", { agentId, message, timestamp, sessionId })
const response = await fetch(webhookUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -55,6 +86,8 @@ export async function POST(request: NextRequest) {
message, message,
timestamp, timestamp,
sessionId, sessionId,
agentId,
images: images && images.length > 0 ? images : undefined,
}), }),
}) })

View File

@ -1,13 +1,66 @@
"use client"
import { useState, useEffect } from "react"
import { ChatInterface } from "@/components/chat-interface" import { ChatInterface } from "@/components/chat-interface"
import { AgentSelector } from "@/components/agent-selector"
import { Header } from "@/components/header" import { Header } from "@/components/header"
import type { Agent } from "@/lib/types"
export default function Home() { export default function Home() {
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
const [showAgentDialog, setShowAgentDialog] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Try to load previously selected agent from localStorage
const savedAgent = localStorage.getItem("selected-agent")
if (savedAgent) {
try {
const agent = JSON.parse(savedAgent)
setSelectedAgent(agent)
} catch (err) {
console.error("[home] Failed to load saved agent:", err)
setShowAgentDialog(true)
}
} else {
// No agent selected, show dialog
setShowAgentDialog(true)
}
setIsLoading(false)
}, [])
const handleAgentSelected = (agent: Agent) => {
setSelectedAgent(agent)
}
const handleSwitchAgent = () => {
setShowAgentDialog(true)
}
if (isLoading) {
return null // Avoid hydration mismatch
}
return ( return (
<div className="flex h-screen flex-col bg-background"> <div className="flex h-screen flex-col bg-background">
<Header /> <Header agent={selectedAgent} onChangeAgent={handleSwitchAgent} />
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
<ChatInterface /> {selectedAgent ? (
<ChatInterface agent={selectedAgent} />
) : (
<div className="flex items-center justify-center h-full">
<p className="text-neutral-400">Select an agent to begin chatting</p>
</div>
)}
</main> </main>
<AgentSelector
open={showAgentDialog}
onOpenChange={setShowAgentDialog}
onAgentSelected={handleAgentSelected}
/>
</div> </div>
) )
} }

View File

@ -0,0 +1,115 @@
"use client"
import { useEffect, useState } from "react"
import { Loader2, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import type { Agent } from "@/lib/types"
interface AgentSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
onAgentSelected: (agent: Agent) => void
}
export function AgentSelector({
open,
onOpenChange,
onAgentSelected,
}: AgentSelectorProps) {
const [agents, setAgents] = useState<Agent[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
const fetchAgents = async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch("/api/agents")
const data = await response.json()
if (!response.ok || !data.agents) {
throw new Error(data.error || "Failed to load agents")
}
setAgents(data.agents)
if (data.agents.length === 0) {
setError("No agents configured. Please add agents via environment variables.")
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load agents",
)
} finally {
setIsLoading(false)
}
}
fetchAgents()
}, [open])
const handleSelectAgent = (agent: Agent) => {
// Store selected agent in localStorage
localStorage.setItem("selected-agent-id", agent.id)
localStorage.setItem("selected-agent", JSON.stringify(agent))
// Close dialog and notify parent component
onOpenChange(false)
onAgentSelected(agent)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex justify-center mb-4">
<Sparkles className="h-8 w-8 text-orange-500" />
</div>
<DialogTitle className="text-center text-2xl">
Select an Agent
</DialogTitle>
<DialogDescription className="text-center">
Choose an agent to start chatting
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-orange-500" />
<p className="mt-2 text-sm text-neutral-400">Loading agents...</p>
</div>
</div>
) : error ? (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-center">
<p className="text-sm text-red-400">{error}</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 py-4">
{agents.map((agent) => (
<button
key={agent.id}
onClick={() => handleSelectAgent(agent)}
className="rounded-lg border border-neutral-700 bg-neutral-800/50 p-4 text-left transition-all duration-200 hover:border-orange-500/50 hover:bg-neutral-700/50"
>
<h3 className="font-semibold text-white">{agent.name}</h3>
<p className="mt-1 text-sm text-neutral-400">
{agent.description}
</p>
</button>
))}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -5,38 +5,54 @@ import type React from "react"
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Send, Bot, Loader2, SquarePen, Sparkles, Paperclip, Mic } from "lucide-react" import { Send, Bot, Loader2, SquarePen, Sparkles, Paperclip, Mic, X } from "lucide-react"
import { MarkdownRenderer } from "./markdown-renderer" import { MarkdownRenderer } from "./markdown-renderer"
import type { Message, Agent } from "@/lib/types"
interface Message { interface ChatInterfaceProps {
id: string agent: Agent
role: "user" | "assistant"
content: string
timestamp: Date
isError?: boolean
hint?: string
} }
export function ChatInterface() { export function ChatInterface({ agent }: ChatInterfaceProps) {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [sessionId, setSessionId] = useState<string>("") const [sessionId, setSessionId] = useState<string>("")
const [selectedImages, setSelectedImages] = useState<string[]>([])
const messagesContainerRef = useRef<HTMLDivElement>(null) const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
// Try to get existing sessionID from localStorage // Use agent-specific session ID: chat-session-{agentId}
let existingSessionId = localStorage.getItem("chat-session-id") const sessionKey = `chat-session-${agent.id}`
let existingSessionId = localStorage.getItem(sessionKey)
if (!existingSessionId) { if (!existingSessionId) {
// Generate new sessionID using timestamp and random string // Generate new sessionID using timestamp and random string
existingSessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 15)}` existingSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
localStorage.setItem("chat-session-id", existingSessionId) localStorage.setItem(sessionKey, existingSessionId)
} }
setSessionId(existingSessionId) setSessionId(existingSessionId)
}, [])
// Load existing messages for this agent
const messagesKey = `chat-messages-${agent.id}`
const savedMessages = localStorage.getItem(messagesKey)
if (savedMessages) {
try {
const parsed = JSON.parse(savedMessages)
// Ensure timestamps are Date objects
const messages = parsed.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
}))
setMessages(messages)
} catch (err) {
console.error("[chat] Failed to load saved messages:", err)
}
}
}, [agent.id])
useEffect(() => { useEffect(() => {
if (messagesContainerRef.current) { if (messagesContainerRef.current) {
@ -44,6 +60,56 @@ export function ChatInterface() {
} }
}, [messages, isLoading]) }, [messages, isLoading])
// Save messages to localStorage whenever they change
useEffect(() => {
const messagesKey = `chat-messages-${agent.id}`
localStorage.setItem(messagesKey, JSON.stringify(messages))
}, [messages, agent.id])
// Handle image file selection
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.currentTarget.files
if (!files) return
const newImages: string[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Only accept image files
if (!file.type.startsWith("image/")) {
console.warn("[chat] Skipping non-image file:", file.name)
continue
}
try {
const base64 = await fileToBase64(file)
newImages.push(base64)
} catch (err) {
console.error("[chat] Failed to convert image:", err)
}
}
setSelectedImages((prev) => [...prev, ...newImages])
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
// Convert file to base64 string
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
})
}
// Remove selected image
const removeImage = (index: number) => {
setSelectedImages((prev) => prev.filter((_, i) => i !== index))
}
const sendMessage = async (e?: React.FormEvent) => { const sendMessage = async (e?: React.FormEvent) => {
if (e) { if (e) {
e.preventDefault() e.preventDefault()
@ -56,10 +122,12 @@ export function ChatInterface() {
role: "user", role: "user",
content: input.trim(), content: input.trim(),
timestamp: new Date(), timestamp: new Date(),
images: selectedImages.length > 0 ? selectedImages : undefined,
} }
setMessages((prev) => [...prev, userMessage]) setMessages((prev) => [...prev, userMessage])
setInput("") setInput("")
setSelectedImages([])
setIsLoading(true) setIsLoading(true)
try { try {
@ -72,6 +140,8 @@ export function ChatInterface() {
message: userMessage.content, message: userMessage.content,
timestamp: userMessage.timestamp.toISOString(), timestamp: userMessage.timestamp.toISOString(),
sessionId: sessionId, sessionId: sessionId,
agentId: agent.id,
images: selectedImages.length > 0 ? selectedImages : undefined,
}), }),
}) })
@ -122,12 +192,14 @@ export function ChatInterface() {
const startNewChat = () => { const startNewChat = () => {
// Clear all messages // Clear all messages
setMessages([]) setMessages([])
// Generate new sessionID // Generate new sessionID for this agent
const newSessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 15)}` const newSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
setSessionId(newSessionId) setSessionId(newSessionId)
localStorage.setItem("chat-session-id", newSessionId) const sessionKey = `chat-session-${agent.id}`
// Clear input localStorage.setItem(sessionKey, newSessionId)
// Clear input and images
setInput("") setInput("")
setSelectedImages([])
// Focus input // Focus input
inputRef.current?.focus() inputRef.current?.focus()
} }
@ -164,8 +236,8 @@ export function ChatInterface() {
<Sparkles className="h-8 w-8 text-white" /> <Sparkles className="h-8 w-8 text-white" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-3xl font-bold text-white">Welcome to Inspiration Repo</h1> <h1 className="text-3xl font-bold text-white">Welcome to {agent.name}</h1>
<p className="text-lg text-neutral-400">Your AI-powered creative assistant is ready to help you brainstorm, create, and innovate.</p> <p className="text-lg text-neutral-400">{agent.description}</p>
</div> </div>
</div> </div>
@ -281,6 +353,28 @@ export function ChatInterface() {
<div className="fixed bottom-6 left-1/2 z-20 w-full max-w-3xl -translate-x-1/2 px-4"> <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"> <form onSubmit={sendMessage} className="relative">
{/* Image preview section */}
{selectedImages.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2 px-3 pt-3">
{selectedImages.map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`Selected ${index}`}
className="h-16 w-16 rounded-lg object-cover"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white hover:bg-red-600"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/95 p-3 shadow-2xl backdrop-blur-md"> <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 items-end gap-3">
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
@ -293,7 +387,7 @@ export function ChatInterface() {
disabled={isLoading} disabled={isLoading}
rows={1} 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" 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={{ style={{
overflow: 'hidden', overflow: 'hidden',
height: 'auto' height: 'auto'
}} }}
@ -303,15 +397,26 @@ export function ChatInterface() {
target.style.height = Math.min(target.scrollHeight, 128) + 'px'; target.style.height = Math.min(target.scrollHeight, 128) + 'px';
}} }}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleImageSelect}
className="hidden"
disabled={isLoading}
/>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="h-8 w-8 text-neutral-400 hover:text-white hover:bg-neutral-800" className="h-8 w-8 text-neutral-400 hover:text-white hover:bg-neutral-800"
title="Attach file" title="Attach image"
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>

View File

@ -1,10 +1,16 @@
"use client" "use client"
import { Sparkles, Settings } from "lucide-react" import { Sparkles, Settings, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ModeToggle } from "@/components/mode-toggle" import { ModeToggle } from "@/components/mode-toggle"
import type { Agent } from "@/lib/types"
export function Header() { interface HeaderProps {
agent: Agent | null
onChangeAgent: () => void
}
export function Header({ agent, onChangeAgent }: HeaderProps) {
return ( return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-md"> <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 h-16 items-center justify-between px-4">
@ -13,11 +19,26 @@ export function Header() {
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Inspiration Repo</h1> <h1 className="text-lg font-semibold text-foreground">
<p className="text-xs text-muted-foreground">AI Creative Assistant</p> {agent ? agent.name : "Inspiration Repo"}
</h1>
<p className="text-xs text-muted-foreground">
{agent ? "AI Agent" : "AI Creative Assistant"}
</p>
</div> </div>
{agent && (
<Button
onClick={onChangeAgent}
variant="ghost"
size="sm"
className="ml-4 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronDown className="mr-1 h-4 w-4" />
Switch Agent
</Button>
)}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<ModeToggle /> <ModeToggle />
<Button <Button

View File

@ -0,0 +1,111 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-800 bg-neutral-900 p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-neutral-900 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-300 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-800 data-[state=open]:text-neutral-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight text-white", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-neutral-400", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

88
src/lib/types.ts Normal file
View File

@ -0,0 +1,88 @@
/**
* Core type definitions for the multi-agent chat application
*/
/**
* Represents an AI agent that users can chat with
*/
export interface Agent {
id: string
name: string
description: string
webhookUrl: string
}
/**
* Represents a single message in the chat
* Images are stored as base64 strings for transmission to the webhook
*/
export interface Message {
id: string
role: "user" | "assistant"
content: string
timestamp: Date
isError?: boolean
hint?: string
images?: string[] // Base64 encoded images (user messages only)
}
/**
* API request body for POST /api/chat
*/
export interface ChatRequest {
message: string
timestamp: string
sessionId: string
agentId: string
images?: string[] // Optional base64 encoded images
}
/**
* API response from POST /api/chat
*/
export interface ChatResponse {
error?: string
hint?: string
response?: string
message?: string
}
/**
* API response from GET /api/agents
*/
export interface AgentsResponse {
agents: Agent[]
error?: string
}
/**
* Props for diff-related components
*/
export interface DiffToolProps {
oldCode: string
newCode: string
title?: string
language?: string
}
export interface DiffDisplayProps {
oldText: string
newText: string
title?: string
language?: string
}
export interface DiffLine {
type: "added" | "removed" | "unchanged"
content: string
oldLineNumber?: number
newLineNumber?: number
}
/**
* Props for markdown renderer
*/
export interface MarkdownRendererProps {
content: string
className?: string
}