Compare commits

...

No commits in common. "377a875eb8d693a857f3e7641695e8c0e29ad96b" and "866398e52e549429ac8bfeee604c8c9b0b24e45c" have entirely different histories.

957 changed files with 2799783 additions and 2 deletions

3
.cursorindexingignore Normal file
View File

@ -0,0 +1,3 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

7
.env.local Normal file
View File

@ -0,0 +1,7 @@
AGENT_1_URL=https://n8n.biohazardvfx.com/webhook/d2ab4653-a107-412c-a905-ccd80e5b76cd
AGENT_1_NAME=Repoguide
AGENT_1_DESCRIPTION=Documenting the development process.
AGENT_2_URL=https://n8n.biohazardvfx.com/webhook/0884bd10-256d-441c-971c-b9f1c8506fdf
AGENT_2_NAME=Morgan
AGENT_2_DESCRIPTION=System Prompt Designer

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
### Dependencies
node_modules/
### Next.js build output
.next/
### OpenNext build artifacts
.open-next/
dist/
### Environment files
.env*
!.env.example
### Logs & diagnostics
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
### OS artifacts
.DS_Store
Thumbs.db

4
.specstory/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# SpecStory project identity file
/.project.json
# SpecStory explanation file
/.what-is-this.md

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

234
AGENTS.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

161
AGENT_DIFF_TOOL_SETUP.md Normal file
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.

134
BUILD.md Normal file
View File

@ -0,0 +1,134 @@
# Build & Deployment Instructions
## Prerequisites
- Node.js (v18 or higher)
- pnpm package manager
- Cloudflare account with Wrangler CLI configured
## Development
Start the development server:
```bash
pnpm dev
```
The app will be available at `http://localhost:3000`
## Building for Production
### 1. Build Next.js Application
```bash
pnpm build
```
This runs `next build` and creates an optimized production build in `.next/`
### 2. Build for Cloudflare with OpenNext
```bash
npx @opennextjs/cloudflare build
```
This command:
- Runs the Next.js build (if not already built)
- Converts the Next.js output to Cloudflare Workers format
- Creates the deployment-ready bundle in `.open-next/`
- Generates the worker script and static assets
**Note:** You must run this step before deploying to Cloudflare. The standard `next build` alone is not sufficient.
## Deployment
### Deploy to Cloudflare Workers
```bash
npx wrangler deploy
```
Or specify an environment explicitly:
```bash
npx wrangler deploy --env="" # Deploy to default environment
```
The deployment will:
- Upload new/modified static assets
- Deploy the worker script
- Provide a live URL (e.g., `https://inspiration-repo-agent.nicholaivogelfilms.workers.dev`)
## Complete Build & Deploy Workflow
```bash
# 1. Build Next.js app and prepare for Cloudflare
npx @opennextjs/cloudflare build
# 2. Deploy to Cloudflare
npx wrangler deploy
```
## Environment Variables
### Local Development
Create a `.env.local` file in the project root:
```env
AGENT_1_URL=https://n8n.example.com/webhook/agent-1-uuid
AGENT_1_NAME=Agent Name
AGENT_1_DESCRIPTION=Description of the agent
AGENT_2_URL=https://n8n.example.com/webhook/agent-2-uuid
AGENT_2_NAME=Another Agent
AGENT_2_DESCRIPTION=Description of another agent
```
### Production (Cloudflare)
Set environment variables in the Cloudflare dashboard:
1. Go to Workers & Pages → `inspiration-repo-agent` → Settings → Environment Variables
2. Add the same `AGENT_*` variables as above
3. Redeploy for changes to take effect
## Troubleshooting
### "No updated asset files to upload"
This means the OpenNext build hasn't been run or hasn't picked up your changes. Run:
```bash
npx @opennextjs/cloudflare build
```
Then deploy again.
### ESLint Configuration Error
If you see ESLint errors during build, the build will still complete successfully. The linting step can be skipped if needed.
### Multiple Environments Warning
If you see a warning about multiple environments, explicitly specify the environment:
```bash
npx wrangler deploy --env=""
```
## Package Manager
This project uses **pnpm** (not npm). Always use `pnpm` commands:
- `pnpm install` - Install dependencies
- `pnpm dev` - Start dev server
- `pnpm build` - Build Next.js app
## Tech Stack
- **Framework:** Next.js 15.5.4 (App Router)
- **Runtime:** Cloudflare Workers
- **Adapter:** @opennextjs/cloudflare
- **Styling:** Tailwind CSS 4.1.9
- **Package Manager:** pnpm

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

46
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,46 @@
Contributing Workflow
=====================
Use this guidance when you spin up a new project from the template or accept contributions from collaborators.
Branching model
---------------
- Work from short-lived topic branches cut from `main`.
- Prefix branches with the work type: `feat/`, `fix/`, `docs/`, `chore/`, `refactor/`, `test/`.
- Keep branch names descriptive but short (e.g. `feat/billing-invoices`, `fix/auth-timeout`).
Commit messages
---------------
Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
```
<type>(<scope>): <subject>
```
Examples:
- `feat(api): add artist listing endpoint`
- `fix(infra): handle wrangler env missing`
- `docs(adr): record storage strategy`
Pull request checklist
----------------------
1. Rebase onto `main` before opening the PR.
2. Fill out `.gitea/pull_request_template.md` so reviewers know how to test.
3. Ensure automated checks pass locally:
```bash
pnpm install
pnpm lint
pnpm test
pnpm build
```
Adjust the commands if your project uses a different toolchain.
4. Link issues with `Fixes #id` or `Refs #id` as appropriate.
5. Squash-merge once approved to keep history clean (use the Conventional Commit format for the squash message).
Quality expectations
--------------------
- Keep docs current. Update the README, edge-case catalogue, or stack decisions when behaviour changes.
- Add or update tests alongside your changes—tests are treated as executable documentation.
- Avoid committing secrets or large binaries; rely on `.env` files, secret managers, or storage buckets instead.
Questions?
----------
Open an issue or start a draft PR and document what you are unsure about. Future readers will thank you for the breadcrumbs.

241
README.md
View File

@ -1,3 +1,240 @@
# Correspondents
<div align="center">
Correspondents is an opinionated web chat interface built specifically for my own custom agents, local inference and other AI/ML related projects.
<!-- DEPLOYMENT COMMAND -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 10px; margin-bottom: 30px;">
<h3 style="color: white; margin: 0;">STARTER COMMAND</h3>
<code style="color: #ffd700; font-size: 16px; font-weight: bold;">./scripts/bootstrap-template.sh</code>
</div>
<a id="readme-top"></a>
<!-- PROJECT SHIELDS -->
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
[![Stargazers][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![LinkedIn][linkedin-shield]][linkedin-url]
<!-- PROJECT LOGO -->
<br />
<div align="center">
<a href="https://git.biohazardvfx.com/nicholai/template">
<img src="public/template-logo.png" alt="Template Logo" width="400">
</a>
<h1 align="center" style="font-size: 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Development Project Template</h1>
<p align="center" style="font-size: 18px; max-width: 680px;">
Opinionated starter kit for new projects deployed through my self-hosted Gitea.<br />
<strong>Documentation-first • Edge-case aware • Automation ready</strong>
<br />
<br />
<a href="#getting-started"><strong>Quick Start »</strong></a>
·
<a href="https://git.biohazardvfx.com/nicholai/template/issues/new?labels=enhancement">Suggest Improvement</a>
</p>
</div>
---
<!-- TABLE OF CONTENTS -->
<details open>
<summary><h2>Table of Contents</h2></summary>
<ol>
<li><a href="#about-the-template">About The Template</a>
<ul>
<li><a href="#why-this-exists">Why This Exists</a></li>
<li><a href="#core-principles">Core Principles</a></li>
</ul>
</li>
<li><a href="#tech-stack">Tech Stack</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Prerequisites</a></li>
<li><a href="#installation">Installation</a></li>
<li><a href="#environment-variables">Environment Variables</a></li>
</ul>
</li>
<li><a href="#development">Development</a>
<ul>
<li><a href="#common-commands">Common Commands</a></li>
<li><a href="#docs--checklists">Docs & Checklists</a></li>
</ul>
</li>
<li><a href="#edge-cases">Edge Cases</a></li>
<li><a href="#testing">Testing</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#license">License</a></li>
<li><a href="#contact">Contact</a></li>
</ol>
</details>
---
</div>
## About The Template
<div align="center">
<img src="public/template-dashboard.png" alt="Template Dashboard Mock" width="800" style="border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
</div>
<br />
This repository is the baseline I use when starting a new product or service. It keeps the process consistent, reduces the time spent wiring boilerplate, and reminds me to account for the edge cases that usually appear late in a project.
### Why This Exists
- **Primed documentation:** Every project starts with a README, stack decision log, bootstrapping checklist, and edge-case catalogue.
- **Automation on day one:** `scripts/` holds helpers to rename the project, configure remotes, and clean example assets.
- **Testing blueprints:** Example Vitest suites (`__tests__/`) demonstrate how to structure API, component, flag, hook, and library tests.
- **Gitea ready:** Pull request templates, Conventional Commit guidance, and workflows match my self-hosted setup.
### Core Principles
| Principle | What it means |
| --- | --- |
| Documentation-first | Write down intent and constraints before diving into code. |
| Edge-case aware | Capture the failure scenarios that repeatedly cause incidents. |
| Reproducible setup | Every project can be re-created from scratch via scripts and docs. |
| Automation ready | Scripts and CI pipelines are easy to adapt or extend. |
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Tech Stack
- **Framework**: Next.js + TypeScript (adjust as needed)
- **Testing**: Vitest + Testing Library
- **Styling**: Tailwind CSS or CSS Modules (pick one per project)
- **Database**: PostgreSQL (Supabase/Neon friendly)
- **Storage**: S3-compatible providers (AWS S3, Cloudflare R2)
- **Auth**: NextAuth.js or custom token flows
- **Deployment**: Wrangler + Cloudflare Pages/Workers (swap for your platform)
Document any deviations in `docs/stack-decisions.md`.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Architecture
```mermaid
flowchart TD
A[Client] -->|HTTP| B[Next.js App]
B -->|API Routes| C[(PostgreSQL)]
B -->|Edge Functions| D[Cloudflare Workers]
B -->|Auth| E[Identity Provider]
B -->|Storage SDK| F[(S3/R2 Bucket)]
D -->|Feature Flags| G[Config Service]
```
- Keep infrastructure definitions under `infra/` once you create them.
- Capture architectural decisions and trade-offs in `docs/stack-decisions.md`.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Getting Started
### Prerequisites
- Node.js 20+
- pnpm (preferred) or your package manager of choice
- `jq` (optional, used by bootstrap script)
- Git & access to your Gitea instance
### Installation
1. **Clone / duplicate the template**
```bash
git clone git@git.biohazardvfx.com:nicholai/template.git my-new-project
cd my-new-project
```
2. **Bootstrap**
```bash
./scripts/bootstrap-template.sh
```
3. **Install dependencies**
```bash
pnpm install
```
4. **Follow the checklist**
- Open `docs/bootstrapping.md` and complete each item.
### Environment Variables
Copy `.env.example` to `.env` and fill only the sections you need. The file is structured by concern (database, auth, storage, observability) so you can strip unused parts.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Development
### Common Commands
| Command | Description |
| --- | --- |
| `pnpm dev` | Start the Next.js dev server. |
| `pnpm lint` | Run ESLint / formatting checks. |
| `pnpm test` | Execute the Vitest suites. |
| `pnpm build` | Generate a production build. |
### Docs & Checklists
- `docs/bootstrapping.md` — tasks to run through when spinning up a new project.
- `docs/edge-cases.md` — prompts for the weird scenarios that usually break things.
- `docs/stack-decisions.md` — record “why” for each notable tech choice.
- `docs/testing-blueprints.md` — guidance for adapting the example tests.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Edge Cases
Edge-case awareness is built into the template:
- Feature flags default to safe behaviour when providers fail.
- Auth, storage, scheduling, and third-party integrations each have dedicated prompts.
- The example tests in `__tests__/flags/` and `__tests__/lib/` show how to assert defensive behaviour.
Add new lessons learned back into `docs/edge-cases.md` so the template evolves with every incident.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Testing
- Tests are organised by domain: `api/`, `components/`, `hooks/`, `flags/`, `lib/`.
- Each suite mocks external dependencies and asserts on both happy-path and failure scenarios.
- See `docs/testing-blueprints.md` for tips on customising them to your project.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Contributing
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for branching conventions, commit style, and review expectations.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## License
Use, remix, or extract any portion of this template for your own projects. Attribution is appreciated but not required.
<p align="right"><a href="#readme-top">back to top ↑</a></p>
## Contact
Nicholai — [@biohazardvfx](https://linkedin.com/in/biohazardvfx) — nicholai@biohazardvfx.com
Project Link: [https://git.biohazardvfx.com/nicholai/template-repo](https://git.biohazardvfx.com/nicholai/template-repo)
<p align="right"><a href="#readme-top">back to top ↑</a></p>
<!-- MARKDOWN LINKS & IMAGES -->
<!-- shields -->
[contributors-shield]: https://img.shields.io/gitea/contributors/nicholai/template?style=for-the-badge
[contributors-url]: https://git.biohazardvfx.com/nicholai/template/graphs/contributors
[forks-shield]: https://img.shields.io/gitea/forks/nicholai/template?style=for-the-badge
[forks-url]: https://git.biohazardvfx.com/nicholai/template/network/members
[stars-shield]: https://img.shields.io/gitea/stars/nicholai/template?style=for-the-badge
[stars-url]: https://git.biohazardvfx.com/nicholai/template/stars
[issues-shield]: https://img.shields.io/gitea/issues/nicholai/template?style=for-the-badge
[issues-url]: https://git.biohazardvfx.com/nicholai/template/issues
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://linkedin.com/in/biohazardvfx

11
__tests__/README.md Normal file
View File

@ -0,0 +1,11 @@
# Test Suite Overview
The tests in this directory act as executable specifications. They were copied from real projects and are meant to be adapted, not run verbatim.
How to use them:
- Rename folders to match the first features you build.
- Replace imports from `@/...` with your actual modules once they exist.
- Trim scenarios that do not apply and add new ones that cover risky behaviours or integrations you care about.
- Keep the error-handling and edge-case checks—they are the reason these suites exist.
Once your implementation is in place, run `pnpm test` (or your preferred command) and fix failing specs until everything passes. The goal is to evolve these tests into living documentation for the application you are building off this template.

View File

@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GET } from '@/app/api/artists/route'
import { NextRequest } from 'next/server'
// Mock the database functions
vi.mock('@/lib/db', () => ({
getPublicArtists: vi.fn(),
}))
import { getPublicArtists } from '@/lib/db'
describe('GET /api/artists', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return artists successfully', async () => {
const mockArtists = [
{
id: '1',
slug: 'test-artist',
name: 'Test Artist',
bio: 'Test bio',
specialties: ['Traditional', 'Realism'],
instagramHandle: '@testartist',
portfolioImages: [],
isActive: true,
hourlyRate: 150,
},
]
vi.mocked(getPublicArtists).mockResolvedValue(mockArtists)
const request = new NextRequest('http://localhost:3000/api/artists')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.artists).toHaveLength(1)
expect(data.artists[0].name).toBe('Test Artist')
})
it('should apply specialty filter', async () => {
const mockArtists = [
{
id: '1',
slug: 'traditional-artist',
name: 'Traditional Artist',
bio: 'Test bio',
specialties: ['Traditional'],
portfolioImages: [],
isActive: true,
},
]
vi.mocked(getPublicArtists).mockResolvedValue(mockArtists)
const request = new NextRequest('http://localhost:3000/api/artists?specialty=Traditional')
await GET(request)
expect(getPublicArtists).toHaveBeenCalledWith(
expect.objectContaining({
specialty: 'Traditional',
}),
undefined
)
})
it('should apply search filter', async () => {
vi.mocked(getPublicArtists).mockResolvedValue([])
const request = new NextRequest('http://localhost:3000/api/artists?search=John')
await GET(request)
expect(getPublicArtists).toHaveBeenCalledWith(
expect.objectContaining({
search: 'John',
}),
undefined
)
})
it('should apply pagination', async () => {
vi.mocked(getPublicArtists).mockResolvedValue([])
const request = new NextRequest('http://localhost:3000/api/artists?limit=10&page=2')
await GET(request)
expect(getPublicArtists).toHaveBeenCalledWith(
expect.objectContaining({
limit: 10,
offset: 10, // page 2 with limit 10 = offset 10
}),
undefined
)
})
it('should handle database errors gracefully', async () => {
vi.mocked(getPublicArtists).mockRejectedValue(new Error('Database error'))
const request = new NextRequest('http://localhost:3000/api/artists')
const response = await GET(request)
expect(response.status).toBe(500)
const data = await response.json()
expect(data).toHaveProperty('error')
})
it('should return empty array when no artists found', async () => {
vi.mocked(getPublicArtists).mockResolvedValue([])
const request = new NextRequest('http://localhost:3000/api/artists')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.artists).toEqual([])
})
})

View File

