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:
parent
207c0fbc98
commit
6189c87bb2
234
CLAUDE.md
Normal file
234
CLAUDE.md
Normal 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
|
||||||
66
src/app/api/agents/route.ts
Normal file
66
src/app/api/agents/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/components/agent-selector.tsx
Normal file
115
src/components/agent-selector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
111
src/components/ui/dialog.tsx
Normal file
111
src/components/ui/dialog.tsx
Normal 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
88
src/lib/types.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user