mukaddamzaid commited on
Commit
5012205
·
0 Parent(s):

init commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +3 -0
  2. .gitattributes +3 -0
  3. .gitignore +41 -0
  4. README.md +138 -0
  5. ai/providers.ts +59 -0
  6. ai/tools.ts +11 -0
  7. app/actions.ts +66 -0
  8. app/api/chat/route.ts +228 -0
  9. app/api/chats/[id]/route.ts +56 -0
  10. app/api/chats/route.ts +21 -0
  11. app/chat/[id]/page.tsx +53 -0
  12. app/favicon.ico +3 -0
  13. app/globals.css +191 -0
  14. app/layout.tsx +44 -0
  15. app/page.tsx +5 -0
  16. app/providers.tsx +35 -0
  17. components.json +21 -0
  18. components/chat-sidebar.tsx +272 -0
  19. components/chat.tsx +227 -0
  20. components/deploy-button.tsx +27 -0
  21. components/icons.tsx +140 -0
  22. components/input.tsx +64 -0
  23. components/markdown.tsx +166 -0
  24. components/mcp-server-manager.tsx +916 -0
  25. components/message.tsx +190 -0
  26. components/messages.tsx +34 -0
  27. components/model-picker.tsx +217 -0
  28. components/project-overview.tsx +26 -0
  29. components/suggested-prompts.tsx +57 -0
  30. components/textarea.tsx +61 -0
  31. components/theme-provider.tsx +9 -0
  32. components/theme-toggle.tsx +24 -0
  33. components/tool-invocation.tsx +139 -0
  34. components/ui/accordion.tsx +66 -0
  35. components/ui/badge.tsx +46 -0
  36. components/ui/button.tsx +59 -0
  37. components/ui/dialog.tsx +135 -0
  38. components/ui/input.tsx +21 -0
  39. components/ui/label.tsx +24 -0
  40. components/ui/scroll-area.tsx +58 -0
  41. components/ui/select.tsx +181 -0
  42. components/ui/separator.tsx +28 -0
  43. components/ui/sheet.tsx +139 -0
  44. components/ui/sidebar.tsx +726 -0
  45. components/ui/skeleton.tsx +13 -0
  46. components/ui/sonner.tsx +25 -0
  47. components/ui/text-morph.tsx +73 -0
  48. components/ui/textarea.tsx +18 -0
  49. components/ui/tooltip.tsx +61 -0
  50. drizzle.config.ts +14 -0
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ XAI_API_KEY=""
2
+ OPENAI_API_KEY=
3
+ DATABASE_URL="postgresql://username:password@host:port/database"
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.ico filter=lfs diff=lfs merge=lfs -text
3
+ *.svg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env.local
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
README.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <a href="https://ai-sdk-starter-xai.vercel.app">
2
+ <h1 align="center">Vercel x xAI Chatbot</h1>
3
+ </a>
4
+
5
+ <p align="center">
6
+ An open-source AI chatbot app template built with Next.js, the AI SDK by Vercel, and xAI.
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="#features"><strong>Features</strong></a> ·
11
+ <a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
12
+ <a href="#running-locally"><strong>Running Locally</strong></a> ·
13
+ <a href="#mcp-server-configuration"><strong>MCP Configuration</strong></a> ·
14
+ <a href="#authors"><strong>Authors</strong></a>
15
+ </p>
16
+ <br/>
17
+
18
+ ## Features
19
+
20
+ - Streaming text responses powered by the [AI SDK by Vercel](https://sdk.vercel.ai/docs), allowing multiple AI providers to be used interchangeably with just a few lines of code.
21
+ - Built-in tool integration for extending AI capabilities (demonstrated with a weather tool example).
22
+ - Support for [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers to expand available tools.
23
+ - Multiple MCP transport types (SSE and stdio) for connecting to various tool providers.
24
+ - Reasoning model support.
25
+ - [shadcn/ui](https://ui.shadcn.com/) components for a modern, responsive UI powered by [Tailwind CSS](https://tailwindcss.com).
26
+ - Built with the latest [Next.js](https://nextjs.org) App Router.
27
+
28
+ ## Deploy Your Own
29
+
30
+ You can deploy your own version to Vercel by clicking the button below:
31
+
32
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?project-name=Vercel+x+xAI+Chatbot&repository-name=ai-sdk-starter-xai&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-starter-xai&demo-title=Vercel+x+xAI+Chatbot&demo-url=https%3A%2F%2Fai-sdk-starter-xai.labs.vercel.dev%2F&demo-description=A+simple+chatbot+application+built+with+Next.js+that+uses+xAI+via+the+AI+SDK+and+the+Vercel+Marketplace&products=[{%22type%22:%22integration%22,%22protocol%22:%22ai%22,%22productSlug%22:%22grok%22,%22integrationSlug%22:%22xai%22}])
33
+
34
+ ## Running Locally
35
+
36
+ 1. Clone the repository and install dependencies:
37
+
38
+ ```bash
39
+ npm install
40
+ # or
41
+ yarn install
42
+ # or
43
+ pnpm install
44
+ ```
45
+
46
+ 2. Install the [Vercel CLI](https://vercel.com/docs/cli):
47
+
48
+ ```bash
49
+ npm i -g vercel
50
+ # or
51
+ yarn global add vercel
52
+ # or
53
+ pnpm install -g vercel
54
+ ```
55
+
56
+ Once installed, link your local project to your Vercel project:
57
+
58
+ ```bash
59
+ vercel link
60
+ ```
61
+
62
+ After linking, pull your environment variables:
63
+
64
+ ```bash
65
+ vercel env pull
66
+ ```
67
+
68
+ This will create a `.env.local` file with all the necessary environment variables.
69
+
70
+ 3. Run the development server:
71
+
72
+ ```bash
73
+ npm run dev
74
+ # or
75
+ yarn dev
76
+ # or
77
+ pnpm dev
78
+ ```
79
+
80
+ 4. Open [http://localhost:3000](http://localhost:3000) to view your new AI chatbot application.
81
+
82
+ ## MCP Server Configuration
83
+
84
+ This application supports connecting to Model Context Protocol (MCP) servers to access their tools. You can add and manage MCP servers through the settings icon in the chat interface.
85
+
86
+ ### Adding an MCP Server
87
+
88
+ 1. Click the settings icon (⚙️) next to the model selector in the chat interface.
89
+ 2. Enter a name for your MCP server.
90
+ 3. Select the transport type:
91
+ - **SSE (Server-Sent Events)**: For HTTP-based remote servers
92
+ - **stdio (Standard I/O)**: For local servers running on the same machine
93
+
94
+ #### SSE Configuration
95
+
96
+ If you select SSE transport:
97
+ 1. Enter the server URL (e.g., `https://mcp.example.com/token/sse`)
98
+ 2. Click "Add Server"
99
+
100
+ #### stdio Configuration
101
+
102
+ If you select stdio transport:
103
+ 1. Enter the command to execute (e.g., `node`)
104
+ 2. Enter the command arguments (e.g., `src/mcp-server.js --port 3001`)
105
+ - You can enter space-separated arguments or paste a JSON array
106
+ 3. Click "Add Server"
107
+
108
+ 4. Click "Use" to activate the server for the current chat session.
109
+
110
+ ### Available MCP Servers
111
+
112
+ You can use any MCP-compatible server with this application. Here are some examples:
113
+
114
+ - [Composio](https://composio.dev) - Provides search, code interpreter, and other tools
115
+ - Any local MCP server using stdio transport
116
+ - [Any other third-party MCP server]
117
+
118
+ ### Installing Required Dependencies
119
+
120
+ If you're running the project locally and encounter issues with MCP integration, make sure to install the required dependencies:
121
+
122
+ ```bash
123
+ npm run install-mcp
124
+ # or
125
+ yarn install-mcp
126
+ # or
127
+ pnpm run install-mcp
128
+ ```
129
+
130
+ ### Creating Your Own MCP Server
131
+
132
+ For information on creating your own MCP server, refer to the [Model Context Protocol documentation](https://modelcontextprotocol.io).
133
+
134
+ ## Authors
135
+
136
+ This repository is maintained by the [Vercel](https://vercel.com) team and community contributors.
137
+
138
+ Contributions are welcome! Feel free to open issues or submit pull requests to enhance functionality or fix bugs.
ai/providers.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { xai } from "@ai-sdk/xai";
2
+ import { openai } from "@ai-sdk/openai";
3
+ import { customProvider } from "ai";
4
+
5
+ export interface ModelInfo {
6
+ provider: string;
7
+ name: string;
8
+ description: string;
9
+ apiVersion: string;
10
+ capabilities: string[];
11
+ }
12
+
13
+ const languageModels = {
14
+ "grok-3": xai("grok-3-latest"),
15
+ "grok-3-mini": xai("grok-3-mini-fast-latest"),
16
+ "gpt-4.1-mini": openai("gpt-4.1-mini"),
17
+ "gpt-4.1-nano": openai("gpt-4.1-nano"),
18
+ };
19
+
20
+ export const modelDetails: Record<keyof typeof languageModels, ModelInfo> = {
21
+ "grok-3": {
22
+ provider: "xAI",
23
+ name: "Grok-3",
24
+ description: "Latest version of xAI's flagship model with strong reasoning and coding capabilities.",
25
+ apiVersion: "grok-3-latest",
26
+ capabilities: ["Balance", "Efficient", "Agentic"]
27
+ },
28
+ "grok-3-mini": {
29
+ provider: "xAI",
30
+ name: "Grok-3 Mini",
31
+ description: "Fast, efficient and smaller xAI model with reasoning capabilities.",
32
+ apiVersion: "grok-3-mini-fast-latest",
33
+ capabilities: ["Fast","Reasoning", "Efficient"]
34
+ },
35
+ "gpt-4.1-mini": {
36
+ provider: "OpenAI",
37
+ name: "GPT-4.1 Mini",
38
+ description: "Compact version of OpenAI's GPT-4.1 with good balance of capabilities, including vision.",
39
+ apiVersion: "gpt-4.1-mini",
40
+ capabilities: [ "Balance", "Creative", "Vision"]
41
+ },
42
+ "gpt-4.1-nano": {
43
+ provider: "OpenAI",
44
+ name: "GPT-4.1 Nano",
45
+ description: "Smallest and fastest GPT-4.1 variant designed for efficient rapid responses.",
46
+ apiVersion: "gpt-4.1-nano",
47
+ capabilities: ["Rapid", "Compact", "Efficient", "Vision"]
48
+ },
49
+ };
50
+
51
+ export const model = customProvider({
52
+ languageModels,
53
+ });
54
+
55
+ export type modelID = keyof typeof languageModels;
56
+
57
+ export const MODELS = Object.keys(languageModels);
58
+
59
+ export const defaultModel: modelID = "grok-3-mini";
ai/tools.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VercelAIToolSet } from "composio-core";
2
+
3
+ const toolset = new VercelAIToolSet();
4
+
5
+ export const composioTools = await toolset.getTools(
6
+ {
7
+ apps: [
8
+ "tavily",
9
+ ]
10
+ }
11
+ );
app/actions.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { openai } from "@ai-sdk/openai";
4
+ import { generateObject } from "ai";
5
+ import { z } from "zod";
6
+
7
+ // Helper to extract text content from a message regardless of format
8
+ function getMessageText(message: any): string {
9
+ // Check if the message has parts (new format)
10
+ if (message.parts && Array.isArray(message.parts)) {
11
+ const textParts = message.parts.filter((p: any) => p.type === 'text' && p.text);
12
+ if (textParts.length > 0) {
13
+ return textParts.map((p: any) => p.text).join('\n');
14
+ }
15
+ }
16
+
17
+ // Fallback to content (old format)
18
+ if (typeof message.content === 'string') {
19
+ return message.content;
20
+ }
21
+
22
+ // If content is an array (potentially of parts), try to extract text
23
+ if (Array.isArray(message.content)) {
24
+ const textItems = message.content.filter((item: any) =>
25
+ typeof item === 'string' || (item.type === 'text' && item.text)
26
+ );
27
+
28
+ if (textItems.length > 0) {
29
+ return textItems.map((item: any) =>
30
+ typeof item === 'string' ? item : item.text
31
+ ).join('\n');
32
+ }
33
+ }
34
+
35
+ return '';
36
+ }
37
+
38
+ export async function generateTitle(messages: any[]) {
39
+ // Convert messages to a format that OpenAI can understand
40
+ const normalizedMessages = messages.map(msg => ({
41
+ role: msg.role,
42
+ content: getMessageText(msg)
43
+ }));
44
+
45
+ const { object } = await generateObject({
46
+ model: openai("gpt-4.1"),
47
+ schema: z.object({
48
+ title: z.string().min(1).max(100),
49
+ }),
50
+ system: `
51
+ You are a helpful assistant that generates titles for chat conversations.
52
+ The title should be a short description of the conversation.
53
+ The title should be no more than 30 characters.
54
+ The title should be unique and not generic.
55
+ `,
56
+ messages: [
57
+ ...normalizedMessages,
58
+ {
59
+ role: "user",
60
+ content: "Generate a title for the conversation.",
61
+ },
62
+ ],
63
+ });
64
+
65
+ return object.title;
66
+ }
app/api/chat/route.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { model, type modelID } from "@/ai/providers";
2
+ import { composioTools } from "@/ai/tools";
3
+ import { streamText, type UIMessage } from "ai";
4
+ import { openai } from '@ai-sdk/openai';
5
+ import { appendResponseMessages } from 'ai';
6
+ import { saveChat, saveMessages, convertToDBMessages } from '@/lib/chat-store';
7
+ import { nanoid } from 'nanoid';
8
+ import { db } from '@/lib/db';
9
+ import { messages, chats } from '@/lib/db/schema';
10
+ import { eq, and } from 'drizzle-orm';
11
+
12
+ import { experimental_createMCPClient as createMCPClient, MCPTransport } from 'ai';
13
+ import { Experimental_StdioMCPTransport as StdioMCPTransport } from 'ai/mcp-stdio';
14
+
15
+ // Allow streaming responses up to 30 seconds
16
+ export const maxDuration = 30;
17
+
18
+ interface KeyValuePair {
19
+ key: string;
20
+ value: string;
21
+ }
22
+
23
+ interface MCPServerConfig {
24
+ url: string;
25
+ type: 'sse' | 'stdio';
26
+ command?: string;
27
+ args?: string[];
28
+ env?: KeyValuePair[];
29
+ headers?: KeyValuePair[];
30
+ }
31
+
32
+ export async function POST(req: Request) {
33
+ const {
34
+ messages,
35
+ chatId,
36
+ selectedModel,
37
+ userId,
38
+ mcpServers = [],
39
+ }: {
40
+ messages: UIMessage[];
41
+ chatId?: string;
42
+ selectedModel: modelID;
43
+ userId: string;
44
+ mcpServers?: MCPServerConfig[];
45
+ } = await req.json();
46
+
47
+ if (!userId) {
48
+ return new Response(
49
+ JSON.stringify({ error: "User ID is required" }),
50
+ { status: 400, headers: { "Content-Type": "application/json" } }
51
+ );
52
+ }
53
+
54
+ const id = chatId || nanoid();
55
+
56
+ // Check if chat already exists for the given ID
57
+ // If not, we'll create it in onFinish
58
+ let isNewChat = false;
59
+ if (chatId) {
60
+ try {
61
+ const existingChat = await db.query.chats.findFirst({
62
+ where: and(
63
+ eq(chats.id, chatId),
64
+ eq(chats.userId, userId)
65
+ )
66
+ });
67
+ isNewChat = !existingChat;
68
+ } catch (error) {
69
+ console.error("Error checking for existing chat:", error);
70
+ // Continue anyway, we'll create the chat in onFinish
71
+ isNewChat = true;
72
+ }
73
+ } else {
74
+ // No ID provided, definitely new
75
+ isNewChat = true;
76
+ }
77
+
78
+ // Initialize tools with Composio tools
79
+ let tools = { ...composioTools };
80
+ const mcpClients: any[] = [];
81
+
82
+ // Process each MCP server configuration
83
+ for (const mcpServer of mcpServers) {
84
+ try {
85
+ // Create appropriate transport based on type
86
+ let transport: MCPTransport | { type: 'sse', url: string, headers?: Record<string, string> };
87
+
88
+ if (mcpServer.type === 'sse') {
89
+ // Convert headers array to object for SSE transport
90
+ const headers: Record<string, string> = {};
91
+ if (mcpServer.headers && mcpServer.headers.length > 0) {
92
+ mcpServer.headers.forEach(header => {
93
+ if (header.key) headers[header.key] = header.value || '';
94
+ });
95
+ }
96
+
97
+ transport = {
98
+ type: 'sse' as const,
99
+ url: mcpServer.url,
100
+ headers: Object.keys(headers).length > 0 ? headers : undefined
101
+ };
102
+ } else if (mcpServer.type === 'stdio') {
103
+ // For stdio transport, we need command and args
104
+ if (!mcpServer.command || !mcpServer.args || mcpServer.args.length === 0) {
105
+ console.warn("Skipping stdio MCP server due to missing command or args");
106
+ continue;
107
+ }
108
+
109
+ // Convert env array to object for stdio transport
110
+ const env: Record<string, string> = {};
111
+ if (mcpServer.env && mcpServer.env.length > 0) {
112
+ mcpServer.env.forEach(envVar => {
113
+ if (envVar.key) env[envVar.key] = envVar.value || '';
114
+ });
115
+ }
116
+
117
+ transport = new StdioMCPTransport({
118
+ command: mcpServer.command,
119
+ args: mcpServer.args,
120
+ env: Object.keys(env).length > 0 ? env : undefined
121
+ });
122
+ } else {
123
+ console.warn(`Skipping MCP server with unsupported transport type: ${mcpServer.type}`);
124
+ continue;
125
+ }
126
+
127
+ const mcpClient = await createMCPClient({ transport });
128
+ mcpClients.push(mcpClient);
129
+
130
+ const mcptools = await mcpClient.tools();
131
+
132
+ console.log(`MCP tools from ${mcpServer.type} transport:`, Object.keys(mcptools));
133
+
134
+ // Add MCP tools to tools object
135
+ tools = { ...tools, ...mcptools };
136
+ } catch (error) {
137
+ console.error("Failed to initialize MCP client:", error);
138
+ // Continue with other servers instead of failing the entire request
139
+ }
140
+ }
141
+
142
+ // Register cleanup for all clients
143
+ if (mcpClients.length > 0) {
144
+ req.signal.addEventListener('abort', async () => {
145
+ for (const client of mcpClients) {
146
+ try {
147
+ await client.close();
148
+ } catch (error) {
149
+ console.error("Error closing MCP client:", error);
150
+ }
151
+ }
152
+ });
153
+ }
154
+
155
+ console.log("messages", messages);
156
+ console.log("parts", messages.map(m => m.parts.map(p => p)));
157
+
158
+ // If there was an error setting up MCP clients but we at least have composio tools, continue
159
+ const result = streamText({
160
+ model: model.languageModel(selectedModel),
161
+ system: `You are a helpful assistant with access to a variety of tools.
162
+
163
+ The tools are very powerful, and you can use them to answer the user's question.
164
+ So choose the tool that is most relevant to the user's question.
165
+
166
+ You can use multiple tools in a single response.
167
+ Always respond after using the tools for better user experience.
168
+ You can run multiple steps using all the tools!!!!
169
+ Make sure to use the right tool to respond to the user's question.
170
+
171
+ ## Response Format
172
+ - Markdown is supported.
173
+ - Respond according to tool's response.
174
+ - Use the tools to answer the user's question.
175
+ - If you don't know the answer, use the tools to find the answer or say you don't know.
176
+ `,
177
+ messages,
178
+ tools,
179
+ maxSteps: 20,
180
+ onError: (error) => {
181
+ console.error(JSON.stringify(error, null, 2));
182
+ },
183
+ async onFinish({ response, steps, toolCalls, toolResults }) {
184
+ console.log("onFinish", response.messages.map(m => {
185
+ return {
186
+ id: m.id,
187
+ content: JSON.stringify(m.content),
188
+ role: m.role,
189
+ }
190
+ }));
191
+ console.log("steps", steps);
192
+ console.log("toolCalls", toolCalls);
193
+ console.log("toolResults", toolResults);
194
+
195
+ // Combine messages for processing
196
+ const allMessages = appendResponseMessages({
197
+ messages,
198
+ responseMessages: response.messages,
199
+ });
200
+
201
+ // Step 1: Save chat with messages for proper title generation
202
+ await saveChat({
203
+ id,
204
+ userId,
205
+ messages: allMessages,
206
+ // No title specified - will be generated
207
+ });
208
+
209
+ // Step 2: Save all messages
210
+ const dbMessages = convertToDBMessages(allMessages, id);
211
+ await saveMessages({ messages: dbMessages });
212
+ }
213
+ });
214
+
215
+ result.consumeStream()
216
+ return result.toDataStreamResponse({
217
+ sendReasoning: true,
218
+ getErrorMessage: (error) => {
219
+ if (error instanceof Error) {
220
+ if (error.message.includes("Rate limit")) {
221
+ return "Rate limit exceeded. Please try again later.";
222
+ }
223
+ }
224
+ console.error(error);
225
+ return "An error occurred.";
226
+ },
227
+ });
228
+ }
app/api/chats/[id]/route.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { getChatById, deleteChat } from "@/lib/chat-store";
3
+
4
+ interface Params {
5
+ params: {
6
+ id: string;
7
+ };
8
+ }
9
+
10
+ export async function GET(request: Request, { params }: Params) {
11
+ try {
12
+ const userId = request.headers.get('x-user-id');
13
+
14
+ if (!userId) {
15
+ return NextResponse.json({ error: "User ID is required" }, { status: 400 });
16
+ }
17
+
18
+ const { id } = await params;
19
+ const chat = await getChatById(id, userId);
20
+
21
+ if (!chat) {
22
+ return NextResponse.json(
23
+ { error: "Chat not found" },
24
+ { status: 404 }
25
+ );
26
+ }
27
+
28
+ return NextResponse.json(chat);
29
+ } catch (error) {
30
+ console.error("Error fetching chat:", error);
31
+ return NextResponse.json(
32
+ { error: "Failed to fetch chat" },
33
+ { status: 500 }
34
+ );
35
+ }
36
+ }
37
+
38
+ export async function DELETE(request: Request, { params }: Params) {
39
+ try {
40
+ const userId = request.headers.get('x-user-id');
41
+
42
+ if (!userId) {
43
+ return NextResponse.json({ error: "User ID is required" }, { status: 400 });
44
+ }
45
+
46
+ const { id } = await params;
47
+ await deleteChat(id, userId);
48
+ return NextResponse.json({ success: true });
49
+ } catch (error) {
50
+ console.error("Error deleting chat:", error);
51
+ return NextResponse.json(
52
+ { error: "Failed to delete chat" },
53
+ { status: 500 }
54
+ );
55
+ }
56
+ }
app/api/chats/route.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { getChats } from "@/lib/chat-store";
3
+
4
+ export async function GET(request: Request) {
5
+ try {
6
+ const userId = request.headers.get('x-user-id');
7
+
8
+ if (!userId) {
9
+ return NextResponse.json({ error: "User ID is required" }, { status: 400 });
10
+ }
11
+
12
+ const chats = await getChats(userId);
13
+ return NextResponse.json(chats);
14
+ } catch (error) {
15
+ console.error("Error fetching chats:", error);
16
+ return NextResponse.json(
17
+ { error: "Failed to fetch chats" },
18
+ { status: 500 }
19
+ );
20
+ }
21
+ }
app/chat/[id]/page.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Chat from "@/components/chat";
4
+ import { getUserId } from "@/lib/user-id";
5
+ import { useQueryClient } from "@tanstack/react-query";
6
+ import { useParams } from "next/navigation";
7
+ import { useEffect } from "react";
8
+
9
+ export default function ChatPage() {
10
+ const params = useParams();
11
+ const chatId = params?.id as string;
12
+ const queryClient = useQueryClient();
13
+ const userId = getUserId();
14
+
15
+ // Prefetch chat data
16
+ useEffect(() => {
17
+ async function prefetchChat() {
18
+ if (!chatId || !userId) return;
19
+
20
+ // Check if data already exists in cache
21
+ const existingData = queryClient.getQueryData(['chat', chatId, userId]);
22
+ if (existingData) return;
23
+
24
+ // Prefetch the data
25
+ await queryClient.prefetchQuery({
26
+ queryKey: ['chat', chatId, userId] as const,
27
+ queryFn: async () => {
28
+ try {
29
+ const response = await fetch(`/api/chats/${chatId}`, {
30
+ headers: {
31
+ 'x-user-id': userId
32
+ }
33
+ });
34
+
35
+ if (!response.ok) {
36
+ throw new Error('Failed to load chat');
37
+ }
38
+
39
+ return response.json();
40
+ } catch (error) {
41
+ console.error('Error prefetching chat:', error);
42
+ return null;
43
+ }
44
+ },
45
+ staleTime: 1000 * 60 * 5, // 5 minutes
46
+ });
47
+ }
48
+
49
+ prefetchChat();
50
+ }, [chatId, userId, queryClient]);
51
+
52
+ return <Chat />;
53
+ }
app/favicon.ico ADDED

Git LFS Details

  • SHA256: fae26f65fd15655032d2a57c8e05d4afb9d3119e9a96759b9240acdbe3bb8026
  • Pointer size: 130 Bytes
  • Size of remote file: 15.4 kB
app/globals.css ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @plugin "tailwindcss-animate";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --font-sans: Montserrat, sans-serif;
11
+ --font-mono: Ubuntu Mono, monospace;
12
+ --color-sidebar-ring: var(--sidebar-ring);
13
+ --color-sidebar-border: var(--sidebar-border);
14
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
15
+ --color-sidebar-accent: var(--sidebar-accent);
16
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
17
+ --color-sidebar-primary: var(--sidebar-primary);
18
+ --color-sidebar-foreground: var(--sidebar-foreground);
19
+ --color-sidebar: var(--sidebar);
20
+ --color-chart-5: var(--chart-5);
21
+ --color-chart-4: var(--chart-4);
22
+ --color-chart-3: var(--chart-3);
23
+ --color-chart-2: var(--chart-2);
24
+ --color-chart-1: var(--chart-1);
25
+ --color-ring: var(--ring);
26
+ --color-input: var(--input);
27
+ --color-border: var(--border);
28
+ --color-destructive-foreground: var(--destructive-foreground);
29
+ --color-destructive: var(--destructive);
30
+ --color-accent-foreground: var(--accent-foreground);
31
+ --color-accent: var(--accent);
32
+ --color-muted-foreground: var(--muted-foreground);
33
+ --color-muted: var(--muted);
34
+ --color-secondary-foreground: var(--secondary-foreground);
35
+ --color-secondary: var(--secondary);
36
+ --color-primary-foreground: var(--primary-foreground);
37
+ --color-primary: var(--primary);
38
+ --color-popover-foreground: var(--popover-foreground);
39
+ --color-popover: var(--popover);
40
+ --color-card-foreground: var(--card-foreground);
41
+ --color-card: var(--card);
42
+ --radius-sm: calc(var(--radius) - 4px);
43
+ --radius-md: calc(var(--radius) - 2px);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) + 4px);
46
+ --font-serif: Merriweather, serif;
47
+ --radius: 0.625rem;
48
+ --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
49
+ --tracking-tight: calc(var(--tracking-normal) - 0.025em);
50
+ --tracking-wide: calc(var(--tracking-normal) + 0.025em);
51
+ --tracking-wider: calc(var(--tracking-normal) + 0.05em);
52
+ --tracking-widest: calc(var(--tracking-normal) + 0.1em);
53
+ --tracking-normal: var(--tracking-normal);
54
+ --shadow-2xl: var(--shadow-2xl);
55
+ --shadow-xl: var(--shadow-xl);
56
+ --shadow-lg: var(--shadow-lg);
57
+ --shadow-md: var(--shadow-md);
58
+ --shadow: var(--shadow);
59
+ --shadow-sm: var(--shadow-sm);
60
+ --shadow-xs: var(--shadow-xs);
61
+ --shadow-2xs: var(--shadow-2xs);
62
+ --spacing: var(--spacing);
63
+ --letter-spacing: var(--letter-spacing);
64
+ --shadow-offset-y: var(--shadow-offset-y);
65
+ --shadow-offset-x: var(--shadow-offset-x);
66
+ --shadow-spread: var(--shadow-spread);
67
+ --shadow-blur: var(--shadow-blur);
68
+ --shadow-opacity: var(--shadow-opacity);
69
+ --color-shadow-color: var(--shadow-color);
70
+ }
71
+
72
+ :root {
73
+ --background: oklch(0.99 0.01 56.32);
74
+ --foreground: oklch(0.34 0.01 2.77);
75
+ --card: oklch(1.00 0 0);
76
+ --card-foreground: oklch(0.34 0.01 2.77);
77
+ --popover: oklch(1.00 0 0);
78
+ --popover-foreground: oklch(0.34 0.01 2.77);
79
+ --primary: oklch(0.74 0.16 34.71);
80
+ --primary-foreground: oklch(1.00 0 0);
81
+ --secondary: oklch(0.96 0.02 28.90);
82
+ --secondary-foreground: oklch(0.56 0.13 32.74);
83
+ --muted: oklch(0.97 0.02 39.40);
84
+ --muted-foreground: oklch(0.49 0.05 26.45);
85
+ --accent: oklch(0.83 0.11 58.00);
86
+ --accent-foreground: oklch(0.34 0.01 2.77);
87
+ --destructive: oklch(0.61 0.21 22.24);
88
+ --destructive-foreground: oklch(1.00 0 0);
89
+ --border: oklch(0.93 0.04 38.69);
90
+ --input: oklch(0.93 0.04 38.69);
91
+ --ring: oklch(0.74 0.16 34.71);
92
+ --chart-1: oklch(0.74 0.16 34.71);
93
+ --chart-2: oklch(0.83 0.11 58.00);
94
+ --chart-3: oklch(0.88 0.08 54.93);
95
+ --chart-4: oklch(0.82 0.11 40.89);
96
+ --chart-5: oklch(0.64 0.13 32.07);
97
+ --radius: 0.625rem;
98
+ --sidebar: oklch(0.97 0.02 39.40);
99
+ --sidebar-foreground: oklch(0.34 0.01 2.77);
100
+ --sidebar-primary: oklch(0.74 0.16 34.71);
101
+ --sidebar-primary-foreground: oklch(1.00 0 0);
102
+ --sidebar-accent: oklch(0.83 0.11 58.00);
103
+ --sidebar-accent-foreground: oklch(0.34 0.01 2.77);
104
+ --sidebar-border: oklch(0.93 0.04 38.69);
105
+ --sidebar-ring: oklch(0.74 0.16 34.71);
106
+ --font-sans: Montserrat, sans-serif;
107
+ --font-serif: Merriweather, serif;
108
+ --font-mono: Ubuntu Mono, monospace;
109
+ --shadow-color: hsl(0 0% 0%);
110
+ --shadow-opacity: 0.09;
111
+ --shadow-blur: 12px;
112
+ --shadow-spread: -3px;
113
+ --shadow-offset-x: 0px;
114
+ --shadow-offset-y: 6px;
115
+ --letter-spacing: 0em;
116
+ --spacing: 0.25rem;
117
+ --shadow-2xs: 0px 6px 12px -3px hsl(0 0% 0% / 0.04);
118
+ --shadow-xs: 0px 6px 12px -3px hsl(0 0% 0% / 0.04);
119
+ --shadow-sm: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 1px 2px -4px hsl(0 0% 0% / 0.09);
120
+ --shadow: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 1px 2px -4px hsl(0 0% 0% / 0.09);
121
+ --shadow-md: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 2px 4px -4px hsl(0 0% 0% / 0.09);
122
+ --shadow-lg: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 4px 6px -4px hsl(0 0% 0% / 0.09);
123
+ --shadow-xl: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 8px 10px -4px hsl(0 0% 0% / 0.09);
124
+ --shadow-2xl: 0px 6px 12px -3px hsl(0 0% 0% / 0.22);
125
+ --tracking-normal: 0em;
126
+ }
127
+
128
+ .dark {
129
+ --background: oklch(0.26 0.02 352.40);
130
+ --foreground: oklch(0.94 0.01 51.32);
131
+ --card: oklch(0.32 0.02 341.45);
132
+ --card-foreground: oklch(0.94 0.01 51.32);
133
+ --popover: oklch(0.32 0.02 341.45);
134
+ --popover-foreground: oklch(0.94 0.01 51.32);
135
+ --primary: oklch(0.74 0.16 34.71);
136
+ --primary-foreground: oklch(1.00 0 0);
137
+ --secondary: oklch(0.36 0.02 342.27);
138
+ --secondary-foreground: oklch(0.94 0.01 51.32);
139
+ --muted: oklch(0.32 0.02 341.45);
140
+ --muted-foreground: oklch(0.84 0.02 52.63);
141
+ --accent: oklch(0.83 0.11 58.00);
142
+ --accent-foreground: oklch(0.26 0.02 352.40);
143
+ --destructive: oklch(0.61 0.21 22.24);
144
+ --destructive-foreground: oklch(1.00 0 0);
145
+ --border: oklch(0.36 0.02 342.27);
146
+ --input: oklch(0.36 0.02 342.27);
147
+ --ring: oklch(0.74 0.16 34.71);
148
+ --chart-1: oklch(0.74 0.16 34.71);
149
+ --chart-2: oklch(0.83 0.11 58.00);
150
+ --chart-3: oklch(0.88 0.08 54.93);
151
+ --chart-4: oklch(0.82 0.11 40.89);
152
+ --chart-5: oklch(0.64 0.13 32.07);
153
+ --sidebar: oklch(0.26 0.02 352.40);
154
+ --sidebar-foreground: oklch(0.94 0.01 51.32);
155
+ --sidebar-primary: oklch(0.74 0.16 34.71);
156
+ --sidebar-primary-foreground: oklch(1.00 0 0);
157
+ --sidebar-accent: oklch(0.83 0.11 58.00);
158
+ --sidebar-accent-foreground: oklch(0.26 0.02 352.40);
159
+ --sidebar-border: oklch(0.36 0.02 342.27);
160
+ --sidebar-ring: oklch(0.74 0.16 34.71);
161
+ --radius: 0.625rem;
162
+ --font-sans: Montserrat, sans-serif;
163
+ --font-serif: Merriweather, serif;
164
+ --font-mono: Ubuntu Mono, monospace;
165
+ --shadow-color: hsl(0 0% 0%);
166
+ --shadow-opacity: 0.09;
167
+ --shadow-blur: 12px;
168
+ --shadow-spread: -3px;
169
+ --shadow-offset-x: 0px;
170
+ --shadow-offset-y: 6px;
171
+ --letter-spacing: 0em;
172
+ --spacing: 0.25rem;
173
+ --shadow-2xs: 0px 6px 12px -3px hsl(0 0% 0% / 0.04);
174
+ --shadow-xs: 0px 6px 12px -3px hsl(0 0% 0% / 0.04);
175
+ --shadow-sm: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 1px 2px -4px hsl(0 0% 0% / 0.09);
176
+ --shadow: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 1px 2px -4px hsl(0 0% 0% / 0.09);
177
+ --shadow-md: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 2px 4px -4px hsl(0 0% 0% / 0.09);
178
+ --shadow-lg: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 4px 6px -4px hsl(0 0% 0% / 0.09);
179
+ --shadow-xl: 0px 6px 12px -3px hsl(0 0% 0% / 0.09), 0px 8px 10px -4px hsl(0 0% 0% / 0.09);
180
+ --shadow-2xl: 0px 6px 12px -3px hsl(0 0% 0% / 0.22);
181
+ }
182
+
183
+ @layer base {
184
+ * {
185
+ @apply border-border outline-ring/50;
186
+ }
187
+ body {
188
+ @apply bg-background text-foreground;
189
+ letter-spacing: var(--tracking-normal);
190
+ }
191
+ }
app/layout.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import { ChatSidebar } from "@/components/chat-sidebar";
4
+ import { SidebarTrigger } from "@/components/ui/sidebar";
5
+ import { Menu } from "lucide-react";
6
+ import { Providers } from "./providers";
7
+ import "./globals.css";
8
+
9
+ const inter = Inter({ subsets: ["latin"] });
10
+
11
+ export const metadata: Metadata = {
12
+ title: "Scira MCP Chat",
13
+ description: "Scira MCP Chat is a chat interface for interacting with MCP servers.",
14
+ };
15
+
16
+ export default function RootLayout({
17
+ children,
18
+ }: Readonly<{
19
+ children: React.ReactNode;
20
+ }>) {
21
+ return (
22
+ <html lang="en" suppressHydrationWarning>
23
+ <body className={`${inter.className}`}>
24
+ <Providers>
25
+ <div className="flex h-dvh w-full">
26
+ <ChatSidebar />
27
+ <main className="flex-1 flex flex-col relative">
28
+ <div className="absolute top-4 left-4 z-50">
29
+ <SidebarTrigger>
30
+ <button className="flex items-center justify-center h-8 w-8 bg-muted hover:bg-accent rounded-md transition-colors">
31
+ <Menu className="h-4 w-4" />
32
+ </button>
33
+ </SidebarTrigger>
34
+ </div>
35
+ <div className="flex-1 flex justify-center">
36
+ {children}
37
+ </div>
38
+ </main>
39
+ </div>
40
+ </Providers>
41
+ </body>
42
+ </html>
43
+ );
44
+ }
app/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import Chat from "@/components/chat";
2
+
3
+ export default function Page() {
4
+ return <Chat />;
5
+ }
app/providers.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ReactNode } from "react";
4
+ import { ThemeProvider } from "@/components/theme-provider";
5
+ import { SidebarProvider } from "@/components/ui/sidebar";
6
+ import { Toaster } from "sonner";
7
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8
+
9
+ // Create a client
10
+ const queryClient = new QueryClient({
11
+ defaultOptions: {
12
+ queries: {
13
+ staleTime: 1000 * 60 * 5, // 5 minutes
14
+ refetchOnWindowFocus: true,
15
+ },
16
+ },
17
+ });
18
+
19
+ export function Providers({ children }: { children: ReactNode }) {
20
+ return (
21
+ <QueryClientProvider client={queryClient}>
22
+ <ThemeProvider
23
+ attribute="class"
24
+ defaultTheme="system"
25
+ enableSystem
26
+ disableTransitionOnChange
27
+ >
28
+ <SidebarProvider defaultOpen={true}>
29
+ {children}
30
+ <Toaster position="top-center" richColors />
31
+ </SidebarProvider>
32
+ </ThemeProvider>
33
+ </QueryClientProvider>
34
+ );
35
+ }
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
components/chat-sidebar.tsx ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter, usePathname } from "next/navigation";
5
+ import { MessageSquare, PlusCircle, Trash2, ServerIcon, Settings, Loader2, Sparkles } from "lucide-react";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarFooter,
10
+ SidebarGroup,
11
+ SidebarGroupContent,
12
+ SidebarGroupLabel,
13
+ SidebarHeader,
14
+ SidebarMenu,
15
+ SidebarMenuButton,
16
+ SidebarMenuItem,
17
+ SidebarMenuBadge,
18
+ SidebarSeparator,
19
+ useSidebar
20
+ } from "@/components/ui/sidebar";
21
+ import { Separator } from "@/components/ui/separator";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Badge } from "@/components/ui/badge";
24
+ import { toast } from "sonner";
25
+ import { type Chat } from "@/lib/db/schema";
26
+ import Image from "next/image";
27
+ import { MCPServerManager, type MCPServer } from "./mcp-server-manager";
28
+ import { ThemeToggle } from "./theme-toggle";
29
+ import { useTheme } from "next-themes";
30
+ import { getUserId } from "@/lib/user-id";
31
+ import { useLocalStorage } from "@/lib/hooks/use-local-storage";
32
+ import { STORAGE_KEYS } from "@/lib/constants";
33
+ import { useChats } from "@/lib/hooks/use-chats";
34
+ import { cn } from "@/lib/utils";
35
+ import Link from "next/link";
36
+
37
+ export function ChatSidebar() {
38
+ const router = useRouter();
39
+ const pathname = usePathname();
40
+ const [userId, setUserId] = useState<string>('');
41
+ const [mcpServers, setMcpServers] = useLocalStorage<MCPServer[]>(STORAGE_KEYS.MCP_SERVERS, []);
42
+ const [selectedMcpServers, setSelectedMcpServers] = useLocalStorage<string[]>(STORAGE_KEYS.SELECTED_MCP_SERVERS, []);
43
+ const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false);
44
+ const { state } = useSidebar();
45
+ const isCollapsed = state === "collapsed";
46
+
47
+ // Initialize userId
48
+ useEffect(() => {
49
+ setUserId(getUserId());
50
+ }, []);
51
+
52
+ // Use TanStack Query to fetch chats
53
+ const { chats, isLoading, deleteChat, refreshChats } = useChats(userId);
54
+
55
+ // Start a new chat
56
+ const handleNewChat = () => {
57
+ router.push('/');
58
+ };
59
+
60
+ // Delete a chat
61
+ const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
62
+ e.stopPropagation();
63
+ e.preventDefault();
64
+
65
+ deleteChat(chatId);
66
+
67
+ // If we're currently on the deleted chat's page, navigate to home
68
+ if (pathname === `/chat/${chatId}`) {
69
+ router.push('/');
70
+ }
71
+ };
72
+
73
+ // Get active MCP servers status
74
+ const activeServersCount = selectedMcpServers.length;
75
+ const multipleServersActive = activeServersCount > 1;
76
+
77
+ // Show loading state if user ID is not yet initialized
78
+ if (!userId) {
79
+ return null; // Or a loading spinner
80
+ }
81
+
82
+ return (
83
+ <Sidebar className="shadow-sm bg-background/80 dark:bg-background/40 backdrop-blur-md" collapsible="icon">
84
+ <SidebarHeader className="p-4 border-b border-border/40">
85
+ <div className="flex items-center justify-start">
86
+ <div className={`flex items-center gap-2 ${isCollapsed ? "justify-center w-full" : ""}`}>
87
+ <div className={`relative rounded-full bg-primary/10 flex items-center justify-center ${isCollapsed ? "size-5 p-3" : "size-6"}`}>
88
+ <Image src="/scira.png" alt="Scira Logo" width={24} height={24} className="invert dark:invert-0 absolute transform scale-75" unoptimized quality={100} />
89
+ </div>
90
+ {!isCollapsed && (
91
+ <div className="font-semibold text-lg text-foreground/90">MCP</div>
92
+ )}
93
+ </div>
94
+ </div>
95
+ </SidebarHeader>
96
+
97
+ <SidebarContent className="pt-4">
98
+ <SidebarGroup>
99
+ <SidebarGroupLabel className={cn(
100
+ "px-4 mb-1 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider",
101
+ isCollapsed ? "sr-only" : ""
102
+ )}>
103
+ Chats
104
+ </SidebarGroupLabel>
105
+ <SidebarGroupContent>
106
+ <SidebarMenu>
107
+ {isLoading ? (
108
+ <div className={`flex items-center justify-center py-4 ${isCollapsed ? "" : "px-4"}`}>
109
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
110
+ {!isCollapsed && (
111
+ <span className="ml-2 text-xs text-muted-foreground">Loading...</span>
112
+ )}
113
+ </div>
114
+ ) : chats.length === 0 ? (
115
+ <div className={`flex flex-col items-center gap-2 py-6 ${isCollapsed ? "" : "px-4"}`}>
116
+ {isCollapsed ? (
117
+ <div className="flex h-7 w-7 items-center justify-center rounded-full bg-secondary/60">
118
+ <Sparkles className="h-3 w-3 text-secondary-foreground" />
119
+ </div>
120
+ ) : (
121
+ <div className="flex flex-col items-center text-center py-4">
122
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary/60 mb-3">
123
+ <Sparkles className="h-5 w-5 text-secondary-foreground" />
124
+ </div>
125
+ <span className="text-sm font-medium text-foreground/90">No chats yet</span>
126
+ <span className="text-xs text-muted-foreground mt-1 max-w-[200px]">
127
+ Start a new conversation below
128
+ </span>
129
+ </div>
130
+ )}
131
+ </div>
132
+ ) : (
133
+ chats.map((chat) => (
134
+ <SidebarMenuItem key={chat.id}>
135
+ <SidebarMenuButton
136
+ asChild
137
+ tooltip={isCollapsed ? chat.title : undefined}
138
+ data-active={pathname === `/chat/${chat.id}`}
139
+ className={cn(
140
+ "transition-all hover:bg-secondary/50 active:bg-secondary/70",
141
+ pathname === `/chat/${chat.id}` ? "bg-secondary/60 hover:bg-secondary/60" : ""
142
+ )}
143
+ >
144
+ <Link
145
+ href={`/chat/${chat.id}`}
146
+ className="flex items-center justify-between w-full gap-1"
147
+ >
148
+ <div className="flex items-center min-w-0 overflow-hidden flex-1 pr-2">
149
+ <MessageSquare className={cn(
150
+ "h-4 w-4 flex-shrink-0",
151
+ pathname === `/chat/${chat.id}` ? "text-foreground" : "text-muted-foreground"
152
+ )} />
153
+ {!isCollapsed && (
154
+ <span className={cn(
155
+ "ml-2 truncate text-sm",
156
+ pathname === `/chat/${chat.id}` ? "text-foreground font-medium" : "text-foreground/80"
157
+ )} title={chat.title}>
158
+ {chat.title.length > 18 ? `${chat.title.slice(0, 18)}...` : chat.title}
159
+ </span>
160
+ )}
161
+ </div>
162
+ {!isCollapsed && (
163
+ <Button
164
+ variant="ghost"
165
+ size="icon"
166
+ className="h-6 w-6 text-muted-foreground hover:text-foreground flex-shrink-0"
167
+ onClick={(e) => handleDeleteChat(chat.id, e)}
168
+ title="Delete chat"
169
+ >
170
+ <Trash2 className="h-3.5 w-3.5" />
171
+ </Button>
172
+ )}
173
+ </Link>
174
+ </SidebarMenuButton>
175
+ </SidebarMenuItem>
176
+ ))
177
+ )}
178
+ </SidebarMenu>
179
+ </SidebarGroupContent>
180
+ </SidebarGroup>
181
+
182
+ <div className="relative my-3">
183
+ <div className="absolute inset-x-0">
184
+ <Separator className="w-full h-px bg-border/40" />
185
+ </div>
186
+ </div>
187
+
188
+ <SidebarGroup>
189
+ <SidebarGroupLabel className={cn(
190
+ "px-4 mb-1 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider",
191
+ isCollapsed ? "sr-only" : ""
192
+ )}>
193
+ MCP Servers
194
+ </SidebarGroupLabel>
195
+ <SidebarGroupContent>
196
+ <SidebarMenu>
197
+ <SidebarMenuItem>
198
+ <SidebarMenuButton
199
+ onClick={() => setMcpSettingsOpen(true)}
200
+ className={cn(
201
+ "w-full flex items-center gap-2 transition-all",
202
+ "hover:bg-secondary/50 active:bg-secondary/70"
203
+ )}
204
+ tooltip={isCollapsed ? "MCP Servers" : undefined}
205
+ >
206
+ <ServerIcon className={cn(
207
+ "h-4 w-4 flex-shrink-0",
208
+ activeServersCount > 0 ? "text-green-500" : "text-muted-foreground"
209
+ )} />
210
+ {!isCollapsed && (
211
+ <span className="flex-grow text-sm text-foreground/80">MCP Servers</span>
212
+ )}
213
+ {activeServersCount > 0 && !isCollapsed ? (
214
+ <Badge
215
+ variant="secondary"
216
+ className="ml-auto text-[10px] px-1.5 py-0 h-5 bg-secondary/80"
217
+ >
218
+ {activeServersCount}
219
+ </Badge>
220
+ ) : activeServersCount > 0 && isCollapsed ? (
221
+ <SidebarMenuBadge className="bg-secondary/80 text-secondary-foreground">
222
+ {activeServersCount}
223
+ </SidebarMenuBadge>
224
+ ) : null}
225
+ </SidebarMenuButton>
226
+ </SidebarMenuItem>
227
+ </SidebarMenu>
228
+ </SidebarGroupContent>
229
+ </SidebarGroup>
230
+ </SidebarContent>
231
+
232
+ <SidebarFooter className="p-4 border-t border-border/40 mt-auto">
233
+ <div className={`flex flex-col ${isCollapsed ? "items-center" : ""} gap-3`}>
234
+ <Button
235
+ variant="default"
236
+ className={cn(
237
+ "w-full bg-primary text-primary-foreground hover:bg-primary/90",
238
+ isCollapsed ? "w-8 h-8 p-0" : ""
239
+ )}
240
+ onClick={handleNewChat}
241
+ title={isCollapsed ? "New Chat" : undefined}
242
+ >
243
+ <PlusCircle className={`${isCollapsed ? "" : "mr-2"} h-4 w-4`} />
244
+ {!isCollapsed && <span>New Chat</span>}
245
+ </Button>
246
+
247
+ <div className={`flex ${isCollapsed ? "flex-col" : ""} gap-2 ${isCollapsed ? "items-center" : "justify-between items-center"}`}>
248
+ <Button
249
+ variant="ghost"
250
+ size="icon"
251
+ className="h-8 w-8 rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground"
252
+ onClick={() => setMcpSettingsOpen(true)}
253
+ >
254
+ <Settings className="h-4 w-4" />
255
+ <span className="sr-only">MCP Settings</span>
256
+ </Button>
257
+ <ThemeToggle className="hover:bg-secondary/50 text-muted-foreground hover:text-foreground" />
258
+ </div>
259
+ </div>
260
+
261
+ <MCPServerManager
262
+ servers={mcpServers}
263
+ onServersChange={setMcpServers}
264
+ selectedServers={selectedMcpServers}
265
+ onSelectedServersChange={setSelectedMcpServers}
266
+ open={mcpSettingsOpen}
267
+ onOpenChange={setMcpSettingsOpen}
268
+ />
269
+ </SidebarFooter>
270
+ </Sidebar>
271
+ );
272
+ }
components/chat.tsx ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { defaultModel, type modelID } from "@/ai/providers";
4
+ import { Message, useChat } from "@ai-sdk/react";
5
+ import { useState, useEffect, useMemo, useCallback } from "react";
6
+ import { Textarea } from "./textarea";
7
+ import { ProjectOverview } from "./project-overview";
8
+ import { Messages } from "./messages";
9
+ import { toast } from "sonner";
10
+ import { useRouter, useParams } from "next/navigation";
11
+ import { getUserId } from "@/lib/user-id";
12
+ import { useLocalStorageValue, useLocalStorage } from "@/lib/hooks/use-local-storage";
13
+ import { STORAGE_KEYS } from "@/lib/constants";
14
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
15
+ import { convertToUIMessages } from "@/lib/chat-store";
16
+ import { type Message as DBMessage } from "@/lib/db/schema";
17
+ import { nanoid } from "nanoid";
18
+
19
+ // Define types for MCP server
20
+ interface KeyValuePair {
21
+ key: string;
22
+ value: string;
23
+ }
24
+
25
+ interface MCPServer {
26
+ id: string;
27
+ name: string;
28
+ url: string;
29
+ type: 'sse' | 'stdio';
30
+ command?: string;
31
+ args?: string[];
32
+ env?: KeyValuePair[];
33
+ headers?: KeyValuePair[];
34
+ }
35
+
36
+
37
+ interface ChatData {
38
+ id: string;
39
+ messages: DBMessage[];
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ }
43
+
44
+ export default function Chat() {
45
+ const router = useRouter();
46
+ const params = useParams();
47
+ const chatId = params?.id as string | undefined;
48
+ const queryClient = useQueryClient();
49
+
50
+ const [selectedModel, setSelectedModel] = useLocalStorage<modelID>("selectedModel", defaultModel);
51
+ const [userId, setUserId] = useState<string>('');
52
+ const [generatedChatId, setGeneratedChatId] = useState<string>('');
53
+
54
+ // Get MCP server data from localStorage via our custom hooks
55
+ const mcpServers = useLocalStorageValue<MCPServer[]>(STORAGE_KEYS.MCP_SERVERS, []);
56
+ const selectedMcpServers = useLocalStorageValue<string[]>(STORAGE_KEYS.SELECTED_MCP_SERVERS, []);
57
+
58
+ // Initialize userId
59
+ useEffect(() => {
60
+ setUserId(getUserId());
61
+ }, []);
62
+
63
+ // Generate a chat ID if needed
64
+ useEffect(() => {
65
+ if (!chatId) {
66
+ setGeneratedChatId(nanoid());
67
+ }
68
+ }, [chatId]);
69
+
70
+ // Use React Query to fetch chat history
71
+ const { data: chatData, isLoading: isLoadingChat } = useQuery({
72
+ queryKey: ['chat', chatId, userId] as const,
73
+ queryFn: async ({ queryKey }) => {
74
+ const [_, chatId, userId] = queryKey;
75
+ if (!chatId || !userId) return null;
76
+
77
+ try {
78
+ const response = await fetch(`/api/chats/${chatId}`, {
79
+ headers: {
80
+ 'x-user-id': userId
81
+ }
82
+ });
83
+
84
+ if (!response.ok) {
85
+ throw new Error('Failed to load chat');
86
+ }
87
+
88
+ const data = await response.json();
89
+ return data as ChatData;
90
+ } catch (error) {
91
+ console.error('Error loading chat history:', error);
92
+ toast.error('Failed to load chat history');
93
+ throw error;
94
+ }
95
+ },
96
+ enabled: !!chatId && !!userId,
97
+ retry: 1,
98
+ staleTime: 1000 * 60 * 5, // 5 minutes
99
+ refetchOnWindowFocus: false
100
+ });
101
+
102
+ // Memoize MCP server configuration for API
103
+ const mcpServersForApi = useMemo(() => {
104
+ if (!selectedMcpServers.length) return [];
105
+
106
+ return selectedMcpServers
107
+ .map(id => mcpServers.find(server => server.id === id))
108
+ .filter((server): server is MCPServer => Boolean(server))
109
+ .map(server => ({
110
+ type: server.type,
111
+ url: server.url,
112
+ command: server.command,
113
+ args: server.args,
114
+ env: server.env,
115
+ headers: server.headers
116
+ }));
117
+ }, [mcpServers, selectedMcpServers]);
118
+
119
+ // Prepare initial messages from query data
120
+ const initialMessages = useMemo(() => {
121
+ if (!chatData || !chatData.messages || chatData.messages.length === 0) {
122
+ return [];
123
+ }
124
+
125
+ // Convert DB messages to UI format, then ensure it matches the Message type from @ai-sdk/react
126
+ const uiMessages = convertToUIMessages(chatData.messages);
127
+ return uiMessages.map(msg => ({
128
+ id: msg.id,
129
+ role: msg.role as Message['role'], // Ensure role is properly typed
130
+ content: msg.content,
131
+ parts: msg.parts,
132
+ } as Message));
133
+ }, [chatData]);
134
+
135
+ const { messages, input, handleInputChange, handleSubmit, status, stop } =
136
+ useChat({
137
+ id: chatId || generatedChatId, // Use generated ID if no chatId in URL
138
+ initialMessages,
139
+ maxSteps: 20,
140
+ body: {
141
+ selectedModel,
142
+ mcpServers: mcpServersForApi,
143
+ chatId: chatId || generatedChatId, // Use generated ID if no chatId in URL
144
+ userId,
145
+ },
146
+ experimental_throttle: 500,
147
+ onFinish: () => {
148
+ // Invalidate the chats query to refresh the sidebar
149
+ if (userId) {
150
+ queryClient.invalidateQueries({ queryKey: ['chats', userId] });
151
+ }
152
+ },
153
+ onError: (error) => {
154
+ toast.error(
155
+ error.message.length > 0
156
+ ? error.message
157
+ : "An error occured, please try again later.",
158
+ { position: "top-center", richColors: true },
159
+ );
160
+ },
161
+ });
162
+
163
+ // Custom submit handler
164
+ const handleFormSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
165
+ e.preventDefault();
166
+
167
+ if (!chatId && generatedChatId && input.trim()) {
168
+ // If this is a new conversation, redirect to the chat page with the generated ID
169
+ const effectiveChatId = generatedChatId;
170
+
171
+ // Submit the form
172
+ handleSubmit(e);
173
+
174
+ // Redirect to the chat page with the generated ID
175
+ router.push(`/chat/${effectiveChatId}`);
176
+ } else {
177
+ // Normal submission for existing chats
178
+ handleSubmit(e);
179
+ }
180
+ }, [chatId, generatedChatId, input, handleSubmit, router]);
181
+
182
+ const isLoading = status === "streaming" || status === "submitted" || isLoadingChat;
183
+
184
+ return (
185
+ <div className="h-dvh flex flex-col justify-center py-4 w-full max-w-3xl">
186
+ {messages.length === 0 && !isLoadingChat ? (
187
+ <div className="max-w-xl mx-auto w-full">
188
+ <ProjectOverview />
189
+ <form
190
+ onSubmit={handleFormSubmit}
191
+ className="mt-4 w-full mx-auto"
192
+ >
193
+ <Textarea
194
+ selectedModel={selectedModel}
195
+ setSelectedModel={setSelectedModel}
196
+ handleInputChange={handleInputChange}
197
+ input={input}
198
+ isLoading={isLoading}
199
+ status={status}
200
+ stop={stop}
201
+ />
202
+ </form>
203
+ </div>
204
+ ) : (
205
+ <>
206
+ <div className="flex-1 overflow-y-auto min-h-0">
207
+ <Messages messages={messages} isLoading={isLoading} status={status} />
208
+ </div>
209
+ <form
210
+ onSubmit={handleFormSubmit}
211
+ className="mt-4 w-full max-w-2xl mx-auto"
212
+ >
213
+ <Textarea
214
+ selectedModel={selectedModel}
215
+ setSelectedModel={setSelectedModel}
216
+ handleInputChange={handleInputChange}
217
+ input={input}
218
+ isLoading={isLoading}
219
+ status={status}
220
+ stop={stop}
221
+ />
222
+ </form>
223
+ </>
224
+ )}
225
+ </div>
226
+ );
227
+ }
components/deploy-button.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+
3
+ export const DeployButton = () => (
4
+ <Link
5
+ href={`https://vercel.com/new/clone?project-name=Vercel+x+xAI+Chatbot&repository-name=ai-sdk-starter-xai&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-starter-xai&demo-title=Vercel+x+xAI+Chatbot&demo-url=https%3A%2F%2Fai-sdk-starter-xai.labs.vercel.dev%2F&demo-description=A+simple+chatbot+application+built+with+Next.js+that+uses+xAI+via+the+AI+SDK+and+the+Vercel+Marketplace&products=%5B%7B%22type%22:%22integration%22,%22protocol%22:%22ai%22,%22productSlug%22:%22grok%22,%22integrationSlug%22:%22xai%22%7D%5D`}
6
+ target="_blank"
7
+ rel="noopener noreferrer"
8
+ className="inline-flex items-center gap-2 ml-2 bg-black text-white text-sm px-3 py-1.5 rounded-md hover:bg-zinc-900 dark:bg-white dark:text-black dark:hover:bg-zinc-100"
9
+ >
10
+ <svg
11
+ data-testid="geist-icon"
12
+ height={14}
13
+ strokeLinejoin="round"
14
+ viewBox="0 0 16 16"
15
+ width={14}
16
+ style={{ color: "currentcolor" }}
17
+ >
18
+ <path
19
+ fillRule="evenodd"
20
+ clipRule="evenodd"
21
+ d="M8 1L16 15H0L8 1Z"
22
+ fill="currentColor"
23
+ />
24
+ </svg>
25
+ Deploy
26
+ </Link>
27
+ );
components/icons.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import * as React from "react";
3
+ import type { SVGProps } from "react";
4
+
5
+ export const VercelIcon = ({ size = 17 }) => {
6
+ return (
7
+ <svg
8
+ height={size}
9
+ strokeLinejoin="round"
10
+ viewBox="0 0 16 16"
11
+ width={size}
12
+ style={{ color: "currentcolor" }}
13
+ >
14
+ <title>Vercel Icon</title>
15
+ <path
16
+ fillRule="evenodd"
17
+ clipRule="evenodd"
18
+ d="M8 1L16 15H0L8 1Z"
19
+ fill="currentColor"
20
+ />
21
+ </svg>
22
+ );
23
+ };
24
+
25
+ export const SpinnerIcon = ({ size = 16 }: { size?: number }) => (
26
+ <svg
27
+ height={size}
28
+ strokeLinejoin="round"
29
+ viewBox="0 0 16 16"
30
+ width={size}
31
+ style={{ color: "currentcolor" }}
32
+ >
33
+ <title>Spinner Icon</title>
34
+ <g clipPath="url(#clip0_2393_1490)">
35
+ <path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
36
+ <path
37
+ opacity="0.5"
38
+ d="M8 16V12"
39
+ stroke="currentColor"
40
+ strokeWidth="1.5"
41
+ />
42
+ <path
43
+ opacity="0.9"
44
+ d="M3.29773 1.52783L5.64887 4.7639"
45
+ stroke="currentColor"
46
+ strokeWidth="1.5"
47
+ />
48
+ <path
49
+ opacity="0.1"
50
+ d="M12.7023 1.52783L10.3511 4.7639"
51
+ stroke="currentColor"
52
+ strokeWidth="1.5"
53
+ />
54
+ <path
55
+ opacity="0.4"
56
+ d="M12.7023 14.472L10.3511 11.236"
57
+ stroke="currentColor"
58
+ strokeWidth="1.5"
59
+ />
60
+ <path
61
+ opacity="0.6"
62
+ d="M3.29773 14.472L5.64887 11.236"
63
+ stroke="currentColor"
64
+ strokeWidth="1.5"
65
+ />
66
+ <path
67
+ opacity="0.2"
68
+ d="M15.6085 5.52783L11.8043 6.7639"
69
+ stroke="currentColor"
70
+ strokeWidth="1.5"
71
+ />
72
+ <path
73
+ opacity="0.7"
74
+ d="M0.391602 10.472L4.19583 9.23598"
75
+ stroke="currentColor"
76
+ strokeWidth="1.5"
77
+ />
78
+ <path
79
+ opacity="0.3"
80
+ d="M15.6085 10.4722L11.8043 9.2361"
81
+ stroke="currentColor"
82
+ strokeWidth="1.5"
83
+ />
84
+ <path
85
+ opacity="0.8"
86
+ d="M0.391602 5.52783L4.19583 6.7639"
87
+ stroke="currentColor"
88
+ strokeWidth="1.5"
89
+ />
90
+ </g>
91
+ <defs>
92
+ <clipPath id="clip0_2393_1490">
93
+ <rect width="16" height="16" fill="white" />
94
+ </clipPath>
95
+ </defs>
96
+ </svg>
97
+ );
98
+
99
+ export const Github = (props: SVGProps<SVGSVGElement>) => (
100
+ <svg
101
+ viewBox="0 0 256 250"
102
+ width="1em"
103
+ height="1em"
104
+ fill="currentColor"
105
+ xmlns="http://www.w3.org/2000/svg"
106
+ preserveAspectRatio="xMidYMid"
107
+ {...props}
108
+ >
109
+ <title>GitHub Icon</title>
110
+ <path d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z" />
111
+ </svg>
112
+ );
113
+
114
+ export function StarButton() {
115
+ return (
116
+ <Link
117
+ href="https://github.com/vercel-labs/ai-sdk-preview-reasoning"
118
+ target="_blank"
119
+ rel="noopener noreferrer"
120
+ className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-300 hover:text-zinc-700 dark:hover:text-zinc-300"
121
+ >
122
+ <Github className="size-4" />
123
+ <span className="hidden sm:inline">Star on GitHub</span>
124
+ </Link>
125
+ );
126
+ }
127
+
128
+ export const XAiIcon = ({ size = 16 }) => {
129
+ return (
130
+ <svg
131
+ xmlns="http://www.w3.org/2000/svg"
132
+ height={size}
133
+ version="1.1"
134
+ viewBox="0 0 438.7 481.4"
135
+ >
136
+ <title>xAI Icon</title>
137
+ <path d="M355.5,155.1l8.3,326.4h66.6l8.3-445.2-83.2,118.8ZM438.7,0h-101.6l-159.4,227.6,50.8,72.5L438.7,0ZM0,481.4h101.6l50.8-72.5-50.8-72.5L0,481.4ZM0,155.1l228.5,326.4h101.6L101.6,155.1H0Z" />
138
+ </svg>
139
+ );
140
+ };
components/input.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ArrowUp } from "lucide-react";
2
+ import { Input as ShadcnInput } from "./ui/input";
3
+
4
+ interface InputProps {
5
+ input: string;
6
+ handleInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
7
+ isLoading: boolean;
8
+ status: string;
9
+ stop: () => void;
10
+ }
11
+
12
+ export const Input = ({
13
+ input,
14
+ handleInputChange,
15
+ isLoading,
16
+ status,
17
+ stop,
18
+ }: InputProps) => {
19
+ return (
20
+ <div className="relative w-full">
21
+ <ShadcnInput
22
+ className="bg-secondary py-6 w-full rounded-xl pr-12"
23
+ value={input}
24
+ autoFocus
25
+ placeholder={"Say something..."}
26
+ onChange={handleInputChange}
27
+ />
28
+ {status === "streaming" || status === "submitted" ? (
29
+ <button
30
+ type="button"
31
+ onClick={stop}
32
+ className="cursor-pointer absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-2 bg-black hover:bg-zinc-800 disabled:bg-zinc-300 disabled:cursor-not-allowed transition-colors"
33
+ >
34
+ <div className="animate-spin h-4 w-4">
35
+ <svg className="h-4 w-4 text-white" viewBox="0 0 24 24">
36
+ <circle
37
+ className="opacity-25"
38
+ cx="12"
39
+ cy="12"
40
+ r="10"
41
+ stroke="currentColor"
42
+ strokeWidth="4"
43
+ fill="none"
44
+ />
45
+ <path
46
+ className="opacity-75"
47
+ fill="currentColor"
48
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
49
+ />
50
+ </svg>
51
+ </div>
52
+ </button>
53
+ ) : (
54
+ <button
55
+ type="submit"
56
+ disabled={isLoading || !input.trim()}
57
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-2 bg-black hover:bg-zinc-800 disabled:bg-zinc-300 disabled:cursor-not-allowed transition-colors"
58
+ >
59
+ <ArrowUp className="h-4 w-4 text-white" />
60
+ </button>
61
+ )}
62
+ </div>
63
+ );
64
+ };
components/markdown.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import Link from "next/link";
3
+ import React, { memo } from "react";
4
+ import ReactMarkdown, { type Components } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const components: Partial<Components> = {
9
+ pre: ({ children, ...props }) => (
10
+ <pre className="overflow-x-auto rounded-lg bg-zinc-100 dark:bg-zinc-800/50 p-4 my-2 text-sm" {...props}>
11
+ {children}
12
+ </pre>
13
+ ),
14
+ code: ({ children, className, ...props }: React.HTMLProps<HTMLElement> & { className?: string }) => {
15
+ const match = /language-(\w+)/.exec(className || '');
16
+ const isInline = !match && !className;
17
+
18
+ if (isInline) {
19
+ return (
20
+ <code
21
+ className="px-1.5 py-0.5 rounded-md bg-zinc-100 dark:bg-zinc-800/50 text-zinc-700 dark:text-zinc-300 text-[0.9em] font-mono"
22
+ {...props}
23
+ >
24
+ {children}
25
+ </code>
26
+ );
27
+ }
28
+ return (
29
+ <code className={cn("block font-mono text-sm", className)} {...props}>
30
+ {children}
31
+ </code>
32
+ );
33
+ },
34
+ ol: ({ node, children, ...props }) => (
35
+ <ol className="list-decimal list-outside ml-4 space-y-1 my-2" {...props}>
36
+ {children}
37
+ </ol>
38
+ ),
39
+ ul: ({ node, children, ...props }) => (
40
+ <ul className="list-disc list-outside ml-4 space-y-1 my-2" {...props}>
41
+ {children}
42
+ </ul>
43
+ ),
44
+ li: ({ node, children, ...props }) => (
45
+ <li className="leading-relaxed" {...props}>
46
+ {children}
47
+ </li>
48
+ ),
49
+ p: ({ node, children, ...props }) => (
50
+ <p className="leading-7" {...props}>
51
+ {children}
52
+ </p>
53
+ ),
54
+ strong: ({ node, children, ...props }) => (
55
+ <strong className="font-semibold" {...props}>
56
+ {children}
57
+ </strong>
58
+ ),
59
+ em: ({ node, children, ...props }) => (
60
+ <em className="italic" {...props}>
61
+ {children}
62
+ </em>
63
+ ),
64
+ blockquote: ({ node, children, ...props }) => (
65
+ <blockquote
66
+ className="border-l-2 border-zinc-200 dark:border-zinc-700 pl-4 my-3 italic text-zinc-600 dark:text-zinc-400"
67
+ {...props}
68
+ >
69
+ {children}
70
+ </blockquote>
71
+ ),
72
+ a: ({ node, children, ...props }) => (
73
+ // @ts-expect-error error
74
+ <Link
75
+ className="text-blue-500 hover:underline hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
76
+ target="_blank"
77
+ rel="noreferrer"
78
+ {...props}
79
+ >
80
+ {children}
81
+ </Link>
82
+ ),
83
+ h1: ({ node, children, ...props }) => (
84
+ <h1 className="text-3xl font-semibold mt-6 mb-4 text-zinc-800 dark:text-zinc-200" {...props}>
85
+ {children}
86
+ </h1>
87
+ ),
88
+ h2: ({ node, children, ...props }) => (
89
+ <h2 className="text-2xl font-semibold mt-6 mb-3 text-zinc-800 dark:text-zinc-200" {...props}>
90
+ {children}
91
+ </h2>
92
+ ),
93
+ h3: ({ node, children, ...props }) => (
94
+ <h3 className="text-xl font-semibold mt-6 mb-3 text-zinc-800 dark:text-zinc-200" {...props}>
95
+ {children}
96
+ </h3>
97
+ ),
98
+ h4: ({ node, children, ...props }) => (
99
+ <h4 className="text-lg font-semibold mt-6 mb-2 text-zinc-800 dark:text-zinc-200" {...props}>
100
+ {children}
101
+ </h4>
102
+ ),
103
+ h5: ({ node, children, ...props }) => (
104
+ <h5 className="text-base font-semibold mt-6 mb-2 text-zinc-800 dark:text-zinc-200" {...props}>
105
+ {children}
106
+ </h5>
107
+ ),
108
+ h6: ({ node, children, ...props }) => (
109
+ <h6 className="text-sm font-semibold mt-6 mb-2 text-zinc-800 dark:text-zinc-200" {...props}>
110
+ {children}
111
+ </h6>
112
+ ),
113
+ table: ({ node, children, ...props }) => (
114
+ <div className="my-4 overflow-x-auto">
115
+ <table className="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700" {...props}>
116
+ {children}
117
+ </table>
118
+ </div>
119
+ ),
120
+ thead: ({ node, children, ...props }) => (
121
+ <thead className="bg-zinc-50 dark:bg-zinc-800/50" {...props}>
122
+ {children}
123
+ </thead>
124
+ ),
125
+ tbody: ({ node, children, ...props }) => (
126
+ <tbody className="divide-y divide-zinc-200 dark:divide-zinc-700 bg-white dark:bg-transparent" {...props}>
127
+ {children}
128
+ </tbody>
129
+ ),
130
+ tr: ({ node, children, ...props }) => (
131
+ <tr className="transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/30" {...props}>
132
+ {children}
133
+ </tr>
134
+ ),
135
+ th: ({ node, children, ...props }) => (
136
+ <th
137
+ className="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider"
138
+ {...props}
139
+ >
140
+ {children}
141
+ </th>
142
+ ),
143
+ td: ({ node, children, ...props }) => (
144
+ <td className="px-4 py-3 text-sm" {...props}>
145
+ {children}
146
+ </td>
147
+ ),
148
+ hr: ({ node, ...props }) => (
149
+ <hr className="my-4 border-zinc-200 dark:border-zinc-700" {...props} />
150
+ ),
151
+ };
152
+
153
+ const remarkPlugins = [remarkGfm];
154
+
155
+ const NonMemoizedMarkdown = ({ children }: { children: string }) => {
156
+ return (
157
+ <ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
158
+ {children}
159
+ </ReactMarkdown>
160
+ );
161
+ };
162
+
163
+ export const Markdown = memo(
164
+ NonMemoizedMarkdown,
165
+ (prevProps, nextProps) => prevProps.children === nextProps.children,
166
+ );
components/mcp-server-manager.tsx ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle
10
+ } from "./ui/dialog";
11
+ import { Button } from "./ui/button";
12
+ import { Input } from "./ui/input";
13
+ import { Label } from "./ui/label";
14
+ import {
15
+ PlusCircle,
16
+ ServerIcon,
17
+ X,
18
+ Terminal,
19
+ Globe,
20
+ ExternalLink,
21
+ Trash2,
22
+ CheckCircle,
23
+ Plus,
24
+ Cog,
25
+ Edit2,
26
+ Eye,
27
+ EyeOff
28
+ } from "lucide-react";
29
+ import { toast } from "sonner";
30
+ import {
31
+ Select,
32
+ SelectContent,
33
+ SelectItem,
34
+ SelectTrigger,
35
+ SelectValue,
36
+ } from "./ui/select";
37
+ import {
38
+ Accordion,
39
+ AccordionContent,
40
+ AccordionItem,
41
+ AccordionTrigger
42
+ } from "./ui/accordion";
43
+
44
+ // Key-value pair for environment variables and headers
45
+ interface KeyValuePair {
46
+ key: string;
47
+ value: string;
48
+ }
49
+
50
+ // Default template for a new MCP server
51
+ const INITIAL_NEW_SERVER: Omit<MCPServer, 'id'> = {
52
+ name: '',
53
+ url: '',
54
+ type: 'sse',
55
+ command: 'node',
56
+ args: [],
57
+ env: [],
58
+ headers: []
59
+ };
60
+
61
+ export interface MCPServer {
62
+ id: string;
63
+ name: string;
64
+ url: string;
65
+ type: 'sse' | 'stdio';
66
+ command?: string;
67
+ args?: string[];
68
+ env?: KeyValuePair[];
69
+ headers?: KeyValuePair[];
70
+ }
71
+
72
+ interface MCPServerManagerProps {
73
+ servers: MCPServer[];
74
+ onServersChange: (servers: MCPServer[]) => void;
75
+ selectedServers: string[];
76
+ onSelectedServersChange: (serverIds: string[]) => void;
77
+ open: boolean;
78
+ onOpenChange: (open: boolean) => void;
79
+ }
80
+
81
+ // Check if a key name might contain sensitive information
82
+ const isSensitiveKey = (key: string): boolean => {
83
+ const sensitivePatterns = [
84
+ /key/i,
85
+ /token/i,
86
+ /secret/i,
87
+ /password/i,
88
+ /pass/i,
89
+ /auth/i,
90
+ /credential/i
91
+ ];
92
+ return sensitivePatterns.some(pattern => pattern.test(key));
93
+ };
94
+
95
+ // Mask a sensitive value
96
+ const maskValue = (value: string): string => {
97
+ if (!value) return '';
98
+ if (value.length < 8) return '••••••';
99
+ return value.substring(0, 3) + '•'.repeat(Math.min(10, value.length - 4)) + value.substring(value.length - 1);
100
+ };
101
+
102
+ export const MCPServerManager = ({
103
+ servers,
104
+ onServersChange,
105
+ selectedServers,
106
+ onSelectedServersChange,
107
+ open,
108
+ onOpenChange
109
+ }: MCPServerManagerProps) => {
110
+ const [newServer, setNewServer] = useState<Omit<MCPServer, 'id'>>(INITIAL_NEW_SERVER);
111
+ const [view, setView] = useState<'list' | 'add'>('list');
112
+ const [newEnvVar, setNewEnvVar] = useState<KeyValuePair>({ key: '', value: '' });
113
+ const [newHeader, setNewHeader] = useState<KeyValuePair>({ key: '', value: '' });
114
+ const [editingServerId, setEditingServerId] = useState<string | null>(null);
115
+ const [showSensitiveEnvValues, setShowSensitiveEnvValues] = useState<Record<number, boolean>>({});
116
+ const [showSensitiveHeaderValues, setShowSensitiveHeaderValues] = useState<Record<number, boolean>>({});
117
+ const [editingEnvIndex, setEditingEnvIndex] = useState<number | null>(null);
118
+ const [editingHeaderIndex, setEditingHeaderIndex] = useState<number | null>(null);
119
+ const [editedEnvValue, setEditedEnvValue] = useState<string>('');
120
+ const [editedHeaderValue, setEditedHeaderValue] = useState<string>('');
121
+
122
+ const resetAndClose = () => {
123
+ setView('list');
124
+ setNewServer(INITIAL_NEW_SERVER);
125
+ setNewEnvVar({ key: '', value: '' });
126
+ setNewHeader({ key: '', value: '' });
127
+ setShowSensitiveEnvValues({});
128
+ setShowSensitiveHeaderValues({});
129
+ setEditingEnvIndex(null);
130
+ setEditingHeaderIndex(null);
131
+ onOpenChange(false);
132
+ };
133
+
134
+ const addServer = () => {
135
+ if (!newServer.name) {
136
+ toast.error("Server name is required");
137
+ return;
138
+ }
139
+
140
+ if (newServer.type === 'sse' && !newServer.url) {
141
+ toast.error("Server URL is required for SSE transport");
142
+ return;
143
+ }
144
+
145
+ if (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length)) {
146
+ toast.error("Command and at least one argument are required for stdio transport");
147
+ return;
148
+ }
149
+
150
+ const id = crypto.randomUUID();
151
+ const updatedServers = [...servers, { ...newServer, id }];
152
+ onServersChange(updatedServers);
153
+
154
+ toast.success(`Added MCP server: ${newServer.name}`);
155
+ setView('list');
156
+ setNewServer(INITIAL_NEW_SERVER);
157
+ setNewEnvVar({ key: '', value: '' });
158
+ setNewHeader({ key: '', value: '' });
159
+ setShowSensitiveEnvValues({});
160
+ setShowSensitiveHeaderValues({});
161
+ };
162
+
163
+ const removeServer = (id: string, e: React.MouseEvent) => {
164
+ e.stopPropagation();
165
+ const updatedServers = servers.filter(server => server.id !== id);
166
+ onServersChange(updatedServers);
167
+
168
+ // If the removed server was selected, remove it from selected servers
169
+ if (selectedServers.includes(id)) {
170
+ onSelectedServersChange(selectedServers.filter(serverId => serverId !== id));
171
+ }
172
+
173
+ toast.success("Server removed");
174
+ };
175
+
176
+ const toggleServer = (id: string) => {
177
+ if (selectedServers.includes(id)) {
178
+ // Remove from selected servers
179
+ onSelectedServersChange(selectedServers.filter(serverId => serverId !== id));
180
+ const server = servers.find(s => s.id === id);
181
+ if (server) {
182
+ toast.success(`Disabled MCP server: ${server.name}`);
183
+ }
184
+ } else {
185
+ // Add to selected servers
186
+ onSelectedServersChange([...selectedServers, id]);
187
+ const server = servers.find(s => s.id === id);
188
+ if (server) {
189
+ toast.success(`Enabled MCP server: ${server.name}`);
190
+ }
191
+ }
192
+ };
193
+
194
+ const clearAllServers = () => {
195
+ if (selectedServers.length > 0) {
196
+ onSelectedServersChange([]);
197
+ toast.success("All MCP servers disabled");
198
+ resetAndClose();
199
+ }
200
+ };
201
+
202
+ const handleArgsChange = (value: string) => {
203
+ try {
204
+ // Try to parse as JSON if it starts with [ (array)
205
+ const argsArray = value.trim().startsWith('[')
206
+ ? JSON.parse(value)
207
+ : value.split(' ').filter(Boolean);
208
+
209
+ setNewServer({ ...newServer, args: argsArray });
210
+ } catch (error) {
211
+ // If parsing fails, just split by spaces
212
+ setNewServer({ ...newServer, args: value.split(' ').filter(Boolean) });
213
+ }
214
+ };
215
+
216
+ const addEnvVar = () => {
217
+ if (!newEnvVar.key) return;
218
+
219
+ setNewServer({
220
+ ...newServer,
221
+ env: [...(newServer.env || []), { ...newEnvVar }]
222
+ });
223
+
224
+ setNewEnvVar({ key: '', value: '' });
225
+ };
226
+
227
+ const removeEnvVar = (index: number) => {
228
+ const updatedEnv = [...(newServer.env || [])];
229
+ updatedEnv.splice(index, 1);
230
+ setNewServer({ ...newServer, env: updatedEnv });
231
+
232
+ // Clean up visibility state for this index
233
+ const updatedVisibility = { ...showSensitiveEnvValues };
234
+ delete updatedVisibility[index];
235
+ setShowSensitiveEnvValues(updatedVisibility);
236
+
237
+ // If currently editing this value, cancel editing
238
+ if (editingEnvIndex === index) {
239
+ setEditingEnvIndex(null);
240
+ }
241
+ };
242
+
243
+ const startEditEnvValue = (index: number, value: string) => {
244
+ setEditingEnvIndex(index);
245
+ setEditedEnvValue(value);
246
+ };
247
+
248
+ const saveEditedEnvValue = () => {
249
+ if (editingEnvIndex !== null) {
250
+ const updatedEnv = [...(newServer.env || [])];
251
+ updatedEnv[editingEnvIndex] = {
252
+ ...updatedEnv[editingEnvIndex],
253
+ value: editedEnvValue
254
+ };
255
+ setNewServer({ ...newServer, env: updatedEnv });
256
+ setEditingEnvIndex(null);
257
+ }
258
+ };
259
+
260
+ const addHeader = () => {
261
+ if (!newHeader.key) return;
262
+
263
+ setNewServer({
264
+ ...newServer,
265
+ headers: [...(newServer.headers || []), { ...newHeader }]
266
+ });
267
+
268
+ setNewHeader({ key: '', value: '' });
269
+ };
270
+
271
+ const removeHeader = (index: number) => {
272
+ const updatedHeaders = [...(newServer.headers || [])];
273
+ updatedHeaders.splice(index, 1);
274
+ setNewServer({ ...newServer, headers: updatedHeaders });
275
+
276
+ // Clean up visibility state for this index
277
+ const updatedVisibility = { ...showSensitiveHeaderValues };
278
+ delete updatedVisibility[index];
279
+ setShowSensitiveHeaderValues(updatedVisibility);
280
+
281
+ // If currently editing this value, cancel editing
282
+ if (editingHeaderIndex === index) {
283
+ setEditingHeaderIndex(null);
284
+ }
285
+ };
286
+
287
+ const startEditHeaderValue = (index: number, value: string) => {
288
+ setEditingHeaderIndex(index);
289
+ setEditedHeaderValue(value);
290
+ };
291
+
292
+ const saveEditedHeaderValue = () => {
293
+ if (editingHeaderIndex !== null) {
294
+ const updatedHeaders = [...(newServer.headers || [])];
295
+ updatedHeaders[editingHeaderIndex] = {
296
+ ...updatedHeaders[editingHeaderIndex],
297
+ value: editedHeaderValue
298
+ };
299
+ setNewServer({ ...newServer, headers: updatedHeaders });
300
+ setEditingHeaderIndex(null);
301
+ }
302
+ };
303
+
304
+ const toggleSensitiveEnvValue = (index: number) => {
305
+ setShowSensitiveEnvValues(prev => ({
306
+ ...prev,
307
+ [index]: !prev[index]
308
+ }));
309
+ };
310
+
311
+ const toggleSensitiveHeaderValue = (index: number) => {
312
+ setShowSensitiveHeaderValues(prev => ({
313
+ ...prev,
314
+ [index]: !prev[index]
315
+ }));
316
+ };
317
+
318
+ const hasAdvancedConfig = (server: MCPServer) => {
319
+ return (server.env && server.env.length > 0) ||
320
+ (server.headers && server.headers.length > 0);
321
+ };
322
+
323
+ // Editing support
324
+ const startEditing = (server: MCPServer) => {
325
+ setEditingServerId(server.id);
326
+ setNewServer({
327
+ name: server.name,
328
+ url: server.url,
329
+ type: server.type,
330
+ command: server.command,
331
+ args: server.args,
332
+ env: server.env,
333
+ headers: server.headers
334
+ });
335
+ setView('add');
336
+ // Reset sensitive value visibility states
337
+ setShowSensitiveEnvValues({});
338
+ setShowSensitiveHeaderValues({});
339
+ setEditingEnvIndex(null);
340
+ setEditingHeaderIndex(null);
341
+ };
342
+
343
+ const handleFormCancel = () => {
344
+ if (view === 'add') {
345
+ setView('list');
346
+ setEditingServerId(null);
347
+ setNewServer(INITIAL_NEW_SERVER);
348
+ setShowSensitiveEnvValues({});
349
+ setShowSensitiveHeaderValues({});
350
+ setEditingEnvIndex(null);
351
+ setEditingHeaderIndex(null);
352
+ } else {
353
+ resetAndClose();
354
+ }
355
+ };
356
+
357
+ const updateServer = () => {
358
+ if (!newServer.name) {
359
+ toast.error("Server name is required");
360
+ return;
361
+ }
362
+ if (newServer.type === 'sse' && !newServer.url) {
363
+ toast.error("Server URL is required for SSE transport");
364
+ return;
365
+ }
366
+ if (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length)) {
367
+ toast.error("Command and at least one argument are required for stdio transport");
368
+ return;
369
+ }
370
+ const updated = servers.map(s =>
371
+ s.id === editingServerId ? { ...newServer, id: editingServerId! } : s
372
+ );
373
+ onServersChange(updated);
374
+ toast.success(`Updated MCP server: ${newServer.name}`);
375
+ setView('list');
376
+ setEditingServerId(null);
377
+ setNewServer(INITIAL_NEW_SERVER);
378
+ setShowSensitiveEnvValues({});
379
+ setShowSensitiveHeaderValues({});
380
+ };
381
+
382
+ return (
383
+ <Dialog open={open} onOpenChange={onOpenChange}>
384
+ <DialogContent className="sm:max-w-[480px] max-h-[85vh] overflow-hidden flex flex-col">
385
+ <DialogHeader>
386
+ <DialogTitle className="flex items-center gap-2">
387
+ <ServerIcon className="h-5 w-5 text-primary" />
388
+ MCP Server Configuration
389
+ </DialogTitle>
390
+ <DialogDescription>
391
+ Connect to Model Context Protocol servers to access additional AI tools.
392
+ {selectedServers.length > 0 && (
393
+ <span className="block mt-1 text-xs font-medium text-primary">
394
+ {selectedServers.length} server{selectedServers.length !== 1 ? 's' : ''} currently active
395
+ </span>
396
+ )}
397
+ </DialogDescription>
398
+ </DialogHeader>
399
+
400
+ {view === 'list' ? (
401
+ <div className="flex-1 overflow-hidden flex flex-col pb-14">
402
+ {servers.length > 0 ? (
403
+ <div className="flex-1 overflow-hidden flex flex-col">
404
+ <div className="flex-1 overflow-hidden flex flex-col">
405
+ <div className="flex items-center justify-between mb-2">
406
+ <h3 className="text-sm font-medium">Available Servers</h3>
407
+ <span className="text-xs text-muted-foreground">
408
+ Select multiple servers to combine their tools
409
+ </span>
410
+ </div>
411
+ <div className="overflow-y-auto pr-1 flex-1 gap-2 flex flex-col">
412
+ {servers.map((server) => {
413
+ const isActive = selectedServers.includes(server.id);
414
+ return (
415
+ <div
416
+ key={server.id}
417
+ className={`
418
+ relative flex flex-col p-3 rounded-lg transition-colors
419
+ border ${isActive
420
+ ? 'border-primary bg-primary/10'
421
+ : 'border-border hover:border-primary/30 hover:bg-primary/5'}
422
+ `}
423
+ >
424
+ {/* Server Header with Type Badge and Delete Button */}
425
+ <div className="flex items-center justify-between mb-1.5">
426
+ <div className="flex items-center gap-2">
427
+ {server.type === 'sse' ? (
428
+ <Globe className="h-4 w-4 text-primary flex-shrink-0" />
429
+ ) : (
430
+ <Terminal className="h-4 w-4 text-primary flex-shrink-0" />
431
+ )}
432
+ <h4 className="text-sm font-medium truncate max-w-[220px]">{server.name}</h4>
433
+ {hasAdvancedConfig(server) && (
434
+ <span className="flex-shrink-0">
435
+ <Cog className="h-3 w-3 text-muted-foreground" />
436
+ </span>
437
+ )}
438
+ </div>
439
+ <div className="flex items-center gap-2">
440
+ <span className="text-xs px-1.5 py-0.5 rounded-full bg-secondary text-secondary-foreground">
441
+ {server.type.toUpperCase()}
442
+ </span>
443
+ <button
444
+ onClick={(e) => removeServer(server.id, e)}
445
+ className="p-1 rounded-full hover:bg-muted/70"
446
+ aria-label="Remove server"
447
+ >
448
+ <Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
449
+ </button>
450
+ <button
451
+ onClick={() => startEditing(server)}
452
+ className="p-1 rounded-full hover:bg-muted/50"
453
+ aria-label="Edit server"
454
+ >
455
+ <Edit2 className="h-3.5 w-3.5 text-muted-foreground" />
456
+ </button>
457
+ </div>
458
+ </div>
459
+
460
+ {/* Server Details */}
461
+ <p className="text-xs text-muted-foreground mb-2 truncate">
462
+ {server.type === 'sse'
463
+ ? server.url
464
+ : `${server.command} ${server.args?.join(' ')}`
465
+ }
466
+ </p>
467
+
468
+ {/* Action Button */}
469
+ <Button
470
+ size="sm"
471
+ className="w-full mt-0.5 gap-1.5"
472
+ variant={isActive ? "default" : "outline"}
473
+ onClick={() => toggleServer(server.id)}
474
+ >
475
+ {isActive && <CheckCircle className="h-3.5 w-3.5" />}
476
+ {isActive ? "Active" : "Enable Server"}
477
+ </Button>
478
+ </div>
479
+ );
480
+ })}
481
+ </div>
482
+ </div>
483
+ </div>
484
+ ) : (
485
+ <div className="flex-1 py-8 pb-16 flex flex-col items-center justify-center space-y-4">
486
+ <div className="rounded-full p-3 bg-primary/10">
487
+ <ServerIcon className="h-7 w-7 text-primary" />
488
+ </div>
489
+ <div className="text-center space-y-1">
490
+ <h3 className="text-base font-medium">No MCP Servers Added</h3>
491
+ <p className="text-sm text-muted-foreground max-w-[300px]">
492
+ Add your first MCP server to access additional AI tools
493
+ </p>
494
+ </div>
495
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-4">
496
+ <a
497
+ href="https://modelcontextprotocol.io"
498
+ target="_blank"
499
+ rel="noopener noreferrer"
500
+ className="flex items-center gap-1 hover:text-primary transition-colors"
501
+ >
502
+ Learn about MCP
503
+ <ExternalLink className="h-3 w-3" />
504
+ </a>
505
+ </div>
506
+ </div>
507
+ )}
508
+ </div>
509
+ ) : (
510
+ <div className="space-y-4 overflow-y-auto px-1 py-0.5 mb-14 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
511
+ <h3 className="text-sm font-medium">{editingServerId ? "Edit MCP Server" : "Add New MCP Server"}</h3>
512
+ <div className="space-y-4">
513
+ <div className="grid gap-1.5">
514
+ <Label htmlFor="name">
515
+ Server Name
516
+ </Label>
517
+ <Input
518
+ id="name"
519
+ value={newServer.name}
520
+ onChange={(e) => setNewServer({ ...newServer, name: e.target.value })}
521
+ placeholder="My MCP Server"
522
+ className="relative z-0"
523
+ />
524
+ </div>
525
+
526
+ <div className="grid gap-1.5">
527
+ <Label htmlFor="transport-type">
528
+ Transport Type
529
+ </Label>
530
+ <div className="space-y-2">
531
+ <p className="text-xs text-muted-foreground">Choose how to connect to your MCP server:</p>
532
+ <div className="grid gap-2 grid-cols-2">
533
+ <button
534
+ type="button"
535
+ onClick={() => setNewServer({ ...newServer, type: 'sse' })}
536
+ className={`flex items-center gap-2 p-3 rounded-md text-left border transition-all ${
537
+ newServer.type === 'sse'
538
+ ? 'border-primary bg-primary/10 ring-1 ring-primary'
539
+ : 'border-border hover:border-border/80 hover:bg-muted/50'
540
+ }`}
541
+ >
542
+ <Globe className={`h-5 w-5 shrink-0 ${newServer.type === 'sse' ? 'text-primary' : ''}`} />
543
+ <div>
544
+ <p className="font-medium">SSE</p>
545
+ <p className="text-xs text-muted-foreground">Server-Sent Events</p>
546
+ </div>
547
+ </button>
548
+
549
+ <button
550
+ type="button"
551
+ onClick={() => setNewServer({ ...newServer, type: 'stdio' })}
552
+ className={`flex items-center gap-2 p-3 rounded-md text-left border transition-all ${
553
+ newServer.type === 'stdio'
554
+ ? 'border-primary bg-primary/10 ring-1 ring-primary'
555
+ : 'border-border hover:border-border/80 hover:bg-muted/50'
556
+ }`}
557
+ >
558
+ <Terminal className={`h-5 w-5 shrink-0 ${newServer.type === 'stdio' ? 'text-primary' : ''}`} />
559
+ <div>
560
+ <p className="font-medium">stdio</p>
561
+ <p className="text-xs text-muted-foreground">Standard I/O</p>
562
+ </div>
563
+ </button>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ {newServer.type === 'sse' ? (
569
+ <div className="grid gap-1.5">
570
+ <Label htmlFor="url">
571
+ Server URL
572
+ </Label>
573
+ <Input
574
+ id="url"
575
+ value={newServer.url}
576
+ onChange={(e) => setNewServer({ ...newServer, url: e.target.value })}
577
+ placeholder="https://mcp.example.com/token/sse"
578
+ className="relative z-0"
579
+ />
580
+ <p className="text-xs text-muted-foreground">
581
+ Full URL to the SSE endpoint of the MCP server
582
+ </p>
583
+ </div>
584
+ ) : (
585
+ <>
586
+ <div className="grid gap-1.5">
587
+ <Label htmlFor="command">
588
+ Command
589
+ </Label>
590
+ <Input
591
+ id="command"
592
+ value={newServer.command}
593
+ onChange={(e) => setNewServer({ ...newServer, command: e.target.value })}
594
+ placeholder="node"
595
+ className="relative z-0"
596
+ />
597
+ <p className="text-xs text-muted-foreground">
598
+ Executable to run (e.g., node, python)
599
+ </p>
600
+ </div>
601
+ <div className="grid gap-1.5">
602
+ <Label htmlFor="args">
603
+ Arguments
604
+ </Label>
605
+ <Input
606
+ id="args"
607
+ value={newServer.args?.join(' ') || ''}
608
+ onChange={(e) => handleArgsChange(e.target.value)}
609
+ placeholder="src/mcp-server.js --port 3001"
610
+ className="relative z-0"
611
+ />
612
+ <p className="text-xs text-muted-foreground">
613
+ Space-separated arguments or JSON array
614
+ </p>
615
+ </div>
616
+ </>
617
+ )}
618
+
619
+ {/* Advanced Configuration */}
620
+ <Accordion type="single" collapsible className="w-full">
621
+ <AccordionItem value="env-vars">
622
+ <AccordionTrigger className="text-sm py-2">
623
+ Environment Variables
624
+ </AccordionTrigger>
625
+ <AccordionContent>
626
+ <div className="space-y-3">
627
+ <div className="flex items-end gap-2">
628
+ <div className="flex-1">
629
+ <Label htmlFor="env-key" className="text-xs mb-1 block">
630
+ Key
631
+ </Label>
632
+ <Input
633
+ id="env-key"
634
+ value={newEnvVar.key}
635
+ onChange={(e) => setNewEnvVar({ ...newEnvVar, key: e.target.value })}
636
+ placeholder="API_KEY"
637
+ className="h-8 relative z-0"
638
+ />
639
+ </div>
640
+ <div className="flex-1">
641
+ <Label htmlFor="env-value" className="text-xs mb-1 block">
642
+ Value
643
+ </Label>
644
+ <Input
645
+ id="env-value"
646
+ value={newEnvVar.value}
647
+ onChange={(e) => setNewEnvVar({ ...newEnvVar, value: e.target.value })}
648
+ placeholder="your-secret-key"
649
+ className="h-8 relative z-0"
650
+ type="text"
651
+ />
652
+ </div>
653
+ <Button
654
+ type="button"
655
+ variant="outline"
656
+ size="sm"
657
+ onClick={addEnvVar}
658
+ disabled={!newEnvVar.key}
659
+ className="h-8 mt-1"
660
+ >
661
+ <Plus className="h-3.5 w-3.5" />
662
+ </Button>
663
+ </div>
664
+
665
+ {newServer.env && newServer.env.length > 0 ? (
666
+ <div className="border rounded-md divide-y">
667
+ {newServer.env.map((env, index) => (
668
+ <div key={index} className="flex items-center justify-between p-2 text-sm">
669
+ <div className="flex-1 flex items-center gap-1 truncate">
670
+ <span className="font-mono text-xs">{env.key}</span>
671
+ <span className="mx-2 text-muted-foreground">=</span>
672
+
673
+ {editingEnvIndex === index ? (
674
+ <div className="flex gap-1 flex-1">
675
+ <Input
676
+ className="h-6 text-xs py-1 px-2"
677
+ value={editedEnvValue}
678
+ onChange={(e) => setEditedEnvValue(e.target.value)}
679
+ onKeyDown={(e) => e.key === 'Enter' && saveEditedEnvValue()}
680
+ autoFocus
681
+ />
682
+ <Button
683
+ size="sm"
684
+ className="h-6 px-2"
685
+ onClick={saveEditedEnvValue}
686
+ >
687
+ Save
688
+ </Button>
689
+ </div>
690
+ ) : (
691
+ <>
692
+ <span className="text-xs text-muted-foreground truncate">
693
+ {isSensitiveKey(env.key) && !showSensitiveEnvValues[index]
694
+ ? maskValue(env.value)
695
+ : env.value}
696
+ </span>
697
+ <span className="flex ml-1 gap-1">
698
+ {isSensitiveKey(env.key) && (
699
+ <button
700
+ onClick={() => toggleSensitiveEnvValue(index)}
701
+ className="p-1 hover:bg-muted/50 rounded-full"
702
+ >
703
+ {showSensitiveEnvValues[index] ? (
704
+ <EyeOff className="h-3 w-3 text-muted-foreground" />
705
+ ) : (
706
+ <Eye className="h-3 w-3 text-muted-foreground" />
707
+ )}
708
+ </button>
709
+ )}
710
+ <button
711
+ onClick={() => startEditEnvValue(index, env.value)}
712
+ className="p-1 hover:bg-muted/50 rounded-full"
713
+ >
714
+ <Edit2 className="h-3 w-3 text-muted-foreground" />
715
+ </button>
716
+ </span>
717
+ </>
718
+ )}
719
+ </div>
720
+ <Button
721
+ type="button"
722
+ variant="ghost"
723
+ size="sm"
724
+ onClick={() => removeEnvVar(index)}
725
+ className="h-6 w-6 p-0 ml-2"
726
+ >
727
+ <X className="h-3 w-3" />
728
+ </Button>
729
+ </div>
730
+ ))}
731
+ </div>
732
+ ) : (
733
+ <p className="text-xs text-muted-foreground text-center py-2">
734
+ No environment variables added
735
+ </p>
736
+ )}
737
+ <p className="text-xs text-muted-foreground">
738
+ Environment variables will be passed to the MCP server process.
739
+ </p>
740
+ </div>
741
+ </AccordionContent>
742
+ </AccordionItem>
743
+
744
+ <AccordionItem value="headers">
745
+ <AccordionTrigger className="text-sm py-2">
746
+ {newServer.type === 'sse' ? 'HTTP Headers' : 'Additional Configuration'}
747
+ </AccordionTrigger>
748
+ <AccordionContent>
749
+ <div className="space-y-3">
750
+ <div className="flex items-end gap-2">
751
+ <div className="flex-1">
752
+ <Label htmlFor="header-key" className="text-xs mb-1 block">
753
+ Key
754
+ </Label>
755
+ <Input
756
+ id="header-key"
757
+ value={newHeader.key}
758
+ onChange={(e) => setNewHeader({ ...newHeader, key: e.target.value })}
759
+ placeholder="Authorization"
760
+ className="h-8 relative z-0"
761
+ />
762
+ </div>
763
+ <div className="flex-1">
764
+ <Label htmlFor="header-value" className="text-xs mb-1 block">
765
+ Value
766
+ </Label>
767
+ <Input
768
+ id="header-value"
769
+ value={newHeader.value}
770
+ onChange={(e) => setNewHeader({ ...newHeader, value: e.target.value })}
771
+ placeholder="Bearer token123"
772
+ className="h-8 relative z-0"
773
+ />
774
+ </div>
775
+ <Button
776
+ type="button"
777
+ variant="outline"
778
+ size="sm"
779
+ onClick={addHeader}
780
+ disabled={!newHeader.key}
781
+ className="h-8 mt-1"
782
+ >
783
+ <Plus className="h-3.5 w-3.5" />
784
+ </Button>
785
+ </div>
786
+
787
+ {newServer.headers && newServer.headers.length > 0 ? (
788
+ <div className="border rounded-md divide-y">
789
+ {newServer.headers.map((header, index) => (
790
+ <div key={index} className="flex items-center justify-between p-2 text-sm">
791
+ <div className="flex-1 flex items-center gap-1 truncate">
792
+ <span className="font-mono text-xs">{header.key}</span>
793
+ <span className="mx-2 text-muted-foreground">:</span>
794
+
795
+ {editingHeaderIndex === index ? (
796
+ <div className="flex gap-1 flex-1">
797
+ <Input
798
+ className="h-6 text-xs py-1 px-2"
799
+ value={editedHeaderValue}
800
+ onChange={(e) => setEditedHeaderValue(e.target.value)}
801
+ onKeyDown={(e) => e.key === 'Enter' && saveEditedHeaderValue()}
802
+ autoFocus
803
+ />
804
+ <Button
805
+ size="sm"
806
+ className="h-6 px-2"
807
+ onClick={saveEditedHeaderValue}
808
+ >
809
+ Save
810
+ </Button>
811
+ </div>
812
+ ) : (
813
+ <>
814
+ <span className="text-xs text-muted-foreground truncate">
815
+ {isSensitiveKey(header.key) && !showSensitiveHeaderValues[index]
816
+ ? maskValue(header.value)
817
+ : header.value}
818
+ </span>
819
+ <span className="flex ml-1 gap-1">
820
+ {isSensitiveKey(header.key) && (
821
+ <button
822
+ onClick={() => toggleSensitiveHeaderValue(index)}
823
+ className="p-1 hover:bg-muted/50 rounded-full"
824
+ >
825
+ {showSensitiveHeaderValues[index] ? (
826
+ <EyeOff className="h-3 w-3 text-muted-foreground" />
827
+ ) : (
828
+ <Eye className="h-3 w-3 text-muted-foreground" />
829
+ )}
830
+ </button>
831
+ )}
832
+ <button
833
+ onClick={() => startEditHeaderValue(index, header.value)}
834
+ className="p-1 hover:bg-muted/50 rounded-full"
835
+ >
836
+ <Edit2 className="h-3 w-3 text-muted-foreground" />
837
+ </button>
838
+ </span>
839
+ </>
840
+ )}
841
+ </div>
842
+ <Button
843
+ type="button"
844
+ variant="ghost"
845
+ size="sm"
846
+ onClick={() => removeHeader(index)}
847
+ className="h-6 w-6 p-0 ml-2"
848
+ >
849
+ <X className="h-3 w-3" />
850
+ </Button>
851
+ </div>
852
+ ))}
853
+ </div>
854
+ ) : (
855
+ <p className="text-xs text-muted-foreground text-center py-2">
856
+ No {newServer.type === 'sse' ? 'headers' : 'additional configuration'} added
857
+ </p>
858
+ )}
859
+ <p className="text-xs text-muted-foreground">
860
+ {newServer.type === 'sse'
861
+ ? 'HTTP headers will be sent with requests to the SSE endpoint.'
862
+ : 'Additional configuration parameters for the stdio transport.'}
863
+ </p>
864
+ </div>
865
+ </AccordionContent>
866
+ </AccordionItem>
867
+ </Accordion>
868
+ </div>
869
+ </div>
870
+ )}
871
+
872
+ {/* Persistent fixed footer with buttons */}
873
+ <div className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border flex justify-between">
874
+ {view === 'list' ? (
875
+ <>
876
+ <Button
877
+ variant="outline"
878
+ onClick={clearAllServers}
879
+ size="sm"
880
+ className="gap-1.5"
881
+ disabled={selectedServers.length === 0}
882
+ >
883
+ <X className="h-3.5 w-3.5" />
884
+ Disable All
885
+ </Button>
886
+ <Button
887
+ onClick={() => setView('add')}
888
+ size="sm"
889
+ className="gap-1.5"
890
+ >
891
+ <PlusCircle className="h-3.5 w-3.5" />
892
+ Add Server
893
+ </Button>
894
+ </>
895
+ ) : (
896
+ <>
897
+ <Button variant="outline" onClick={handleFormCancel}>
898
+ Cancel
899
+ </Button>
900
+ <Button
901
+ onClick={editingServerId ? updateServer : addServer}
902
+ disabled={
903
+ !newServer.name ||
904
+ (newServer.type === 'sse' && !newServer.url) ||
905
+ (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length))
906
+ }
907
+ >
908
+ {editingServerId ? "Save Changes" : "Add Server"}
909
+ </Button>
910
+ </>
911
+ )}
912
+ </div>
913
+ </DialogContent>
914
+ </Dialog>
915
+ );
916
+ };
components/message.tsx ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import type { Message as TMessage } from "ai";
4
+ import { AnimatePresence, motion } from "motion/react";
5
+ import { memo, useCallback, useEffect, useState } from "react";
6
+ import equal from "fast-deep-equal";
7
+ import { Markdown } from "./markdown";
8
+ import { cn } from "@/lib/utils";
9
+ import { ChevronDownIcon, ChevronUpIcon, LightbulbIcon } from "lucide-react";
10
+ import { SpinnerIcon } from "./icons";
11
+ import { ToolInvocation } from "./tool-invocation";
12
+
13
+ interface ReasoningPart {
14
+ type: "reasoning";
15
+ reasoning: string;
16
+ details: Array<{ type: "text"; text: string }>;
17
+ }
18
+
19
+ interface ReasoningMessagePartProps {
20
+ part: ReasoningPart;
21
+ isReasoning: boolean;
22
+ }
23
+
24
+ export function ReasoningMessagePart({
25
+ part,
26
+ isReasoning,
27
+ }: ReasoningMessagePartProps) {
28
+ const [isExpanded, setIsExpanded] = useState(false);
29
+
30
+ const memoizedSetIsExpanded = useCallback((value: boolean) => {
31
+ setIsExpanded(value);
32
+ }, []);
33
+
34
+ useEffect(() => {
35
+ memoizedSetIsExpanded(isReasoning);
36
+ }, [isReasoning, memoizedSetIsExpanded]);
37
+
38
+ return (
39
+ <div className="flex flex-col py-1">
40
+ {isReasoning ? (
41
+ <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
42
+ <div className="animate-spin">
43
+ <SpinnerIcon />
44
+ </div>
45
+ <div className="text-sm">Thinking...</div>
46
+ </div>
47
+ ) : (
48
+ <div className="flex items-center gap-2 group">
49
+ <div className="flex items-center gap-2">
50
+ <LightbulbIcon className="h-4 w-4 text-zinc-400" />
51
+ <div className="text-sm text-zinc-600 dark:text-zinc-300">Reasoning</div>
52
+ </div>
53
+ <button
54
+ className={cn(
55
+ "cursor-pointer rounded-md p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100",
56
+ {
57
+ "opacity-100 bg-zinc-100 dark:bg-zinc-800": isExpanded,
58
+ },
59
+ )}
60
+ onClick={() => setIsExpanded(!isExpanded)}
61
+ >
62
+ {isExpanded ? (
63
+ <ChevronDownIcon className="h-3.5 w-3.5" />
64
+ ) : (
65
+ <ChevronUpIcon className="h-3.5 w-3.5" />
66
+ )}
67
+ </button>
68
+ </div>
69
+ )}
70
+
71
+ <AnimatePresence initial={false}>
72
+ {isExpanded && (
73
+ <motion.div
74
+ key="reasoning"
75
+ className="text-sm text-zinc-600 dark:text-zinc-400 flex flex-col gap-3 border-l-2 pl-4 mt-2 border-zinc-200 dark:border-zinc-700 overflow-hidden"
76
+ initial={{ height: 0, opacity: 0 }}
77
+ animate={{ height: "auto", opacity: 1 }}
78
+ exit={{ height: 0, opacity: 0 }}
79
+ transition={{ duration: 0.2, ease: "easeInOut" }}
80
+ >
81
+ {part.details.map((detail, detailIndex) =>
82
+ detail.type === "text" ? (
83
+ <Markdown key={detailIndex}>{detail.text}</Markdown>
84
+ ) : (
85
+ "<redacted>"
86
+ ),
87
+ )}
88
+ </motion.div>
89
+ )}
90
+ </AnimatePresence>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ const PurePreviewMessage = ({
96
+ message,
97
+ isLatestMessage,
98
+ status,
99
+ }: {
100
+ message: TMessage;
101
+ isLoading: boolean;
102
+ status: "error" | "submitted" | "streaming" | "ready";
103
+ isLatestMessage: boolean;
104
+ }) => {
105
+ return (
106
+ <AnimatePresence key={message.id}>
107
+ <motion.div
108
+ className={cn(
109
+ "w-full mx-auto px-4 group/message",
110
+ message.role === "assistant" ? "mb-8" : "mb-6"
111
+ )}
112
+ initial={{ y: 5, opacity: 0 }}
113
+ animate={{ y: 0, opacity: 1 }}
114
+ key={`message-${message.id}`}
115
+ data-role={message.role}
116
+ >
117
+ <div
118
+ className={cn(
119
+ "flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl",
120
+ "group-data-[role=user]/message:w-fit",
121
+ )}
122
+ >
123
+ <div className="flex flex-col w-full space-y-3">
124
+ {message.parts?.map((part, i) => {
125
+ switch (part.type) {
126
+ case "text":
127
+ return (
128
+ <motion.div
129
+ initial={{ y: 5, opacity: 0 }}
130
+ animate={{ y: 0, opacity: 1 }}
131
+ key={`message-${message.id}-part-${i}`}
132
+ className="flex flex-row gap-2 items-start w-full"
133
+ >
134
+ <div
135
+ className={cn("flex flex-col gap-3 w-full", {
136
+ "bg-secondary text-secondary-foreground px-4 py-3 rounded-2xl":
137
+ message.role === "user",
138
+ })}
139
+ >
140
+ <Markdown>{part.text}</Markdown>
141
+ </div>
142
+ </motion.div>
143
+ );
144
+ case "tool-invocation":
145
+ const { toolName, state, args } = part.toolInvocation;
146
+ const result = 'result' in part.toolInvocation ? part.toolInvocation.result : null;
147
+
148
+ return (
149
+ <ToolInvocation
150
+ key={`message-${message.id}-part-${i}`}
151
+ toolName={toolName}
152
+ state={state}
153
+ args={args}
154
+ result={result}
155
+ isLatestMessage={isLatestMessage}
156
+ status={status}
157
+ />
158
+ );
159
+ case "reasoning":
160
+ return (
161
+ <ReasoningMessagePart
162
+ key={`message-${message.id}-${i}`}
163
+ // @ts-expect-error part
164
+ part={part}
165
+ isReasoning={
166
+ (message.parts &&
167
+ status === "streaming" &&
168
+ i === message.parts.length - 1) ??
169
+ false
170
+ }
171
+ />
172
+ );
173
+ default:
174
+ return null;
175
+ }
176
+ })}
177
+ </div>
178
+ </div>
179
+ </motion.div>
180
+ </AnimatePresence>
181
+ );
182
+ };
183
+
184
+ export const Message = memo(PurePreviewMessage, (prevProps, nextProps) => {
185
+ if (prevProps.status !== nextProps.status) return false;
186
+ if (prevProps.message.annotations !== nextProps.message.annotations)
187
+ return false;
188
+ if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
189
+ return true;
190
+ });
components/messages.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message as TMessage } from "ai";
2
+ import { Message } from "./message";
3
+ import { useScrollToBottom } from "@/lib/hooks/use-scroll-to-bottom";
4
+
5
+ export const Messages = ({
6
+ messages,
7
+ isLoading,
8
+ status,
9
+ }: {
10
+ messages: TMessage[];
11
+ isLoading: boolean;
12
+ status: "error" | "submitted" | "streaming" | "ready";
13
+ }) => {
14
+ // const [containerRef, endRef] = useScrollToBottom();
15
+ return (
16
+ <div
17
+ className="h-full overflow-y-auto"
18
+ // ref={containerRef}
19
+ >
20
+ <div className="max-w-xl mx-auto py-4">
21
+ {messages.map((m, i) => (
22
+ <Message
23
+ key={i}
24
+ isLatestMessage={i === messages.length - 1}
25
+ isLoading={isLoading}
26
+ message={m}
27
+ status={status}
28
+ />
29
+ ))}
30
+ {/* <div className="h-1" ref={endRef} /> */}
31
+ </div>
32
+ </div>
33
+ );
34
+ };
components/model-picker.tsx ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { MODELS, modelDetails, type modelID, defaultModel } from "@/ai/providers";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectGroup,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "./ui/select";
11
+ import { cn } from "@/lib/utils";
12
+ import { Sparkles, Zap, Info, Bolt, Code, Brain, Lightbulb, Image, Gauge, Rocket, Bot } from "lucide-react";
13
+ import { useState, useEffect } from "react";
14
+ import { TextMorph } from "./ui/text-morph";
15
+
16
+ interface ModelPickerProps {
17
+ selectedModel: modelID;
18
+ setSelectedModel: (model: modelID) => void;
19
+ }
20
+
21
+ export const ModelPicker = ({ selectedModel, setSelectedModel }: ModelPickerProps) => {
22
+ const [hoveredModel, setHoveredModel] = useState<modelID | null>(null);
23
+
24
+ // Ensure we always have a valid model ID
25
+ const validModelId = MODELS.includes(selectedModel) ? selectedModel : defaultModel;
26
+
27
+ // If the selected model is invalid, update it to the default
28
+ useEffect(() => {
29
+ if (selectedModel !== validModelId) {
30
+ setSelectedModel(validModelId as modelID);
31
+ }
32
+ }, [selectedModel, validModelId, setSelectedModel]);
33
+
34
+ // Function to get the appropriate icon for each provider
35
+ const getProviderIcon = (provider: string) => {
36
+ switch (provider.toLowerCase()) {
37
+ case 'xai':
38
+ return <Sparkles className="h-3 w-3 text-yellow-500" />;
39
+ case 'openai':
40
+ return <Zap className="h-3 w-3 text-green-500" />;
41
+ default:
42
+ return <Info className="h-3 w-3 text-blue-500" />;
43
+ }
44
+ };
45
+
46
+ // Function to get capability icon
47
+ const getCapabilityIcon = (capability: string) => {
48
+ switch (capability.toLowerCase()) {
49
+ case 'code':
50
+ return <Code className="h-2.5 w-2.5" />;
51
+ case 'reasoning':
52
+ return <Brain className="h-2.5 w-2.5" />;
53
+ case 'research':
54
+ return <Lightbulb className="h-2.5 w-2.5" />;
55
+ case 'vision':
56
+ return <Image className="h-2.5 w-2.5" />;
57
+ case 'fast':
58
+ case 'rapid':
59
+ return <Bolt className="h-2.5 w-2.5" />;
60
+ case 'efficient':
61
+ case 'compact':
62
+ return <Gauge className="h-2.5 w-2.5" />;
63
+ case 'creative':
64
+ case 'balance':
65
+ return <Rocket className="h-2.5 w-2.5" />;
66
+ case 'agentic':
67
+ return <Bot className="h-2.5 w-2.5" />;
68
+ default:
69
+ return <Info className="h-2.5 w-2.5" />;
70
+ }
71
+ };
72
+
73
+ // Get capability badge color
74
+ const getCapabilityColor = (capability: string) => {
75
+ switch (capability.toLowerCase()) {
76
+ case 'code':
77
+ return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
78
+ case 'reasoning':
79
+ case 'research':
80
+ return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300";
81
+ case 'vision':
82
+ return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300";
83
+ case 'fast':
84
+ case 'rapid':
85
+ return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300";
86
+ case 'efficient':
87
+ case 'compact':
88
+ return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300";
89
+ case 'creative':
90
+ case 'balance':
91
+ return "bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300";
92
+ case 'agentic':
93
+ return "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300";
94
+ default:
95
+ return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300";
96
+ }
97
+ };
98
+
99
+ // Get current model details to display
100
+ const displayModelId = hoveredModel || validModelId;
101
+ const currentModelDetails = modelDetails[displayModelId];
102
+
103
+ // Handle model change
104
+ const handleModelChange = (modelId: string) => {
105
+ if (MODELS.includes(modelId)) {
106
+ const typedModelId = modelId as modelID;
107
+ setSelectedModel(typedModelId);
108
+ }
109
+ };
110
+
111
+ return (
112
+ <div className="absolute bottom-2 left-2">
113
+ <Select
114
+ value={validModelId}
115
+ onValueChange={handleModelChange}
116
+ defaultValue={validModelId}
117
+ >
118
+ <SelectTrigger
119
+ className="w-48 px-3 h-9 rounded-full group border-border/80 bg-background/80 backdrop-blur-sm hover:bg-muted/30 transition-all duration-200"
120
+ >
121
+ <SelectValue
122
+ placeholder="Select model"
123
+ className="text-xs font-medium flex items-center gap-2"
124
+ >
125
+ <div className="flex items-center gap-2">
126
+ {getProviderIcon(modelDetails[validModelId].provider)}
127
+ <TextMorph>{modelDetails[validModelId].name}</TextMorph>
128
+ </div>
129
+ </SelectValue>
130
+ </SelectTrigger>
131
+ <SelectContent
132
+ align="start"
133
+ className="bg-background/95 dark:bg-muted/95 backdrop-blur-sm border-border/80 rounded-lg overflow-hidden p-0"
134
+ style={{ width: "485px" }}
135
+ >
136
+ <div className="grid grid-cols-[170px_1fr] items-start gap-x-4">
137
+ {/* Model selector column */}
138
+ <div className="border-r border-border/40 bg-muted/20 p-2">
139
+ <SelectGroup className="space-y-1">
140
+ {MODELS.map((id) => {
141
+ const modelId = id as modelID;
142
+ return (
143
+ <SelectItem
144
+ key={id}
145
+ value={id}
146
+ onMouseEnter={() => setHoveredModel(modelId)}
147
+ onMouseLeave={() => setHoveredModel(null)}
148
+ className={cn(
149
+ "!px-3 py-2 cursor-pointer rounded-md text-xs transition-colors duration-150",
150
+ "hover:bg-primary/5 hover:text-primary-foreground",
151
+ "focus:bg-primary/10 focus:text-primary focus:outline-none",
152
+ "data-[highlighted]:bg-primary/10 data-[highlighted]:text-primary",
153
+ validModelId === id && "!bg-primary/15 !text-primary font-medium"
154
+ )}
155
+ >
156
+ <div className="flex flex-col gap-0.5">
157
+ <div className="flex items-center gap-2">
158
+ {getProviderIcon(modelDetails[modelId].provider)}
159
+ <span className="font-medium truncate">{modelDetails[modelId].name}</span>
160
+ </div>
161
+ <span className="text-xs text-muted-foreground">
162
+ {modelDetails[modelId].provider}
163
+ </span>
164
+ </div>
165
+ </SelectItem>
166
+ );
167
+ })}
168
+ </SelectGroup>
169
+ </div>
170
+
171
+ {/* Model details column */}
172
+ <div className="p-4 flex flex-col">
173
+ <div>
174
+ <div className="flex items-center gap-2 mb-1">
175
+ {getProviderIcon(currentModelDetails.provider)}
176
+ <h3 className="text-sm font-semibold">{currentModelDetails.name}</h3>
177
+ </div>
178
+ <div className="text-xs text-muted-foreground mb-1">
179
+ Provider: <span className="font-medium">{currentModelDetails.provider}</span>
180
+ </div>
181
+
182
+ {/* Capability badges */}
183
+ <div className="flex flex-wrap gap-1 mt-2 mb-3">
184
+ {currentModelDetails.capabilities.map((capability) => (
185
+ <span
186
+ key={capability}
187
+ className={cn(
188
+ "inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded-full font-medium",
189
+ getCapabilityColor(capability)
190
+ )}
191
+ >
192
+ {getCapabilityIcon(capability)}
193
+ <span>{capability}</span>
194
+ </span>
195
+ ))}
196
+ </div>
197
+
198
+ <div className="text-xs text-foreground/90 leading-relaxed mb-3">
199
+ {currentModelDetails.description}
200
+ </div>
201
+ </div>
202
+
203
+ <div className="bg-muted/40 rounded-md p-2">
204
+ <div className="text-[10px] text-muted-foreground flex justify-between items-center">
205
+ <span>API Version:</span>
206
+ <code className="bg-background/80 px-2 py-0.5 rounded text-[10px] font-mono">
207
+ {currentModelDetails.apiVersion}
208
+ </code>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </SelectContent>
214
+ </Select>
215
+ </div>
216
+ );
217
+ };
components/project-overview.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import NextLink from "next/link";
2
+ export const ProjectOverview = () => {
3
+ return (
4
+ <div className="flex flex-col items-center justify-end">
5
+ <h1 className="text-3xl font-semibold mb-4">Scira MCP Chat</h1>
6
+ </div>
7
+ );
8
+ };
9
+
10
+ const Link = ({
11
+ children,
12
+ href,
13
+ }: {
14
+ children: React.ReactNode;
15
+ href: string;
16
+ }) => {
17
+ return (
18
+ <NextLink
19
+ target="_blank"
20
+ className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors duration-75"
21
+ href={href}
22
+ >
23
+ {children}
24
+ </NextLink>
25
+ );
26
+ };
components/suggested-prompts.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { motion } from "motion/react";
4
+ import { Button } from "./ui/button";
5
+ import { memo } from "react";
6
+
7
+ interface SuggestedPromptsProps {
8
+ sendMessage: (input: string) => void;
9
+ }
10
+
11
+ function PureSuggestedPrompts({ sendMessage }: SuggestedPromptsProps) {
12
+ const suggestedActions = [
13
+ {
14
+ title: "What are the advantages",
15
+ label: "of using Next.js?",
16
+ action: "What are the advantages of using Next.js?",
17
+ },
18
+ {
19
+ title: "What is the weather",
20
+ label: "in San Francisco?",
21
+ action: "What is the weather in San Francisco?",
22
+ },
23
+ ];
24
+
25
+ return (
26
+ <div
27
+ data-testid="suggested-actions"
28
+ className="grid sm:grid-cols-2 gap-2 w-full"
29
+ >
30
+ {suggestedActions.map((suggestedAction, index) => (
31
+ <motion.div
32
+ initial={{ opacity: 0, y: 20 }}
33
+ animate={{ opacity: 1, y: 0 }}
34
+ exit={{ opacity: 0, y: 20 }}
35
+ transition={{ delay: 0.05 * index }}
36
+ key={`suggested-action-${suggestedAction.title}-${index}`}
37
+ className={index > 1 ? "hidden sm:block" : "block"}
38
+ >
39
+ <Button
40
+ variant="ghost"
41
+ onClick={async () => {
42
+ sendMessage(suggestedAction.action);
43
+ }}
44
+ className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
45
+ >
46
+ <span className="font-medium">{suggestedAction.title}</span>
47
+ <span className="text-muted-foreground">
48
+ {suggestedAction.label}
49
+ </span>
50
+ </Button>
51
+ </motion.div>
52
+ ))}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export const SuggestedPrompts = memo(PureSuggestedPrompts, () => true);
components/textarea.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { modelID } from "@/ai/providers";
2
+ import { Textarea as ShadcnTextarea } from "@/components/ui/textarea";
3
+ import { ArrowUp, Loader2 } from "lucide-react";
4
+ import { ModelPicker } from "./model-picker";
5
+
6
+ interface InputProps {
7
+ input: string;
8
+ handleInputChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
9
+ isLoading: boolean;
10
+ status: string;
11
+ stop: () => void;
12
+ selectedModel: modelID;
13
+ setSelectedModel: (model: modelID) => void;
14
+ }
15
+
16
+ export const Textarea = ({
17
+ input,
18
+ handleInputChange,
19
+ isLoading,
20
+ status,
21
+ stop,
22
+ selectedModel,
23
+ setSelectedModel,
24
+ }: InputProps) => {
25
+ const isStreaming = status === "streaming" || status === "submitted";
26
+
27
+ return (
28
+ <div className="relative w-full">
29
+ <ShadcnTextarea
30
+ className="resize-none bg-background/50 dark:bg-muted/50 backdrop-blur-sm w-full rounded-2xl pr-12 pt-4 pb-16 border-input focus-visible:ring-ring placeholder:text-muted-foreground"
31
+ value={input}
32
+ autoFocus
33
+ placeholder="Send a message..."
34
+ onChange={handleInputChange}
35
+ onKeyDown={(e) => {
36
+ if (e.key === "Enter" && !e.shiftKey && !isLoading && input.trim()) {
37
+ e.preventDefault();
38
+ e.currentTarget.form?.requestSubmit();
39
+ }
40
+ }}
41
+ />
42
+ <ModelPicker
43
+ setSelectedModel={setSelectedModel}
44
+ selectedModel={selectedModel}
45
+ />
46
+
47
+ <button
48
+ type={isStreaming ? "button" : "submit"}
49
+ onClick={isStreaming ? stop : undefined}
50
+ disabled={(!isStreaming && !input.trim()) || (isStreaming && status === "submitted")}
51
+ className="absolute right-2 bottom-2 rounded-full p-2 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:cursor-not-allowed transition-all duration-200"
52
+ >
53
+ {isStreaming ? (
54
+ <Loader2 className="h-4 w-4 text-primary-foreground animate-spin" />
55
+ ) : (
56
+ <ArrowUp className="h-4 w-4 text-primary-foreground" />
57
+ )}
58
+ </button>
59
+ </div>
60
+ );
61
+ };
components/theme-provider.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ThemeProvider as NextThemesProvider } from "next-themes"
5
+ import { type ThemeProviderProps } from "next-themes"
6
+
7
+ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>
9
+ }
components/theme-toggle.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Moon, Sun } from "lucide-react"
5
+ import { useTheme } from "next-themes"
6
+ import { Button } from "./ui/button"
7
+
8
+ export function ThemeToggle({ className, ...props }: React.ComponentProps<typeof Button>) {
9
+ const { theme, setTheme } = useTheme()
10
+
11
+ return (
12
+ <Button
13
+ variant="ghost"
14
+ size="icon"
15
+ onClick={() => setTheme(theme === "light" ? "dark" : "light")}
16
+ className={`rounded-md h-8 w-8 ${className}`}
17
+ {...props}
18
+ >
19
+ <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
20
+ <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
21
+ <span className="sr-only">Toggle theme</span>
22
+ </Button>
23
+ )
24
+ }
components/tool-invocation.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { motion, AnimatePresence } from "motion/react";
5
+ import {
6
+ ChevronDownIcon,
7
+ ChevronUpIcon,
8
+ Loader2,
9
+ PocketKnife,
10
+ CheckCircle,
11
+ StopCircle,
12
+ Code2,
13
+ Terminal,
14
+ } from "lucide-react";
15
+
16
+ interface ToolInvocationProps {
17
+ toolName: string;
18
+ state: string;
19
+ args: any;
20
+ result: any;
21
+ isLatestMessage: boolean;
22
+ status: string;
23
+ }
24
+
25
+ export function ToolInvocation({
26
+ toolName,
27
+ state,
28
+ args,
29
+ result,
30
+ isLatestMessage,
31
+ status,
32
+ }: ToolInvocationProps) {
33
+ const [isExpanded, setIsExpanded] = useState(false);
34
+
35
+ const variants = {
36
+ collapsed: {
37
+ height: 0,
38
+ opacity: 0,
39
+ },
40
+ expanded: {
41
+ height: "auto",
42
+ opacity: 1,
43
+ },
44
+ };
45
+
46
+ const getStatusIcon = () => {
47
+ if (state === "call") {
48
+ if (isLatestMessage && status !== "ready") {
49
+ return <Loader2 className="animate-spin h-4 w-4 text-muted-foreground" />;
50
+ }
51
+ return <StopCircle className="h-4 w-4 text-destructive" />;
52
+ }
53
+ return <CheckCircle size={14} className="text-success" />;
54
+ };
55
+
56
+ const formatContent = (content: any): string => {
57
+ try {
58
+ if (typeof content === "string") {
59
+ try {
60
+ const parsed = JSON.parse(content);
61
+ return JSON.stringify(parsed, null, 2);
62
+ } catch {
63
+ return content;
64
+ }
65
+ }
66
+ return JSON.stringify(content, null, 2);
67
+ } catch {
68
+ return String(content);
69
+ }
70
+ };
71
+
72
+ return (
73
+ <div className="flex flex-col gap-2 p-4 mb-4 bg-muted/50 rounded-xl border border-border backdrop-blur-sm">
74
+ <div className="flex items-center gap-3">
75
+ <div className="flex items-center justify-center w-8 h-8 bg-muted rounded-lg">
76
+ <PocketKnife className="h-4 w-4" />
77
+ </div>
78
+ <div className="flex-1 flex items-center gap-2">
79
+ <span className="text-sm font-medium">
80
+ {state === "call" ? "Calling" : "Called"}
81
+ </span>
82
+ <code className="px-2 py-1 text-xs font-mono rounded-md bg-muted text-muted-foreground">
83
+ {toolName}
84
+ </code>
85
+ </div>
86
+ <div className="flex items-center gap-2">
87
+ {getStatusIcon()}
88
+ <button
89
+ onClick={() => setIsExpanded(!isExpanded)}
90
+ className="p-1 hover:bg-muted rounded-md transition-colors"
91
+ >
92
+ {isExpanded ? (
93
+ <ChevronUpIcon className="h-4 w-4" />
94
+ ) : (
95
+ <ChevronDownIcon className="h-4 w-4" />
96
+ )}
97
+ </button>
98
+ </div>
99
+ </div>
100
+
101
+ <AnimatePresence initial={false}>
102
+ {isExpanded && (
103
+ <motion.div
104
+ initial="collapsed"
105
+ animate="expanded"
106
+ exit="collapsed"
107
+ variants={variants}
108
+ transition={{ duration: 0.2 }}
109
+ className="space-y-3 pt-2"
110
+ >
111
+ {!!args && (
112
+ <div className="space-y-1.5">
113
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
114
+ <Terminal className="h-3.5 w-3.5" />
115
+ <span>Arguments</span>
116
+ </div>
117
+ <pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto">
118
+ {formatContent(args)}
119
+ </pre>
120
+ </div>
121
+ )}
122
+
123
+ {!!result && (
124
+ <div className="space-y-1.5">
125
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
126
+ <Code2 className="h-3.5 w-3.5" />
127
+ <span>Result</span>
128
+ </div>
129
+ <pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto max-h-[300px] overflow-y-auto">
130
+ {formatContent(result)}
131
+ </pre>
132
+ </div>
133
+ )}
134
+ </motion.div>
135
+ )}
136
+ </AnimatePresence>
137
+ </div>
138
+ );
139
+ }
components/ui/accordion.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
5
+ import { ChevronDownIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Accordion({
10
+ ...props
11
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
12
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
13
+ }
14
+
15
+ function AccordionItem({
16
+ className,
17
+ ...props
18
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
19
+ return (
20
+ <AccordionPrimitive.Item
21
+ data-slot="accordion-item"
22
+ className={cn("mb-1", className)}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ function AccordionTrigger({
29
+ className,
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
33
+ return (
34
+ <AccordionPrimitive.Header className="flex">
35
+ <AccordionPrimitive.Trigger
36
+ data-slot="accordion-trigger"
37
+ className={cn(
38
+ "focus-visible:ring-ring/30 flex flex-1 items-center justify-between py-3 text-left text-sm font-medium transition-all outline-none focus-visible:ring-2 rounded-md disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
39
+ className
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ <ChevronDownIcon className="text-muted-foreground/70 size-3.5 shrink-0 transition-transform duration-200" />
45
+ </AccordionPrimitive.Trigger>
46
+ </AccordionPrimitive.Header>
47
+ )
48
+ }
49
+
50
+ function AccordionContent({
51
+ className,
52
+ children,
53
+ ...props
54
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
55
+ return (
56
+ <AccordionPrimitive.Content
57
+ data-slot="accordion-content"
58
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
59
+ {...props}
60
+ >
61
+ <div className={cn("py-2 pl-1", className)}>{children}</div>
62
+ </AccordionPrimitive.Content>
63
+ )
64
+ }
65
+
66
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
components/ui/badge.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14
+ secondary:
15
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16
+ destructive:
17
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18
+ outline:
19
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ }
26
+ )
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : "span"
36
+
37
+ return (
38
+ <Comp
39
+ data-slot="badge"
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ export { Badge, badgeVariants }
components/ui/button.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "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",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
+ outline:
17
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
+ ghost:
21
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ size: "default",
34
+ },
35
+ }
36
+ )
37
+
38
+ function Button({
39
+ className,
40
+ variant,
41
+ size,
42
+ asChild = false,
43
+ ...props
44
+ }: React.ComponentProps<"button"> &
45
+ VariantProps<typeof buttonVariants> & {
46
+ asChild?: boolean
47
+ }) {
48
+ const Comp = asChild ? Slot : "button"
49
+
50
+ return (
51
+ <Comp
52
+ data-slot="button"
53
+ className={cn(buttonVariants({ variant, size, className }))}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ export { Button, buttonVariants }
components/ui/dialog.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { XIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Dialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof DialogPrimitive.Root>) {
12
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
13
+ }
14
+
15
+ function DialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19
+ }
20
+
21
+ function DialogPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
25
+ }
26
+
27
+ function DialogClose({
28
+ ...props
29
+ }: React.ComponentProps<typeof DialogPrimitive.Close>) {
30
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
31
+ }
32
+
33
+ function DialogOverlay({
34
+ className,
35
+ ...props
36
+ }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
37
+ return (
38
+ <DialogPrimitive.Overlay
39
+ data-slot="dialog-overlay"
40
+ className={cn(
41
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
42
+ className
43
+ )}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+
49
+ function DialogContent({
50
+ className,
51
+ children,
52
+ ...props
53
+ }: React.ComponentProps<typeof DialogPrimitive.Content>) {
54
+ return (
55
+ <DialogPortal data-slot="dialog-portal">
56
+ <DialogOverlay />
57
+ <DialogPrimitive.Content
58
+ data-slot="dialog-content"
59
+ className={cn(
60
+ "bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
61
+ className
62
+ )}
63
+ {...props}
64
+ >
65
+ {children}
66
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
67
+ <XIcon />
68
+ <span className="sr-only">Close</span>
69
+ </DialogPrimitive.Close>
70
+ </DialogPrimitive.Content>
71
+ </DialogPortal>
72
+ )
73
+ }
74
+
75
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76
+ return (
77
+ <div
78
+ data-slot="dialog-header"
79
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
80
+ {...props}
81
+ />
82
+ )
83
+ }
84
+
85
+ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
86
+ return (
87
+ <div
88
+ data-slot="dialog-footer"
89
+ className={cn(
90
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
91
+ className
92
+ )}
93
+ {...props}
94
+ />
95
+ )
96
+ }
97
+
98
+ function DialogTitle({
99
+ className,
100
+ ...props
101
+ }: React.ComponentProps<typeof DialogPrimitive.Title>) {
102
+ return (
103
+ <DialogPrimitive.Title
104
+ data-slot="dialog-title"
105
+ className={cn("text-lg leading-none font-semibold", className)}
106
+ {...props}
107
+ />
108
+ )
109
+ }
110
+
111
+ function DialogDescription({
112
+ className,
113
+ ...props
114
+ }: React.ComponentProps<typeof DialogPrimitive.Description>) {
115
+ return (
116
+ <DialogPrimitive.Description
117
+ data-slot="dialog-description"
118
+ className={cn("text-muted-foreground text-sm", className)}
119
+ {...props}
120
+ />
121
+ )
122
+ }
123
+
124
+ export {
125
+ Dialog,
126
+ DialogClose,
127
+ DialogContent,
128
+ DialogDescription,
129
+ DialogFooter,
130
+ DialogHeader,
131
+ DialogOverlay,
132
+ DialogPortal,
133
+ DialogTitle,
134
+ DialogTrigger,
135
+ }
components/ui/input.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex 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",
12
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }
components/ui/label.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as LabelPrimitive from "@radix-ui/react-label"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Label({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
12
+ return (
13
+ <LabelPrimitive.Root
14
+ data-slot="label"
15
+ className={cn(
16
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ export { Label }
components/ui/scroll-area.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function ScrollArea({
9
+ className,
10
+ children,
11
+ ...props
12
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
13
+ return (
14
+ <ScrollAreaPrimitive.Root
15
+ data-slot="scroll-area"
16
+ className={cn("relative", className)}
17
+ {...props}
18
+ >
19
+ <ScrollAreaPrimitive.Viewport
20
+ data-slot="scroll-area-viewport"
21
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
22
+ >
23
+ {children}
24
+ </ScrollAreaPrimitive.Viewport>
25
+ <ScrollBar />
26
+ <ScrollAreaPrimitive.Corner />
27
+ </ScrollAreaPrimitive.Root>
28
+ )
29
+ }
30
+
31
+ function ScrollBar({
32
+ className,
33
+ orientation = "vertical",
34
+ ...props
35
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
36
+ return (
37
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
38
+ data-slot="scroll-area-scrollbar"
39
+ orientation={orientation}
40
+ className={cn(
41
+ "flex touch-none p-px transition-colors select-none",
42
+ orientation === "vertical" &&
43
+ "h-full w-2.5 border-l border-l-transparent",
44
+ orientation === "horizontal" &&
45
+ "h-2.5 flex-col border-t border-t-transparent",
46
+ className
47
+ )}
48
+ {...props}
49
+ >
50
+ <ScrollAreaPrimitive.ScrollAreaThumb
51
+ data-slot="scroll-area-thumb"
52
+ className="bg-border relative flex-1 rounded-full"
53
+ />
54
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
55
+ )
56
+ }
57
+
58
+ export { ScrollArea, ScrollBar }
components/ui/select.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SelectPrimitive from "@radix-ui/react-select"
5
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Select({
10
+ ...props
11
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
+ return <SelectPrimitive.Root data-slot="select" {...props} />
13
+ }
14
+
15
+ function SelectGroup({
16
+ ...props
17
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
19
+ }
20
+
21
+ function SelectValue({
22
+ ...props
23
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
25
+ }
26
+
27
+ function SelectTrigger({
28
+ className,
29
+ children,
30
+ ...props
31
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
32
+ return (
33
+ <SelectPrimitive.Trigger
34
+ data-slot="select-trigger"
35
+ className={cn(
36
+ "text-muted-foreground data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-secondary data-[state=open]:bg-secondary disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
37
+ className
38
+ )}
39
+ {...props}
40
+ >
41
+ {children}
42
+ <SelectPrimitive.Icon asChild>
43
+ <ChevronDownIcon className="size-4 opacity-50" />
44
+ </SelectPrimitive.Icon>
45
+ </SelectPrimitive.Trigger>
46
+ )
47
+ }
48
+
49
+ function SelectContent({
50
+ className,
51
+ children,
52
+ position = "popper",
53
+ ...props
54
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
55
+ return (
56
+ <SelectPrimitive.Portal>
57
+ <SelectPrimitive.Content
58
+ data-slot="select-content"
59
+ className={cn(
60
+ "bg-secondary text-secondary-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
61
+ position === "popper" &&
62
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
63
+ className
64
+ )}
65
+ position={position}
66
+ {...props}
67
+ >
68
+ <SelectScrollUpButton />
69
+ <SelectPrimitive.Viewport
70
+ className={cn(
71
+ "p-1",
72
+ position === "popper" &&
73
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
74
+ )}
75
+ >
76
+ {children}
77
+ </SelectPrimitive.Viewport>
78
+ <SelectScrollDownButton />
79
+ </SelectPrimitive.Content>
80
+ </SelectPrimitive.Portal>
81
+ )
82
+ }
83
+
84
+ function SelectLabel({
85
+ className,
86
+ ...props
87
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
88
+ return (
89
+ <SelectPrimitive.Label
90
+ data-slot="select-label"
91
+ className={cn("px-2 py-1.5 text-sm font-medium", className)}
92
+ {...props}
93
+ />
94
+ )
95
+ }
96
+
97
+ function SelectItem({
98
+ className,
99
+ children,
100
+ ...props
101
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
102
+ return (
103
+ <SelectPrimitive.Item
104
+ data-slot="select-item"
105
+ className={cn(
106
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
107
+ className
108
+ )}
109
+ {...props}
110
+ >
111
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
112
+ <SelectPrimitive.ItemIndicator>
113
+ <CheckIcon className="size-4" />
114
+ </SelectPrimitive.ItemIndicator>
115
+ </span>
116
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
117
+ </SelectPrimitive.Item>
118
+ )
119
+ }
120
+
121
+ function SelectSeparator({
122
+ className,
123
+ ...props
124
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
125
+ return (
126
+ <SelectPrimitive.Separator
127
+ data-slot="select-separator"
128
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
129
+ {...props}
130
+ />
131
+ )
132
+ }
133
+
134
+ function SelectScrollUpButton({
135
+ className,
136
+ ...props
137
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
138
+ return (
139
+ <SelectPrimitive.ScrollUpButton
140
+ data-slot="select-scroll-up-button"
141
+ className={cn(
142
+ "flex cursor-default items-center justify-center py-1",
143
+ className
144
+ )}
145
+ {...props}
146
+ >
147
+ <ChevronUpIcon className="size-4" />
148
+ </SelectPrimitive.ScrollUpButton>
149
+ )
150
+ }
151
+
152
+ function SelectScrollDownButton({
153
+ className,
154
+ ...props
155
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
156
+ return (
157
+ <SelectPrimitive.ScrollDownButton
158
+ data-slot="select-scroll-down-button"
159
+ className={cn(
160
+ "flex cursor-default items-center justify-center py-1",
161
+ className
162
+ )}
163
+ {...props}
164
+ >
165
+ <ChevronDownIcon className="size-4" />
166
+ </SelectPrimitive.ScrollDownButton>
167
+ )
168
+ }
169
+
170
+ export {
171
+ Select,
172
+ SelectContent,
173
+ SelectGroup,
174
+ SelectItem,
175
+ SelectLabel,
176
+ SelectScrollDownButton,
177
+ SelectScrollUpButton,
178
+ SelectSeparator,
179
+ SelectTrigger,
180
+ SelectValue,
181
+ }
components/ui/separator.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SeparatorPrimitive from "@radix-ui/react-separator"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Separator({
9
+ className,
10
+ orientation = "horizontal",
11
+ decorative = true,
12
+ ...props
13
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
14
+ return (
15
+ <SeparatorPrimitive.Root
16
+ data-slot="separator-root"
17
+ decorative={decorative}
18
+ orientation={orientation}
19
+ className={cn(
20
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ export { Separator }
components/ui/sheet.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SheetPrimitive from "@radix-ui/react-dialog"
5
+ import { XIcon } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
10
+ return <SheetPrimitive.Root data-slot="sheet" {...props} />
11
+ }
12
+
13
+ function SheetTrigger({
14
+ ...props
15
+ }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
16
+ return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
17
+ }
18
+
19
+ function SheetClose({
20
+ ...props
21
+ }: React.ComponentProps<typeof SheetPrimitive.Close>) {
22
+ return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
23
+ }
24
+
25
+ function SheetPortal({
26
+ ...props
27
+ }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
28
+ return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
29
+ }
30
+
31
+ function SheetOverlay({
32
+ className,
33
+ ...props
34
+ }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
35
+ return (
36
+ <SheetPrimitive.Overlay
37
+ data-slot="sheet-overlay"
38
+ className={cn(
39
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function SheetContent({
48
+ className,
49
+ children,
50
+ side = "right",
51
+ ...props
52
+ }: React.ComponentProps<typeof SheetPrimitive.Content> & {
53
+ side?: "top" | "right" | "bottom" | "left"
54
+ }) {
55
+ return (
56
+ <SheetPortal>
57
+ <SheetOverlay />
58
+ <SheetPrimitive.Content
59
+ data-slot="sheet-content"
60
+ className={cn(
61
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
62
+ side === "right" &&
63
+ "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
64
+ side === "left" &&
65
+ "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
66
+ side === "top" &&
67
+ "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
68
+ side === "bottom" &&
69
+ "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
70
+ className
71
+ )}
72
+ {...props}
73
+ >
74
+ {children}
75
+ <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
76
+ <XIcon className="size-4" />
77
+ <span className="sr-only">Close</span>
78
+ </SheetPrimitive.Close>
79
+ </SheetPrimitive.Content>
80
+ </SheetPortal>
81
+ )
82
+ }
83
+
84
+ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85
+ return (
86
+ <div
87
+ data-slot="sheet-header"
88
+ className={cn("flex flex-col gap-1.5 p-4", className)}
89
+ {...props}
90
+ />
91
+ )
92
+ }
93
+
94
+ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95
+ return (
96
+ <div
97
+ data-slot="sheet-footer"
98
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
99
+ {...props}
100
+ />
101
+ )
102
+ }
103
+
104
+ function SheetTitle({
105
+ className,
106
+ ...props
107
+ }: React.ComponentProps<typeof SheetPrimitive.Title>) {
108
+ return (
109
+ <SheetPrimitive.Title
110
+ data-slot="sheet-title"
111
+ className={cn("text-foreground font-semibold", className)}
112
+ {...props}
113
+ />
114
+ )
115
+ }
116
+
117
+ function SheetDescription({
118
+ className,
119
+ ...props
120
+ }: React.ComponentProps<typeof SheetPrimitive.Description>) {
121
+ return (
122
+ <SheetPrimitive.Description
123
+ data-slot="sheet-description"
124
+ className={cn("text-muted-foreground text-sm", className)}
125
+ {...props}
126
+ />
127
+ )
128
+ }
129
+
130
+ export {
131
+ Sheet,
132
+ SheetTrigger,
133
+ SheetClose,
134
+ SheetContent,
135
+ SheetHeader,
136
+ SheetFooter,
137
+ SheetTitle,
138
+ SheetDescription,
139
+ }
components/ui/sidebar.tsx ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Slot } from "@radix-ui/react-slot"
5
+ import { VariantProps, cva } from "class-variance-authority"
6
+ import { PanelLeftIcon } from "lucide-react"
7
+
8
+ import { useIsMobile } from "@/hooks/use-mobile"
9
+ import { cn } from "@/lib/utils"
10
+ import { Button } from "@/components/ui/button"
11
+ import { Input } from "@/components/ui/input"
12
+ import { Separator } from "@/components/ui/separator"
13
+ import {
14
+ Sheet,
15
+ SheetContent,
16
+ SheetDescription,
17
+ SheetHeader,
18
+ SheetTitle,
19
+ } from "@/components/ui/sheet"
20
+ import { Skeleton } from "@/components/ui/skeleton"
21
+ import {
22
+ Tooltip,
23
+ TooltipContent,
24
+ TooltipProvider,
25
+ TooltipTrigger,
26
+ } from "@/components/ui/tooltip"
27
+
28
+ const SIDEBAR_COOKIE_NAME = "sidebar_state"
29
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
30
+ const SIDEBAR_WIDTH = "16rem"
31
+ const SIDEBAR_WIDTH_MOBILE = "18rem"
32
+ const SIDEBAR_WIDTH_ICON = "3rem"
33
+ const SIDEBAR_KEYBOARD_SHORTCUT = "b"
34
+
35
+ type SidebarContextProps = {
36
+ state: "expanded" | "collapsed"
37
+ open: boolean
38
+ setOpen: (open: boolean) => void
39
+ openMobile: boolean
40
+ setOpenMobile: (open: boolean) => void
41
+ isMobile: boolean
42
+ toggleSidebar: () => void
43
+ }
44
+
45
+ const SidebarContext = React.createContext<SidebarContextProps | null>(null)
46
+
47
+ function useSidebar() {
48
+ const context = React.useContext(SidebarContext)
49
+ if (!context) {
50
+ throw new Error("useSidebar must be used within a SidebarProvider.")
51
+ }
52
+
53
+ return context
54
+ }
55
+
56
+ function SidebarProvider({
57
+ defaultOpen = true,
58
+ open: openProp,
59
+ onOpenChange: setOpenProp,
60
+ className,
61
+ style,
62
+ children,
63
+ ...props
64
+ }: React.ComponentProps<"div"> & {
65
+ defaultOpen?: boolean
66
+ open?: boolean
67
+ onOpenChange?: (open: boolean) => void
68
+ }) {
69
+ const isMobile = useIsMobile()
70
+ const [openMobile, setOpenMobile] = React.useState(false)
71
+
72
+ // This is the internal state of the sidebar.
73
+ // We use openProp and setOpenProp for control from outside the component.
74
+ const [_open, _setOpen] = React.useState(defaultOpen)
75
+ const open = openProp ?? _open
76
+ const setOpen = React.useCallback(
77
+ (value: boolean | ((value: boolean) => boolean)) => {
78
+ const openState = typeof value === "function" ? value(open) : value
79
+ if (setOpenProp) {
80
+ setOpenProp(openState)
81
+ } else {
82
+ _setOpen(openState)
83
+ }
84
+
85
+ // This sets the cookie to keep the sidebar state.
86
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
87
+ },
88
+ [setOpenProp, open]
89
+ )
90
+
91
+ // Helper to toggle the sidebar.
92
+ const toggleSidebar = React.useCallback(() => {
93
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
94
+ }, [isMobile, setOpen, setOpenMobile])
95
+
96
+ // Adds a keyboard shortcut to toggle the sidebar.
97
+ React.useEffect(() => {
98
+ const handleKeyDown = (event: KeyboardEvent) => {
99
+ if (
100
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
101
+ (event.metaKey || event.ctrlKey)
102
+ ) {
103
+ event.preventDefault()
104
+ toggleSidebar()
105
+ }
106
+ }
107
+
108
+ window.addEventListener("keydown", handleKeyDown)
109
+ return () => window.removeEventListener("keydown", handleKeyDown)
110
+ }, [toggleSidebar])
111
+
112
+ // We add a state so that we can do data-state="expanded" or "collapsed".
113
+ // This makes it easier to style the sidebar with Tailwind classes.
114
+ const state = open ? "expanded" : "collapsed"
115
+
116
+ const contextValue = React.useMemo<SidebarContextProps>(
117
+ () => ({
118
+ state,
119
+ open,
120
+ setOpen,
121
+ isMobile,
122
+ openMobile,
123
+ setOpenMobile,
124
+ toggleSidebar,
125
+ }),
126
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
127
+ )
128
+
129
+ return (
130
+ <SidebarContext.Provider value={contextValue}>
131
+ <TooltipProvider delayDuration={0}>
132
+ <div
133
+ data-slot="sidebar-wrapper"
134
+ style={
135
+ {
136
+ "--sidebar-width": SIDEBAR_WIDTH,
137
+ "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
138
+ ...style,
139
+ } as React.CSSProperties
140
+ }
141
+ className={cn(
142
+ "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
143
+ className
144
+ )}
145
+ {...props}
146
+ >
147
+ {children}
148
+ </div>
149
+ </TooltipProvider>
150
+ </SidebarContext.Provider>
151
+ )
152
+ }
153
+
154
+ function Sidebar({
155
+ side = "left",
156
+ variant = "sidebar",
157
+ collapsible = "offcanvas",
158
+ className,
159
+ children,
160
+ ...props
161
+ }: React.ComponentProps<"div"> & {
162
+ side?: "left" | "right"
163
+ variant?: "sidebar" | "floating" | "inset"
164
+ collapsible?: "offcanvas" | "icon" | "none"
165
+ }) {
166
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
167
+
168
+ if (collapsible === "none") {
169
+ return (
170
+ <div
171
+ data-slot="sidebar"
172
+ className={cn(
173
+ "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
174
+ className
175
+ )}
176
+ {...props}
177
+ >
178
+ {children}
179
+ </div>
180
+ )
181
+ }
182
+
183
+ if (isMobile) {
184
+ return (
185
+ <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
186
+ <SheetContent
187
+ data-sidebar="sidebar"
188
+ data-slot="sidebar"
189
+ data-mobile="true"
190
+ className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
191
+ style={
192
+ {
193
+ "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
194
+ } as React.CSSProperties
195
+ }
196
+ side={side}
197
+ >
198
+ <SheetHeader className="sr-only">
199
+ <SheetTitle>Sidebar</SheetTitle>
200
+ <SheetDescription>Displays the mobile sidebar.</SheetDescription>
201
+ </SheetHeader>
202
+ <div className="flex h-full w-full flex-col">{children}</div>
203
+ </SheetContent>
204
+ </Sheet>
205
+ )
206
+ }
207
+
208
+ return (
209
+ <div
210
+ className="group peer text-sidebar-foreground hidden md:block"
211
+ data-state={state}
212
+ data-collapsible={state === "collapsed" ? collapsible : ""}
213
+ data-variant={variant}
214
+ data-side={side}
215
+ data-slot="sidebar"
216
+ >
217
+ {/* This is what handles the sidebar gap on desktop */}
218
+ <div
219
+ data-slot="sidebar-gap"
220
+ className={cn(
221
+ "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
222
+ "group-data-[collapsible=offcanvas]:w-0",
223
+ "group-data-[side=right]:rotate-180",
224
+ variant === "floating" || variant === "inset"
225
+ ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
226
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
227
+ )}
228
+ />
229
+ <div
230
+ data-slot="sidebar-container"
231
+ className={cn(
232
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
233
+ side === "left"
234
+ ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
235
+ : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
236
+ // Adjust the padding for floating and inset variants.
237
+ variant === "floating" || variant === "inset"
238
+ ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
239
+ : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
240
+ className
241
+ )}
242
+ {...props}
243
+ >
244
+ <div
245
+ data-sidebar="sidebar"
246
+ data-slot="sidebar-inner"
247
+ className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
248
+ >
249
+ {children}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ )
254
+ }
255
+
256
+ function SidebarTrigger({
257
+ className,
258
+ onClick,
259
+ ...props
260
+ }: React.ComponentProps<typeof Button>) {
261
+ const { toggleSidebar } = useSidebar()
262
+
263
+ return (
264
+ <Button
265
+ data-sidebar="trigger"
266
+ data-slot="sidebar-trigger"
267
+ variant="ghost"
268
+ size="icon"
269
+ className={cn("size-7", className)}
270
+ onClick={(event) => {
271
+ onClick?.(event)
272
+ toggleSidebar()
273
+ }}
274
+ {...props}
275
+ >
276
+ <PanelLeftIcon />
277
+ <span className="sr-only">Toggle Sidebar</span>
278
+ </Button>
279
+ )
280
+ }
281
+
282
+ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
283
+ const { toggleSidebar } = useSidebar()
284
+
285
+ return (
286
+ <button
287
+ data-sidebar="rail"
288
+ data-slot="sidebar-rail"
289
+ aria-label="Toggle Sidebar"
290
+ tabIndex={-1}
291
+ onClick={toggleSidebar}
292
+ title="Toggle Sidebar"
293
+ className={cn(
294
+ "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
295
+ "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
296
+ "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
297
+ "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
298
+ "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
299
+ "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
300
+ className
301
+ )}
302
+ {...props}
303
+ />
304
+ )
305
+ }
306
+
307
+ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
308
+ return (
309
+ <main
310
+ data-slot="sidebar-inset"
311
+ className={cn(
312
+ "bg-background relative flex w-full flex-1 flex-col",
313
+ "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
314
+ className
315
+ )}
316
+ {...props}
317
+ />
318
+ )
319
+ }
320
+
321
+ function SidebarInput({
322
+ className,
323
+ ...props
324
+ }: React.ComponentProps<typeof Input>) {
325
+ return (
326
+ <Input
327
+ data-slot="sidebar-input"
328
+ data-sidebar="input"
329
+ className={cn("bg-background h-8 w-full shadow-none", className)}
330
+ {...props}
331
+ />
332
+ )
333
+ }
334
+
335
+ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
336
+ return (
337
+ <div
338
+ data-slot="sidebar-header"
339
+ data-sidebar="header"
340
+ className={cn("flex flex-col gap-2 p-2", className)}
341
+ {...props}
342
+ />
343
+ )
344
+ }
345
+
346
+ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
347
+ return (
348
+ <div
349
+ data-slot="sidebar-footer"
350
+ data-sidebar="footer"
351
+ className={cn("flex flex-col gap-2 p-2", className)}
352
+ {...props}
353
+ />
354
+ )
355
+ }
356
+
357
+ function SidebarSeparator({
358
+ className,
359
+ ...props
360
+ }: React.ComponentProps<typeof Separator>) {
361
+ return (
362
+ <Separator
363
+ data-slot="sidebar-separator"
364
+ data-sidebar="separator"
365
+ className={cn("bg-sidebar-border mx-2 w-auto", className)}
366
+ {...props}
367
+ />
368
+ )
369
+ }
370
+
371
+ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
372
+ return (
373
+ <div
374
+ data-slot="sidebar-content"
375
+ data-sidebar="content"
376
+ className={cn(
377
+ "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
378
+ className
379
+ )}
380
+ {...props}
381
+ />
382
+ )
383
+ }
384
+
385
+ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
386
+ return (
387
+ <div
388
+ data-slot="sidebar-group"
389
+ data-sidebar="group"
390
+ className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
391
+ {...props}
392
+ />
393
+ )
394
+ }
395
+
396
+ function SidebarGroupLabel({
397
+ className,
398
+ asChild = false,
399
+ ...props
400
+ }: React.ComponentProps<"div"> & { asChild?: boolean }) {
401
+ const Comp = asChild ? Slot : "div"
402
+
403
+ return (
404
+ <Comp
405
+ data-slot="sidebar-group-label"
406
+ data-sidebar="group-label"
407
+ className={cn(
408
+ "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
409
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
410
+ className
411
+ )}
412
+ {...props}
413
+ />
414
+ )
415
+ }
416
+
417
+ function SidebarGroupAction({
418
+ className,
419
+ asChild = false,
420
+ ...props
421
+ }: React.ComponentProps<"button"> & { asChild?: boolean }) {
422
+ const Comp = asChild ? Slot : "button"
423
+
424
+ return (
425
+ <Comp
426
+ data-slot="sidebar-group-action"
427
+ data-sidebar="group-action"
428
+ className={cn(
429
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
430
+ // Increases the hit area of the button on mobile.
431
+ "after:absolute after:-inset-2 md:after:hidden",
432
+ "group-data-[collapsible=icon]:hidden",
433
+ className
434
+ )}
435
+ {...props}
436
+ />
437
+ )
438
+ }
439
+
440
+ function SidebarGroupContent({
441
+ className,
442
+ ...props
443
+ }: React.ComponentProps<"div">) {
444
+ return (
445
+ <div
446
+ data-slot="sidebar-group-content"
447
+ data-sidebar="group-content"
448
+ className={cn("w-full text-sm", className)}
449
+ {...props}
450
+ />
451
+ )
452
+ }
453
+
454
+ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
455
+ return (
456
+ <ul
457
+ data-slot="sidebar-menu"
458
+ data-sidebar="menu"
459
+ className={cn("flex w-full min-w-0 flex-col gap-1", className)}
460
+ {...props}
461
+ />
462
+ )
463
+ }
464
+
465
+ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
466
+ return (
467
+ <li
468
+ data-slot="sidebar-menu-item"
469
+ data-sidebar="menu-item"
470
+ className={cn("group/menu-item relative", className)}
471
+ {...props}
472
+ />
473
+ )
474
+ }
475
+
476
+ const sidebarMenuButtonVariants = cva(
477
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
478
+ {
479
+ variants: {
480
+ variant: {
481
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
482
+ outline:
483
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
484
+ },
485
+ size: {
486
+ default: "h-8 text-sm",
487
+ sm: "h-7 text-xs",
488
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
489
+ },
490
+ },
491
+ defaultVariants: {
492
+ variant: "default",
493
+ size: "default",
494
+ },
495
+ }
496
+ )
497
+
498
+ function SidebarMenuButton({
499
+ asChild = false,
500
+ isActive = false,
501
+ variant = "default",
502
+ size = "default",
503
+ tooltip,
504
+ className,
505
+ ...props
506
+ }: React.ComponentProps<"button"> & {
507
+ asChild?: boolean
508
+ isActive?: boolean
509
+ tooltip?: string | React.ComponentProps<typeof TooltipContent>
510
+ } & VariantProps<typeof sidebarMenuButtonVariants>) {
511
+ const Comp = asChild ? Slot : "button"
512
+ const { isMobile, state } = useSidebar()
513
+
514
+ const button = (
515
+ <Comp
516
+ data-slot="sidebar-menu-button"
517
+ data-sidebar="menu-button"
518
+ data-size={size}
519
+ data-active={isActive}
520
+ className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
521
+ {...props}
522
+ />
523
+ )
524
+
525
+ if (!tooltip) {
526
+ return button
527
+ }
528
+
529
+ if (typeof tooltip === "string") {
530
+ tooltip = {
531
+ children: tooltip,
532
+ }
533
+ }
534
+
535
+ return (
536
+ <Tooltip>
537
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
538
+ <TooltipContent
539
+ side="right"
540
+ align="center"
541
+ hidden={state !== "collapsed" || isMobile}
542
+ {...tooltip}
543
+ />
544
+ </Tooltip>
545
+ )
546
+ }
547
+
548
+ function SidebarMenuAction({
549
+ className,
550
+ asChild = false,
551
+ showOnHover = false,
552
+ ...props
553
+ }: React.ComponentProps<"button"> & {
554
+ asChild?: boolean
555
+ showOnHover?: boolean
556
+ }) {
557
+ const Comp = asChild ? Slot : "button"
558
+
559
+ return (
560
+ <Comp
561
+ data-slot="sidebar-menu-action"
562
+ data-sidebar="menu-action"
563
+ className={cn(
564
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
565
+ // Increases the hit area of the button on mobile.
566
+ "after:absolute after:-inset-2 md:after:hidden",
567
+ "peer-data-[size=sm]/menu-button:top-1",
568
+ "peer-data-[size=default]/menu-button:top-1.5",
569
+ "peer-data-[size=lg]/menu-button:top-2.5",
570
+ "group-data-[collapsible=icon]:hidden",
571
+ showOnHover &&
572
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
573
+ className
574
+ )}
575
+ {...props}
576
+ />
577
+ )
578
+ }
579
+
580
+ function SidebarMenuBadge({
581
+ className,
582
+ ...props
583
+ }: React.ComponentProps<"div">) {
584
+ return (
585
+ <div
586
+ data-slot="sidebar-menu-badge"
587
+ data-sidebar="menu-badge"
588
+ className={cn(
589
+ "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
590
+ "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
591
+ "peer-data-[size=sm]/menu-button:top-1",
592
+ "peer-data-[size=default]/menu-button:top-1.5",
593
+ "peer-data-[size=lg]/menu-button:top-2.5",
594
+ "group-data-[collapsible=icon]:hidden",
595
+ className
596
+ )}
597
+ {...props}
598
+ />
599
+ )
600
+ }
601
+
602
+ function SidebarMenuSkeleton({
603
+ className,
604
+ showIcon = false,
605
+ ...props
606
+ }: React.ComponentProps<"div"> & {
607
+ showIcon?: boolean
608
+ }) {
609
+ // Random width between 50 to 90%.
610
+ const width = React.useMemo(() => {
611
+ return `${Math.floor(Math.random() * 40) + 50}%`
612
+ }, [])
613
+
614
+ return (
615
+ <div
616
+ data-slot="sidebar-menu-skeleton"
617
+ data-sidebar="menu-skeleton"
618
+ className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
619
+ {...props}
620
+ >
621
+ {showIcon && (
622
+ <Skeleton
623
+ className="size-4 rounded-md"
624
+ data-sidebar="menu-skeleton-icon"
625
+ />
626
+ )}
627
+ <Skeleton
628
+ className="h-4 max-w-(--skeleton-width) flex-1"
629
+ data-sidebar="menu-skeleton-text"
630
+ style={
631
+ {
632
+ "--skeleton-width": width,
633
+ } as React.CSSProperties
634
+ }
635
+ />
636
+ </div>
637
+ )
638
+ }
639
+
640
+ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
641
+ return (
642
+ <ul
643
+ data-slot="sidebar-menu-sub"
644
+ data-sidebar="menu-sub"
645
+ className={cn(
646
+ "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
647
+ "group-data-[collapsible=icon]:hidden",
648
+ className
649
+ )}
650
+ {...props}
651
+ />
652
+ )
653
+ }
654
+
655
+ function SidebarMenuSubItem({
656
+ className,
657
+ ...props
658
+ }: React.ComponentProps<"li">) {
659
+ return (
660
+ <li
661
+ data-slot="sidebar-menu-sub-item"
662
+ data-sidebar="menu-sub-item"
663
+ className={cn("group/menu-sub-item relative", className)}
664
+ {...props}
665
+ />
666
+ )
667
+ }
668
+
669
+ function SidebarMenuSubButton({
670
+ asChild = false,
671
+ size = "md",
672
+ isActive = false,
673
+ className,
674
+ ...props
675
+ }: React.ComponentProps<"a"> & {
676
+ asChild?: boolean
677
+ size?: "sm" | "md"
678
+ isActive?: boolean
679
+ }) {
680
+ const Comp = asChild ? Slot : "a"
681
+
682
+ return (
683
+ <Comp
684
+ data-slot="sidebar-menu-sub-button"
685
+ data-sidebar="menu-sub-button"
686
+ data-size={size}
687
+ data-active={isActive}
688
+ className={cn(
689
+ "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
690
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
691
+ size === "sm" && "text-xs",
692
+ size === "md" && "text-sm",
693
+ "group-data-[collapsible=icon]:hidden",
694
+ className
695
+ )}
696
+ {...props}
697
+ />
698
+ )
699
+ }
700
+
701
+ export {
702
+ Sidebar,
703
+ SidebarContent,
704
+ SidebarFooter,
705
+ SidebarGroup,
706
+ SidebarGroupAction,
707
+ SidebarGroupContent,
708
+ SidebarGroupLabel,
709
+ SidebarHeader,
710
+ SidebarInput,
711
+ SidebarInset,
712
+ SidebarMenu,
713
+ SidebarMenuAction,
714
+ SidebarMenuBadge,
715
+ SidebarMenuButton,
716
+ SidebarMenuItem,
717
+ SidebarMenuSkeleton,
718
+ SidebarMenuSub,
719
+ SidebarMenuSubButton,
720
+ SidebarMenuSubItem,
721
+ SidebarProvider,
722
+ SidebarRail,
723
+ SidebarSeparator,
724
+ SidebarTrigger,
725
+ useSidebar,
726
+ }
components/ui/skeleton.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils"
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("bg-accent animate-pulse rounded-md", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
12
+
13
+ export { Skeleton }
components/ui/sonner.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useTheme } from "next-themes"
4
+ import { Toaster as Sonner, ToasterProps } from "sonner"
5
+
6
+ const Toaster = ({ ...props }: ToasterProps) => {
7
+ const { theme = "system" } = useTheme()
8
+
9
+ return (
10
+ <Sonner
11
+ theme={theme as ToasterProps["theme"]}
12
+ className="toaster group"
13
+ style={
14
+ {
15
+ "--normal-bg": "var(--popover)",
16
+ "--normal-text": "var(--popover-foreground)",
17
+ "--normal-border": "var(--border)",
18
+ } as React.CSSProperties
19
+ }
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ export { Toaster }
components/ui/text-morph.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import { cn } from '@/lib/utils';
3
+ import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
4
+ import { useMemo, useId } from 'react';
5
+
6
+ export type TextMorphProps = {
7
+ children: string;
8
+ as?: React.ElementType;
9
+ className?: string;
10
+ style?: React.CSSProperties;
11
+ variants?: Variants;
12
+ transition?: Transition;
13
+ };
14
+
15
+ export function TextMorph({
16
+ children,
17
+ as: Component = 'p',
18
+ className,
19
+ style,
20
+ variants,
21
+ transition,
22
+ }: TextMorphProps) {
23
+ const uniqueId = useId();
24
+
25
+ const characters = useMemo(() => {
26
+ const charCounts: Record<string, number> = {};
27
+
28
+ return children.split('').map((char) => {
29
+ const lowerChar = char.toLowerCase();
30
+ charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1;
31
+
32
+ return {
33
+ id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
34
+ label: char === ' ' ? '\u00A0' : char,
35
+ };
36
+ });
37
+ }, [children, uniqueId]);
38
+
39
+ const defaultVariants: Variants = {
40
+ initial: { opacity: 0 },
41
+ animate: { opacity: 1 },
42
+ exit: { opacity: 0 },
43
+ };
44
+
45
+ const defaultTransition: Transition = {
46
+ type: 'spring',
47
+ stiffness: 280,
48
+ damping: 18,
49
+ mass: 0.3,
50
+ };
51
+
52
+ return (
53
+ <Component className={cn(className)} aria-label={children} style={style}>
54
+ <AnimatePresence mode='popLayout' initial={false}>
55
+ {characters.map((character) => (
56
+ <motion.span
57
+ key={character.id}
58
+ layoutId={character.id}
59
+ className='inline-block'
60
+ aria-hidden='true'
61
+ initial='initial'
62
+ animate='animate'
63
+ exit='exit'
64
+ variants={variants || defaultVariants}
65
+ transition={transition || defaultTransition}
66
+ >
67
+ {character.label}
68
+ </motion.span>
69
+ ))}
70
+ </AnimatePresence>
71
+ </Component>
72
+ );
73
+ }
components/ui/textarea.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ export { Textarea }
components/ui/tooltip.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function TooltipProvider({
9
+ delayDuration = 0,
10
+ ...props
11
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
12
+ return (
13
+ <TooltipPrimitive.Provider
14
+ data-slot="tooltip-provider"
15
+ delayDuration={delayDuration}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ function Tooltip({
22
+ ...props
23
+ }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
24
+ return (
25
+ <TooltipProvider>
26
+ <TooltipPrimitive.Root data-slot="tooltip" {...props} />
27
+ </TooltipProvider>
28
+ )
29
+ }
30
+
31
+ function TooltipTrigger({
32
+ ...props
33
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
34
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
35
+ }
36
+
37
+ function TooltipContent({
38
+ className,
39
+ sideOffset = 0,
40
+ children,
41
+ ...props
42
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
43
+ return (
44
+ <TooltipPrimitive.Portal>
45
+ <TooltipPrimitive.Content
46
+ data-slot="tooltip-content"
47
+ sideOffset={sideOffset}
48
+ className={cn(
49
+ "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
50
+ className
51
+ )}
52
+ {...props}
53
+ >
54
+ {children}
55
+ <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
56
+ </TooltipPrimitive.Content>
57
+ </TooltipPrimitive.Portal>
58
+ )
59
+ }
60
+
61
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
drizzle.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "drizzle-kit";
2
+ import dotenv from "dotenv";
3
+
4
+ // Load environment variables
5
+ dotenv.config({ path: ".env.local" });
6
+
7
+ export default {
8
+ schema: "./lib/db/schema.ts",
9
+ out: "./drizzle",
10
+ dialect: "postgresql",
11
+ dbCredentials: {
12
+ url: process.env.DATABASE_URL!,
13
+ },
14
+ } satisfies Config;