@ -0,0 +1,82 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AftercarePage } from '@/components/aftercare-page'
describe('AftercarePage ShadCN UI Consistency', () => {
it('uses ShadCN design tokens and primitives correctly', () => {
render(<AftercarePage />)
// Verify main container uses ShadCN background tokens
const mainContainer = document.querySelector('.min-h-screen')
expect(mainContainer).toHaveClass('bg-background', 'text-foreground')
// Verify Tabs primitives are present
expect(screen.getByRole('tablist')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /general tattoo aftercare/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /transparent bandage aftercare/i })).toBeInTheDocument()
// Verify Alert primitives are present (there are multiple alerts)
const alerts = screen.getAllByRole('alert')
expect(alerts.length).toBeGreaterThan(0)
// Verify Card primitives are present (multiple cards should exist)
const cards = document.querySelectorAll('[data-slot="card"]')
expect(cards.length).toBeGreaterThan(0)
// Verify no ad-hoc color classes are used (specifically no text-white)
const htmlContent = document.documentElement.innerHTML
expect(htmlContent).not.toContain('text-white')
// Verify ShadCN design tokens are used
expect(htmlContent).toContain('text-muted-foreground')
expect(htmlContent).toContain('bg-background')
expect(htmlContent).toContain('text-foreground')
})
it('uses consistent ShadCN component structure', () => {
render(<AftercarePage />)
// Verify TabsList has proper ShadCN structure
const tabsList = screen.getByRole('tablist')
expect(tabsList).toHaveClass('grid', 'w-full', 'grid-cols-2', 'bg-muted', 'border')
// Verify Alert uses ShadCN structure with proper icon placement
const alerts = screen.getAllByRole('alert')
expect(alerts[0]).toHaveAttribute('data-slot', 'alert')
// Verify Cards use proper ShadCN structure
const cardHeaders = document.querySelectorAll('[data-slot="card-header"]')
expect(cardHeaders.length).toBeGreaterThan(0)
const cardContents = document.querySelectorAll('[data-slot="card-content"]')
expect(cardContents.length).toBeGreaterThan(0)
})
it('maintains consistent typography and spacing scales', () => {
render(<AftercarePage />)
// Verify heading uses consistent font classes
const mainHeading = screen.getByText('Tattoo Aftercare')
expect(mainHeading).toHaveClass('font-playfair')
// Verify muted text uses consistent token
const mutedElements = document.querySelectorAll('.text-muted-foreground')
expect(mutedElements.length).toBeGreaterThan(0)
// Verify consistent spacing classes are used
const htmlContent = document.documentElement.innerHTML
expect(htmlContent).toContain('space-y-')
expect(htmlContent).toContain('gap-')
expect(htmlContent).toContain('px-8')
expect(htmlContent).toContain('py-6') // Cards use py-6, not py-8
})
it('applies motion classes with reduced-motion safeguard', () => {
render(<AftercarePage />)
const html = document.documentElement.innerHTML
expect(html).toContain('animate-in')
expect(html).toContain('motion-reduce:animate-none')
})
})

View File

@ -0,0 +1,99 @@
import React from 'react'
import { render } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { ArtistPortfolio } from '@/components/artist-portfolio'
// Mock requestAnimationFrame / cancel
global.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 0) as unknown as number)
global.cancelAnimationFrame = vi.fn((id) => clearTimeout(id as unknown as number))
// Default matchMedia mock (no reduced motion)
const createMatchMedia = (matches: boolean) =>
vi.fn().mockImplementation((query) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
// Basic getBoundingClientRect mock for panels
const defaultRect = {
top: 0,
bottom: 800,
left: 0,
right: 1200,
width: 1200,
height: 800,
x: 0,
y: 0,
toJSON: () => {},
}
describe('ArtistPortfolio Split Hero', () => {
beforeEach(() => {
vi.clearAllMocks()
// default to no reduced-motion preference
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMedia(false),
})
// Mock IntersectionObserver (class-like mock to satisfy TS typings)
class MockIntersectionObserver {
constructor(private cb?: IntersectionObserverCallback, private options?: IntersectionObserverInit) {}
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
takeRecords() { return [] }
}
// Assign the mock class for the test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(global as any).IntersectionObserver = MockIntersectionObserver
// Mock getBoundingClientRect for all elements
Element.prototype.getBoundingClientRect = vi.fn(() => defaultRect)
})
it('initializes left/right panels with CSS var of 0 and transform style when motion allowed', () => {
const { getByTestId } = render(<ArtistPortfolio artistId="1" />)
const left = getByTestId('artist-left-panel')
const right = getByTestId('artist-right-panel')
expect(left).toBeInTheDocument()
expect(right).toBeInTheDocument()
// CSS var should be initialized to 0px on mount
expect(left.style.getPropertyValue('--parallax-offset')).toBe('0px')
expect(right.style.getPropertyValue('--parallax-offset')).toBe('0px')
// When motion is allowed, the element should expose the translateY style (uses CSS var)
expect(left).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
expect(right).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
})
it('does not apply parallax transform when prefers-reduced-motion is true', () => {
// Mock reduced motion preference
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMedia(true),
})
const { getByTestId } = render(<ArtistPortfolio artistId="1" />)
const left = getByTestId('artist-left-panel')
const right = getByTestId('artist-right-panel')
// With reduced motion, the hook should not add transform/willChange styles
expect(left).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
expect(left).not.toHaveStyle({ willChange: 'transform' })
expect(right).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
expect(right).not.toHaveStyle({ willChange: 'transform' })
})
})

View File

@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { ArtistsGrid } from '@/components/artists-grid'
import '@testing-library/jest-dom'
// Mock the custom hook
vi.mock('@/hooks/use-artist-data', () => ({
useArtists: vi.fn(),
}))
import { useArtists } from '@/hooks/use-artist-data'
describe('ArtistsGrid Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should display loading state', () => {
vi.mocked(useArtists).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any)
render(<ArtistsGrid />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should display artists when loaded', async () => {
const mockArtists = [
{
id: '1',
slug: 'test-artist',
name: 'Test Artist',
bio: 'Test bio',
specialties: ['Traditional', 'Realism'],
instagramHandle: '@testartist',
portfolioImages: [
{
id: '1',
artistId: '1',
url: 'https://example.com/image.jpg',
caption: 'Test image',
tags: ['Traditional'],
isPublic: true,
orderIndex: 0,
createdAt: new Date(),
},
],
isActive: true,
hourlyRate: 150,
},
]
vi.mocked(useArtists).mockReturnValue({
data: mockArtists,
isLoading: false,
error: null,
} as any)
render(<ArtistsGrid />)
await waitFor(() => {
expect(screen.getByText('Test Artist')).toBeInTheDocument()
})
expect(screen.getByText(/Traditional, Realism/i)).toBeInTheDocument()
expect(screen.getByText('Available')).toBeInTheDocument()
})
it('should display error state', () => {
vi.mocked(useArtists).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to fetch'),
} as any)
render(<ArtistsGrid />)
expect(screen.getByText(/Failed to load artists/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
it('should display empty state when no artists match filter', async () => {
vi.mocked(useArtists).mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any)
render(<ArtistsGrid />)
await waitFor(() => {
expect(screen.getByText(/No artists found/i)).toBeInTheDocument()
})
})
it('should display artist cards with portfolio images', async () => {
const mockArtists = [
{
id: '1',
slug: 'artist-one',
name: 'Artist One',
bio: 'Bio one',
specialties: ['Traditional'],
portfolioImages: [
{
id: '1',
artistId: '1',
url: 'https://example.com/img1.jpg',
tags: ['profile'],
isPublic: true,
orderIndex: 0,
createdAt: new Date(),
},
],
isActive: true,
hourlyRate: 100,
},
]
vi.mocked(useArtists).mockReturnValue({
data: mockArtists,
isLoading: false,
error: null,
} as any)
render(<ArtistsGrid />)
await waitFor(() => {
// Check for View Portfolio link
const portfolioLink = screen.getByRole('link', { name: /View Portfolio/i })
expect(portfolioLink).toHaveAttribute('href', '/artists/artist-one')
// Check for Book Now link
const bookLink = screen.getByRole('link', { name: /Book Now/i })
expect(bookLink).toHaveAttribute('href', '/book?artist=artist-one')
// Check for hourly rate display
expect(screen.getByText(/\$100\/hr/i)).toBeInTheDocument()
})
})
it('should display specialties as badges', async () => {
const mockArtists = [
{
id: '1',
slug: 'multi-specialty-artist',
name: 'Multi Specialty Artist',
bio: 'Expert in multiple styles',
specialties: ['Traditional', 'Realism', 'Fine Line', 'Japanese'],
portfolioImages: [],
isActive: true,
},
]
vi.mocked(useArtists).mockReturnValue({
data: mockArtists,
isLoading: false,
error: null,
} as any)
render(<ArtistsGrid />)
await waitFor(() => {
// Should show first 3 specialties
expect(screen.getByText('Traditional')).toBeInTheDocument()
expect(screen.getByText('Realism')).toBeInTheDocument()
expect(screen.getByText('Fine Line')).toBeInTheDocument()
// Should show "+1 more" badge for the 4th specialty
expect(screen.getByText('+1 more')).toBeInTheDocument()
})
})
it('should show inactive badge for inactive artists', async () => {
const mockArtists = [
{
id: '1',
slug: 'inactive-artist',
name: 'Inactive Artist',
bio: 'Currently unavailable',
specialties: ['Traditional'],
portfolioImages: [],
isActive: false,
},
]
vi.mocked(useArtists).mockReturnValue({
data: mockArtists,
isLoading: false,
error: null,
} as any)
render(<ArtistsGrid />)
await waitFor(() => {
expect(screen.getByText('Unavailable')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,132 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { HeroSection } from '@/components/hero-section'
// Mock the feature flags provider
vi.mock('@/components/feature-flags-provider', () => ({
useFeatureFlag: vi.fn(() => true),
}))
// Mock the parallax hooks
vi.mock('@/hooks/use-parallax', () => ({
useMultiLayerParallax: vi.fn(() => ({
background: {
ref: { current: null },
style: { transform: 'translateY(0px)' },
},
midground: {
ref: { current: null },
style: { transform: 'translateY(0px)' },
},
foreground: {
ref: { current: null },
style: { transform: 'translateY(0px)' },
},
})),
useReducedMotion: vi.fn(() => false),
}))
describe('HeroSection Parallax Implementation', () => {
beforeEach(() => {
// Reset mocks
vi.clearAllMocks()
})
it("renders hero section with all layers", () => {
render(<HeroSection />)
// Check for main heading
expect(screen.getByRole("heading", { name: /united tattoo/i })).toBeInTheDocument()
// Check for tagline
expect(screen.getByText(/where artistry meets precision/i)).toBeInTheDocument()
// Check for CTA button
expect(screen.getByRole("button", { name: /book consultation/i })).toBeInTheDocument()
})
it('applies reduced motion data attribute when reduced motion is preferred', async () => {
const { useReducedMotion } = await import('@/hooks/use-parallax')
vi.mocked(useReducedMotion).mockReturnValue(true)
render(<HeroSection />)
const section = document.querySelector('section')
expect(section).toHaveAttribute('data-reduced-motion', 'true')
})
it("has proper accessibility attributes for decorative images", () => {
render(<HeroSection />)
// Background and midground layers should be aria-hidden
const decorativeElements = document.querySelectorAll('[aria-hidden="true"]')
expect(decorativeElements.length).toBeGreaterThan(0)
})
it("uses proper semantic structure", () => {
render(<HeroSection />)
// Should have proper heading hierarchy
const heading = screen.getByRole("heading", { name: /united tattoo/i })
expect(heading.tagName).toBe("H1")
// Should have proper section structure
const section = document.querySelector("section")
expect(section).toHaveAttribute("id", "home")
})
it("applies will-change-transform for performance optimization", () => {
render(<HeroSection />)
const transformElements = document.querySelectorAll(".will-change-transform")
expect(transformElements.length).toBeGreaterThan(0)
})
it('respects feature flag for advanced animations', async () => {
const { useFeatureFlag } = await import('@/components/feature-flags-provider')
const { useMultiLayerParallax } = await import('@/hooks/use-parallax')
// Test with feature flag disabled
vi.mocked(useFeatureFlag).mockReturnValue(false)
render(<HeroSection />)
// Should pass disabled=true to parallax hook when feature flag is off
expect(useMultiLayerParallax).toHaveBeenCalledWith(true)
})
it("has responsive design classes", () => {
render(<HeroSection />)
const heading = screen.getByRole("heading", { name: /united tattoo/i })
expect(heading).toHaveClass("text-5xl", "lg:text-7xl")
const tagline = screen.getByText(/where artistry meets precision/i)
expect(tagline).toHaveClass("text-xl", "lg:text-2xl")
})
it("initializes parallax transforms to 0 at mount", () => {
render(<HeroSection />)
// All parallax layers should initialize with 0px transform
const backgroundLayer = document.querySelector('[style*="translateY(0px)"]')
const midgroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[1]
const foregroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[2]
expect(backgroundLayer).toBeInTheDocument()
expect(midgroundLayer).toBeInTheDocument()
expect(foregroundLayer).toBeInTheDocument()
})
it("disables parallax transforms when reduced motion is preferred", async () => {
const { useReducedMotion } = await import('@/hooks/use-parallax')
vi.mocked(useReducedMotion).mockReturnValue(true)
render(<HeroSection />)
// When reduced motion is preferred, parallax should be disabled
const section = document.querySelector('section')
expect(section).toHaveAttribute('data-reduced-motion', 'true')
})
})

View File

@ -0,0 +1,109 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { PrivacyPage } from '@/components/privacy-page'
describe('PrivacyPage ShadCN UI Consistency', () => {
it('uses standardized heading and body scales with ShadCN primitives', () => {
render(<PrivacyPage />)
// Verify main container uses ShadCN background tokens
const mainContainer = document.querySelector('.min-h-screen')
expect(mainContainer).toHaveClass('bg-background', 'text-foreground')
// Verify heading uses consistent font classes and scale
const mainHeading = screen.getByText('Privacy Policy')
expect(mainHeading).toHaveClass('font-playfair', 'text-5xl', 'lg:text-7xl')
// Verify body text uses consistent muted foreground token
const bodyText = screen.getByText(/We respect your privacy/)
expect(bodyText).toHaveClass('text-muted-foreground')
// Verify no ad-hoc color classes are used
const htmlContent = document.documentElement.innerHTML
expect(htmlContent).not.toContain('text-white')
expect(htmlContent).not.toContain('text-gray-300')
expect(htmlContent).not.toContain('bg-white/5')
expect(htmlContent).not.toContain('border-white/10')
// Verify ShadCN design tokens are consistently used
expect(htmlContent).toContain('text-muted-foreground')
expect(htmlContent).toContain('bg-background')
expect(htmlContent).toContain('text-foreground')
})
it('uses ShadCN primitives correctly throughout the page', () => {
render(<PrivacyPage />)
// Verify Alert primitive is present and properly structured
const alert = screen.getByRole('alert')
expect(alert).toHaveAttribute('data-slot', 'alert')
// Verify Badge primitive is present
const badge = screen.getByText('Last updated: 2025-09-16')
expect(badge).toBeInTheDocument()
// Verify Card primitives are present (multiple cards should exist)
const cards = document.querySelectorAll('[data-slot="card"]')
expect(cards.length).toBeGreaterThan(0)
// Verify Card headers and content use proper ShadCN structure
const cardHeaders = document.querySelectorAll('[data-slot="card-header"]')
expect(cardHeaders.length).toBeGreaterThan(0)
const cardContents = document.querySelectorAll('[data-slot="card-content"]')
expect(cardContents.length).toBeGreaterThan(0)
// Verify all CardContent uses muted foreground token
const cardContentElements = document.querySelectorAll('[data-slot="card-content"]')
cardContentElements.forEach(element => {
expect(element).toHaveClass('text-muted-foreground')
})
})
it('maintains consistent spacing and typography patterns', () => {
render(<PrivacyPage />)
// Verify consistent spacing classes are used
const htmlContent = document.documentElement.innerHTML
expect(htmlContent).toContain('space-y-3')
expect(htmlContent).toContain('gap-6')
expect(htmlContent).toContain('px-8')
expect(htmlContent).toContain('lg:px-16')
// Verify consistent text sizing
expect(htmlContent).toContain('text-xl')
expect(htmlContent).toContain('leading-relaxed')
// Verify grid layout consistency
expect(htmlContent).toContain('grid-cols-1')
expect(htmlContent).toContain('lg:grid-cols-2')
// Verify responsive design patterns
expect(htmlContent).toContain('max-w-4xl')
expect(htmlContent).toContain('max-w-6xl')
})
it('uses proper icon integration with ShadCN components', () => {
render(<PrivacyPage />)
// Verify icons are properly integrated without ad-hoc color classes
const infoIcon = document.querySelector('.lucide-info')
expect(infoIcon).toBeInTheDocument()
// Verify icons use consistent sizing
const htmlContent = document.documentElement.innerHTML
expect(htmlContent).toContain('w-5 h-5')
// Verify icons don't have ad-hoc color overrides
expect(htmlContent).not.toContain('text-white')
})
it('applies motion classes with reduced-motion safeguard', () => {
render(<PrivacyPage />)
const html = document.documentElement.innerHTML
expect(html).toContain('animate-in')
expect(html).toContain('motion-reduce:animate-none')
})
})

View File

@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/flags', () => ({
Flags: { BOOKING_ENABLED: false },
}))
vi.mock('@/lib/auth', () => ({
authOptions: {},
}))
vi.mock('next-auth', () => ({
getServerSession: vi.fn(),
}))
describe('Booking appointments mutations with BOOKING_ENABLED=false', () => {
it('POST returns 503 without invoking booking logic', async () => {
const { POST } = await import('../../app/api/appointments/route')
const response = await POST({} as any)
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
})
it('PUT returns 503 without invoking booking logic', async () => {
const { PUT } = await import('../../app/api/appointments/route')
const response = await PUT({} as any)
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
})
it('DELETE returns 503 without invoking booking logic', async () => {
const { DELETE } = await import('../../app/api/appointments/route')
const response = await DELETE({} as any)
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
})
})

View File

@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/lib/flags', () => ({
Flags: { UPLOADS_ADMIN_ENABLED: false },
}))
vi.mock('@/lib/auth', () => ({
authOptions: {},
requireAuth: vi.fn(),
}))
vi.mock('next-auth', () => ({
getServerSession: vi.fn(async () => null),
}))
describe('Uploads admin disabled', () => {
it('returns 503 for files bulk-delete when UPLOADS_ADMIN_ENABLED=false', async () => {
const { POST } = await import('../../app/api/files/bulk-delete/route')
const fakeReq: any = { json: async () => ({ fileIds: ['1'] }) }
const res = await POST(fakeReq as any)
const body = await res.json()
expect(res.status).toBe(503)
expect(body).toHaveProperty('error')
})
})

View File

@ -0,0 +1,25 @@
import React from 'react'
import { renderToString } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { ArtistsSection } from '@/components/artists-section'
import { FeatureFlagsProvider } from '@/components/feature-flags-provider'
import { FLAG_DEFAULTS } from '@/lib/flags'
const disabledAnimationFlags = {
...FLAG_DEFAULTS,
ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED: false,
} as typeof FLAG_DEFAULTS
describe('ArtistsSection static fallback when animations disabled', () => {
it('renders cards visible without animation classes', () => {
const html = renderToString(
<FeatureFlagsProvider value={disabledAnimationFlags}>
<ArtistsSection />
</FeatureFlagsProvider>,
)
expect(html).not.toContain('opacity-0 translate-y-8')
expect(html).toContain('opacity-100 translate-y-0')
})
})

View File

@ -0,0 +1,22 @@
import React from "react"
import { renderToString } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { BookingForm } from "@/components/booking-form"
import { FeatureFlagsProvider } from "@/components/feature-flags-provider"
import { FLAG_DEFAULTS } from "@/lib/flags"
const disabledFlags = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS
describe("BookingForm disabled mode (SSR string)", () => {
it("includes disabled notice when BOOKING_ENABLED=false", () => {
const html = renderToString(
<FeatureFlagsProvider value={disabledFlags}>
<BookingForm />
</FeatureFlagsProvider>,
)
expect(html).toContain("Online booking is temporarily unavailable")
expect(html).toContain("contact the studio")
})
})

View File

@ -0,0 +1,199 @@
import React from 'react'
import { render, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { useParallax, useReducedMotion } from '@/hooks/use-parallax'
// Mock window methods
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock window properties
Object.defineProperty(window, 'pageYOffset', {
writable: true,
value: 0,
})
Object.defineProperty(window, 'innerHeight', {
writable: true,
value: 800,
})
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn(callback => setTimeout(callback, 0))
global.cancelAnimationFrame = vi.fn(id => clearTimeout(id))
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Mock getBoundingClientRect
Element.prototype.getBoundingClientRect = vi.fn(() => ({
top: 0,
bottom: 100,
left: 0,
right: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => {},
}))
// Test component that uses the parallax hook
const TestComponent = ({ depth = 0.1, disabled = false }: { depth?: number; disabled?: boolean }) => {
const parallax = useParallax({ depth, disabled })
return (
<div
ref={parallax.ref}
style={parallax.style}
data-testid="parallax-element"
>
Test Element
</div>
)
}
describe('useParallax Hook', () => {
beforeEach(() => {
// Reset mocks
vi.clearAllMocks()
// Reset window properties
Object.defineProperty(window, 'pageYOffset', {
writable: true,
value: 0,
})
// Reset mock implementations
Element.prototype.getBoundingClientRect = vi.fn(() => ({
top: 0,
bottom: 100,
left: 0,
right: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => {},
}))
})
it('initializes CSS transform to 0 at mount', () => {
render(<TestComponent />)
const element = document.querySelector('[data-testid="parallax-element"]')
expect(element).toBeInTheDocument()
// Initially should have 0px transform via CSS variable
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
})
it('does not apply translation until scroll occurs', () => {
render(<TestComponent depth={0.1} />)
const element = document.querySelector('[data-testid="parallax-element"]')
expect(element).toBeInTheDocument()
// Initially should have 0px transform via CSS variable
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
// Simulate scroll
act(() => {
Object.defineProperty(window, 'pageYOffset', {
writable: true,
value: 100,
})
window.dispatchEvent(new Event('scroll'))
})
// After scroll, transform should still use CSS variable
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
})
it('respects disabled prop and does not apply transforms', () => {
render(<TestComponent depth={0.1} disabled={true} />)
const element = document.querySelector('[data-testid="parallax-element"]')
expect(element).toBeInTheDocument()
// With disabled=true, should have no transform styles
expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
expect(element).not.toHaveStyle({ willChange: 'transform' })
})
})
describe('useReducedMotion Hook', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('initializes with correct boolean value from prefersReducedMotion()', () => {
// Mock matchMedia to return true for reduced motion
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
let reducedMotionValue: boolean
const TestReducedMotionComponent = () => {
reducedMotionValue = useReducedMotion()
return <div>Test</div>
}
render(<TestReducedMotionComponent />)
// Should be a boolean value, not a function reference
expect(typeof reducedMotionValue).toBe('boolean')
expect(reducedMotionValue).toBe(true)
})
it('disables parallax transforms when reduced motion is preferred', () => {
// Mock matchMedia to return true for reduced motion
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
render(<TestComponent depth={0.1} />)
const element = document.querySelector('[data-testid="parallax-element"]')
expect(element).toBeInTheDocument()
// With reduced motion, should have no transform styles
expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
expect(element).not.toHaveStyle({ willChange: 'transform' })
})
})

View File

@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
// Mock the database using proper Vitest patterns
const mockStmt = {
bind: vi.fn().mockReturnThis(),
run: vi.fn().mockResolvedValue({ success: true, changes: 1 }),
get: vi.fn(),
all: vi.fn().mockResolvedValue({ results: [] }),
first: vi.fn().mockResolvedValue(null),
}
const mockDB = {
prepare: vi.fn().mockReturnValue(mockStmt),
exec: vi.fn(),
}
// Mock the entire lib/db module
vi.mock('@/lib/db', () => ({
getDB: vi.fn(() => mockDB),
}))
// Mock the artists data with proper structure
vi.mock('@/data/artists', () => ({
artists: [
{
id: '1',
name: 'Test Artist',
bio: 'Test bio',
styles: ['Traditional', 'Realism'],
instagram: 'https://instagram.com/testartist',
experience: '5 years',
workImages: ['/test-image.jpg'],
faceImage: '/test-face.jpg',
},
{
id: '2',
name: 'Another Artist',
bio: 'Another bio',
styles: ['Japanese', 'Blackwork'],
instagram: 'https://instagram.com/anotherartist',
experience: '8 years',
workImages: [],
faceImage: '/another-face.jpg',
},
],
}))
describe('DataMigrator', () => {
let DataMigrator: any
let migrator: any
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock implementations
mockDB.prepare.mockReturnValue(mockStmt)
mockStmt.first.mockResolvedValue(null)
mockStmt.run.mockResolvedValue({ success: true, changes: 1 })
// Import the DataMigrator class after mocks are set up
const module = await import('@/lib/data-migration')
DataMigrator = module.DataMigrator
migrator = new DataMigrator()
})
afterEach(() => {
vi.resetAllMocks()
})
describe('isMigrationCompleted', () => {
it('should return false when no artists exist', async () => {
mockStmt.first.mockResolvedValueOnce({ count: 0 })
const isCompleted = await migrator.isMigrationCompleted()
expect(isCompleted).toBe(false)
})
it('should return true when artists exist', async () => {
mockStmt.first.mockResolvedValueOnce({ count: 2 })
const isCompleted = await migrator.isMigrationCompleted()
expect(isCompleted).toBe(true)
})
})
describe('migrateArtistData', () => {
it('should migrate all artists successfully', async () => {
await migrator.migrateArtistData()
// Verify user creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO users')
)
// Verify artist creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO artists')
)
// Verify portfolio image creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO portfolio_images')
)
})
it('should handle errors gracefully', async () => {
mockStmt.run.mockRejectedValueOnce(new Error('Database error'))
await expect(migrator.migrateArtistData()).rejects.toThrow('Database error')
})
})
describe('clearMigratedData', () => {
it('should clear all data successfully', async () => {
await migrator.clearMigratedData()
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM portfolio_images')
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM artists')
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM users WHERE role = "ARTIST"')
})
it('should handle clear data errors', async () => {
mockStmt.run.mockRejectedValueOnce(new Error('Clear error'))
await expect(migrator.clearMigratedData()).rejects.toThrow('Clear error')
})
})
describe('getMigrationStats', () => {
it('should return correct migration statistics', async () => {
mockStmt.first
.mockResolvedValueOnce({ count: 3 }) // total users
.mockResolvedValueOnce({ count: 2 }) // total artists
.mockResolvedValueOnce({ count: 1 }) // total portfolio images
const stats = await migrator.getMigrationStats()
expect(stats.totalUsers).toBe(3)
expect(stats.totalArtists).toBe(2)
expect(stats.totalPortfolioImages).toBe(1)
})
})
})

269
__tests__/lib/db.test.ts Normal file
View File

@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getArtists,
getArtistWithPortfolio,
getPublicArtists,
getArtistBySlug,
updateArtist,
addPortfolioImage,
updatePortfolioImage,
deletePortfolioImage,
} from '@/lib/db'
// Mock D1 database
const createMockD1 = () => ({
prepare: vi.fn().mockReturnThis(),
bind: vi.fn().mockReturnThis(),
first: vi.fn(),
all: vi.fn(),
run: vi.fn(),
})
describe('Database Functions', () => {
let mockEnv: { DB: ReturnType<typeof createMockD1> }
beforeEach(() => {
mockEnv = {
DB: createMockD1(),
}
vi.clearAllMocks()
})
describe('getArtists', () => {
it('should fetch all artists and parse JSON fields', async () => {
const mockArtists = [
{
id: '1',
name: 'Test Artist',
bio: 'Test bio',
specialties: '["Traditional","Realism"]',
isActive: 1,
},
]
mockEnv.DB.all.mockResolvedValue({
results: mockArtists,
success: true,
})
const result = await getArtists(mockEnv)
expect(result).toHaveLength(1)
expect(result[0].specialties).toEqual(['Traditional', 'Realism'])
expect(result[0].isActive).toBe(true)
})
it('should handle empty results', async () => {
mockEnv.DB.all.mockResolvedValue({
results: [],
success: true,
})
const result = await getArtists(mockEnv)
expect(result).toEqual([])
})
it('should handle database errors', async () => {
mockEnv.DB.all.mockRejectedValue(new Error('Database error'))
await expect(getArtists(mockEnv)).rejects.toThrow('Database error')
})
})
describe('getArtistWithPortfolio', () => {
it('should fetch artist with portfolio images', async () => {
const mockArtist = {
id: '1',
name: 'Test Artist',
bio: 'Test bio',
specialties: '["Traditional"]',
isActive: 1,
}
const mockImages = [
{
id: '1',
artistId: '1',
url: 'https://example.com/image.jpg',
caption: 'Test image',
tags: '["Traditional","Portrait"]',
isPublic: 1,
orderIndex: 0,
},
]
mockEnv.DB.first.mockResolvedValueOnce(mockArtist)
mockEnv.DB.all.mockResolvedValueOnce({
results: mockImages,
success: true,
})
const result = await getArtistWithPortfolio('1', mockEnv)
expect(result).toBeDefined()
expect(result?.name).toBe('Test Artist')
expect(result?.portfolioImages).toHaveLength(1)
expect(result?.portfolioImages[0].tags).toEqual(['Traditional', 'Portrait'])
})
it('should return null for non-existent artist', async () => {
mockEnv.DB.first.mockResolvedValue(null)
const result = await getArtistWithPortfolio('999', mockEnv)
expect(result).toBeNull()
})
})
describe('getPublicArtists', () => {
it('should return only active artists with public images', async () => {
const mockArtists = [
{
id: '1',
name: 'Active Artist',
specialties: '["Traditional"]',
isActive: 1,
},
{
id: '2',
name: 'Inactive Artist',
specialties: '["Realism"]',
isActive: 0,
},
]
mockEnv.DB.all.mockResolvedValue({
results: mockArtists.filter(a => a.isActive),
success: true,
})
const result = await getPublicArtists({}, mockEnv)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Active Artist')
})
it('should filter by specialty', async () => {
const mockArtists = [
{
id: '1',
name: 'Traditional Artist',
specialties: '["Traditional"]',
isActive: 1,
},
]
mockEnv.DB.all.mockResolvedValue({
results: mockArtists,
success: true,
})
await getPublicArtists({ specialty: 'Traditional' }, mockEnv)
// Verify the bind was called (specialty filter applied)
expect(mockEnv.DB.bind).toHaveBeenCalled()
})
})
describe('getArtistBySlug', () => {
it('should fetch artist by slug', async () => {
const mockArtist = {
id: '1',
slug: 'test-artist',
name: 'Test Artist',
specialties: '["Traditional"]',
}
mockEnv.DB.first.mockResolvedValue(mockArtist)
mockEnv.DB.all.mockResolvedValue({
results: [],
success: true,
})
const result = await getArtistBySlug('test-artist', mockEnv)
expect(result).toBeDefined()
expect(result?.slug).toBe('test-artist')
expect(mockEnv.DB.bind).toHaveBeenCalledWith('test-artist')
})
})
describe('updateArtist', () => {
it('should update artist and stringify JSON fields', async () => {
const updateData = {
id: '1',
name: 'Updated Name',
bio: 'Updated bio',
specialties: ['Traditional', 'Realism'],
hourlyRate: 150,
}
mockEnv.DB.run.mockResolvedValue({
success: true,
meta: { changes: 1 },
})
await updateArtist('1', updateData, mockEnv)
// Verify the update was called
expect(mockEnv.DB.run).toHaveBeenCalled()
expect(mockEnv.DB.bind).toHaveBeenCalled()
})
})
describe('Portfolio Image Operations', () => {
it('should add portfolio image', async () => {
const imageData = {
url: 'https://example.com/image.jpg',
caption: 'Test caption',
tags: ['Traditional'],
isPublic: true,
orderIndex: 0,
}
mockEnv.DB.run.mockResolvedValue({
success: true,
meta: { last_row_id: 1 },
})
mockEnv.DB.first.mockResolvedValue({
id: '1',
...imageData,
artistId: '1',
tags: JSON.stringify(imageData.tags),
})
const result = await addPortfolioImage('1', imageData, mockEnv)
expect(result).toBeDefined()
expect(result.caption).toBe('Test caption')
})
it('should update portfolio image', async () => {
const updateData = {
caption: 'Updated caption',
tags: ['Traditional', 'Portrait'],
isPublic: false,
}
mockEnv.DB.run.mockResolvedValue({
success: true,
meta: { changes: 1 },
})
await updatePortfolioImage('1', updateData, mockEnv)
expect(mockEnv.DB.run).toHaveBeenCalled()
})
it('should delete portfolio image', async () => {
mockEnv.DB.run.mockResolvedValue({
success: true,
meta: { changes: 1 },
})
await deletePortfolioImage('1', mockEnv)
expect(mockEnv.DB.run).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,92 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"
import {
FLAG_DEFAULTS,
Flags,
getFlags,
registerRuntimeFlags,
resetFlagsCache,
parseBool,
} from "@/lib/flags"
type FlagName = keyof typeof FLAG_DEFAULTS
const flagKeys = Object.keys(FLAG_DEFAULTS) as FlagName[]
const originalEnv: Partial<Record<FlagName, string | undefined>> = {}
beforeEach(() => {
resetFlagsCache()
for (const key of flagKeys) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key]
}
delete process.env[key]
}
delete (globalThis as Record<string, unknown>).__UNITED_TATTOO_RUNTIME_FLAGS__
})
afterEach(() => {
resetFlagsCache()
for (const key of flagKeys) {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete (globalThis as Record<string, unknown>).__UNITED_TATTOO_RUNTIME_FLAGS__
})
describe("parseBool", () => {
it("handles string coercion and defaults", () => {
expect(parseBool("true", false)).toBe(true)
expect(parseBool(" FALSE ", true)).toBe(false)
expect(parseBool("1", false)).toBe(true)
expect(parseBool(undefined, true)).toBe(true)
})
})
describe("getFlags", () => {
it("falls back to defaults and logs missing keys", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const snapshot = getFlags({ refresh: true })
expect(snapshot).toMatchObject(FLAG_DEFAULTS)
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it("honours environment overrides", () => {
process.env.BOOKING_ENABLED = "false"
process.env.PUBLIC_APPOINTMENT_REQUESTS_ENABLED = "true"
const snapshot = getFlags({ refresh: true })
expect(snapshot.BOOKING_ENABLED).toBe(false)
expect(snapshot.PUBLIC_APPOINTMENT_REQUESTS_ENABLED).toBe(true)
})
})
describe("registerRuntimeFlags", () => {
it("allows runtime overrides to take precedence", () => {
process.env.BOOKING_ENABLED = "true"
const override = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS
registerRuntimeFlags(override)
const snapshot = getFlags()
expect(snapshot.BOOKING_ENABLED).toBe(false)
})
})
describe("Flags proxy", () => {
it("reflects current snapshot values", () => {
process.env.ADMIN_ENABLED = "false"
const snapshot = getFlags({ refresh: true })
expect(snapshot.ADMIN_ENABLED).toBe(false)
expect(Flags.ADMIN_ENABLED).toBe(false)
})
})

View File

@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { createArtistSchema, createAppointmentSchema } from '@/lib/validations'
describe('Validation Schemas', () => {
describe('createArtistSchema', () => {
it('should validate a valid artist object', () => {
const validArtist = {
name: 'John Doe',
bio: 'Experienced tattoo artist',
specialties: ['Traditional', 'Realism'],
instagramHandle: 'johndoe',
hourlyRate: 150,
isActive: true,
}
const result = createArtistSchema.safeParse(validArtist)
expect(result.success).toBe(true)
})
it('should reject artist with invalid data', () => {
const invalidArtist = {
name: '', // Empty name should fail
bio: 'Bio',
specialties: [],
hourlyRate: -50, // Negative rate should fail
}
const result = createArtistSchema.safeParse(invalidArtist)
expect(result.success).toBe(false)
})
it('should require name field', () => {
const artistWithoutName = {
bio: 'Bio',
specialties: ['Traditional'],
hourlyRate: 150,
}
const result = createArtistSchema.safeParse(artistWithoutName)
expect(result.success).toBe(false)
})
})
describe('createAppointmentSchema', () => {
it('should validate a valid appointment object', () => {
const validAppointment = {
clientName: 'Jane Smith',
clientEmail: 'jane@example.com',
clientPhone: '+1234567890',
artistId: 'artist-123',
startTime: new Date('2024-12-01T10:00:00Z'),
endTime: new Date('2024-12-01T12:00:00Z'),
description: 'Traditional rose tattoo',
estimatedPrice: 300,
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(validAppointment)
expect(result.success).toBe(true)
})
it('should reject appointment with invalid email', () => {
const invalidAppointment = {
clientName: 'Jane Smith',
clientEmail: 'invalid-email', // Invalid email format
artistId: 'artist-123',
startTime: new Date('2024-12-01T10:00:00Z'),
endTime: new Date('2024-12-01T12:00:00Z'),
description: 'Tattoo description',
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(invalidAppointment)
expect(result.success).toBe(false)
})
it('should reject appointment with end time before start time', () => {
const invalidAppointment = {
clientName: 'Jane Smith',
clientEmail: 'jane@example.com',
artistId: 'artist-123',
startTime: new Date('2024-12-01T12:00:00Z'),
endTime: new Date('2024-12-01T10:00:00Z'), // End before start
description: 'Tattoo description',
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(invalidAppointment)
expect(result.success).toBe(false)
})
})
})

144
app_root_backup/globals.css Normal file
View File

@ -0,0 +1,144 @@
@import "tailwindcss";
@import "tw-animate-css";
@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);
--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);
--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);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.15 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.68 0.19 45);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.68 0.19 45);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: #ff8c00 #000000;
}
body {
@apply bg-background text-foreground;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #000000;
}
*::-webkit-scrollbar-thumb {
background: #ff8c00;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #ff9d1a;
}
}

8342
cloudflare-env.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

22
components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

46
docs/bootstrapping.md Normal file
View File

@ -0,0 +1,46 @@
# Bootstrapping Checklist
This checklist walks you from cloning the template to having a runnable project with confident defaults. Keep it open while you initialise a new repo.
## 1. Template hygiene
- [ ] Create a fresh repository (local or Gitea) and copy the template into it.
- [ ] Run `./scripts/bootstrap-template.sh` to update the package name, git remotes, and README badges.
- [ ] Remove example images or assets you do not plan to ship (`public/`, `docs/img/`, etc.).
- [ ] Delete unused test suites so the CI noise floor stays low.
## 2. Runtime setup
- [ ] Review `.env.example` and duplicate it to `.env` for local development.
- [ ] Fill only the sections that match the integrations you intend to use (auth, storage, calendar, analytics).
- [ ] Create secrets in your chosen manager (doppler, sops, 1Password CLI, environment repository) and document where they live.
- [ ] Configure feature flags or toggles that gate optional modules; default to safe fallbacks.
## 3. Dependencies & tooling
- [ ] Decide on a package manager (pnpm, npm, yarn) and lock it in the README + CI.
- [ ] Install linting and formatting tools (`eslint`, `prettier`, `biome`, etc.) and wire them into `package.json` scripts.
- [ ] Add base Git hooks (Husky, Lefthook, or pre-commit) if you rely on pre-push validation.
- [ ] Configure TypeScript paths/aliases so the example tests resolve once you create real modules.
## 4. Infrastructure & services
- [ ] Document deployment tooling (Wrangler, Vercel, Fly.io, Docker) in `docs/stack-decisions.md`.
- [ ] Provision staging and production environments or capture the manual steps.
- [ ] Outline database migration flow (Prisma, Drizzle, Kysely) and how to run it locally.
- [ ] For third-party integrations (OAuth, storage, calendar), confirm rate limits and timeout behaviour.
## 5. CI/CD wiring
- [ ] Choose a pipeline runner (Gitea Actions, Woodpecker, GitHub Actions, etc.).
- [ ] Add jobs for lint, typecheck, unit tests, and build (if applicable).
- [ ] Cache dependencies to keep pipelines fast.
- [ ] Gate deployments on green checks and review status.
## 6. Documentation & knowledge
- [ ] Update `README.md` with product-specific copy, screenshots, and deployment commands.
- [ ] Record architectural decisions in `docs/stack-decisions.md` (lean ADRs are ideal).
- [ ] Extend `docs/edge-cases.md` with anything unique to this project.
- [ ] Share workflow instructions (branching, PR labels, release cadence) in `CONTRIBUTING.md`.
## 7. Launch readiness
- [ ] Smoke-test the bootstrap by running `pnpm test` (or your equivalent) and ensuring the example specs fail because modules are missing—this keeps you honest.
- [ ] Create a tracking issue or project board with tasks generated from the checklist.
- [ ] Archive or export the checklist with completed items for future reference.
Repeat this ritual for every new project so you ship with fewer unknowns and more confidence.

50
docs/edge-cases.md Normal file
View File

@ -0,0 +1,50 @@
# Edge Case Catalogue
Capture every non-happy-path scenario that has bitten you in past projects. Use these prompts when planning features, writing tests, and updating documentation.
## Authentication & Authorization
- What happens when the identity provider is unavailable or rate-limited?
- Can users authenticate with multiple providers? How do you reconcile identities?
- Do access tokens expire mid-session? Plan silent refresh and forced logout flows.
- Are admin-only routes guarded on the server, not just the client?
- How do you roll keys or secrets without booting everyone?
## Feature Flags & Configuration
- Can new features be disabled quickly without redeploying?
- Are default values safe when the config service is unreachable?
- What is logged when a flag evaluation fails?
## Data & Persistence
- Are migrations idempotent? Can you roll them back?
- Do background jobs tolerate partial failure or duplicate delivery?
- What size assumptions exist for JSON payloads, binary blobs, or text fields?
- How do you seed development data without leaking production secrets?
## Scheduling & Calendars
- Do you store timestamps in UTC and render them with the user's offset?
- How do you handle daylight saving transitions and leap seconds?
- Can overlapping events be created? If not, validate and surface clear errors.
- What is the source of truth when multiple calendars sync into one timeline?
## File & Asset Management
- Maximum file size? Enforce both client and server-side.
- Are uploads scanned, transcoded, or resized? Where is the queue?
- How do you serve private files? Signed URLs, download proxies, expiring tokens?
- What is the retention policy and deletion workflow?
## External Services
- Plan for timeouts, retries, and rate limits on each integration.
- If a vendor returns partial data, does your UI still render something helpful?
- Document SLAs and fallbacks in `docs/stack-decisions.md`.
## Observability & Recovery
- Which metrics, logs, and traces are mandatory before launch?
- Do alerts route to a real person with enough context to act?
- After an incident, what automated reports or scripts help recreate the scenario?
## Compliance & Privacy
- How do you handle data export, erasure, and consent?
- What environments carry production data? Are they encrypted at rest?
- Which audit logs must be preserved, and where?
When a new surprise occurs, write the story here, then open a PR to harden the template so the next project benefits immediately.

32
docs/stack-decisions.md Normal file
View File

@ -0,0 +1,32 @@
# Stack Decisions Log
Use this document to capture the "why" behind platform and tooling choices. Treat each entry as a lightweight ADR (Architecture Decision Record).
## Template baseline
- **Framework**: Next.js + TypeScript (edge-friendly, hybrid rendering, great DX)
- **Testing**: Vitest (fast unit/integration runner with React ecosystem support)
- **Styling**: Tailwind CSS or CSS Modules (choose one per project)
- **Deployment**: Cloudflare Pages + Workers (immutable deploys, global edge)
- **Database**: PostgreSQL (Supabase/Neon friendly), accessed via ORM of choice
- **Storage**: S3-compatible buckets (AWS S3, Cloudflare R2)
## Recording a decision
1. Title — short phrase (`Adopt Drizzle ORM`, `Switch CI to Woodpecker`)
2. Context — what problem are you solving? Mention constraints, stakeholders, and trade-offs.
3. Decision — what did you pick and why?
4. Status — proposed, accepted, deprecated, superseded.
5. Consequences — positive and negative effects, migrations required, follow-up work.
## Example entry
```
## Adopt Drizzle ORM
Status: Accepted (2024-02-12)
Context: Need a type-safe query builder that works in serverless environments without generating heavyweight clients.
Decision: Replace Prisma with Drizzle ORM because it offers SQL-first migrations, small runtime footprint, and better edge compatibility.
Consequences:
- Rewrite existing Prisma migrations → Drizzle SQL migrations.
- Update CI to run `drizzle-kit push` instead of `prisma migrate deploy`.
- Developers need to learn the new query builder API.
```
Keep this log close to the code. When you revisit a project months later, these notes will save hours of rediscovery.

View File

@ -0,0 +1,41 @@
# Testing Blueprints
The template ships with full Vitest suites under `__tests__/`. They currently reference the `@/` module alias from a Next.js project. Replace those imports with your actual modules as you build the app. Use this guide to adapt the patterns.
## API routes
- File: `__tests__/api/*`
- Focus: HTTP status codes, query parsing, pagination, error handling.
- Mocks: database adapters, third-party SDKs, feature flags.
- Tips: assert on both response body and the parameters passed into mocked dependencies.
## Components
- File: `__tests__/components/*`
- Focus: accessibility, copy, conditional rendering, navigation flows.
- Mocks: Next.js router, contexts, external hooks.
- Tips: render minimal UI tree, interact with `@testing-library/react` utilities, assert on semantics not implementation.
## Hooks
- File: `__tests__/hooks/*`
- Focus: lifecycle, browser APIs (scroll, resize), async behaviour.
- Mocks: `window`, `document`, timers, intersection observers.
- Tips: wrap `act` around updates, reset mocks between tests, include teardown coverage.
## Flags & configuration
- File: `__tests__/flags/*`
- Focus: toggling features on/off, server-side overrides, fallbacks.
- Mocks: flag evaluation client, configuration store.
- Tips: include “flag service offline” scenarios to enforce safe defaults.
## Libraries
- File: `__tests__/lib/*`
- Focus: data migration guards, validation, persistence abstractions.
- Mocks: filesystem, database clients, clock.
- Tips: write table-driven tests so new edge cases are easy to add.
### Making them yours
1. Rename folders to match real domains (`users`, `billing`, `cms`).
2. Swap module imports from `@/lib/...` to wherever your implementation lives.
3. Keep the error-handling tests, even if you simplify the happy path—they are cheap insurance.
4. Run `pnpm test` (or your equivalent) often; treat failures as documentation gaps.
These suites double as onboarding material. New contributors can read the tests to understand intent before diving into production code.

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc/dist/eslintrc.cjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

2599913
next Normal file

File diff suppressed because it is too large Load Diff

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
next-steps.md Normal file
View File

@ -0,0 +1,21 @@
Here are the new features that should be added/changes that should be made.
Firstly, this web application was originally made for a friend who was less code-savvy than myself
and it allowed him to stay updated on our development project as it was ongoing (without having
to bother me). Now, however, I've realized that I am in need of a good chat interface for my n8n
powered agents. and I think this would make a good starting point.
The biggest changes are two things:
1. The application needs to support connecting to multiple agents in different chats.
2. I would like to deploy the application to a cloudflare worker, at the domain `agents.nicholai.work`.
The flow of the UI will be different, starting with what users are greeted with when they first visit the page.
When a user visits the site, they should be greeted by a menu of agents (their names & descriptions), they will have the option to select an agent,
and upon selection, the menu will close and their chat may begin.
I would like to be able to add new agents at any time, by adding environment variables in the cloudflare UI.
The chat needs to support rich text, which it mostly already does. And users should be able to pass images
to the agents. (if the agent is compatible).

11
next.config.ts Normal file
View File

@ -0,0 +1,11 @@
import type { NextConfig } from "next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
// Initialize OpenNext for Cloudflare in development
initOpenNextCloudflareForDev();
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

9
open-next.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
// Uncomment to enable R2 cache,
// It should be imported as:
// `import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";`
// See https://opennext.js.org/cloudflare/caching for more details
// incrementalCache: r2IncrementalCache,
});

32
opennext-deploy-debug.log Normal file
View File

@ -0,0 +1,32 @@
ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ
Γöé OpenNext ΓÇö Cloudflare deploy Γöé
ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ
WARN OpenNext is not fully compatible with Windows.
WARN For optimal performance, it is recommended to use Windows Subsystem for Linux (WSL).
WARN While OpenNext may function on Windows, it could encounter unpredictable failures during runtime.
opennextjs-cloudflare deploy
Deploy a built OpenNext app to Cloudflare Workers
Options:
--help Show help [boolean]
--version Show version number [boolean]
-c, --config Path to Wrangler configuration file [string]
--configPath Path to Wrangler configuration file[deprecated] [string]
-e, --env Wrangler environment to use for operations [string]
--cacheChunkSize Number of entries per chunk when populating the cache
[number]
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'
at throwIfUnsupportedURLScheme (node:internal/modules/esm/load:184:11)
at defaultLoad (node:internal/modules/esm/load:82:3)
at ModuleLoader.load (node:internal/modules/esm/loader:803:12)
at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:582:43)
at #createModuleJob (node:internal/modules/esm/loader:606:36)
at #getJobFromResolveResult (node:internal/modules/esm/loader:340:34)
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:308:41)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:646:25) {
code: 'ERR_UNSUPPORTED_ESM_URL_SCHEME'
}

19626
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

82
package.json Normal file
View File

@ -0,0 +1,82 @@
{
"name": "my-v0-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@opennextjs/cloudflare": "^1.12.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"diff": "^8.0.2",
"embla-carousel-react": "8.5.1",
"framer-motion": "^12.23.24",
"geist": "^1.3.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "15.5.4",
"next-themes": "^0.4.6",
"react": "19.1.0",
"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",
"vaul": "^0.9.9",
"zod": "3.25.67"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9.39.1",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5",
"wrangler": "^4.42.0"
}
}

9468
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

3
public/_headers Normal file
View File

@ -0,0 +1,3 @@
# https://developers.cloudflare.com/workers/static-assets/headers
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

90
scripts/bootstrap-template.sh Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Bootstrap a fresh project from this template.
# - Updates README heading with the new project name.
# - Creates an initial commit if none exists.
# - Optionally rewires git remotes.
# - Touches package.json if you want to set the name field (only when present).
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
echo "🚀 Template bootstrap"
read -r -p "Project name (e.g. \"Atlas Console\"): " project_name
if [[ -z "${project_name}" ]]; then
echo "Project name cannot be empty. Aborting."
exit 1
fi
default_slug="$(echo "${project_name}" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')"
read -r -p "Project slug [${default_slug}]: " project_slug
project_slug="${project_slug:-$default_slug}"
# Update README heading if it still contains the template title.
readme="${repo_root}/README.md"
if grep -q "^Development Project Template" "${readme}"; then
echo "Updating README title..."
tmp_readme="$(mktemp)"
{
echo "${project_name}"
echo "${project_name//?/=}"
tail -n +3 "${readme}"
} > "${tmp_readme}"
mv "${tmp_readme}" "${readme}"
else
echo "README already customised; skipping title update."
fi
# Update package.json name if present.
package_json="${repo_root}/package.json"
if [[ -f "${package_json}" ]]; then
if command -v jq >/dev/null 2>&1; then
echo "Updating package.json name → ${project_slug}"
tmp_package="$(mktemp)"
jq --arg name "${project_slug}" '.name = $name' "${package_json}" > "${tmp_package}"
mv "${tmp_package}" "${package_json}"
else
echo "jq is not installed; skipping package.json update. Install jq and rerun if needed."
fi
else
echo "No package.json found; skipping package rename."
fi
# Offer to update git origin remote.
if git -C "${repo_root}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
current_remote="$(git -C "${repo_root}" remote get-url origin 2>/dev/null || true)"
echo "Current git remote: ${current_remote:-<none>}"
read -r -p "Update git remote? (y/N): " change_remote
if [[ "${change_remote,,}" == "y" ]]; then
read -r -p "New remote URL: " remote_url
if git -C "${repo_root}" remote | grep -q "^origin$"; then
git -C "${repo_root}" remote set-url origin "${remote_url}"
else
git -C "${repo_root}" remote add origin "${remote_url}"
fi
echo "Origin remote updated."
else
echo "Skipping remote update."
fi
fi
# Stamp a .project-name file so scripts/tools can read the canonical name.
echo "${project_name}" > "${repo_root}/.project-name"
cat <<EOT
✅ Bootstrap complete!
- README updated (if the template title was untouched).
- package.json name set to ${project_slug} (if package.json exists).
- Project name written to .project-name.
Next steps:
1. Review docs/bootstrapping.md and keep working through the checklist.
2. Remove or adapt example tests under __tests__/.
3. Replace template copy and assets with your project's branding.
Happy building!
EOT

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 },
)
}
}

196
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,196 @@
import { type NextRequest, NextResponse } from "next/server"
import type { ChatRequest, ChatResponse } from "@/lib/types"
/**
* 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
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): Promise<NextResponse<ChatResponse>> {
try {
const body = await request.json()
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
}
const { message, timestamp, sessionId, agentId, images } = body as ChatRequest
// Validate required fields
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
if (!agentId || typeof agentId !== "string") {
return NextResponse.json({ error: "Agent ID is required" }, { status: 400 })
}
// 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 },
)
}
console.log("[chat] Sending to webhook:", { agentId, message, timestamp, sessionId })
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
timestamp,
sessionId,
agentId,
images: images && images.length > 0 ? images : undefined,
}),
})
console.log("[v0] Webhook response status:", response.status)
const responseText = await response.text()
console.log("[v0] Webhook response body (first 200 chars):", responseText.substring(0, 200))
if (!response.ok) {
// Try to parse as JSON if possible, otherwise use text
let errorData
try {
errorData = responseText ? JSON.parse(responseText) : {}
} catch {
errorData = { message: responseText || "Unknown error" }
}
console.error("[v0] Webhook error:", errorData)
return NextResponse.json(
{
error: errorData.message || "Failed to communicate with webhook",
hint: errorData.hint,
code: errorData.code,
},
{ status: response.status },
)
}
if (!responseText) {
console.log("[v0] Empty response from webhook")
return NextResponse.json({
response:
"The webhook received your message but didn't return a response. Please ensure your n8n workflow includes a 'Respond to Webhook' node that returns data.",
hint: "Add a 'Respond to Webhook' node in your n8n workflow to send responses back to the chat.",
})
}
try {
// Split response by newlines to get individual JSON objects
const lines = responseText.trim().split("\n")
const chunks: string[] = []
for (const line of lines) {
if (!line.trim()) continue
try {
const chunk = JSON.parse(line)
// Extract content from "item" type chunks
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)
}
}
// Combine all chunks into a single message
if (chunks.length > 0) {
const fullMessage = chunks.join("")
console.log("[v0] Combined message from", chunks.length, "chunks")
return NextResponse.json({ response: fullMessage })
}
// If no chunks found, try parsing as regular JSON
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
// If the response is an object, try to extract from nested fields
if (typeof responseMessage === "object") {
responseMessage =
responseMessage.response || responseMessage.message || responseMessage.output || responseMessage.text
}
// If still no message found, stringify the entire response
if (!responseMessage) {
responseMessage = JSON.stringify(data)
}
return NextResponse.json({ response: responseMessage })
} catch {
console.log("[v0] Response is not JSON, returning as text")
// If not JSON, return the text as the response
return NextResponse.json({ response: responseText })
}
} catch (error) {
console.error("[v0] API route error:", error)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

971
src/app/globals.css Normal file
View File

@ -0,0 +1,971 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--burnt-orange: #e67e50;
--terracotta: #d87850;
--sage-concrete: #7a8b8b;
--charcoal-ink: #2d2d2d;
--sandstone: #f3e8d1;
--ink-veil: #fdf9f4;
--background: #f6f2eb;
--foreground: var(--charcoal-ink);
--card: var(--sage-concrete);
--card-foreground: var(--charcoal-ink);
--popover: #fdfaf6;
--popover-foreground: var(--charcoal-ink);
--primary: var(--burnt-orange);
--primary-foreground: #1b110a;
--secondary: var(--terracotta);
--secondary-foreground: #2a140c;
--muted: #cbd2d2;
--muted-foreground: #394646;
--accent: var(--sandstone);
--accent-foreground: var(--charcoal-ink);
--destructive: #b3473b;
--destructive-foreground: #fff5f2;
--border: #5f6c6b;
--input: #7b8c8c;
--ring: var(--terracotta);
--chart-1: var(--burnt-orange);
--chart-2: var(--terracotta);
--chart-3: #f1c6a2;
--chart-4: #8c9898;
--chart-5: var(--charcoal-ink);
--radius: 0.75rem;
--sidebar: #f8f3ec;
--sidebar-foreground: var(--charcoal-ink);
--sidebar-primary: var(--burnt-orange);
--sidebar-primary-foreground: #1f140c;
--sidebar-accent: #d8c7b1;
--sidebar-accent-foreground: var(--charcoal-ink);
--sidebar-border: #d4dad8;
--sidebar-ring: var(--terracotta);
--panel-tint: transparent;
--swatch-color: transparent;
}
.dark {
--background: #111111;
--foreground: #f6ede0;
--card: #202425;
--card-foreground: #f6ede0;
--popover: #161919;
--popover-foreground: #f6ede0;
--primary: var(--burnt-orange);
--primary-foreground: #140b06;
--secondary: color-mix(in srgb, var(--terracotta) 85%, #1a130f);
--secondary-foreground: #f6ede0;
--muted: #1f2626;
--muted-foreground: #c4cdcd;
--accent: #2c3233;
--accent-foreground: #f6ede0;
--destructive: #ff8f7f;
--destructive-foreground: #2d0400;
--border: #3d4444;
--input: #394040;
--ring: var(--burnt-orange);
--chart-1: #ffb285;
--chart-2: #f18d62;
--chart-3: #fbd4b6;
--chart-4: #4a5354;
--chart-5: #f6ede0;
--sidebar: #141717;
--sidebar-foreground: #f6ede0;
--sidebar-primary: #ff9a6c;
--sidebar-primary-foreground: #1d0903;
--sidebar-accent: #2c3233;
--sidebar-accent-foreground: #f6ede0;
--sidebar-border: #272c2c;
--sidebar-ring: var(--terracotta);
}
@theme inline {
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
}
body {
@apply text-foreground;
font-family: var(--font-body), "Space Grotesk", system-ui, sans-serif;
background-color: var(--background);
background-image:
radial-gradient(circle at 20% 20%, rgba(230, 126, 80, 0.25), transparent 55%),
radial-gradient(circle at 80% 0%, rgba(216, 120, 80, 0.18), transparent 45%),
linear-gradient(135deg, rgba(243, 232, 209, 0.8), transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(122, 139, 139, 0.28) 65%, rgba(122, 139, 139, 0.45));
min-height: 100vh;
letter-spacing: 0.01em;
}
h1,
h2,
h3,
h4,
.font-heading {
font-family: var(--font-heading), "Playfair Display", "Times New Roman", serif;
font-feature-settings: "liga", "clig";
letter-spacing: -0.01em;
color: var(--charcoal-ink);
}
small,
.eyebrow {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: color-mix(in srgb, var(--charcoal-ink) 70%, #fff 30%);
}
/* Light mode scrollbar - hidden by default, show on hover */
* {
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.3s ease;
}
*:hover {
scrollbar-color: var(--burnt-orange) #dcdede;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
transition: background 0.3s ease;
}
*:hover::-webkit-scrollbar-track {
background: #dcdede;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
transition: background 0.3s ease;
}
*:hover::-webkit-scrollbar-thumb {
background: var(--burnt-orange);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #f29b6f;
}
/* Dark mode scrollbar - hidden by default, show on hover */
.dark * {
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.3s ease;
}
.dark *:hover {
scrollbar-color: var(--burnt-orange) #000000;
}
.dark *::-webkit-scrollbar-track {
background: transparent;
transition: background 0.3s ease;
}
.dark *:hover::-webkit-scrollbar-track {
background: #000000;
}
.dark *::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
transition: background 0.3s ease;
}
.dark *:hover::-webkit-scrollbar-thumb {
background: var(--burnt-orange);
border-radius: 4px;
}
/* Hidden scrollbar for chat input textarea */
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
}
@keyframes fade-slide {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@layer components {
.gallery-shell {
position: relative;
min-height: 100vh;
background: none;
isolation: isolate;
}
.mobile-shell {
transition: background 300ms ease;
}
@media (max-width: 639px) {
body {
overflow: hidden;
overscroll-behavior: none;
}
.gallery-shell {
min-height: 100dvh;
height: 100dvh;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-inline: clamp(0.75rem, 4vw, 1.5rem);
overflow: hidden;
}
@supports (height: 100svh) {
.gallery-shell {
min-height: 100svh;
height: 100svh;
}
}
@supports (height: 100dvh) {
.gallery-shell {
min-height: 100dvh;
height: 100dvh;
}
}
.gallery-shell::after {
opacity: 0.6;
}
.mobile-shell {
background: none;
}
.chat-panel {
border-radius: 1.75rem;
background: linear-gradient(180deg, #f6ede0, #f3e8d1 100%);
border: 1px solid rgba(243, 232, 209, 0.6);
box-shadow: 0 25px 55px rgba(45, 45, 45, 0.15);
padding: clamp(1.25rem, 4vw, 1.75rem) clamp(1rem, 4vw, 1.5rem);
backdrop-filter: blur(18px);
overflow: hidden !important;
}
.mobile-feed {
border-radius: 1.4rem;
background: transparent;
padding-inline: 0;
height: 100%;
overflow-y: auto;
}
.message-frame {
gap: 0.75rem;
}
.message-bubble {
border-radius: 1.25rem;
font-size: 0.95rem;
line-height: 1.5;
}
.message-bubble.user {
background: rgba(200, 200, 200, 0.75);
border: 1px solid rgba(180, 180, 180, 0.4);
color: rgba(45, 45, 45, 0.95);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.message-bubble.assistant {
background: transparent;
border: none;
color: rgba(45, 45, 45, 0.9);
box-shadow: none;
}
.mobile-hero-card {
display: flex;
align-items: center;
gap: 1rem;
border-radius: 1.25rem;
padding: 1rem 1.25rem;
background: transparent;
border: none;
box-shadow: none;
}
.mobile-hero-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
border: 1px solid rgba(45, 45, 45, 0.12);
background: rgba(255, 255, 255, 0.4);
}
.mobile-hero-icon span {
display: block;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: var(--burnt-orange);
box-shadow: 0 0 28px rgba(230, 126, 80, 0.4);
}
.mobile-hero-heading {
margin-top: 0.35rem;
font-family: var(--font-heading), "Playfair Display", serif;
font-size: 1.5rem;
color: rgba(45, 45, 45, 0.85);
letter-spacing: -0.01em;
}
.mobile-hero-label {
font-size: 0.62rem;
letter-spacing: 0.4em;
text-transform: uppercase;
color: rgba(45, 45, 45, 0.6);
}
.mobile-agent-stack {
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
}
.mobile-agent-chip {
width: 100%;
border-radius: 1.1rem;
padding: 0.95rem 1.25rem;
border: 1px solid rgba(45, 45, 45, 0.1);
background: rgba(255, 255, 255, 0.5);
text-align: left;
font-size: 0.72rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: rgba(45, 45, 45, 0.8);
transition: transform 200ms ease, border-color 200ms ease, background 200ms ease;
}
.mobile-agent-chip.is-active {
border-color: rgba(45, 45, 45, 0.2);
background: rgba(255, 255, 255, 0.75);
color: rgba(45, 45, 45, 0.95);
box-shadow: 0 8px 20px rgba(45, 45, 45, 0.12);
transform: translateY(-2px);
}
.chat-panel button[title="Start a fresh conversation"] {
border-radius: 0.75rem;
border: 1px solid rgba(180, 180, 180, 0.4);
background: rgba(200, 200, 200, 0.75);
color: rgba(45, 45, 45, 0.9);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.chat-panel button[title="Start a fresh conversation"]:hover {
background: rgba(210, 210, 210, 0.85);
border-color: rgba(190, 190, 190, 0.5);
}
.chat-panel .text-white\/60,
.chat-panel .text-white\/80,
.chat-panel .text-white\/70 {
color: rgba(45, 45, 45, 0.6) !important;
}
.chat-panel .scroll-reveal {
background: rgba(255, 255, 255, 0.6) !important;
border-color: rgba(45, 45, 45, 0.15) !important;
color: rgba(45, 45, 45, 0.9) !important;
}
.chat-panel .scroll-reveal:hover {
background: rgba(255, 255, 255, 0.75) !important;
border-color: rgba(45, 45, 45, 0.25) !important;
}
.chat-panel .text-charcoal {
color: rgba(45, 45, 45, 0.9) !important;
}
.chat-panel .text-muted-foreground {
color: rgba(45, 45, 45, 0.7) !important;
}
.chat-panel .border-white\/10 {
border-color: rgba(45, 45, 45, 0.15) !important;
}
.chat-panel .bg-white\/10 {
background: rgba(255, 255, 255, 0.4) !important;
}
.chat-panel .bg-white\/40 {
background: rgba(255, 255, 255, 0.5) !important;
}
.chat-panel .text-white\/70 {
color: rgba(45, 45, 45, 0.7) !important;
}
.chat-panel .text-destructive {
color: rgba(200, 50, 50, 0.9) !important;
}
.composer-affix {
position: relative;
width: 100%;
padding-bottom: max(env(safe-area-inset-bottom), 0.65rem);
background: transparent;
}
.composer-panel {
border-radius: 0;
background: transparent;
border: none;
box-shadow: none;
}
.dark .composer-panel {
background: transparent;
border: none;
}
.mobile-composer {
border-radius: 1.5rem;
border: none;
background: rgba(60, 60, 60, 0.95);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
}
.mobile-composer .text-foreground,
.mobile-composer .text-muted-foreground,
.mobile-composer input,
.mobile-composer textarea,
.mobile-composer button {
color: rgba(255, 255, 255, 0.95) !important;
}
.mobile-composer textarea::placeholder {
color: rgba(255, 255, 255, 0.6) !important;
}
.mobile-composer .text-white,
.mobile-composer .text-white\/70,
.mobile-composer .text-white\/80 {
color: rgba(255, 255, 255, 0.95) !important;
}
.mobile-composer .composer-send-button {
background: var(--burnt-orange) !important;
border-color: rgba(230, 126, 80, 0.8) !important;
color: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 4px 12px rgba(230, 126, 80, 0.4) !important;
}
.mobile-composer .composer-send-button:hover:not(:disabled) {
background: color-mix(in srgb, var(--burnt-orange) 90%, white) !important;
border-color: var(--burnt-orange) !important;
}
.mobile-composer .composer-action-button {
background: rgba(180, 180, 180, 0.4) !important;
border-color: rgba(160, 160, 160, 0.5) !important;
color: rgba(255, 255, 255, 0.95) !important;
}
.mobile-composer .composer-action-button:hover {
background: rgba(200, 200, 200, 0.5) !important;
border-color: rgba(180, 180, 180, 0.6) !important;
}
.mobile-composer .text-white\/90 {
color: rgba(255, 255, 255, 0.95) !important;
}
.mobile-composer [role="menuitem"]:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
.mobile-composer [role="menuitem"]:hover * {
color: rgba(255, 255, 255, 1) !important;
}
.dark .mobile-composer {
border-color: rgba(80, 80, 80, 0.5);
background: rgba(60, 60, 60, 0.95);
box-shadow: 0 20px 35px rgba(0, 0, 0, 0.4);
}
.composer-form {
display: flex;
justify-content: center;
width: 100%;
}
.composer-form .composer-panel {
width: min(100%, 640px);
padding: 1.25rem 1.35rem 1.4rem;
}
.composer-images {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.composer-image-thumb {
position: relative;
width: 3.4rem;
height: 3.4rem;
border-radius: 0.9rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
}
.composer-image-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.composer-image-remove {
position: absolute;
top: -0.4rem;
right: -0.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
transition: opacity 160ms ease;
}
.composer-image-remove:hover {
opacity: 0.8;
}
.composer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.composer-dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid rgba(45, 45, 45, 0.15);
background: rgba(255, 255, 255, 0.85);
padding: 0.65rem 1rem;
font-size: 0.55rem;
letter-spacing: 0.3em;
text-transform: uppercase;
backdrop-filter: blur(6px);
transition: background 180ms ease, border-color 180ms ease;
color: var(--charcoal-ink);
}
.composer-dropdown-trigger:hover {
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.15);
}
.composer-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.composer-action-button {
width: 2.75rem !important;
height: 2.75rem !important;
border-radius: 0.95rem;
border: 1px solid rgba(45, 45, 45, 0.12);
background: rgba(255, 255, 255, 0.85);
color: var(--charcoal-ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
transition: background 180ms ease, transform 180ms ease;
}
.composer-action-button:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-1px);
}
.composer-send-button {
width: 3rem !important;
height: 3rem !important;
border-radius: 1rem;
border: none;
background: linear-gradient(135deg, var(--burnt-orange), color-mix(in srgb, var(--burnt-orange) 70%, #ffffff));
color: #1f0d06;
box-shadow: 0 12px 25px rgba(230, 126, 80, 0.28);
transition: transform 160ms ease, box-shadow 160ms ease;
}
.composer-send-button:disabled {
opacity: 0.6;
box-shadow: none;
}
.composer-send-button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 16px 25px rgba(230, 126, 80, 0.45);
}
}
.gallery-shell::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at 15% 5%, rgba(230, 126, 80, 0.18), transparent 45%),
radial-gradient(circle at 85% 20%, rgba(216, 120, 80, 0.17), transparent 50%),
linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
opacity: 0.85;
pointer-events: none;
z-index: -1;
}
.palette-shell {
position: relative;
padding: 2.5rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0.32)),
var(--card);
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 20%, transparent);
box-shadow: 0 25px 45px rgba(45, 45, 45, 0.25);
overflow: hidden;
}
.palette-shell::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at 20% 0%, var(--panel-tint, transparent), transparent 55%);
opacity: 0.85;
transition: background 300ms ease, opacity 300ms ease;
pointer-events: none;
}
.swatch-card {
position: relative;
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 15%, transparent);
background: color-mix(in srgb, var(--sandstone) 40%, white 60%);
backdrop-filter: blur(12px);
transition: border-color 220ms ease, transform 220ms ease;
isolation: isolate;
}
.swatch-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 70%);
opacity: 0.4;
transition: opacity 220ms ease;
}
.swatch-card::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--swatch-color, var(--burnt-orange)) 18%, transparent);
opacity: 0;
transition: opacity 220ms ease;
}
.swatch-card:hover,
.swatch-card:focus-visible {
transform: translateY(-4px);
border-color: color-mix(in srgb, var(--swatch-color, var(--burnt-orange)) 45%, transparent);
}
.swatch-card:hover::after,
.swatch-card:focus-visible::after {
opacity: 1;
}
.palette-row {
display: flex;
justify-content: center;
gap: 0.6rem;
}
.palette-chip {
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: transform 200ms ease, box-shadow 200ms ease;
}
.palette-chip:hover,
.palette-chip:focus-visible {
transform: translateY(-3px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.25);
}
.message-frame {
animation: fade-slide 260ms ease-out both;
}
.message-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.08em;
}
.message-avatar.assistant {
background: linear-gradient(135deg, rgba(230, 126, 80, 0.9), rgba(45, 45, 45, 0.9));
color: #fff;
}
.message-avatar.user {
background: linear-gradient(135deg, rgba(216, 120, 80, 0.9), rgba(255, 205, 166, 0.85));
color: var(--charcoal-ink);
}
.message-bubble {
border-radius: 1.5rem;
padding: 1.1rem 1.4rem;
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 12%, transparent);
background: rgba(255, 255, 255, 0.56);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
color: var(--charcoal-ink);
}
.message-bubble.user {
padding: 0.85rem 1.1rem;
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 2px 6px rgba(45, 45, 45, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.25), inset 0 8px 14px rgba(255, 255, 255, 0.2);
color: var(--charcoal-ink);
}
.message-bubble.assistant {
background: color-mix(in srgb, var(--sage-concrete) 28%, #ffffff);
color: var(--charcoal-ink);
}
.dark .message-bubble {
background: rgba(12, 12, 12, 0.7);
border-color: rgba(255, 255, 255, 0.15);
color: var(--foreground);
}
.dark .message-bubble.user {
padding: 0.85rem 1.1rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(255, 255, 255, 0.04), inset 0 8px 14px rgba(255, 255, 255, 0.035);
color: rgba(255, 255, 255, 0.92);
}
.dark .message-bubble.assistant {
background: color-mix(in srgb, var(--sage-concrete) 45%, rgba(8, 8, 8, 0.7));
color: var(--foreground);
}
@keyframes agent-picker-breathe {
0% {
background-position: 0% 50%;
box-shadow: 0 8px 18px rgba(230, 126, 80, 0.18);
opacity: 0.85;
}
50% {
background-position: 100% 50%;
box-shadow: 0 16px 28px rgba(216, 120, 80, 0.35);
opacity: 1;
}
100% {
background-position: 0% 50%;
box-shadow: 0 8px 18px rgba(230, 126, 80, 0.18);
opacity: 0.85;
}
}
.agent-picker-prompt {
border-color: rgba(255, 255, 255, 0.65) !important;
color: #fff !important;
background-image: linear-gradient(130deg, rgba(230, 126, 80, 0.85), rgba(122, 139, 139, 0.75));
background-size: 180% 180%;
animation: agent-picker-breathe 3.8s ease-in-out infinite;
}
.manuscript-panel {
border-radius: 1.75rem;
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 15%, transparent);
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 30px 70px rgba(45, 45, 45, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.35), inset 0 12px 25px rgba(255, 255, 255, 0.15);
backdrop-filter: blur(18px);
}
.dark .manuscript-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(9, 9, 9, 0.4);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 10px 20px rgba(255, 255, 255, 0.04);
color: var(--foreground);
}
}
@layer utilities {
.text-charcoal {
color: var(--charcoal-ink);
}
.bg-burnt {
background-color: var(--burnt-orange);
}
.text-burnt {
color: var(--burnt-orange);
}
.bg-terracotta {
background-color: var(--terracotta);
}
.bg-sage {
background-color: var(--sage-concrete);
}
.border-burnt {
border-color: var(--burnt-orange);
}
.scroll-reveal {
opacity: 0;
transform: translateY(18px);
transition: transform 260ms ease-out, opacity 260ms ease-out;
}
.scroll-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.pt-safe {
padding-top: env(safe-area-inset-top);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-safe {
bottom: env(safe-area-inset-bottom);
}
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal,
.message-frame {
animation: none;
transition: none;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
.markdown-glass pre {
background: rgba(255, 255, 255, 0.35);
border: 1px solid rgba(255, 255, 255, 0.5);
color: var(--charcoal-ink);
}
.dark .markdown-glass pre {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.2);
color: var(--foreground);
}
.markdown-glass .hljs {
background: transparent !important;
color: inherit;
}

60
src/app/layout.tsx Normal file
View File

@ -0,0 +1,60 @@
import type React from "react"
import type { Metadata } from "next"
import { GeistMono } from "geist/font/mono"
import { Playfair_Display, Space_Grotesk } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import { Suspense } from "react"
import "./globals.css"
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-heading",
display: "swap",
})
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
})
export const metadata: Metadata = {
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",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<head>
<link rel="canonical" href="https://inspiration-repo-agent.com" />
</head>
<body className={`${spaceGrotesk.variable} ${playfair.variable} ${GeistMono.variable} font-sans antialiased`}>
<Suspense fallback={null}>{children}</Suspense>
<Analytics />
</body>
</html>
)
}

133
src/app/page.tsx Normal file
View File

@ -0,0 +1,133 @@
"use client"
import { useState, useEffect } from "react"
import { motion } from "framer-motion"
import { ChatInterface } from "@/components/chat-interface"
import type { Agent } from "@/lib/types"
export default function Home() {
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [agents, setAgents] = useState<Agent[]>([])
const [agentsError, setAgentsError] = useState<string | null>(null)
const [isAgentsLoading, setIsAgentsLoading] = 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)
}
}
setIsLoading(false)
}, [])
useEffect(() => {
const fetchAgents = async () => {
try {
setIsAgentsLoading(true)
setAgentsError(null)
const response = await fetch("/api/agents")
const data = (await response.json()) as { agents?: unknown; error?: string }
if (!response.ok || !data.agents) {
throw new Error(data.error || "Failed to load agents")
}
setAgents(data.agents as typeof agents)
} catch (err) {
setAgents([])
setAgentsError(err instanceof Error ? err.message : "Failed to load agents")
} finally {
setIsAgentsLoading(false)
}
}
fetchAgents()
}, [])
useEffect(() => {
if (!selectedAgent || agents.length === 0) return
const match = agents.find((agent) => agent.id === selectedAgent.id)
if (!match) {
setSelectedAgent(null)
localStorage.removeItem("selected-agent")
localStorage.removeItem("selected-agent-id")
return
}
if (
match.name !== selectedAgent.name ||
match.description !== selectedAgent.description
) {
setSelectedAgent(match)
localStorage.setItem("selected-agent-id", match.id)
localStorage.setItem("selected-agent", JSON.stringify(match))
}
}, [agents, selectedAgent])
const handleAgentSelected = (agent: Agent) => {
setSelectedAgent(agent)
localStorage.setItem("selected-agent-id", agent.id)
localStorage.setItem("selected-agent", JSON.stringify(agent))
}
if (isLoading) {
return null // Avoid hydration mismatch
}
// If no agent is selected but we have agents loaded, select the first one
// This ensures we always show the ChatInterface with its beautiful selection UI
const activeAgent = selectedAgent || (agents.length > 0 ? agents[0] : null)
if (!activeAgent) {
return (
<motion.div
className="gallery-shell h-screen"
initial={{ opacity: 0, y: 25 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
>
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
{agentsError ? (
<p className="text-xs text-destructive">{agentsError}</p>
) : (
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
Loading agents...
</p>
)}
</div>
</motion.div>
)
}
return (
<motion.div
className="gallery-shell mobile-shell h-screen"
initial={{ opacity: 0, y: 25 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
>
<div className="flex h-full flex-col">
<main className="flex-1 overflow-hidden px-3 py-4 sm:px-6 sm:py-6">
<div className="mx-auto flex h-full max-w-5xl justify-center">
<div className="h-full w-full">
<ChatInterface
agent={activeAgent}
agents={agents}
onAgentSelected={handleAgentSelected}
isAgentsLoading={isAgentsLoading}
/>
</div>
</div>
</main>
</div>
</motion.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

@ -0,0 +1,166 @@
"use client"
import { useEffect, useState, type CSSProperties } from "react"
import { Loader2, Sparkles } from "lucide-react"
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()) as { agents?: unknown; error?: string }
if (!response.ok || !data.agents) {
throw new Error(data.error || "Failed to load agents")
}
const agentsList = data.agents as typeof agents
setAgents(agentsList)
if (agentsList.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])
useEffect(() => {
if (!open) return
const cards = document.querySelectorAll<HTMLElement>("[data-reveal='agent-card']")
if (!cards.length) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible")
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.25 }
)
cards.forEach((card) => observer.observe(card))
return () => observer.disconnect()
}, [agents, 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 border-none bg-transparent p-0 shadow-none">
<div
className="palette-shell"
style={
{
"--panel-tint": "rgba(230, 126, 80, 0.25)",
} as CSSProperties
}
>
<DialogHeader>
<div className="mb-4 flex justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-border/40 bg-white/40 text-burnt shadow-md">
<Sparkles className="h-6 w-6 text-burnt" />
</div>
</div>
<DialogTitle className="text-center text-3xl font-heading">
Select Your Correspondent
</DialogTitle>
<DialogDescription className="text-center text-base leading-relaxed text-muted-foreground">
Each agent keeps a different archive. Take a breath, read their placard, then choose the voice you trust.
</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-burnt" />
<p className="mt-2 text-sm text-muted-foreground">Cataloguing configured agents</p>
</div>
</div>
) : error ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/15 p-4 text-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : (
<div className="grid gap-4 py-6 md:grid-cols-2">
{agents.map((agent, index) => (
<button
key={agent.id}
onClick={() => handleSelectAgent(agent)}
className="swatch-card scroll-reveal group p-5 text-left focus-visible:ring-2 focus-visible:ring-ring"
data-reveal="agent-card"
style={
{
animationDelay: `${index * 50}ms`,
"--swatch-color": index % 2 === 0 ? "var(--burnt-orange)" : "var(--terracotta)",
} as CSSProperties
}
>
<div className="flex items-start justify-between">
<div>
<span className="eyebrow text-[0.65rem] text-muted-foreground">Agent</span>
<h3 className="mt-2 font-heading text-xl text-charcoal">{agent.name}</h3>
</div>
<div className="message-avatar assistant !h-9 !w-9 rounded-full text-[0.6rem] uppercase tracking-[0.25em]">
{agent.name.slice(0, 2)}
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{agent.description}
</p>
<p className="mt-4 text-[0.65rem] uppercase tracking-[0.2em] text-muted-foreground">
Press enter to begin
</p>
</button>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,612 @@
"use client"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Button } from "@/components/ui/button"
import { Send, Loader2, SquarePen, Paperclip, Copy, X, ChevronDown } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { MarkdownRenderer } from "./markdown-renderer"
import type { Message, Agent } from "@/lib/types"
import { cn } from "@/lib/utils"
interface ChatInterfaceProps {
agent: Agent
agents: Agent[]
onAgentSelected: (agent: Agent) => void
isAgentsLoading: boolean
}
export function ChatInterface({
agent,
agents,
onAgentSelected,
isAgentsLoading,
}: ChatInterfaceProps) {
const heroGreeting = "hello, user"
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [sessionId, setSessionId] = useState<string>("")
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [composerAgentId, setComposerAgentId] = useState<string | null>(null)
const [textareaHeight, setTextareaHeight] = useState<number>(32)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
useEffect(() => {
// Use agent-specific session ID: chat-session-{agentId}
const sessionKey = `chat-session-${agent.id}`
let existingSessionId = localStorage.getItem(sessionKey)
if (!existingSessionId) {
// Generate new sessionID using timestamp and random string
existingSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
localStorage.setItem(sessionKey, 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(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
}
}, [messages, isLoading])
// Update textarea height based on content
useEffect(() => {
if (inputRef.current) {
const element = inputRef.current
element.style.height = "auto"
const newHeight = Math.min(element.scrollHeight, 224)
setTextareaHeight(newHeight)
}
}, [input])
// Save messages to localStorage whenever they change
useEffect(() => {
const messagesKey = `chat-messages-${agent.id}`
localStorage.setItem(messagesKey, JSON.stringify(messages))
}, [messages, agent.id])
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = "auto"
inputRef.current.style.height = Math.min(inputRef.current.scrollHeight, 160) + "px"
}
}, [input])
useEffect(() => {
if (messages.length > 0 && composerAgentId !== agent.id) {
setComposerAgentId(agent.id)
}
}, [messages.length, 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) => {
if (e) {
e.preventDefault()
}
if (!input.trim() || isLoading) return
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
images: selectedImages.length > 0 ? selectedImages : undefined,
}
setMessages((prev) => [...prev, userMessage])
setInput("")
setSelectedImages([])
setIsLoading(true)
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: userMessage.content,
timestamp: userMessage.timestamp.toISOString(),
sessionId: sessionId,
agentId: agent.id,
images: selectedImages.length > 0 ? selectedImages : undefined,
}),
})
const data = (await response.json()) as {
error?: string
hint?: string
response?: string
message?: string
}
if (!response.ok) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.error || "Failed to communicate with the webhook.",
timestamp: new Date(),
isError: true,
hint: data.hint,
}
setMessages((prev) => [...prev, errorMessage])
} else {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: data.response || data.message || JSON.stringify(data),
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
}
} catch (error) {
console.error("[v0] Error sending message:", error)
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Sorry, I encountered an error processing your message. Please try again.",
timestamp: new Date(),
isError: true,
}
setMessages((prev) => [...prev, errorMessage])
} finally {
setIsLoading(false)
inputRef.current?.focus()
}
}
const startNewChat = () => {
// Clear all messages
setMessages([])
// Generate new sessionID for this agent
const newSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
setSessionId(newSessionId)
const sessionKey = `chat-session-${agent.id}`
localStorage.setItem(sessionKey, newSessionId)
// Clear input and images
setInput("")
setSelectedImages([])
setComposerAgentId(null)
// Focus input
inputRef.current?.focus()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
const handleCopyMessage = async (id: string, content: string) => {
try {
await navigator.clipboard.writeText(content)
setCopiedMessageId(id)
setTimeout(() => {
setCopiedMessageId((current) => (current === id ? null : current))
}, 1200)
} catch (error) {
console.error("[chat] Failed to copy message", error)
}
}
const handleComposerAgentSelect = (entry: Agent) => {
setComposerAgentId(entry.id)
onAgentSelected(entry)
}
const canSwitchAgents = agents.length > 0 && !isAgentsLoading
const hasMessages = messages.length > 0
const dropdownSelectedId = composerAgentId ?? (hasMessages ? agent.id : null)
const dropdownAgentEntry = dropdownSelectedId
? agents.find((entry) => entry.id === dropdownSelectedId) ?? agent
: null
const dropdownLabel = dropdownAgentEntry ? dropdownAgentEntry.name : "Select a correspondent"
const highlightAgentDropdown = !dropdownSelectedId && !hasMessages
return (
<motion.div
initial={{ opacity: 0, y: 35 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, ease: "easeOut" }}
className="chat-panel relative flex h-full w-full flex-col overflow-hidden rounded-[2.5rem] bg-gradient-to-b from-white/0 via-white/15 to-white/45 px-4 py-8 shadow-[0_15px_35px_rgba(45,45,45,0.1),0_0_0_1px_rgba(255,255,255,0.25)_inset,0_15px_25px_rgba(255,255,255,0.12)_inset] backdrop-blur-xl dark:bg-gradient-to-b dark:from-transparent dark:via-white/5 dark:to-white/20 dark:shadow-[0_12px_25px_rgba(0,0,0,0.35),0_0_0_1px_rgba(255,255,255,0.06)_inset,0_12px_20px_rgba(255,255,255,0.04)_inset] sm:px-8 sm:py-10"
>
<div className="mb-4 flex justify-end">
{messages.length > 0 && (
<Button
onClick={startNewChat}
variant="ghost"
size="icon"
className="group h-11 w-11 rounded-2xl border border-white/25 bg-white/15 text-white shadow-[0_2px_6px_rgba(0,0,0,0.12)] backdrop-blur transition hover:bg-white/25"
title="Start a fresh conversation"
>
<SquarePen className="h-4 w-4" />
</Button>
)}
</div>
<div
ref={messagesContainerRef}
className={cn(
"mobile-feed px-1 pt-4 sm:px-0",
hasMessages ? "flex-1 overflow-y-auto pb-10" : "pb-6"
)}
>
<div className="mx-auto max-w-[52rem] space-y-10 px-2 sm:px-4">
<AnimatePresence mode="wait">
{hasMessages ? (
<motion.div
key="conversation"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="space-y-10"
>
{messages.map((message) => {
const isUser = message.role === "user"
return (
<motion.div
key={message.id}
layout
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className={cn("message-frame flex flex-col gap-3", isUser ? "items-end text-right" : "")}
>
{isUser ? (
<div className="message-bubble user">
<MarkdownRenderer content={message.content} tone="bubble" />
</div>
) : message.isError ? (
<div className="text-sm font-medium text-destructive">
<p className="whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
</div>
) : (
<div className="relative text-sm text-charcoal dark:text-foreground">
<MarkdownRenderer content={message.content} />
<div className="mt-4 flex items-center justify-end gap-3 border-t border-white/10 pt-3 opacity-50 transition hover:opacity-100">
<button
type="button"
onClick={() => handleCopyMessage(message.id, message.content)}
className={`inline-flex h-7 w-7 items-center justify-center rounded border border-white/20 bg-white/8 text-white/70 shadow-[0_2px_5px_rgba(0,0,0,0.07)] backdrop-blur transition-transform duration-150 hover:bg-white/18 ${
copiedMessageId === message.id ? "scale-90 bg-white/20 text-white" : ""
}`}
aria-label="Copy response"
>
<Copy className="h-2.5 w-2.5" />
</button>
</div>
</div>
)}
{message.hint && (
<div className="rounded-lg border border-accent/60 bg-accent/40 px-3 py-2 text-xs text-charcoal">
{message.hint}
</div>
)}
</motion.div>
)
})}
{isLoading && (
<div className="message-frame flex flex-col gap-3">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="uppercase tracking-[0.25em] text-white/70">Correspondent</span>
<span className="relative flex h-3 w-24 overflow-hidden rounded-full bg-white/10">
<span className="absolute inset-y-0 w-1/2 animate-[shimmer_1.4s_infinite] bg-white/40"></span>
</span>
</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="empty-state"
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.65, ease: "easeOut" }}
className="flex min-h-[40vh] flex-col items-center justify-center gap-6 text-center"
>
<div className="text-center">
<h1 className="font-heading text-[3.5rem] lowercase tracking-tight text-white/85 drop-shadow-[0_12px_30px_rgba(0,0,0,0.4)] sm:text-[7rem]">
{heroGreeting.split("").map((char, index) => (
<motion.span
key={`${char}-${index}`}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + index * 0.05, duration: 0.35, ease: "easeOut" }}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</h1>
</div>
<div className="w-full max-w-3xl space-y-4">
<p className="text-sm uppercase tracking-[0.35em] text-white/80">
Select a correspondent to begin
</p>
{agents.length > 0 ? (
<div className="flex flex-wrap items-center justify-center gap-3">
{agents.map((entry) => {
const isActive = dropdownSelectedId === entry.id
return (
<button
key={entry.id}
onClick={() => handleComposerAgentSelect(entry)}
className={cn(
"rounded-full border px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition",
isActive
? "border-white/25 bg-white/25 text-white shadow-[0_5px_20px_rgba(0,0,0,0.35)]"
: "border-white/10 bg-white/5 text-white/70 hover:border-white/30 hover:text-white"
)}
>
{entry.name}
</button>
)
})}
</div>
) : (
<p className="text-sm text-white/60">No agents available yet.</p>
)}
</div>
<div className="grid w-full max-w-2xl gap-4 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={prompt}
onClick={() => setInput(prompt)}
className="scroll-reveal rounded-2xl border border-border/30 bg-white/80 p-4 text-left text-sm text-charcoal shadow-sm transition hover:border-ring/60 hover:bg-white"
style={{ animationDelay: `${index * 50}ms` }}
>
{prompt}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<motion.div
layout
className="composer-affix relative mt-auto pt-6 pb-4 transition-all duration-500"
animate={{ y: hasMessages ? 0 : -140, scale: hasMessages ? 1 : 1.05 }}
transition={{ type: "spring", stiffness: 160, damping: 24 }}
>
<form onSubmit={sendMessage} className="composer-form relative flex justify-center">
{/* Image preview section */}
{selectedImages.length > 0 && (
<div className="composer-images mb-3 flex flex-wrap gap-3 px-3 pt-2">
{selectedImages.map((image, index) => (
<div key={index} className="composer-image-thumb relative">
<img
src={image}
alt={`Selected ${index}`}
className="h-16 w-16 rounded-lg border border-border/40 object-cover shadow-md"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full text-white shadow-md hover:opacity-80"
style={{ backgroundColor: "var(--charcoal-ink)" }}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div
className={cn(
"manuscript-panel composer-panel w-[85%] max-w-2xl p-5",
"max-sm:mobile-composer max-sm:w-full max-sm:p-4"
)}
>
<div className="flex flex-col gap-4">
<motion.textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write a note, share a hunch, or paste a brief…"
disabled={isLoading}
rows={1}
className="hide-scrollbar w-full resize-none border-0 bg-transparent text-lg text-foreground placeholder:text-muted-foreground/80 focus:outline-none"
animate={{
height: textareaHeight
}}
transition={{
height: {
type: "spring",
stiffness: 600,
damping: 35,
mass: 0.5,
}
}}
style={{
overflowY: "auto",
minHeight: "32px",
maxHeight: "224px",
}}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"composer-dropdown-trigger inline-flex max-w-[12rem] items-center gap-2 rounded-2xl border border-white/20 bg-white/30 px-3 py-2 text-left text-[0.55rem] uppercase tracking-[0.3em] shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 hover:text-white disabled:opacity-50",
highlightAgentDropdown ? "agent-picker-prompt text-white" : "text-white"
)}
disabled={!canSwitchAgents}
>
<span className="truncate text-xs font-heading normal-case tracking-normal text-white">
{dropdownLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 text-white/70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[12rem] rounded-2xl border border-white/15 bg-white/10 p-2 text-white shadow-[0_20px_40px_rgba(0,0,0,0.3)] backdrop-blur"
>
{isAgentsLoading ? (
<DropdownMenuItem disabled className="text-white/50">
Gathering correspondents
</DropdownMenuItem>
) : agents.length === 0 ? (
<DropdownMenuItem disabled className="text-white/50">
No agents configured
</DropdownMenuItem>
) : (
agents.map((entry) => {
const isActive = dropdownSelectedId === entry.id
return (
<DropdownMenuItem
key={entry.id}
onClick={() => handleComposerAgentSelect(entry)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs transition ${
isActive
? "bg-white/15 text-white"
: "text-white/90 hover:bg-white/5 hover:text-white"
}`}
>
<span className="font-heading text-sm">{entry.name}</span>
{isActive && <span className="text-[0.55rem] uppercase tracking-[0.3em]">Active</span>}
</DropdownMenuItem>
)
})
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center justify-end gap-3 text-xs uppercase tracking-[0.25em] text-muted-foreground">
<Button
type="submit"
disabled={!input.trim() || isLoading}
size="icon"
className="composer-send-button group h-12 w-12 flex-shrink-0 rounded-2xl border border-white/20 bg-white/30 text-white shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*"
onChange={handleImageSelect}
className="hidden"
disabled={isLoading}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="composer-action-button h-11 w-11 rounded-2xl border border-white/20 bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white"
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</form>
</motion.div>
</motion.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
}
}
}

81
src/components/header.tsx Normal file
View File

@ -0,0 +1,81 @@
"use client"
import { ChevronDown } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { Agent } from "@/lib/types"
interface HeaderProps {
agent: Agent | null
agents: Agent[]
onAgentSelected: (agent: Agent) => void
isLoadingAgents: boolean
}
export function Header({ agent, agents, onAgentSelected, isLoadingAgents }: HeaderProps) {
const canSelect = agents.length > 0 && !isLoadingAgents
const showDropdown = !agent
return (
<header className="sticky top-0 z-40 mx-auto w-full max-w-6xl px-4 pt-4 sm:px-6">
<div className="mx-auto flex max-w-5xl flex-col gap-3 text-white">
<div className="space-y-1">
<p className="text-[0.6rem] uppercase tracking-[0.5em] text-white/45">
{agent ? "Current correspondent" : "Select a correspondent"}
</p>
<h1 className="font-heading text-3xl text-white">
{agent ? agent.name : "Inspiration Repo"}
</h1>
</div>
{showDropdown && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex w-full items-center justify-between rounded-2xl border border-white/20 bg-white/10 px-4 py-3 text-left text-[0.65rem] uppercase tracking-[0.35em] text-white/80 shadow-[0_12px_25px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:bg-white/20 disabled:opacity-45"
disabled={!canSelect}
>
<span className="flex flex-col text-sm normal-case tracking-normal text-white">
Select correspondent
</span>
<ChevronDown className="h-4 w-4 text-white/80" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[260px] rounded-3xl border border-white/20 bg-black/75 p-2 text-white shadow-[0_20px_45px_rgba(0,0,0,0.45)] backdrop-blur"
>
{isLoadingAgents ? (
<DropdownMenuItem disabled className="text-white/50">
Gathering correspondents
</DropdownMenuItem>
) : agents.length === 0 ? (
<DropdownMenuItem disabled className="text-white/50">
No agents configured
</DropdownMenuItem>
) : (
agents.map((entry) => (
<DropdownMenuItem
key={entry.id}
onClick={() => onAgentSelected(entry)}
className="flex w-full flex-col items-start gap-1 rounded-2xl border border-transparent px-4 py-3 text-left text-xs text-white/70 transition hover:border-white/15 hover:bg-white/5 hover:text-white"
>
<span className="font-heading text-base">{entry.name}</span>
<span className="text-[0.6rem] uppercase tracking-[0.35em] text-white/60">
{entry.description}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
)
}

View File

@ -0,0 +1,246 @@
"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"
import { useState, isValidElement, type ReactNode } from "react"
import { cn } from "@/lib/utils"
import { Copy } from "lucide-react"
interface MarkdownRendererProps {
content: string
className?: string
tone?: "default" | "bubble"
}
// 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 = "", tone = "default" }: 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}__`)
})
const baseTone = tone === "bubble"
? "text-charcoal dark:text-white"
: "text-charcoal dark:text-foreground"
const mutedTone = tone === "bubble"
? "text-charcoal/80 dark:text-white/80"
: "text-charcoal/80 dark:text-foreground/75"
return (
<div className={cn("markdown-glass space-y-3 text-sm leading-relaxed", baseTone, 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={cn("mb-2 text-sm leading-relaxed last:mb-0", baseTone)}>
{children}
</p>
)
},
// Custom styling for different elements
h1: ({ children }) => (
<h1 className={cn("text-[2rem] font-semibold tracking-tight", baseTone)}>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className={cn("text-[1.75rem] font-semibold tracking-tight", baseTone)}>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className={cn("text-[1.5rem] font-semibold", baseTone)}>
{children}
</h3>
),
ul: ({ children }) => (
<ul className={cn("mb-2 list-disc space-y-1 pl-4 text-sm", mutedTone)}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className={cn("mb-2 list-decimal space-y-1 pl-4 text-sm", mutedTone)}>
{children}
</ol>
),
li: ({ children }) => (
<li className={cn("text-sm", mutedTone)}>
{children}
</li>
),
code: ({ children, className }) => {
// Check if this is inline code (no language class) or block code
const isInline = !className
if (isInline) {
return (
<code className="rounded bg-white/60 px-1.5 py-0.5 font-mono text-xs text-charcoal dark:bg-white/10 dark:text-foreground">
{children}
</code>
)
}
return (
<code className={className}>
{children}
</code>
)
},
pre: ({ children, className }) => (
<PreWithCopy className={className}>{children}</PreWithCopy>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-burnt/70 pl-4 text-sm italic text-muted-foreground dark:text-foreground/80">
{children}
</blockquote>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-burnt/40 decoration-2 underline-offset-4 text-burnt hover:text-terracotta dark:text-white dark:hover:text-burnt"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-charcoal dark:text-white">
{children}
</strong>
),
em: ({ children }) => (
<em className={cn("italic", mutedTone)}>
{children}
</em>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-3">
<table className="min-w-full rounded-lg border border-border/50">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-white/70 text-charcoal dark:bg-white/10 dark:text-foreground">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="bg-white/40 text-charcoal dark:bg-white/5 dark:text-foreground">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="border-b border-border/40">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-sm font-semibold text-charcoal dark:text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-sm text-charcoal dark:text-foreground">
{children}
</td>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}
function PreWithCopy({ children, className }: { children?: ReactNode; className?: string }) {
const [copied, setCopied] = useState(false)
const text = extractCodeText(children)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text.trimEnd())
setCopied(true)
setTimeout(() => setCopied(false), 1200)
} catch (error) {
console.error("[markdown] Code copy failed", error)
}
}
return (
<div className="relative mb-3">
<pre className={cn("overflow-x-auto rounded-xl border border-border/50 p-4 text-sm text-charcoal shadow-sm dark:border-white/10 dark:text-foreground", className)}>
{children}
</pre>
<button
type="button"
onClick={handleCopy}
className={cn(
"absolute right-3 top-3 inline-flex h-5 w-5 items-center justify-center rounded border border-white/25 bg-white/8 text-white/70 shadow-[0_2px_4px_rgba(0,0,0,0.06)] backdrop-blur transition-transform duration-150 hover:bg-white/18",
copied && "scale-90 bg-white/30 text-white"
)}
aria-label="Copy code"
>
<Copy className="h-2.5 w-2.5" />
</button>
</div>
)
}
function extractCodeText(node: ReactNode): string {
if (typeof node === "string") {
return node
}
if (Array.isArray(node)) {
return node.map(extractCodeText).join("")
}
if (isValidElement(node)) {
return extractCodeText(node.props.children)
}
return ""
}

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,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

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/30 backdrop-blur-sm transition-opacity duration-300 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 rounded-2xl border border-border/60 bg-background/95 p-6 shadow-2xl backdrop-blur-xl 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%]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/40 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent/40 data-[state=open]:text-foreground">
<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-foreground", 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-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

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,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

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
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

1
test/mock-n8n-server/node_modules/.bin/esbuild generated vendored Symbolic link
View File

@ -0,0 +1 @@
../esbuild/bin/esbuild

1
test/mock-n8n-server/node_modules/.bin/mime generated vendored Symbolic link
View File

@ -0,0 +1 @@
../mime/cli.js

1
test/mock-n8n-server/node_modules/.bin/tsx generated vendored Symbolic link
View File

@ -0,0 +1 @@
../tsx/dist/cli.mjs

1084
test/mock-n8n-server/node_modules/.package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
# esbuild
This is the Linux 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.

Binary file not shown.

View File

@ -0,0 +1,20 @@
{
"name": "@esbuild/linux-x64",
"version": "0.25.12",
"description": "The Linux 64-bit binary for esbuild, a JavaScript bundler.",
"repository": {
"type": "git",
"url": "git+https://github.com/evanw/esbuild.git"
},
"license": "MIT",
"preferUnplugged": true,
"engines": {
"node": ">=18"
},
"os": [
"linux"
],
"cpu": [
"x64"
]
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@ -0,0 +1,15 @@
# Installation
> `npm install --save @types/body-parser`
# Summary
This package contains type definitions for body-parser (https://github.com/expressjs/body-parser).
# Details
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/body-parser.
### Additional Details
* Last updated: Sat, 07 Jun 2025 02:15:25 GMT
* Dependencies: [@types/connect](https://npmjs.com/package/@types/connect), [@types/node](https://npmjs.com/package/@types/node)
# Credits
These definitions were written by [Santi Albo](https://github.com/santialbo), [Vilic Vane](https://github.com/vilic), [Jonathan Häberle](https://github.com/dreampulse), [Gevik Babakhani](https://github.com/blendsdk), [Tomasz Łaziuk](https://github.com/tlaziuk), [Jason Walton](https://github.com/jwalton), [Piotr Błażejewicz](https://github.com/peterblazejewicz), and [Sebastian Beltran](https://github.com/bjohansebas).

View File

@ -0,0 +1,95 @@
/// <reference types="node" />
import { NextHandleFunction } from "connect";
import * as http from "http";
// for docs go to https://github.com/expressjs/body-parser/tree/1.19.0#body-parser
declare namespace bodyParser {
interface BodyParser {
/**
* @deprecated use individual json/urlencoded middlewares
*/
(options?: OptionsJson & OptionsText & OptionsUrlencoded): NextHandleFunction;
/**
* Returns middleware that only parses json and only looks at requests
* where the Content-Type header matches the type option.
*/
json(options?: OptionsJson): NextHandleFunction;
/**
* Returns middleware that parses all bodies as a Buffer and only looks at requests
* where the Content-Type header matches the type option.
*/
raw(options?: Options): NextHandleFunction;
/**
* Returns middleware that parses all bodies as a string and only looks at requests
* where the Content-Type header matches the type option.
*/
text(options?: OptionsText): NextHandleFunction;
/**
* Returns middleware that only parses urlencoded bodies and only looks at requests
* where the Content-Type header matches the type option
*/
urlencoded(options?: OptionsUrlencoded): NextHandleFunction;
}
interface Options {
/** When set to true, then deflated (compressed) bodies will be inflated; when false, deflated bodies are rejected. Defaults to true. */
inflate?: boolean | undefined;
/**
* Controls the maximum request body size. If this is a number,
* then the value specifies the number of bytes; if it is a string,
* the value is passed to the bytes library for parsing. Defaults to '100kb'.
*/
limit?: number | string | undefined;
/**
* The type option is used to determine what media type the middleware will parse
*/
type?: string | string[] | ((req: http.IncomingMessage) => any) | undefined;
/**
* The verify option, if supplied, is called as verify(req, res, buf, encoding),
* where buf is a Buffer of the raw request body and encoding is the encoding of the request.
*/
verify?(req: http.IncomingMessage, res: http.ServerResponse, buf: Buffer, encoding: string): void;
}
interface OptionsJson extends Options {
/**
* The reviver option is passed directly to JSON.parse as the second argument.
*/
reviver?(key: string, value: any): any;
/**
* When set to `true`, will only accept arrays and objects;
* when `false` will accept anything JSON.parse accepts. Defaults to `true`.
*/
strict?: boolean | undefined;
}
interface OptionsText extends Options {
/**
* Specify the default character set for the text content if the charset
* is not specified in the Content-Type header of the request.
* Defaults to `utf-8`.
*/
defaultCharset?: string | undefined;
}
interface OptionsUrlencoded extends Options {
/**
* The extended option allows to choose between parsing the URL-encoded data
* with the querystring library (when `false`) or the qs library (when `true`).
*/
extended?: boolean | undefined;
/**
* The parameterLimit option controls the maximum number of parameters
* that are allowed in the URL-encoded data. If a request contains more parameters than this value,
* a 413 will be returned to the client. Defaults to 1000.
*/
parameterLimit?: number | undefined;
}
}
declare const bodyParser: bodyParser.BodyParser;
export = bodyParser;

View File

@ -0,0 +1,64 @@
{
"name": "@types/body-parser",
"version": "1.19.6",
"description": "TypeScript definitions for body-parser",
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/body-parser",
"license": "MIT",
"contributors": [
{
"name": "Santi Albo",
"githubUsername": "santialbo",
"url": "https://github.com/santialbo"
},
{
"name": "Vilic Vane",
"githubUsername": "vilic",
"url": "https://github.com/vilic"
},
{
"name": "Jonathan Häberle",
"githubUsername": "dreampulse",
"url": "https://github.com/dreampulse"
},
{
"name": "Gevik Babakhani",
"githubUsername": "blendsdk",
"url": "https://github.com/blendsdk"
},
{
"name": "Tomasz Łaziuk",
"githubUsername": "tlaziuk",
"url": "https://github.com/tlaziuk"
},
{
"name": "Jason Walton",
"githubUsername": "jwalton",
"url": "https://github.com/jwalton"
},
{
"name": "Piotr Błażejewicz",
"githubUsername": "peterblazejewicz",
"url": "https://github.com/peterblazejewicz"
},
{
"name": "Sebastian Beltran",
"githubUsername": "bjohansebas",
"url": "https://github.com/bjohansebas"
}
],
"main": "",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
"directory": "types/body-parser"
},
"scripts": {},
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
},
"peerDependencies": {},
"typesPublisherContentHash": "d788c843f427d6ca19640ee90eb433324a18f23aed05402a82c4e47e6d60b29d",
"typeScriptVersion": "5.1"
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@ -0,0 +1,15 @@
# Installation
> `npm install --save @types/connect`
# Summary
This package contains type definitions for connect (https://github.com/senchalabs/connect).
# Details
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/connect.
### Additional Details
* Last updated: Mon, 06 Nov 2023 22:41:05 GMT
* Dependencies: [@types/node](https://npmjs.com/package/@types/node)
# Credits
These definitions were written by [Maxime LUCE](https://github.com/SomaticIT), and [Evan Hahn](https://github.com/EvanHahn).

View File

@ -0,0 +1,91 @@
/// <reference types="node" />
import * as http from "http";
/**
* Create a new connect server.
*/
declare function createServer(): createServer.Server;
declare namespace createServer {
export type ServerHandle = HandleFunction | http.Server;
export class IncomingMessage extends http.IncomingMessage {
originalUrl?: http.IncomingMessage["url"] | undefined;
}
type NextFunction = (err?: any) => void;
export type SimpleHandleFunction = (req: IncomingMessage, res: http.ServerResponse) => void;
export type NextHandleFunction = (req: IncomingMessage, res: http.ServerResponse, next: NextFunction) => void;
export type ErrorHandleFunction = (
err: any,
req: IncomingMessage,
res: http.ServerResponse,
next: NextFunction,
) => void;
export type HandleFunction = SimpleHandleFunction | NextHandleFunction | ErrorHandleFunction;
export interface ServerStackItem {
route: string;
handle: ServerHandle;
}
export interface Server extends NodeJS.EventEmitter {
(req: http.IncomingMessage, res: http.ServerResponse, next?: Function): void;
route: string;
stack: ServerStackItem[];
/**
* Utilize the given middleware `handle` to the given `route`,
* defaulting to _/_. This "route" is the mount-point for the
* middleware, when given a value other than _/_ the middleware
* is only effective when that segment is present in the request's
* pathname.
*
* For example if we were to mount a function at _/admin_, it would
* be invoked on _/admin_, and _/admin/settings_, however it would
* not be invoked for _/_, or _/posts_.
*/
use(fn: NextHandleFunction): Server;
use(fn: HandleFunction): Server;
use(route: string, fn: NextHandleFunction): Server;
use(route: string, fn: HandleFunction): Server;
/**
* Handle server requests, punting them down
* the middleware stack.
*/
handle(req: http.IncomingMessage, res: http.ServerResponse, next: Function): void;
/**
* Listen for connections.
*
* This method takes the same arguments
* as node's `http.Server#listen()`.
*
* HTTP and HTTPS:
*
* If you run your application both as HTTP
* and HTTPS you may wrap them individually,
* since your Connect "server" is really just
* a JavaScript `Function`.
*
* var connect = require('connect')
* , http = require('http')
* , https = require('https');
*
* var app = connect();
*
* http.createServer(app).listen(80);
* https.createServer(options, app).listen(443);
*/
listen(port: number, hostname?: string, backlog?: number, callback?: Function): http.Server;
listen(port: number, hostname?: string, callback?: Function): http.Server;
listen(path: string, callback?: Function): http.Server;
listen(handle: any, listeningListener?: Function): http.Server;
}
}
export = createServer;

View File

@ -0,0 +1,32 @@
{
"name": "@types/connect",
"version": "3.4.38",
"description": "TypeScript definitions for connect",
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/connect",
"license": "MIT",
"contributors": [
{
"name": "Maxime LUCE",
"githubUsername": "SomaticIT",
"url": "https://github.com/SomaticIT"
},
{
"name": "Evan Hahn",
"githubUsername": "EvanHahn",
"url": "https://github.com/EvanHahn"
}
],
"main": "",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
"directory": "types/connect"
},
"scripts": {},
"dependencies": {
"@types/node": "*"
},
"typesPublisherContentHash": "8990242237504bdec53088b79e314b94bec69286df9de56db31f22de403b4092",
"typeScriptVersion": "4.5"
}

21
test/mock-n8n-server/node_modules/@types/cors/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@ -0,0 +1,75 @@
# Installation
> `npm install --save @types/cors`
# Summary
This package contains type definitions for cors (https://github.com/expressjs/cors/).
# Details
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors.
## [index.d.ts](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors/index.d.ts)
````ts
/// <reference types="node" />
import { IncomingHttpHeaders } from "http";
type StaticOrigin = boolean | string | RegExp | Array<boolean | string | RegExp>;
type CustomOrigin = (
requestOrigin: string | undefined,
callback: (err: Error | null, origin?: StaticOrigin) => void,
) => void;
declare namespace e {
interface CorsRequest {
method?: string | undefined;
headers: IncomingHttpHeaders;
}
interface CorsOptions {
/**
* @default '*'
*/
origin?: StaticOrigin | CustomOrigin | undefined;
/**
* @default 'GET,HEAD,PUT,PATCH,POST,DELETE'
*/
methods?: string | string[] | undefined;
allowedHeaders?: string | string[] | undefined;
exposedHeaders?: string | string[] | undefined;
credentials?: boolean | undefined;
maxAge?: number | undefined;
/**
* @default false
*/
preflightContinue?: boolean | undefined;
/**
* @default 204
*/
optionsSuccessStatus?: number | undefined;
}
type CorsOptionsDelegate<T extends CorsRequest = CorsRequest> = (
req: T,
callback: (err: Error | null, options?: CorsOptions) => void,
) => void;
}
declare function e<T extends e.CorsRequest = e.CorsRequest>(
options?: e.CorsOptions | e.CorsOptionsDelegate<T>,
): (
req: T,
res: {
statusCode?: number | undefined;
setHeader(key: string, value: string): any;
end(): any;
},
next: (err?: any) => any,
) => void;
export = e;
````
### Additional Details
* Last updated: Sat, 07 Jun 2025 02:15:25 GMT
* Dependencies: [@types/node](https://npmjs.com/package/@types/node)
# Credits
These definitions were written by [Alan Plum](https://github.com/pluma), [Gaurav Sharma](https://github.com/gtpan77), and [Sebastian Beltran](https://github.com/bjohansebas).

View File

@ -0,0 +1,56 @@
/// <reference types="node" />
import { IncomingHttpHeaders } from "http";
type StaticOrigin = boolean | string | RegExp | Array<boolean | string | RegExp>;
type CustomOrigin = (
requestOrigin: string | undefined,
callback: (err: Error | null, origin?: StaticOrigin) => void,
) => void;
declare namespace e {
interface CorsRequest {
method?: string | undefined;
headers: IncomingHttpHeaders;
}
interface CorsOptions {
/**
* @default '*'
*/
origin?: StaticOrigin | CustomOrigin | undefined;
/**
* @default 'GET,HEAD,PUT,PATCH,POST,DELETE'
*/
methods?: string | string[] | undefined;
allowedHeaders?: string | string[] | undefined;
exposedHeaders?: string | string[] | undefined;
credentials?: boolean | undefined;
maxAge?: number | undefined;
/**
* @default false
*/
preflightContinue?: boolean | undefined;
/**
* @default 204
*/
optionsSuccessStatus?: number | undefined;
}
type CorsOptionsDelegate<T extends CorsRequest = CorsRequest> = (
req: T,
callback: (err: Error | null, options?: CorsOptions) => void,
) => void;
}
declare function e<T extends e.CorsRequest = e.CorsRequest>(
options?: e.CorsOptions | e.CorsOptionsDelegate<T>,
): (
req: T,
res: {
statusCode?: number | undefined;
setHeader(key: string, value: string): any;
end(): any;
},
next: (err?: any) => any,
) => void;
export = e;

View File

@ -0,0 +1,38 @@
{
"name": "@types/cors",
"version": "2.8.19",
"description": "TypeScript definitions for cors",
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors",
"license": "MIT",
"contributors": [
{
"name": "Alan Plum",
"githubUsername": "pluma",
"url": "https://github.com/pluma"
},
{
"name": "Gaurav Sharma",
"githubUsername": "gtpan77",
"url": "https://github.com/gtpan77"
},
{
"name": "Sebastian Beltran",
"githubUsername": "bjohansebas",
"url": "https://github.com/bjohansebas"
}
],
"main": "",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
"directory": "types/cors"
},
"scripts": {},
"dependencies": {
"@types/node": "*"
},
"peerDependencies": {},
"typesPublisherContentHash": "a090e558c5f443573318c2955deecddc840bd8dfaac7cdedf31c7f6ede8d0b47",
"typeScriptVersion": "5.1"
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@ -0,0 +1,15 @@
# Installation
> `npm install --save @types/express-serve-static-core`
# Summary
This package contains type definitions for express-serve-static-core (http://expressjs.com).
# Details
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express-serve-static-core/v4.
### Additional Details
* Last updated: Mon, 06 Oct 2025 21:02:40 GMT
* Dependencies: [@types/node](https://npmjs.com/package/@types/node), [@types/qs](https://npmjs.com/package/@types/qs), [@types/range-parser](https://npmjs.com/package/@types/range-parser), [@types/send](https://npmjs.com/package/@types/send)
# Credits
These definitions were written by [Boris Yankov](https://github.com/borisyankov), [Satana Charuwichitratana](https://github.com/micksatana), [Jose Luis Leon](https://github.com/JoseLion), [David Stephens](https://github.com/dwrss), and [Shin Ando](https://github.com/andoshin11).

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
{
"name": "@types/express-serve-static-core",
"version": "4.19.7",
"description": "TypeScript definitions for express-serve-static-core",
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express-serve-static-core",
"license": "MIT",
"contributors": [
{
"name": "Boris Yankov",
"githubUsername": "borisyankov",
"url": "https://github.com/borisyankov"
},
{
"name": "Satana Charuwichitratana",
"githubUsername": "micksatana",
"url": "https://github.com/micksatana"
},
{
"name": "Jose Luis Leon",
"githubUsername": "JoseLion",
"url": "https://github.com/JoseLion"
},
{
"name": "David Stephens",
"githubUsername": "dwrss",
"url": "https://github.com/dwrss"
},
{
"name": "Shin Ando",
"githubUsername": "andoshin11",
"url": "https://github.com/andoshin11"
}
],
"main": "",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
"directory": "types/express-serve-static-core"
},
"scripts": {},
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
},
"peerDependencies": {},
"typesPublisherContentHash": "a4797d651510430b6b53a07eb01d86881a113b9ca00290eadb6d46d91e8cedf2",
"typeScriptVersion": "5.2"
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE

View File

@ -0,0 +1,15 @@
# Installation
> `npm install --save @types/express`
# Summary
This package contains type definitions for express (http://expressjs.com).
# Details
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express/v4.
### Additional Details
* Last updated: Mon, 27 Oct 2025 20:34:59 GMT
* Dependencies: [@types/body-parser](https://npmjs.com/package/@types/body-parser), [@types/express-serve-static-core](https://npmjs.com/package/@types/express-serve-static-core), [@types/qs](https://npmjs.com/package/@types/qs), [@types/serve-static](https://npmjs.com/package/@types/serve-static)
# Credits
These definitions were written by [Boris Yankov](https://github.com/borisyankov), [Puneet Arora](https://github.com/puneetar), [Dylan Frankland](https://github.com/dfrankland), and [Sebastian Beltran](https://github.com/bjohansebas).

Some files were not shown because too many files have changed in this diff Show More