├── .env.example
├── .gitignore
├── LICENSE
├── README.md
├── ai
└── providers.ts
├── app
├── actions.ts
├── api
│ ├── chat
│ │ └── route.ts
│ └── chats
│ │ ├── [id]
│ │ └── route.ts
│ │ └── route.ts
├── chat
│ └── [id]
│ │ └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── opengraph-image.png
├── page.tsx
├── providers.tsx
└── twitter-image.png
├── components.json
├── components
├── api-key-manager.tsx
├── chat-sidebar.tsx
├── chat.tsx
├── copy-button.tsx
├── deploy-button.tsx
├── icons.tsx
├── input.tsx
├── markdown.tsx
├── mcp-server-manager.tsx
├── message.tsx
├── messages.tsx
├── model-picker.tsx
├── project-overview.tsx
├── suggested-prompts.tsx
├── textarea.tsx
├── theme-provider.tsx
├── theme-toggle.tsx
├── tool-invocation.tsx
└── ui
│ ├── accordion.tsx
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ ├── scroll-area.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── sidebar.tsx
│ ├── skeleton.tsx
│ ├── sonner.tsx
│ ├── text-morph.tsx
│ ├── textarea.tsx
│ └── tooltip.tsx
├── drizzle.config.ts
├── drizzle
├── 0000_supreme_rocket_raccoon.sql
├── 0001_curious_paper_doll.sql
├── 0002_free_cobalt_man.sql
├── 0003_oval_energizer.sql
├── 0004_tense_ricochet.sql
├── 0005_early_payback.sql
└── meta
│ ├── 0000_snapshot.json
│ ├── 0001_snapshot.json
│ ├── 0002_snapshot.json
│ ├── 0003_snapshot.json
│ ├── 0004_snapshot.json
│ ├── 0005_snapshot.json
│ └── _journal.json
├── eslint.config.mjs
├── hooks
└── use-mobile.ts
├── lib
├── chat-store.ts
├── constants.ts
├── context
│ └── mcp-context.tsx
├── db
│ ├── index.ts
│ └── schema.ts
├── hooks
│ ├── use-chats.ts
│ ├── use-copy.ts
│ ├── use-local-storage.ts
│ └── use-scroll-to-bottom.tsx
├── user-id.ts
└── utils.ts
├── next.config.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
├── file.svg
├── globe.svg
├── next.svg
├── scira.png
├── vercel.svg
└── window.svg
├── railpack.json
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | XAI_API_KEY=""
2 | OPENAI_API_KEY=
3 | DATABASE_URL="postgresql://username:password@host:port/database"
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
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 | .env
36 |
37 | # vercel
38 | .vercel
39 |
40 | # typescript
41 | *.tsbuildinfo
42 | next-env.d.ts
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | Copyright 2025 Zaid Mukaddam
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Scira MCP Chat
3 |
4 |
5 |
6 | An open-source AI chatbot app powered by Model Context Protocol (MCP), built with Next.js and the AI SDK by Vercel.
7 |
8 |
9 |
10 | Features •
11 | MCP Configuration •
12 | License
13 |
14 |
15 |
16 | ## Features
17 |
18 | - 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.
19 | - Full integration with [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers to expand available tools and capabilities.
20 | - Multiple MCP transport types (SSE and stdio) for connecting to various tool providers.
21 | - Built-in tool integration for extending AI capabilities.
22 | - Reasoning model support.
23 | - [shadcn/ui](https://ui.shadcn.com/) components for a modern, responsive UI powered by [Tailwind CSS](https://tailwindcss.com).
24 | - Built with the latest [Next.js](https://nextjs.org) App Router.
25 |
26 | ## MCP Server Configuration
27 |
28 | 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.
29 |
30 | ### Adding an MCP Server
31 |
32 | 1. Click the settings icon (⚙️) next to the model selector in the chat interface.
33 | 2. Enter a name for your MCP server.
34 | 3. Select the transport type:
35 | - **SSE (Server-Sent Events)**: For HTTP-based remote servers
36 | - **stdio (Standard I/O)**: For local servers running on the same machine
37 |
38 | #### SSE Configuration
39 |
40 | If you select SSE transport:
41 | 1. Enter the server URL (e.g., `https://mcp.example.com/token/sse`)
42 | 2. Click "Add Server"
43 |
44 | #### stdio Configuration
45 |
46 | If you select stdio transport:
47 | 1. Enter the command to execute (e.g., `npx`)
48 | 2. Enter the command arguments (e.g., `-y @modelcontextprotocol/server-google-maps`)
49 | - You can enter space-separated arguments or paste a JSON array
50 | 3. Click "Add Server"
51 |
52 | 4. Click "Use" to activate the server for the current chat session.
53 |
54 | ### Available MCP Servers
55 |
56 | You can use any MCP-compatible server with this application. Here are some examples:
57 |
58 | - [Composio](https://composio.dev/mcp) - Provides search, code interpreter, and other tools
59 | - [Zapier MCP](https://zapier.com/mcp) - Provides access to Zapier tools
60 | - Any MCP server using stdio transport with npx and python3
61 |
62 | ## License
63 |
64 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
--------------------------------------------------------------------------------
/ai/providers.ts:
--------------------------------------------------------------------------------
1 | import { createOpenAI } from "@ai-sdk/openai";
2 | import { createGroq } from "@ai-sdk/groq";
3 | import { createAnthropic } from "@ai-sdk/anthropic";
4 | import { createXai } from "@ai-sdk/xai";
5 |
6 | import {
7 | customProvider,
8 | wrapLanguageModel,
9 | extractReasoningMiddleware
10 | } from "ai";
11 |
12 | export interface ModelInfo {
13 | provider: string;
14 | name: string;
15 | description: string;
16 | apiVersion: string;
17 | capabilities: string[];
18 | }
19 |
20 | const middleware = extractReasoningMiddleware({
21 | tagName: 'think',
22 | });
23 |
24 | // Helper to get API keys from environment variables first, then localStorage
25 | const getApiKey = (key: string): string | undefined => {
26 | // Check for environment variables first
27 | if (process.env[key]) {
28 | return process.env[key] || undefined;
29 | }
30 |
31 | // Fall back to localStorage if available
32 | if (typeof window !== 'undefined') {
33 | return window.localStorage.getItem(key) || undefined;
34 | }
35 |
36 | return undefined;
37 | };
38 |
39 | // Create provider instances with API keys from localStorage
40 | const openaiClient = createOpenAI({
41 | apiKey: getApiKey('OPENAI_API_KEY'),
42 | });
43 |
44 | const anthropicClient = createAnthropic({
45 | apiKey: getApiKey('ANTHROPIC_API_KEY'),
46 | });
47 |
48 | const groqClient = createGroq({
49 | apiKey: getApiKey('GROQ_API_KEY'),
50 | });
51 |
52 | const xaiClient = createXai({
53 | apiKey: getApiKey('XAI_API_KEY'),
54 | });
55 |
56 | const languageModels = {
57 | "gpt-4.1-mini": openaiClient("gpt-4.1-mini"),
58 | "claude-3-7-sonnet": anthropicClient('claude-3-7-sonnet-20250219'),
59 | "qwen-qwq": wrapLanguageModel(
60 | {
61 | model: groqClient("qwen-qwq-32b"),
62 | middleware
63 | }
64 | ),
65 | "grok-3-mini": xaiClient("grok-3-mini-latest"),
66 | };
67 |
68 | export const modelDetails: Record = {
69 | "gpt-4.1-mini": {
70 | provider: "OpenAI",
71 | name: "GPT-4.1 Mini",
72 | description: "Compact version of OpenAI's GPT-4.1 with good balance of capabilities, including vision.",
73 | apiVersion: "gpt-4.1-mini",
74 | capabilities: ["Balance", "Creative", "Vision"]
75 | },
76 | "claude-3-7-sonnet": {
77 | provider: "Anthropic",
78 | name: "Claude 3.7 Sonnet",
79 | description: "Latest version of Anthropic's Claude 3.7 Sonnet with strong reasoning and coding capabilities.",
80 | apiVersion: "claude-3-7-sonnet-20250219",
81 | capabilities: ["Reasoning", "Efficient", "Agentic"]
82 | },
83 | "qwen-qwq": {
84 | provider: "Groq",
85 | name: "Qwen QWQ",
86 | description: "Latest version of Alibaba's Qwen QWQ with strong reasoning and coding capabilities.",
87 | apiVersion: "qwen-qwq",
88 | capabilities: ["Reasoning", "Efficient", "Agentic"]
89 | },
90 | "grok-3-mini": {
91 | provider: "XAI",
92 | name: "Grok 3 Mini",
93 | description: "Latest version of XAI's Grok 3 Mini with strong reasoning and coding capabilities.",
94 | apiVersion: "grok-3-mini-latest",
95 | capabilities: ["Reasoning", "Efficient", "Agentic"]
96 | },
97 | };
98 |
99 | // Update API keys when localStorage changes (for runtime updates)
100 | if (typeof window !== 'undefined') {
101 | window.addEventListener('storage', (event) => {
102 | // Reload the page if any API key changed to refresh the providers
103 | if (event.key?.includes('API_KEY')) {
104 | window.location.reload();
105 | }
106 | });
107 | }
108 |
109 | export const model = customProvider({
110 | languageModels,
111 | });
112 |
113 | export type modelID = keyof typeof languageModels;
114 |
115 | export const MODELS = Object.keys(languageModels);
116 |
117 | export const defaultModel: modelID = "qwen-qwq";
118 |
--------------------------------------------------------------------------------
/app/actions.ts:
--------------------------------------------------------------------------------
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 | }
67 |
--------------------------------------------------------------------------------
/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { model, type modelID } from "@/ai/providers";
2 | import { streamText, type UIMessage } from "ai";
3 | import { appendResponseMessages } from 'ai';
4 | import { saveChat, saveMessages, convertToDBMessages } from '@/lib/chat-store';
5 | import { nanoid } from 'nanoid';
6 | import { db } from '@/lib/db';
7 | import { chats } from '@/lib/db/schema';
8 | import { eq, and } from 'drizzle-orm';
9 |
10 | import { experimental_createMCPClient as createMCPClient, MCPTransport } from 'ai';
11 | import { Experimental_StdioMCPTransport as StdioMCPTransport } from 'ai/mcp-stdio';
12 | import { spawn } from "child_process";
13 |
14 | // Allow streaming responses up to 30 seconds
15 | export const maxDuration = 120;
16 |
17 | interface KeyValuePair {
18 | key: string;
19 | value: string;
20 | }
21 |
22 | interface MCPServerConfig {
23 | url: string;
24 | type: 'sse' | 'stdio';
25 | command?: string;
26 | args?: string[];
27 | env?: KeyValuePair[];
28 | headers?: KeyValuePair[];
29 | }
30 |
31 | export async function POST(req: Request) {
32 | const {
33 | messages,
34 | chatId,
35 | selectedModel,
36 | userId,
37 | mcpServers = [],
38 | }: {
39 | messages: UIMessage[];
40 | chatId?: string;
41 | selectedModel: modelID;
42 | userId: string;
43 | mcpServers?: MCPServerConfig[];
44 | } = await req.json();
45 |
46 | if (!userId) {
47 | return new Response(
48 | JSON.stringify({ error: "User ID is required" }),
49 | { status: 400, headers: { "Content-Type": "application/json" } }
50 | );
51 | }
52 |
53 | const id = chatId || nanoid();
54 |
55 | // Check if chat already exists for the given ID
56 | // If not, we'll create it in onFinish
57 | let isNewChat = false;
58 | if (chatId) {
59 | try {
60 | const existingChat = await db.query.chats.findFirst({
61 | where: and(
62 | eq(chats.id, chatId),
63 | eq(chats.userId, userId)
64 | )
65 | });
66 | isNewChat = !existingChat;
67 | } catch (error) {
68 | console.error("Error checking for existing chat:", error);
69 | // Continue anyway, we'll create the chat in onFinish
70 | isNewChat = true;
71 | }
72 | } else {
73 | // No ID provided, definitely new
74 | isNewChat = true;
75 | }
76 |
77 | // Initialize tools
78 | let tools = {};
79 | const mcpClients: any[] = [];
80 |
81 | // Process each MCP server configuration
82 | for (const mcpServer of mcpServers) {
83 | try {
84 | // Create appropriate transport based on type
85 | let transport: MCPTransport | { type: 'sse', url: string, headers?: Record };
86 |
87 | if (mcpServer.type === 'sse') {
88 | // Convert headers array to object for SSE transport
89 | const headers: Record = {};
90 | if (mcpServer.headers && mcpServer.headers.length > 0) {
91 | mcpServer.headers.forEach(header => {
92 | if (header.key) headers[header.key] = header.value || '';
93 | });
94 | }
95 |
96 | transport = {
97 | type: 'sse' as const,
98 | url: mcpServer.url,
99 | headers: Object.keys(headers).length > 0 ? headers : undefined
100 | };
101 | } else if (mcpServer.type === 'stdio') {
102 | // For stdio transport, we need command and args
103 | if (!mcpServer.command || !mcpServer.args || mcpServer.args.length === 0) {
104 | console.warn("Skipping stdio MCP server due to missing command or args");
105 | continue;
106 | }
107 |
108 | // Convert env array to object for stdio transport
109 | const env: Record = {};
110 | if (mcpServer.env && mcpServer.env.length > 0) {
111 | mcpServer.env.forEach(envVar => {
112 | if (envVar.key) env[envVar.key] = envVar.value || '';
113 | });
114 | }
115 |
116 | // Check for uvx pattern and transform to python3 -m uv run
117 | if (mcpServer.command === 'uvx') {
118 | // install uv
119 | const subprocess = spawn('pip3', ['install', 'uv']);
120 | subprocess.on('close', (code: number) => {
121 | if (code !== 0) {
122 | console.error(`Failed to install uv: ${code}`);
123 | }
124 | });
125 | // wait for the subprocess to finish
126 | await new Promise((resolve) => {
127 | subprocess.on('close', resolve);
128 | console.log("installed uv");
129 | });
130 | console.log("Detected uvx pattern, transforming to python3 -m uv run");
131 | mcpServer.command = 'python3';
132 | // Get the tool name (first argument)
133 | const toolName = mcpServer.args[0];
134 | // Replace args with the new pattern
135 | mcpServer.args = ['-m', 'uv', 'run', toolName, ...mcpServer.args.slice(1)];
136 | }
137 | // if python is passed in the command, install the python package mentioned in args after -m with subprocess or use regex to find the package name
138 | else if (mcpServer.command.includes('python3')) {
139 | const packageName = mcpServer.args[mcpServer.args.indexOf('-m') + 1];
140 | console.log("installing python package", packageName);
141 | const subprocess = spawn('pip3', ['install', packageName]);
142 | subprocess.on('close', (code: number) => {
143 | if (code !== 0) {
144 | console.error(`Failed to install python package: ${code}`);
145 | }
146 | });
147 | // wait for the subprocess to finish
148 | await new Promise((resolve) => {
149 | subprocess.on('close', resolve);
150 | console.log("installed python package", packageName);
151 | });
152 | }
153 |
154 | transport = new StdioMCPTransport({
155 | command: mcpServer.command,
156 | args: mcpServer.args,
157 | env: Object.keys(env).length > 0 ? env : undefined
158 | });
159 | } else {
160 | console.warn(`Skipping MCP server with unsupported transport type: ${mcpServer.type}`);
161 | continue;
162 | }
163 |
164 | const mcpClient = await createMCPClient({ transport });
165 | mcpClients.push(mcpClient);
166 |
167 | const mcptools = await mcpClient.tools();
168 |
169 | console.log(`MCP tools from ${mcpServer.type} transport:`, Object.keys(mcptools));
170 |
171 | // Add MCP tools to tools object
172 | tools = { ...tools, ...mcptools };
173 | } catch (error) {
174 | console.error("Failed to initialize MCP client:", error);
175 | // Continue with other servers instead of failing the entire request
176 | }
177 | }
178 |
179 | // Register cleanup for all clients
180 | if (mcpClients.length > 0) {
181 | req.signal.addEventListener('abort', async () => {
182 | for (const client of mcpClients) {
183 | try {
184 | await client.close();
185 | } catch (error) {
186 | console.error("Error closing MCP client:", error);
187 | }
188 | }
189 | });
190 | }
191 |
192 | console.log("messages", messages);
193 | console.log("parts", messages.map(m => m.parts.map(p => p)));
194 |
195 | // If there was an error setting up MCP clients but we at least have composio tools, continue
196 | const result = streamText({
197 | model: model.languageModel(selectedModel),
198 | system: `You are a helpful assistant with access to a variety of tools.
199 |
200 | Today's date is ${new Date().toISOString().split('T')[0]}.
201 |
202 | The tools are very powerful, and you can use them to answer the user's question.
203 | So choose the tool that is most relevant to the user's question.
204 |
205 | If tools are not available, say you don't know or if the user wants a tool they can add one from the server icon in bottom left corner in the sidebar.
206 |
207 | You can use multiple tools in a single response.
208 | Always respond after using the tools for better user experience.
209 | You can run multiple steps using all the tools!!!!
210 | Make sure to use the right tool to respond to the user's question.
211 |
212 | Multiple tools can be used in a single response and multiple steps can be used to answer the user's question.
213 |
214 | ## Response Format
215 | - Markdown is supported.
216 | - Respond according to tool's response.
217 | - Use the tools to answer the user's question.
218 | - If you don't know the answer, use the tools to find the answer or say you don't know.
219 | `,
220 | messages,
221 | tools,
222 | maxSteps: 20,
223 | providerOptions: {
224 | google: {
225 | thinkingConfig: {
226 | thinkingBudget: 2048,
227 | },
228 | },
229 | anthropic: {
230 | thinking: {
231 | type: 'enabled',
232 | budgetTokens: 12000
233 | },
234 | }
235 | },
236 | onError: (error) => {
237 | console.error(JSON.stringify(error, null, 2));
238 | },
239 | async onFinish({ response }) {
240 | const allMessages = appendResponseMessages({
241 | messages,
242 | responseMessages: response.messages,
243 | });
244 |
245 | await saveChat({
246 | id,
247 | userId,
248 | messages: allMessages,
249 | });
250 |
251 | const dbMessages = convertToDBMessages(allMessages, id);
252 | await saveMessages({ messages: dbMessages });
253 | // close all mcp clients
254 | // for (const client of mcpClients) {
255 | // await client.close();
256 | // }
257 | }
258 | });
259 |
260 | result.consumeStream()
261 | return result.toDataStreamResponse({
262 | sendReasoning: true,
263 | getErrorMessage: (error) => {
264 | if (error instanceof Error) {
265 | if (error.message.includes("Rate limit")) {
266 | return "Rate limit exceeded. Please try again later.";
267 | }
268 | }
269 | console.error(error);
270 | return "An error occurred.";
271 | },
272 | });
273 | }
274 |
--------------------------------------------------------------------------------
/app/api/chats/[id]/route.ts:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
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:
--------------------------------------------------------------------------------
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 ;
53 | }
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zaidmukaddam/scira-mcp-chat/a476a7eaa714f3952d7ad07cca8227e0c898b18e/app/favicon.ico
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
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 | import Script from "next/script";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | metadataBase: new URL("https://mcp.scira.ai"),
14 | title: "Scira MCP Chat",
15 | description: "Scira MCP Chat is a minimalistic MCP client with a good feature set.",
16 | openGraph: {
17 | siteName: "Scira MCP Chat",
18 | url: "https://mcp.scira.ai",
19 | images: [
20 | {
21 | url: "https://mcp.scira.ai/opengraph-image.png",
22 | width: 1200,
23 | height: 630,
24 | },
25 | ],
26 | },
27 | twitter: {
28 | card: "summary_large_image",
29 | title: "Scira MCP Chat",
30 | description: "Scira MCP Chat is a minimalistic MCP client with a good feature set.",
31 | images: ["https://mcp.scira.ai/twitter-image.png"],
32 | },
33 | };
34 |
35 | export default function RootLayout({
36 | children,
37 | }: Readonly<{
38 | children: React.ReactNode;
39 | }>) {
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {children}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zaidmukaddam/scira-mcp-chat/a476a7eaa714f3952d7ad07cca8227e0c898b18e/app/opengraph-image.png
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Chat from "@/components/chat";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode, useEffect, useState } 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 | import { useLocalStorage } from "@/lib/hooks/use-local-storage";
9 | import { STORAGE_KEYS } from "@/lib/constants";
10 | import { MCPProvider } from "@/lib/context/mcp-context";
11 |
12 | // Create a client
13 | const queryClient = new QueryClient({
14 | defaultOptions: {
15 | queries: {
16 | staleTime: 1000 * 60 * 5, // 5 minutes
17 | refetchOnWindowFocus: true,
18 | },
19 | },
20 | });
21 |
22 | export function Providers({ children }: { children: ReactNode }) {
23 | const [sidebarOpen, setSidebarOpen] = useLocalStorage(
24 | STORAGE_KEYS.SIDEBAR_STATE,
25 | true
26 | );
27 |
28 | return (
29 |
30 |
37 |
38 |
39 | {children}
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
--------------------------------------------------------------------------------
/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zaidmukaddam/scira-mcp-chat/a476a7eaa714f3952d7ad07cca8227e0c898b18e/app/twitter-image.png
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
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/api-key-manager.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3 | import { Button } from "@/components/ui/button";
4 | import { Input } from "@/components/ui/input";
5 | import { Label } from "@/components/ui/label";
6 | import { toast } from "sonner";
7 |
8 | // API key configuration
9 | interface ApiKeyConfig {
10 | name: string;
11 | key: string;
12 | storageKey: string;
13 | label: string;
14 | placeholder: string;
15 | }
16 |
17 | // Available API keys configuration
18 | const API_KEYS_CONFIG: ApiKeyConfig[] = [
19 | {
20 | name: "OpenAI",
21 | key: "openai",
22 | storageKey: "OPENAI_API_KEY",
23 | label: "OpenAI API Key",
24 | placeholder: "sk-..."
25 | },
26 | {
27 | name: "Anthropic",
28 | key: "anthropic",
29 | storageKey: "ANTHROPIC_API_KEY",
30 | label: "Anthropic API Key",
31 | placeholder: "sk-ant-..."
32 | },
33 | {
34 | name: "Groq",
35 | key: "groq",
36 | storageKey: "GROQ_API_KEY",
37 | label: "Groq API Key",
38 | placeholder: "gsk_..."
39 | },
40 | {
41 | name: "XAI",
42 | key: "xai",
43 | storageKey: "XAI_API_KEY",
44 | label: "XAI API Key",
45 | placeholder: "xai-..."
46 | }
47 | ];
48 |
49 | interface ApiKeyManagerProps {
50 | open: boolean;
51 | onOpenChange: (open: boolean) => void;
52 | }
53 |
54 | export function ApiKeyManager({ open, onOpenChange }: ApiKeyManagerProps) {
55 | // State to store API keys
56 | const [apiKeys, setApiKeys] = useState>({});
57 |
58 | // Load API keys from localStorage on initial mount
59 | useEffect(() => {
60 | const storedKeys: Record = {};
61 |
62 | API_KEYS_CONFIG.forEach(config => {
63 | const value = localStorage.getItem(config.storageKey);
64 | if (value) {
65 | storedKeys[config.key] = value;
66 | }
67 | });
68 |
69 | setApiKeys(storedKeys);
70 | }, []);
71 |
72 | // Update API key in state
73 | const handleApiKeyChange = (key: string, value: string) => {
74 | setApiKeys(prev => ({
75 | ...prev,
76 | [key]: value
77 | }));
78 | };
79 |
80 | // Save API keys to localStorage
81 | const handleSaveApiKeys = () => {
82 | try {
83 | API_KEYS_CONFIG.forEach(config => {
84 | const value = apiKeys[config.key];
85 |
86 | if (value && value.trim()) {
87 | localStorage.setItem(config.storageKey, value.trim());
88 | } else {
89 | localStorage.removeItem(config.storageKey);
90 | }
91 | });
92 |
93 | toast.success("API keys saved successfully");
94 | onOpenChange(false);
95 | } catch (error) {
96 | console.error("Error saving API keys:", error);
97 | toast.error("Failed to save API keys");
98 | }
99 | };
100 |
101 | // Clear all API keys
102 | const handleClearApiKeys = () => {
103 | try {
104 | API_KEYS_CONFIG.forEach(config => {
105 | localStorage.removeItem(config.storageKey);
106 | });
107 |
108 | setApiKeys({});
109 | toast.success("All API keys cleared");
110 | } catch (error) {
111 | console.error("Error clearing API keys:", error);
112 | toast.error("Failed to clear API keys");
113 | }
114 | };
115 |
116 | return (
117 |
118 |
119 |
120 | API Key Settings
121 |
122 | Enter your own API keys for different AI providers. Keys are stored securely in your browser's local storage.
123 |
124 |
125 |
126 |
127 | {API_KEYS_CONFIG.map(config => (
128 |
129 | {config.label}
130 | handleApiKeyChange(config.key, e.target.value)}
135 | placeholder={config.placeholder}
136 | />
137 |
138 | ))}
139 |
140 |
141 |
142 |
146 | Clear All Keys
147 |
148 |
149 | onOpenChange(false)}
152 | >
153 | Cancel
154 |
155 |
156 | Save Keys
157 |
158 |
159 |
160 |
161 |
162 | );
163 | }
--------------------------------------------------------------------------------
/components/chat.tsx:
--------------------------------------------------------------------------------
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 { 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 | import { useMCP } from "@/lib/context/mcp-context";
19 |
20 | // Type for chat data from DB
21 | interface ChatData {
22 | id: string;
23 | messages: DBMessage[];
24 | createdAt: string;
25 | updatedAt: string;
26 | }
27 |
28 | export default function Chat() {
29 | const router = useRouter();
30 | const params = useParams();
31 | const chatId = params?.id as string | undefined;
32 | const queryClient = useQueryClient();
33 |
34 | const [selectedModel, setSelectedModel] = useLocalStorage("selectedModel", defaultModel);
35 | const [userId, setUserId] = useState('');
36 | const [generatedChatId, setGeneratedChatId] = useState('');
37 |
38 | // Get MCP server data from context
39 | const { mcpServersForApi } = useMCP();
40 |
41 | // Initialize userId
42 | useEffect(() => {
43 | setUserId(getUserId());
44 | }, []);
45 |
46 | // Generate a chat ID if needed
47 | useEffect(() => {
48 | if (!chatId) {
49 | setGeneratedChatId(nanoid());
50 | }
51 | }, [chatId]);
52 |
53 | // Use React Query to fetch chat history
54 | const { data: chatData, isLoading: isLoadingChat } = useQuery({
55 | queryKey: ['chat', chatId, userId] as const,
56 | queryFn: async ({ queryKey }) => {
57 | const [_, chatId, userId] = queryKey;
58 | if (!chatId || !userId) return null;
59 |
60 | try {
61 | const response = await fetch(`/api/chats/${chatId}`, {
62 | headers: {
63 | 'x-user-id': userId
64 | }
65 | });
66 |
67 | if (!response.ok) {
68 | throw new Error('Failed to load chat');
69 | }
70 |
71 | const data = await response.json();
72 | return data as ChatData;
73 | } catch (error) {
74 | console.error('Error loading chat history:', error);
75 | toast.error('Failed to load chat history');
76 | throw error;
77 | }
78 | },
79 | enabled: !!chatId && !!userId,
80 | retry: 1,
81 | staleTime: 1000 * 60 * 5, // 5 minutes
82 | refetchOnWindowFocus: false
83 | });
84 |
85 | // Prepare initial messages from query data
86 | const initialMessages = useMemo(() => {
87 | if (!chatData || !chatData.messages || chatData.messages.length === 0) {
88 | return [];
89 | }
90 |
91 | // Convert DB messages to UI format, then ensure it matches the Message type from @ai-sdk/react
92 | const uiMessages = convertToUIMessages(chatData.messages);
93 | return uiMessages.map(msg => ({
94 | id: msg.id,
95 | role: msg.role as Message['role'], // Ensure role is properly typed
96 | content: msg.content,
97 | parts: msg.parts,
98 | } as Message));
99 | }, [chatData]);
100 |
101 | const { messages, input, handleInputChange, handleSubmit, status, stop } =
102 | useChat({
103 | id: chatId || generatedChatId, // Use generated ID if no chatId in URL
104 | initialMessages,
105 | maxSteps: 20,
106 | body: {
107 | selectedModel,
108 | mcpServers: mcpServersForApi,
109 | chatId: chatId || generatedChatId, // Use generated ID if no chatId in URL
110 | userId,
111 | },
112 | experimental_throttle: 500,
113 | onFinish: () => {
114 | // Invalidate the chats query to refresh the sidebar
115 | if (userId) {
116 | queryClient.invalidateQueries({ queryKey: ['chats', userId] });
117 | }
118 | },
119 | onError: (error) => {
120 | toast.error(
121 | error.message.length > 0
122 | ? error.message
123 | : "An error occured, please try again later.",
124 | { position: "top-center", richColors: true },
125 | );
126 | },
127 | });
128 |
129 | // Custom submit handler
130 | const handleFormSubmit = useCallback((e: React.FormEvent) => {
131 | e.preventDefault();
132 |
133 | if (!chatId && generatedChatId && input.trim()) {
134 | // If this is a new conversation, redirect to the chat page with the generated ID
135 | const effectiveChatId = generatedChatId;
136 |
137 | // Submit the form
138 | handleSubmit(e);
139 |
140 | // Redirect to the chat page with the generated ID
141 | router.push(`/chat/${effectiveChatId}`);
142 | } else {
143 | // Normal submission for existing chats
144 | handleSubmit(e);
145 | }
146 | }, [chatId, generatedChatId, input, handleSubmit, router]);
147 |
148 | const isLoading = status === "streaming" || status === "submitted" || isLoadingChat;
149 |
150 | return (
151 |
152 | {messages.length === 0 && !isLoadingChat ? (
153 |
170 | ) : (
171 | <>
172 |
173 |
174 |
175 |
179 |
188 |
189 | >
190 | )}
191 |
192 | );
193 | }
194 |
--------------------------------------------------------------------------------
/components/copy-button.tsx:
--------------------------------------------------------------------------------
1 | import { CheckIcon, CopyIcon } from "lucide-react";
2 | import { cn } from "@/lib/utils";
3 | import { useCopy } from "@/lib/hooks/use-copy";
4 | import { Button } from "./ui/button";
5 |
6 | interface CopyButtonProps {
7 | text: string;
8 | className?: string;
9 | }
10 |
11 | export function CopyButton({ text, className }: CopyButtonProps) {
12 | const { copied, copy } = useCopy();
13 |
14 | return (
15 | copy(text)}
23 | title="Copy to clipboard"
24 | >
25 | {copied ? (
26 | <>
27 |
28 | Copied!
29 | >
30 | ) : (
31 | <>
32 |
33 | Copy
34 | >
35 | )}
36 |
37 | );
38 | }
--------------------------------------------------------------------------------
/components/deploy-button.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export const DeployButton = () => (
4 |
10 |
18 |
24 |
25 | Deploy
26 |
27 | );
28 |
--------------------------------------------------------------------------------
/components/icons.tsx:
--------------------------------------------------------------------------------
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 |
14 | Vercel Icon
15 |
21 |
22 | );
23 | };
24 |
25 | export const SpinnerIcon = ({ size = 16 }: { size?: number }) => (
26 |
33 | Spinner Icon
34 |
35 |
36 |
42 |
48 |
54 |
60 |
66 |
72 |
78 |
84 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | );
98 |
99 | export const Github = (props: SVGProps) => (
100 |
109 | GitHub Icon
110 |
111 |
112 | );
113 |
114 | export function StarButton() {
115 | return (
116 |
122 |
123 | Star on GitHub
124 |
125 | );
126 | }
127 |
128 | export const XAiIcon = ({ size = 16 }) => {
129 | return (
130 |
136 | xAI Icon
137 |
138 |
139 | );
140 | };
141 |
--------------------------------------------------------------------------------
/components/input.tsx:
--------------------------------------------------------------------------------
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) => 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 |
21 |
28 | {status === "streaming" || status === "submitted" ? (
29 |
34 |
52 |
53 | ) : (
54 |
59 |
60 |
61 | )}
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/components/markdown.tsx:
--------------------------------------------------------------------------------
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 = {
9 | pre: ({ children, ...props }) => (
10 |
11 | {children}
12 |
13 | ),
14 | code: ({ children, className, ...props }: React.HTMLProps & { className?: string }) => {
15 | const match = /language-(\w+)/.exec(className || '');
16 | const isInline = !match && !className;
17 |
18 | if (isInline) {
19 | return (
20 |
24 | {children}
25 |
26 | );
27 | }
28 | return (
29 |
30 | {children}
31 |
32 | );
33 | },
34 | ol: ({ node, children, ...props }) => (
35 |
36 | {children}
37 |
38 | ),
39 | ul: ({ node, children, ...props }) => (
40 |
43 | ),
44 | li: ({ node, children, ...props }) => (
45 |
46 | {children}
47 |
48 | ),
49 | p: ({ node, children, ...props }) => (
50 |
51 | {children}
52 |
53 | ),
54 | strong: ({ node, children, ...props }) => (
55 |
56 | {children}
57 |
58 | ),
59 | em: ({ node, children, ...props }) => (
60 |
61 | {children}
62 |
63 | ),
64 | blockquote: ({ node, children, ...props }) => (
65 |
69 | {children}
70 |
71 | ),
72 | a: ({ node, children, ...props }) => (
73 | // @ts-expect-error error
74 |
80 | {children}
81 |
82 | ),
83 | h1: ({ node, children, ...props }) => (
84 |
85 | {children}
86 |
87 | ),
88 | h2: ({ node, children, ...props }) => (
89 |
90 | {children}
91 |
92 | ),
93 | h3: ({ node, children, ...props }) => (
94 |
95 | {children}
96 |
97 | ),
98 | h4: ({ node, children, ...props }) => (
99 |
100 | {children}
101 |
102 | ),
103 | h5: ({ node, children, ...props }) => (
104 |
105 | {children}
106 |
107 | ),
108 | h6: ({ node, children, ...props }) => (
109 |
110 | {children}
111 |
112 | ),
113 | table: ({ node, children, ...props }) => (
114 |
119 | ),
120 | thead: ({ node, children, ...props }) => (
121 |
122 | {children}
123 |
124 | ),
125 | tbody: ({ node, children, ...props }) => (
126 |
127 | {children}
128 |
129 | ),
130 | tr: ({ node, children, ...props }) => (
131 |
132 | {children}
133 |
134 | ),
135 | th: ({ node, children, ...props }) => (
136 |
140 | {children}
141 |
142 | ),
143 | td: ({ node, children, ...props }) => (
144 |
145 | {children}
146 |
147 | ),
148 | hr: ({ node, ...props }) => (
149 |
150 | ),
151 | };
152 |
153 | const remarkPlugins = [remarkGfm];
154 |
155 | const NonMemoizedMarkdown = ({ children }: { children: string }) => {
156 | return (
157 |
158 | {children}
159 |
160 | );
161 | };
162 |
163 | export const Markdown = memo(
164 | NonMemoizedMarkdown,
165 | (prevProps, nextProps) => prevProps.children === nextProps.children,
166 | );
--------------------------------------------------------------------------------
/components/message.tsx:
--------------------------------------------------------------------------------
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, BrainIcon } from "lucide-react";
10 | import { SpinnerIcon } from "./icons";
11 | import { ToolInvocation } from "./tool-invocation";
12 | import { CopyButton } from "./copy-button";
13 |
14 | interface ReasoningPart {
15 | type: "reasoning";
16 | reasoning: string;
17 | details: Array<{ type: "text"; text: string }>;
18 | }
19 |
20 | interface ReasoningMessagePartProps {
21 | part: ReasoningPart;
22 | isReasoning: boolean;
23 | }
24 |
25 | export function ReasoningMessagePart({
26 | part,
27 | isReasoning,
28 | }: ReasoningMessagePartProps) {
29 | const [isExpanded, setIsExpanded] = useState(false);
30 |
31 | const memoizedSetIsExpanded = useCallback((value: boolean) => {
32 | setIsExpanded(value);
33 | }, []);
34 |
35 | useEffect(() => {
36 | memoizedSetIsExpanded(isReasoning);
37 | }, [isReasoning, memoizedSetIsExpanded]);
38 |
39 | return (
40 |
41 | {isReasoning ? (
42 |
47 |
48 |
49 |
50 |
Thinking...
51 |
52 | ) : (
53 |
setIsExpanded(!isExpanded)}
55 | className={cn(
56 | "flex items-center justify-between w-full",
57 | "rounded-md py-2 px-3 mb-0.5",
58 | "bg-muted/50 border border-border/60 hover:border-border/80",
59 | "transition-all duration-150 cursor-pointer",
60 | isExpanded ? "bg-muted border-primary/20" : ""
61 | )}
62 | >
63 |
64 |
69 |
70 |
71 |
72 | Reasoning
73 |
74 | (click to {isExpanded ? "hide" : "view"})
75 |
76 |
77 |
78 |
85 | {isExpanded ? (
86 |
87 | ) : (
88 |
89 | )}
90 |
91 |
92 | )}
93 |
94 |
95 | {isExpanded && (
96 |
108 |
109 | The assistant's thought process:
110 |
111 | {part.details.map((detail, detailIndex) =>
112 | detail.type === "text" ? (
113 |
114 | {detail.text}
115 |
116 | ) : (
117 | ""
118 | ),
119 | )}
120 |
121 | )}
122 |
123 |
124 | );
125 | }
126 |
127 | const PurePreviewMessage = ({
128 | message,
129 | isLatestMessage,
130 | status,
131 | }: {
132 | message: TMessage;
133 | isLoading: boolean;
134 | status: "error" | "submitted" | "streaming" | "ready";
135 | isLatestMessage: boolean;
136 | }) => {
137 | // Create a string with all text parts for copy functionality
138 | const getMessageText = () => {
139 | if (!message.parts) return "";
140 | return message.parts
141 | .filter(part => part.type === "text")
142 | .map(part => (part.type === "text" ? part.text : ""))
143 | .join("\n\n");
144 | };
145 |
146 | // Only show copy button if the message is from the assistant and not currently streaming
147 | const shouldShowCopyButton = message.role === "assistant" && (!isLatestMessage || status !== "streaming");
148 |
149 | return (
150 |
151 |
161 |
167 |
168 | {message.parts?.map((part, i) => {
169 | switch (part.type) {
170 | case "text":
171 | return (
172 |
178 |
184 | {part.text}
185 |
186 |
187 | );
188 | case "tool-invocation":
189 | const { toolName, state, args } = part.toolInvocation;
190 | const result = 'result' in part.toolInvocation ? part.toolInvocation.result : null;
191 |
192 | return (
193 |
202 | );
203 | case "reasoning":
204 | return (
205 |
216 | );
217 | default:
218 | return null;
219 | }
220 | })}
221 | {shouldShowCopyButton && (
222 |
223 |
224 |
225 | )}
226 |
227 |
228 |
229 |
230 | );
231 | };
232 |
233 | export const Message = memo(PurePreviewMessage, (prevProps, nextProps) => {
234 | if (prevProps.status !== nextProps.status) return false;
235 | if (prevProps.message.annotations !== nextProps.message.annotations)
236 | return false;
237 | if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
238 | return true;
239 | });
240 |
--------------------------------------------------------------------------------
/components/messages.tsx:
--------------------------------------------------------------------------------
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 |
16 | return (
17 |
21 |
22 | {messages.map((m, i) => (
23 |
30 | ))}
31 |
32 |
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/components/model-picker.tsx:
--------------------------------------------------------------------------------
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 |
15 | interface ModelPickerProps {
16 | selectedModel: modelID;
17 | setSelectedModel: (model: modelID) => void;
18 | }
19 |
20 | export const ModelPicker = ({ selectedModel, setSelectedModel }: ModelPickerProps) => {
21 | const [hoveredModel, setHoveredModel] = useState(null);
22 |
23 | // Ensure we always have a valid model ID
24 | const validModelId = MODELS.includes(selectedModel) ? selectedModel : defaultModel;
25 |
26 | // If the selected model is invalid, update it to the default
27 | useEffect(() => {
28 | if (selectedModel !== validModelId) {
29 | setSelectedModel(validModelId as modelID);
30 | }
31 | }, [selectedModel, validModelId, setSelectedModel]);
32 |
33 | // Function to get the appropriate icon for each provider
34 | const getProviderIcon = (provider: string) => {
35 | switch (provider.toLowerCase()) {
36 | case 'anthropic':
37 | return ;
38 | case 'openai':
39 | return ;
40 | case 'google':
41 | return ;
42 | case 'groq':
43 | return ;
44 | case 'xai':
45 | return ;
46 | default:
47 | return ;
48 | }
49 | };
50 |
51 | // Function to get capability icon
52 | const getCapabilityIcon = (capability: string) => {
53 | switch (capability.toLowerCase()) {
54 | case 'code':
55 | return
;
56 | case 'reasoning':
57 | return ;
58 | case 'research':
59 | return ;
60 | case 'vision':
61 | return ;
62 | case 'fast':
63 | case 'rapid':
64 | return ;
65 | case 'efficient':
66 | case 'compact':
67 | return ;
68 | case 'creative':
69 | case 'balance':
70 | return ;
71 | case 'agentic':
72 | return ;
73 | default:
74 | return ;
75 | }
76 | };
77 |
78 | // Get capability badge color
79 | const getCapabilityColor = (capability: string) => {
80 | switch (capability.toLowerCase()) {
81 | case 'code':
82 | return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
83 | case 'reasoning':
84 | case 'research':
85 | return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300";
86 | case 'vision':
87 | return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300";
88 | case 'fast':
89 | case 'rapid':
90 | return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300";
91 | case 'efficient':
92 | case 'compact':
93 | return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300";
94 | case 'creative':
95 | case 'balance':
96 | return "bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300";
97 | case 'agentic':
98 | return "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300";
99 | default:
100 | return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300";
101 | }
102 | };
103 |
104 | // Get current model details to display
105 | const displayModelId = hoveredModel || validModelId;
106 | const currentModelDetails = modelDetails[displayModelId];
107 |
108 | // Handle model change
109 | const handleModelChange = (modelId: string) => {
110 | if (MODELS.includes(modelId)) {
111 | const typedModelId = modelId as modelID;
112 | setSelectedModel(typedModelId);
113 | }
114 | };
115 |
116 | return (
117 |
118 |
123 |
126 |
130 |
131 | {getProviderIcon(modelDetails[validModelId].provider)}
132 | {modelDetails[validModelId].name}
133 |
134 |
135 |
136 |
140 |
141 | {/* Model selector column */}
142 |
143 |
144 | {MODELS.map((id) => {
145 | const modelId = id as modelID;
146 | return (
147 | setHoveredModel(modelId)}
151 | onMouseLeave={() => setHoveredModel(null)}
152 | className={cn(
153 | "!px-2 sm:!px-3 py-1.5 sm:py-2 cursor-pointer rounded-md text-xs transition-colors duration-150",
154 | "hover:bg-primary/5 hover:text-primary-foreground",
155 | "focus:bg-primary/10 focus:text-primary focus:outline-none",
156 | "data-[highlighted]:bg-primary/10 data-[highlighted]:text-primary",
157 | validModelId === id && "!bg-primary/15 !text-primary font-medium"
158 | )}
159 | >
160 |
161 |
162 | {getProviderIcon(modelDetails[modelId].provider)}
163 | {modelDetails[modelId].name}
164 |
165 |
166 | {modelDetails[modelId].provider}
167 |
168 |
169 |
170 | );
171 | })}
172 |
173 |
174 |
175 | {/* Model details column - hidden on smallest screens, visible on sm+ */}
176 |
177 |
178 |
179 | {getProviderIcon(currentModelDetails.provider)}
180 |
{currentModelDetails.name}
181 |
182 |
183 | Provider: {currentModelDetails.provider}
184 |
185 |
186 | {/* Capability badges */}
187 |
188 | {currentModelDetails.capabilities.map((capability) => (
189 |
196 | {getCapabilityIcon(capability)}
197 | {capability}
198 |
199 | ))}
200 |
201 |
202 |
203 | {currentModelDetails.description}
204 |
205 |
206 |
207 |
208 |
209 | API Version:
210 |
211 | {currentModelDetails.apiVersion}
212 |
213 |
214 |
215 |
216 |
217 | {/* Condensed model details for mobile only */}
218 |
219 |
220 | {currentModelDetails.capabilities.slice(0, 4).map((capability) => (
221 |
228 | {getCapabilityIcon(capability)}
229 | {capability}
230 |
231 | ))}
232 | {currentModelDetails.capabilities.length > 4 && (
233 | +{currentModelDetails.capabilities.length - 4} more
234 | )}
235 |
236 |
237 |
238 |
239 |
240 |
241 | );
242 | };
243 |
--------------------------------------------------------------------------------
/components/project-overview.tsx:
--------------------------------------------------------------------------------
1 | import NextLink from "next/link";
2 | export const ProjectOverview = () => {
3 | return (
4 |
5 |
Scira MCP Chat
6 |
7 | );
8 | };
9 |
10 | const Link = ({
11 | children,
12 | href,
13 | }: {
14 | children: React.ReactNode;
15 | href: string;
16 | }) => {
17 | return (
18 |
23 | {children}
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/components/suggested-prompts.tsx:
--------------------------------------------------------------------------------
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 |
30 | {suggestedActions.map((suggestedAction, index) => (
31 | 1 ? "hidden sm:block" : "block"}
38 | >
39 | {
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 | {suggestedAction.title}
47 |
48 | {suggestedAction.label}
49 |
50 |
51 |
52 | ))}
53 |
54 | );
55 | }
56 |
57 | export const SuggestedPrompts = memo(PureSuggestedPrompts, () => true);
58 |
--------------------------------------------------------------------------------
/components/textarea.tsx:
--------------------------------------------------------------------------------
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) => 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 |
29 |
{
36 | if (e.key === "Enter" && !e.shiftKey && !isLoading && input.trim()) {
37 | e.preventDefault();
38 | e.currentTarget.form?.requestSubmit();
39 | }
40 | }}
41 | />
42 |
46 |
47 |
53 | {isStreaming ? (
54 |
55 | ) : (
56 |
57 | )}
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
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 {children}
9 | }
--------------------------------------------------------------------------------
/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { CircleDashed, Flame, Sun } from "lucide-react"
5 | import { useTheme } from "next-themes"
6 | import { Button } from "./ui/button"
7 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
8 | import { cn } from "@/lib/utils"
9 |
10 | export function ThemeToggle({ className, ...props }: React.ComponentProps) {
11 | const { setTheme } = useTheme()
12 |
13 | return (
14 |
15 |
16 |
22 |
23 |
24 |
25 | Toggle theme
26 |
27 |
28 |
29 | setTheme("dark")}>
30 |
31 | Dark
32 |
33 | setTheme("light")}>
34 |
35 | Light
36 |
37 | setTheme("black")}>
38 |
39 | Black
40 |
41 | {/* sunset theme */}
42 | setTheme("sunset")}>
43 |
44 | Sunset
45 |
46 |
47 |
48 | )
49 | }
--------------------------------------------------------------------------------
/components/tool-invocation.tsx:
--------------------------------------------------------------------------------
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 | CheckCircle2,
10 | TerminalSquare,
11 | Code,
12 | ArrowRight,
13 | Circle,
14 | } from "lucide-react";
15 | import { cn } from "@/lib/utils";
16 |
17 | interface ToolInvocationProps {
18 | toolName: string;
19 | state: string;
20 | args: any;
21 | result: any;
22 | isLatestMessage: boolean;
23 | status: string;
24 | }
25 |
26 | export function ToolInvocation({
27 | toolName,
28 | state,
29 | args,
30 | result,
31 | isLatestMessage,
32 | status,
33 | }: ToolInvocationProps) {
34 | const [isExpanded, setIsExpanded] = useState(false);
35 |
36 | const variants = {
37 | collapsed: {
38 | height: 0,
39 | opacity: 0,
40 | },
41 | expanded: {
42 | height: "auto",
43 | opacity: 1,
44 | },
45 | };
46 |
47 | const getStatusIcon = () => {
48 | if (state === "call") {
49 | if (isLatestMessage && status !== "ready") {
50 | return ;
51 | }
52 | return ;
53 | }
54 | return ;
55 | };
56 |
57 | const getStatusClass = () => {
58 | if (state === "call") {
59 | if (isLatestMessage && status !== "ready") {
60 | return "text-primary";
61 | }
62 | return "text-muted-foreground";
63 | }
64 | return "text-primary";
65 | };
66 |
67 | const formatContent = (content: any): string => {
68 | try {
69 | if (typeof content === "string") {
70 | try {
71 | const parsed = JSON.parse(content);
72 | return JSON.stringify(parsed, null, 2);
73 | } catch {
74 | return content;
75 | }
76 | }
77 | return JSON.stringify(content, null, 2);
78 | } catch {
79 | return String(content);
80 | }
81 | };
82 |
83 | return (
84 |
89 |
setIsExpanded(!isExpanded)}
95 | >
96 |
97 |
98 |
99 |
100 |
{toolName}
101 |
102 |
103 | {state === "call" ? (isLatestMessage && status !== "ready" ? "Running" : "Waiting") : "Completed"}
104 |
105 |
106 |
107 | {getStatusIcon()}
108 |
109 | {isExpanded ? (
110 |
111 | ) : (
112 |
113 | )}
114 |
115 |
116 |
117 |
118 |
119 | {isExpanded && (
120 |
128 | {!!args && (
129 |
130 |
131 |
132 | Arguments
133 |
134 |
138 | {formatContent(args)}
139 |
140 |
141 | )}
142 |
143 | {!!result && (
144 |
145 |
149 |
153 | {formatContent(result)}
154 |
155 |
156 | )}
157 |
158 | )}
159 |
160 |
161 | );
162 | }
--------------------------------------------------------------------------------
/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
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) {
12 | return
13 | }
14 |
15 | function AccordionItem({
16 | className,
17 | ...props
18 | }: React.ComponentProps) {
19 | return (
20 |
25 | )
26 | }
27 |
28 | function AccordionTrigger({
29 | className,
30 | children,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
35 | svg]:rotate-180",
39 | className
40 | )}
41 | {...props}
42 | >
43 | {children}
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | function AccordionContent({
51 | className,
52 | children,
53 | ...props
54 | }: React.ComponentProps) {
55 | return (
56 |
61 | {children}
62 |
63 | )
64 | }
65 |
66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
67 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function AvatarImage({
25 | className,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
34 | )
35 | }
36 |
37 | function AvatarFallback({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | export { Avatar, AvatarImage, AvatarFallback }
54 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
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 & { asChild?: boolean }) {
35 | const Comp = asChild ? Slot : "span"
36 |
37 | return (
38 |
43 | )
44 | }
45 |
46 | export { Badge, badgeVariants }
47 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
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 & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
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) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | ...props
53 | }: React.ComponentProps) {
54 | return (
55 |
56 |
57 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | )
83 | }
84 |
85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
95 | )
96 | }
97 |
98 | function DialogTitle({
99 | className,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
108 | )
109 | }
110 |
111 | function DialogDescription({
112 | className,
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
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 | }
136 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
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 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
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) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Popover({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function PopoverTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function PopoverContent({
21 | className,
22 | align = "center",
23 | sideOffset = 4,
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 |
38 |
39 | )
40 | }
41 |
42 | function PopoverAnchor({
43 | ...props
44 | }: React.ComponentProps) {
45 | return
46 | }
47 |
48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
49 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
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) {
13 | return (
14 |
19 |
23 | {children}
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function ScrollBar({
32 | className,
33 | orientation = "vertical",
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
50 |
54 |
55 | )
56 | }
57 |
58 | export { ScrollArea, ScrollBar }
59 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
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) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | children,
30 | ...props
31 | }: React.ComponentProps) {
32 | return (
33 |
41 | {children}
42 |
43 |
44 |
45 |
46 | )
47 | }
48 |
49 | function SelectContent({
50 | className,
51 | children,
52 | position = "popper",
53 | ...props
54 | }: React.ComponentProps) {
55 | return (
56 |
57 |
68 |
69 |
76 | {children}
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SelectLabel({
85 | className,
86 | ...props
87 | }: React.ComponentProps) {
88 | return (
89 |
94 | )
95 | }
96 |
97 | function SelectItem({
98 | className,
99 | children,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
111 |
112 |
113 |
114 |
115 |
116 | {children}
117 |
118 | )
119 | }
120 |
121 | function SelectSeparator({
122 | className,
123 | ...props
124 | }: React.ComponentProps) {
125 | return (
126 |
131 | )
132 | }
133 |
134 | function SelectScrollUpButton({
135 | className,
136 | ...props
137 | }: React.ComponentProps) {
138 | return (
139 |
147 |
148 |
149 | )
150 | }
151 |
152 | function SelectScrollDownButton({
153 | className,
154 | ...props
155 | }: React.ComponentProps) {
156 | return (
157 |
165 |
166 |
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 | }
182 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
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) {
14 | return (
15 |
25 | )
26 | }
27 |
28 | export { Separator }
29 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
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) {
10 | return
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left"
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | )
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | )
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | }
140 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
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 |
22 | )
23 | }
24 |
25 | export { Toaster }
26 |
--------------------------------------------------------------------------------
/components/ui/text-morph.tsx:
--------------------------------------------------------------------------------
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 = {};
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 |
54 |
55 | {characters.map((character) => (
56 |
67 | {character.label}
68 |
69 | ))}
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
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 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
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) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function TooltipTrigger({
32 | ...props
33 | }: React.ComponentProps) {
34 | return
35 | }
36 |
37 | function TooltipContent({
38 | className,
39 | sideOffset = 0,
40 | children,
41 | ...props
42 | }: React.ComponentProps) {
43 | return (
44 |
45 |
54 | {children}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
62 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
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;
--------------------------------------------------------------------------------
/drizzle/0000_supreme_rocket_raccoon.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "chats" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "title" text DEFAULT 'New Chat' NOT NULL,
4 | "created_at" timestamp DEFAULT now() NOT NULL,
5 | "updated_at" timestamp DEFAULT now() NOT NULL
6 | );
7 | --> statement-breakpoint
8 | CREATE TABLE "messages" (
9 | "id" text PRIMARY KEY NOT NULL,
10 | "chat_id" text NOT NULL,
11 | "content" text NOT NULL,
12 | "role" text NOT NULL,
13 | "created_at" timestamp DEFAULT now() NOT NULL
14 | );
15 | --> statement-breakpoint
16 | ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."chats"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/drizzle/0001_curious_paper_doll.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "users" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "client_id" text NOT NULL,
4 | "created_at" timestamp DEFAULT now() NOT NULL,
5 | "updated_at" timestamp DEFAULT now() NOT NULL,
6 | CONSTRAINT "users_client_id_unique" UNIQUE("client_id")
7 | );
8 | --> statement-breakpoint
9 | ALTER TABLE "chats" ADD COLUMN "user_id" text;--> statement-breakpoint
10 | ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/drizzle/0002_free_cobalt_man.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "steps" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "message_id" text NOT NULL,
4 | "step_type" text NOT NULL,
5 | "text" text,
6 | "reasoning" text,
7 | "finish_reason" text,
8 | "created_at" timestamp DEFAULT now() NOT NULL,
9 | "tool_calls" json,
10 | "tool_results" json
11 | );
12 | --> statement-breakpoint
13 | ALTER TABLE "users" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
14 | DROP TABLE "users" CASCADE;--> statement-breakpoint
15 | ALTER TABLE "chats" DROP CONSTRAINT "chats_user_id_users_id_fk";
16 | --> statement-breakpoint
17 | ALTER TABLE "chats" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint
18 | ALTER TABLE "messages" ADD COLUMN "reasoning" text;--> statement-breakpoint
19 | ALTER TABLE "messages" ADD COLUMN "tool_calls" json;--> statement-breakpoint
20 | ALTER TABLE "messages" ADD COLUMN "tool_results" json;--> statement-breakpoint
21 | ALTER TABLE "messages" ADD COLUMN "has_tool_use" boolean DEFAULT false;--> statement-breakpoint
22 | ALTER TABLE "steps" ADD CONSTRAINT "steps_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/drizzle/0003_oval_energizer.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "steps" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
2 | DROP TABLE "steps" CASCADE;--> statement-breakpoint
3 | ALTER TABLE "messages" ALTER COLUMN "tool_calls" SET DATA TYPE jsonb;--> statement-breakpoint
4 | ALTER TABLE "messages" ALTER COLUMN "tool_results" SET DATA TYPE jsonb;--> statement-breakpoint
5 | ALTER TABLE "messages" ADD COLUMN "step_type" text;--> statement-breakpoint
6 | ALTER TABLE "messages" ADD COLUMN "finish_reason" text;--> statement-breakpoint
7 | ALTER TABLE "messages" DROP COLUMN "has_tool_use";
--------------------------------------------------------------------------------
/drizzle/0004_tense_ricochet.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "messages" DROP COLUMN "reasoning";--> statement-breakpoint
2 | ALTER TABLE "messages" DROP COLUMN "tool_calls";--> statement-breakpoint
3 | ALTER TABLE "messages" DROP COLUMN "tool_results";--> statement-breakpoint
4 | ALTER TABLE "messages" DROP COLUMN "step_type";--> statement-breakpoint
5 | ALTER TABLE "messages" DROP COLUMN "finish_reason";
--------------------------------------------------------------------------------
/drizzle/0005_early_payback.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "messages" ADD COLUMN "parts" json NOT NULL;--> statement-breakpoint
2 | ALTER TABLE "messages" DROP COLUMN "content";
--------------------------------------------------------------------------------
/drizzle/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "70cfd958-05b3-4673-81b2-be05beb0a237",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "title": {
18 | "name": "title",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true,
22 | "default": "'New Chat'"
23 | },
24 | "created_at": {
25 | "name": "created_at",
26 | "type": "timestamp",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "default": "now()"
30 | },
31 | "updated_at": {
32 | "name": "updated_at",
33 | "type": "timestamp",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "default": "now()"
37 | }
38 | },
39 | "indexes": {},
40 | "foreignKeys": {},
41 | "compositePrimaryKeys": {},
42 | "uniqueConstraints": {},
43 | "policies": {},
44 | "checkConstraints": {},
45 | "isRLSEnabled": false
46 | },
47 | "public.messages": {
48 | "name": "messages",
49 | "schema": "",
50 | "columns": {
51 | "id": {
52 | "name": "id",
53 | "type": "text",
54 | "primaryKey": true,
55 | "notNull": true
56 | },
57 | "chat_id": {
58 | "name": "chat_id",
59 | "type": "text",
60 | "primaryKey": false,
61 | "notNull": true
62 | },
63 | "content": {
64 | "name": "content",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true
68 | },
69 | "role": {
70 | "name": "role",
71 | "type": "text",
72 | "primaryKey": false,
73 | "notNull": true
74 | },
75 | "created_at": {
76 | "name": "created_at",
77 | "type": "timestamp",
78 | "primaryKey": false,
79 | "notNull": true,
80 | "default": "now()"
81 | }
82 | },
83 | "indexes": {},
84 | "foreignKeys": {
85 | "messages_chat_id_chats_id_fk": {
86 | "name": "messages_chat_id_chats_id_fk",
87 | "tableFrom": "messages",
88 | "tableTo": "chats",
89 | "columnsFrom": [
90 | "chat_id"
91 | ],
92 | "columnsTo": [
93 | "id"
94 | ],
95 | "onDelete": "cascade",
96 | "onUpdate": "no action"
97 | }
98 | },
99 | "compositePrimaryKeys": {},
100 | "uniqueConstraints": {},
101 | "policies": {},
102 | "checkConstraints": {},
103 | "isRLSEnabled": false
104 | }
105 | },
106 | "enums": {},
107 | "schemas": {},
108 | "sequences": {},
109 | "roles": {},
110 | "policies": {},
111 | "views": {},
112 | "_meta": {
113 | "columns": {},
114 | "schemas": {},
115 | "tables": {}
116 | }
117 | }
--------------------------------------------------------------------------------
/drizzle/meta/0001_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "c25dbd1f-846e-4ca4-b2f3-d24f70977d6f",
3 | "prevId": "70cfd958-05b3-4673-81b2-be05beb0a237",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "user_id": {
18 | "name": "user_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": false
22 | },
23 | "title": {
24 | "name": "title",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true,
28 | "default": "'New Chat'"
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | },
37 | "updated_at": {
38 | "name": "updated_at",
39 | "type": "timestamp",
40 | "primaryKey": false,
41 | "notNull": true,
42 | "default": "now()"
43 | }
44 | },
45 | "indexes": {},
46 | "foreignKeys": {
47 | "chats_user_id_users_id_fk": {
48 | "name": "chats_user_id_users_id_fk",
49 | "tableFrom": "chats",
50 | "tableTo": "users",
51 | "columnsFrom": [
52 | "user_id"
53 | ],
54 | "columnsTo": [
55 | "id"
56 | ],
57 | "onDelete": "cascade",
58 | "onUpdate": "no action"
59 | }
60 | },
61 | "compositePrimaryKeys": {},
62 | "uniqueConstraints": {},
63 | "policies": {},
64 | "checkConstraints": {},
65 | "isRLSEnabled": false
66 | },
67 | "public.messages": {
68 | "name": "messages",
69 | "schema": "",
70 | "columns": {
71 | "id": {
72 | "name": "id",
73 | "type": "text",
74 | "primaryKey": true,
75 | "notNull": true
76 | },
77 | "chat_id": {
78 | "name": "chat_id",
79 | "type": "text",
80 | "primaryKey": false,
81 | "notNull": true
82 | },
83 | "content": {
84 | "name": "content",
85 | "type": "text",
86 | "primaryKey": false,
87 | "notNull": true
88 | },
89 | "role": {
90 | "name": "role",
91 | "type": "text",
92 | "primaryKey": false,
93 | "notNull": true
94 | },
95 | "created_at": {
96 | "name": "created_at",
97 | "type": "timestamp",
98 | "primaryKey": false,
99 | "notNull": true,
100 | "default": "now()"
101 | }
102 | },
103 | "indexes": {},
104 | "foreignKeys": {
105 | "messages_chat_id_chats_id_fk": {
106 | "name": "messages_chat_id_chats_id_fk",
107 | "tableFrom": "messages",
108 | "tableTo": "chats",
109 | "columnsFrom": [
110 | "chat_id"
111 | ],
112 | "columnsTo": [
113 | "id"
114 | ],
115 | "onDelete": "cascade",
116 | "onUpdate": "no action"
117 | }
118 | },
119 | "compositePrimaryKeys": {},
120 | "uniqueConstraints": {},
121 | "policies": {},
122 | "checkConstraints": {},
123 | "isRLSEnabled": false
124 | },
125 | "public.users": {
126 | "name": "users",
127 | "schema": "",
128 | "columns": {
129 | "id": {
130 | "name": "id",
131 | "type": "text",
132 | "primaryKey": true,
133 | "notNull": true
134 | },
135 | "client_id": {
136 | "name": "client_id",
137 | "type": "text",
138 | "primaryKey": false,
139 | "notNull": true
140 | },
141 | "created_at": {
142 | "name": "created_at",
143 | "type": "timestamp",
144 | "primaryKey": false,
145 | "notNull": true,
146 | "default": "now()"
147 | },
148 | "updated_at": {
149 | "name": "updated_at",
150 | "type": "timestamp",
151 | "primaryKey": false,
152 | "notNull": true,
153 | "default": "now()"
154 | }
155 | },
156 | "indexes": {},
157 | "foreignKeys": {},
158 | "compositePrimaryKeys": {},
159 | "uniqueConstraints": {
160 | "users_client_id_unique": {
161 | "name": "users_client_id_unique",
162 | "nullsNotDistinct": false,
163 | "columns": [
164 | "client_id"
165 | ]
166 | }
167 | },
168 | "policies": {},
169 | "checkConstraints": {},
170 | "isRLSEnabled": false
171 | }
172 | },
173 | "enums": {},
174 | "schemas": {},
175 | "sequences": {},
176 | "roles": {},
177 | "policies": {},
178 | "views": {},
179 | "_meta": {
180 | "columns": {},
181 | "schemas": {},
182 | "tables": {}
183 | }
184 | }
--------------------------------------------------------------------------------
/drizzle/meta/0002_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "9ea87331-4108-40dd-8ac1-32fb1d2f1149",
3 | "prevId": "c25dbd1f-846e-4ca4-b2f3-d24f70977d6f",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "user_id": {
18 | "name": "user_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "title": {
24 | "name": "title",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true,
28 | "default": "'New Chat'"
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | },
37 | "updated_at": {
38 | "name": "updated_at",
39 | "type": "timestamp",
40 | "primaryKey": false,
41 | "notNull": true,
42 | "default": "now()"
43 | }
44 | },
45 | "indexes": {},
46 | "foreignKeys": {},
47 | "compositePrimaryKeys": {},
48 | "uniqueConstraints": {},
49 | "policies": {},
50 | "checkConstraints": {},
51 | "isRLSEnabled": false
52 | },
53 | "public.messages": {
54 | "name": "messages",
55 | "schema": "",
56 | "columns": {
57 | "id": {
58 | "name": "id",
59 | "type": "text",
60 | "primaryKey": true,
61 | "notNull": true
62 | },
63 | "chat_id": {
64 | "name": "chat_id",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true
68 | },
69 | "content": {
70 | "name": "content",
71 | "type": "text",
72 | "primaryKey": false,
73 | "notNull": true
74 | },
75 | "role": {
76 | "name": "role",
77 | "type": "text",
78 | "primaryKey": false,
79 | "notNull": true
80 | },
81 | "created_at": {
82 | "name": "created_at",
83 | "type": "timestamp",
84 | "primaryKey": false,
85 | "notNull": true,
86 | "default": "now()"
87 | },
88 | "reasoning": {
89 | "name": "reasoning",
90 | "type": "text",
91 | "primaryKey": false,
92 | "notNull": false
93 | },
94 | "tool_calls": {
95 | "name": "tool_calls",
96 | "type": "json",
97 | "primaryKey": false,
98 | "notNull": false
99 | },
100 | "tool_results": {
101 | "name": "tool_results",
102 | "type": "json",
103 | "primaryKey": false,
104 | "notNull": false
105 | },
106 | "has_tool_use": {
107 | "name": "has_tool_use",
108 | "type": "boolean",
109 | "primaryKey": false,
110 | "notNull": false,
111 | "default": false
112 | }
113 | },
114 | "indexes": {},
115 | "foreignKeys": {
116 | "messages_chat_id_chats_id_fk": {
117 | "name": "messages_chat_id_chats_id_fk",
118 | "tableFrom": "messages",
119 | "tableTo": "chats",
120 | "columnsFrom": [
121 | "chat_id"
122 | ],
123 | "columnsTo": [
124 | "id"
125 | ],
126 | "onDelete": "cascade",
127 | "onUpdate": "no action"
128 | }
129 | },
130 | "compositePrimaryKeys": {},
131 | "uniqueConstraints": {},
132 | "policies": {},
133 | "checkConstraints": {},
134 | "isRLSEnabled": false
135 | },
136 | "public.steps": {
137 | "name": "steps",
138 | "schema": "",
139 | "columns": {
140 | "id": {
141 | "name": "id",
142 | "type": "text",
143 | "primaryKey": true,
144 | "notNull": true
145 | },
146 | "message_id": {
147 | "name": "message_id",
148 | "type": "text",
149 | "primaryKey": false,
150 | "notNull": true
151 | },
152 | "step_type": {
153 | "name": "step_type",
154 | "type": "text",
155 | "primaryKey": false,
156 | "notNull": true
157 | },
158 | "text": {
159 | "name": "text",
160 | "type": "text",
161 | "primaryKey": false,
162 | "notNull": false
163 | },
164 | "reasoning": {
165 | "name": "reasoning",
166 | "type": "text",
167 | "primaryKey": false,
168 | "notNull": false
169 | },
170 | "finish_reason": {
171 | "name": "finish_reason",
172 | "type": "text",
173 | "primaryKey": false,
174 | "notNull": false
175 | },
176 | "created_at": {
177 | "name": "created_at",
178 | "type": "timestamp",
179 | "primaryKey": false,
180 | "notNull": true,
181 | "default": "now()"
182 | },
183 | "tool_calls": {
184 | "name": "tool_calls",
185 | "type": "json",
186 | "primaryKey": false,
187 | "notNull": false
188 | },
189 | "tool_results": {
190 | "name": "tool_results",
191 | "type": "json",
192 | "primaryKey": false,
193 | "notNull": false
194 | }
195 | },
196 | "indexes": {},
197 | "foreignKeys": {
198 | "steps_message_id_messages_id_fk": {
199 | "name": "steps_message_id_messages_id_fk",
200 | "tableFrom": "steps",
201 | "tableTo": "messages",
202 | "columnsFrom": [
203 | "message_id"
204 | ],
205 | "columnsTo": [
206 | "id"
207 | ],
208 | "onDelete": "cascade",
209 | "onUpdate": "no action"
210 | }
211 | },
212 | "compositePrimaryKeys": {},
213 | "uniqueConstraints": {},
214 | "policies": {},
215 | "checkConstraints": {},
216 | "isRLSEnabled": false
217 | }
218 | },
219 | "enums": {},
220 | "schemas": {},
221 | "sequences": {},
222 | "roles": {},
223 | "policies": {},
224 | "views": {},
225 | "_meta": {
226 | "columns": {},
227 | "schemas": {},
228 | "tables": {}
229 | }
230 | }
--------------------------------------------------------------------------------
/drizzle/meta/0003_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "4d2bf069-17f7-4848-a16e-ce008e47d268",
3 | "prevId": "9ea87331-4108-40dd-8ac1-32fb1d2f1149",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "user_id": {
18 | "name": "user_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "title": {
24 | "name": "title",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true,
28 | "default": "'New Chat'"
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | },
37 | "updated_at": {
38 | "name": "updated_at",
39 | "type": "timestamp",
40 | "primaryKey": false,
41 | "notNull": true,
42 | "default": "now()"
43 | }
44 | },
45 | "indexes": {},
46 | "foreignKeys": {},
47 | "compositePrimaryKeys": {},
48 | "uniqueConstraints": {},
49 | "policies": {},
50 | "checkConstraints": {},
51 | "isRLSEnabled": false
52 | },
53 | "public.messages": {
54 | "name": "messages",
55 | "schema": "",
56 | "columns": {
57 | "id": {
58 | "name": "id",
59 | "type": "text",
60 | "primaryKey": true,
61 | "notNull": true
62 | },
63 | "chat_id": {
64 | "name": "chat_id",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true
68 | },
69 | "content": {
70 | "name": "content",
71 | "type": "text",
72 | "primaryKey": false,
73 | "notNull": true
74 | },
75 | "role": {
76 | "name": "role",
77 | "type": "text",
78 | "primaryKey": false,
79 | "notNull": true
80 | },
81 | "created_at": {
82 | "name": "created_at",
83 | "type": "timestamp",
84 | "primaryKey": false,
85 | "notNull": true,
86 | "default": "now()"
87 | },
88 | "reasoning": {
89 | "name": "reasoning",
90 | "type": "text",
91 | "primaryKey": false,
92 | "notNull": false
93 | },
94 | "tool_calls": {
95 | "name": "tool_calls",
96 | "type": "jsonb",
97 | "primaryKey": false,
98 | "notNull": false
99 | },
100 | "tool_results": {
101 | "name": "tool_results",
102 | "type": "jsonb",
103 | "primaryKey": false,
104 | "notNull": false
105 | },
106 | "step_type": {
107 | "name": "step_type",
108 | "type": "text",
109 | "primaryKey": false,
110 | "notNull": false
111 | },
112 | "finish_reason": {
113 | "name": "finish_reason",
114 | "type": "text",
115 | "primaryKey": false,
116 | "notNull": false
117 | }
118 | },
119 | "indexes": {},
120 | "foreignKeys": {
121 | "messages_chat_id_chats_id_fk": {
122 | "name": "messages_chat_id_chats_id_fk",
123 | "tableFrom": "messages",
124 | "tableTo": "chats",
125 | "columnsFrom": [
126 | "chat_id"
127 | ],
128 | "columnsTo": [
129 | "id"
130 | ],
131 | "onDelete": "cascade",
132 | "onUpdate": "no action"
133 | }
134 | },
135 | "compositePrimaryKeys": {},
136 | "uniqueConstraints": {},
137 | "policies": {},
138 | "checkConstraints": {},
139 | "isRLSEnabled": false
140 | }
141 | },
142 | "enums": {},
143 | "schemas": {},
144 | "sequences": {},
145 | "roles": {},
146 | "policies": {},
147 | "views": {},
148 | "_meta": {
149 | "columns": {},
150 | "schemas": {},
151 | "tables": {}
152 | }
153 | }
--------------------------------------------------------------------------------
/drizzle/meta/0004_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "6369cdc2-8254-4270-a54d-6765b2b04c1a",
3 | "prevId": "4d2bf069-17f7-4848-a16e-ce008e47d268",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "user_id": {
18 | "name": "user_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "title": {
24 | "name": "title",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true,
28 | "default": "'New Chat'"
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | },
37 | "updated_at": {
38 | "name": "updated_at",
39 | "type": "timestamp",
40 | "primaryKey": false,
41 | "notNull": true,
42 | "default": "now()"
43 | }
44 | },
45 | "indexes": {},
46 | "foreignKeys": {},
47 | "compositePrimaryKeys": {},
48 | "uniqueConstraints": {},
49 | "policies": {},
50 | "checkConstraints": {},
51 | "isRLSEnabled": false
52 | },
53 | "public.messages": {
54 | "name": "messages",
55 | "schema": "",
56 | "columns": {
57 | "id": {
58 | "name": "id",
59 | "type": "text",
60 | "primaryKey": true,
61 | "notNull": true
62 | },
63 | "chat_id": {
64 | "name": "chat_id",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true
68 | },
69 | "content": {
70 | "name": "content",
71 | "type": "text",
72 | "primaryKey": false,
73 | "notNull": true
74 | },
75 | "role": {
76 | "name": "role",
77 | "type": "text",
78 | "primaryKey": false,
79 | "notNull": true
80 | },
81 | "created_at": {
82 | "name": "created_at",
83 | "type": "timestamp",
84 | "primaryKey": false,
85 | "notNull": true,
86 | "default": "now()"
87 | }
88 | },
89 | "indexes": {},
90 | "foreignKeys": {
91 | "messages_chat_id_chats_id_fk": {
92 | "name": "messages_chat_id_chats_id_fk",
93 | "tableFrom": "messages",
94 | "tableTo": "chats",
95 | "columnsFrom": [
96 | "chat_id"
97 | ],
98 | "columnsTo": [
99 | "id"
100 | ],
101 | "onDelete": "cascade",
102 | "onUpdate": "no action"
103 | }
104 | },
105 | "compositePrimaryKeys": {},
106 | "uniqueConstraints": {},
107 | "policies": {},
108 | "checkConstraints": {},
109 | "isRLSEnabled": false
110 | }
111 | },
112 | "enums": {},
113 | "schemas": {},
114 | "sequences": {},
115 | "roles": {},
116 | "policies": {},
117 | "views": {},
118 | "_meta": {
119 | "columns": {},
120 | "schemas": {},
121 | "tables": {}
122 | }
123 | }
--------------------------------------------------------------------------------
/drizzle/meta/0005_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "938dd39c-9206-4289-a8ce-f2a81656b4fe",
3 | "prevId": "6369cdc2-8254-4270-a54d-6765b2b04c1a",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.chats": {
8 | "name": "chats",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "user_id": {
18 | "name": "user_id",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "title": {
24 | "name": "title",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true,
28 | "default": "'New Chat'"
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | },
37 | "updated_at": {
38 | "name": "updated_at",
39 | "type": "timestamp",
40 | "primaryKey": false,
41 | "notNull": true,
42 | "default": "now()"
43 | }
44 | },
45 | "indexes": {},
46 | "foreignKeys": {},
47 | "compositePrimaryKeys": {},
48 | "uniqueConstraints": {},
49 | "policies": {},
50 | "checkConstraints": {},
51 | "isRLSEnabled": false
52 | },
53 | "public.messages": {
54 | "name": "messages",
55 | "schema": "",
56 | "columns": {
57 | "id": {
58 | "name": "id",
59 | "type": "text",
60 | "primaryKey": true,
61 | "notNull": true
62 | },
63 | "chat_id": {
64 | "name": "chat_id",
65 | "type": "text",
66 | "primaryKey": false,
67 | "notNull": true
68 | },
69 | "parts": {
70 | "name": "parts",
71 | "type": "json",
72 | "primaryKey": false,
73 | "notNull": true
74 | },
75 | "role": {
76 | "name": "role",
77 | "type": "text",
78 | "primaryKey": false,
79 | "notNull": true
80 | },
81 | "created_at": {
82 | "name": "created_at",
83 | "type": "timestamp",
84 | "primaryKey": false,
85 | "notNull": true,
86 | "default": "now()"
87 | }
88 | },
89 | "indexes": {},
90 | "foreignKeys": {
91 | "messages_chat_id_chats_id_fk": {
92 | "name": "messages_chat_id_chats_id_fk",
93 | "tableFrom": "messages",
94 | "tableTo": "chats",
95 | "columnsFrom": [
96 | "chat_id"
97 | ],
98 | "columnsTo": [
99 | "id"
100 | ],
101 | "onDelete": "cascade",
102 | "onUpdate": "no action"
103 | }
104 | },
105 | "compositePrimaryKeys": {},
106 | "uniqueConstraints": {},
107 | "policies": {},
108 | "checkConstraints": {},
109 | "isRLSEnabled": false
110 | }
111 | },
112 | "enums": {},
113 | "schemas": {},
114 | "sequences": {},
115 | "roles": {},
116 | "policies": {},
117 | "views": {},
118 | "_meta": {
119 | "columns": {},
120 | "schemas": {},
121 | "tables": {}
122 | }
123 | }
--------------------------------------------------------------------------------
/drizzle/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1745127149643,
9 | "tag": "0000_supreme_rocket_raccoon",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1745134273539,
16 | "tag": "0001_curious_paper_doll",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1745164597075,
23 | "tag": "0002_free_cobalt_man",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1745165154121,
30 | "tag": "0003_oval_energizer",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1745168811293,
37 | "tag": "0004_tense_ricochet",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "7",
43 | "when": 1745172826749,
44 | "tag": "0005_early_payback",
45 | "breakpoints": true
46 | }
47 | ]
48 | }
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | {
15 | rules: {
16 | "@typescript-eslint/no-unused-vars": "off",
17 | "@typescript-eslint/no-explicit-any": "off"
18 | }
19 | }
20 | ];
21 |
22 | export default eslintConfig;
23 |
--------------------------------------------------------------------------------
/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/lib/chat-store.ts:
--------------------------------------------------------------------------------
1 | import { db } from "./db";
2 | import { chats, messages, type Chat, type Message, MessageRole, type MessagePart, type DBMessage } from "./db/schema";
3 | import { eq, desc, and } from "drizzle-orm";
4 | import { nanoid } from "nanoid";
5 | import { generateTitle } from "@/app/actions";
6 |
7 | type AIMessage = {
8 | role: string;
9 | content: string | any[];
10 | id?: string;
11 | parts?: MessagePart[];
12 | };
13 |
14 | type UIMessage = {
15 | id: string;
16 | role: string;
17 | content: string;
18 | parts: MessagePart[];
19 | createdAt?: Date;
20 | };
21 |
22 | type SaveChatParams = {
23 | id?: string;
24 | userId: string;
25 | messages?: any[];
26 | title?: string;
27 | };
28 |
29 | type ChatWithMessages = Chat & {
30 | messages: Message[];
31 | };
32 |
33 | export async function saveMessages({
34 | messages: dbMessages,
35 | }: {
36 | messages: Array;
37 | }) {
38 | try {
39 | if (dbMessages.length > 0) {
40 | const chatId = dbMessages[0].chatId;
41 |
42 | // First delete any existing messages for this chat
43 | await db
44 | .delete(messages)
45 | .where(eq(messages.chatId, chatId));
46 |
47 | // Then insert the new messages
48 | return await db.insert(messages).values(dbMessages);
49 | }
50 | return null;
51 | } catch (error) {
52 | console.error('Failed to save messages in database', error);
53 | throw error;
54 | }
55 | }
56 |
57 | // Function to convert AI messages to DB format
58 | export function convertToDBMessages(aiMessages: AIMessage[], chatId: string): DBMessage[] {
59 | return aiMessages.map(msg => {
60 | // Use existing id or generate a new one
61 | const messageId = msg.id || nanoid();
62 |
63 | // If msg has parts, use them directly
64 | if (msg.parts) {
65 | return {
66 | id: messageId,
67 | chatId,
68 | role: msg.role,
69 | parts: msg.parts,
70 | createdAt: new Date()
71 | };
72 | }
73 |
74 | // Otherwise, convert content to parts
75 | let parts: MessagePart[];
76 |
77 | if (typeof msg.content === 'string') {
78 | parts = [{ type: 'text', text: msg.content }];
79 | } else if (Array.isArray(msg.content)) {
80 | if (msg.content.every(item => typeof item === 'object' && item !== null)) {
81 | // Content is already in parts-like format
82 | parts = msg.content as MessagePart[];
83 | } else {
84 | // Content is an array but not in parts format
85 | parts = [{ type: 'text', text: JSON.stringify(msg.content) }];
86 | }
87 | } else {
88 | // Default case
89 | parts = [{ type: 'text', text: String(msg.content) }];
90 | }
91 |
92 | return {
93 | id: messageId,
94 | chatId,
95 | role: msg.role,
96 | parts,
97 | createdAt: new Date()
98 | };
99 | });
100 | }
101 |
102 | // Convert DB messages to UI format
103 | export function convertToUIMessages(dbMessages: Array): Array {
104 | return dbMessages.map((message) => ({
105 | id: message.id,
106 | parts: message.parts as MessagePart[],
107 | role: message.role as string,
108 | content: getTextContent(message), // For backward compatibility
109 | createdAt: message.createdAt,
110 | }));
111 | }
112 |
113 | export async function saveChat({ id, userId, messages: aiMessages, title }: SaveChatParams) {
114 | // Generate a new ID if one wasn't provided
115 | const chatId = id || nanoid();
116 |
117 | // Check if title is provided, if not generate one
118 | let chatTitle = title;
119 |
120 | // Generate title if messages are provided and no title is specified
121 | if (aiMessages && aiMessages.length > 0) {
122 | const hasEnoughMessages = aiMessages.length >= 2 &&
123 | aiMessages.some(m => m.role === 'user') &&
124 | aiMessages.some(m => m.role === 'assistant');
125 |
126 | if (!chatTitle || chatTitle === 'New Chat' || chatTitle === undefined) {
127 | if (hasEnoughMessages) {
128 | try {
129 | // Use AI to generate a meaningful title based on conversation
130 | chatTitle = await generateTitle(aiMessages);
131 | } catch (error) {
132 | console.error('Error generating title:', error);
133 | // Fallback to basic title extraction if AI title generation fails
134 | const firstUserMessage = aiMessages.find(m => m.role === 'user');
135 | if (firstUserMessage) {
136 | // Check for parts first (new format)
137 | if (firstUserMessage.parts && Array.isArray(firstUserMessage.parts)) {
138 | const textParts = firstUserMessage.parts.filter((p: MessagePart) => p.type === 'text' && p.text);
139 | if (textParts.length > 0) {
140 | chatTitle = textParts[0].text?.slice(0, 50) || 'New Chat';
141 | if ((textParts[0].text?.length || 0) > 50) {
142 | chatTitle += '...';
143 | }
144 | } else {
145 | chatTitle = 'New Chat';
146 | }
147 | }
148 | // Fallback to content (old format)
149 | else if (typeof firstUserMessage.content === 'string') {
150 | chatTitle = firstUserMessage.content.slice(0, 50);
151 | if (firstUserMessage.content.length > 50) {
152 | chatTitle += '...';
153 | }
154 | } else {
155 | chatTitle = 'New Chat';
156 | }
157 | } else {
158 | chatTitle = 'New Chat';
159 | }
160 | }
161 | } else {
162 | // Not enough messages for AI title, use first message
163 | const firstUserMessage = aiMessages.find(m => m.role === 'user');
164 | if (firstUserMessage) {
165 | // Check for parts first (new format)
166 | if (firstUserMessage.parts && Array.isArray(firstUserMessage.parts)) {
167 | const textParts = firstUserMessage.parts.filter((p: MessagePart) => p.type === 'text' && p.text);
168 | if (textParts.length > 0) {
169 | chatTitle = textParts[0].text?.slice(0, 50) || 'New Chat';
170 | if ((textParts[0].text?.length || 0) > 50) {
171 | chatTitle += '...';
172 | }
173 | } else {
174 | chatTitle = 'New Chat';
175 | }
176 | }
177 | // Fallback to content (old format)
178 | else if (typeof firstUserMessage.content === 'string') {
179 | chatTitle = firstUserMessage.content.slice(0, 50);
180 | if (firstUserMessage.content.length > 50) {
181 | chatTitle += '...';
182 | }
183 | } else {
184 | chatTitle = 'New Chat';
185 | }
186 | } else {
187 | chatTitle = 'New Chat';
188 | }
189 | }
190 | }
191 | } else {
192 | chatTitle = chatTitle || 'New Chat';
193 | }
194 |
195 | // Check if chat already exists
196 | const existingChat = await db.query.chats.findFirst({
197 | where: and(
198 | eq(chats.id, chatId),
199 | eq(chats.userId, userId)
200 | ),
201 | });
202 |
203 | if (existingChat) {
204 | // Update existing chat
205 | await db
206 | .update(chats)
207 | .set({
208 | title: chatTitle,
209 | updatedAt: new Date()
210 | })
211 | .where(and(
212 | eq(chats.id, chatId),
213 | eq(chats.userId, userId)
214 | ));
215 | } else {
216 | // Create new chat
217 | await db.insert(chats).values({
218 | id: chatId,
219 | userId,
220 | title: chatTitle,
221 | createdAt: new Date(),
222 | updatedAt: new Date()
223 | });
224 | }
225 |
226 | return { id: chatId };
227 | }
228 |
229 | // Helper to get just the text content for display
230 | export function getTextContent(message: Message): string {
231 | try {
232 | const parts = message.parts as MessagePart[];
233 | return parts
234 | .filter(part => part.type === 'text' && part.text)
235 | .map(part => part.text)
236 | .join('\n');
237 | } catch (e) {
238 | // If parsing fails, return empty string
239 | return '';
240 | }
241 | }
242 |
243 | export async function getChats(userId: string) {
244 | return await db.query.chats.findMany({
245 | where: eq(chats.userId, userId),
246 | orderBy: [desc(chats.updatedAt)]
247 | });
248 | }
249 |
250 | export async function getChatById(id: string, userId: string): Promise {
251 | const chat = await db.query.chats.findFirst({
252 | where: and(
253 | eq(chats.id, id),
254 | eq(chats.userId, userId)
255 | ),
256 | });
257 |
258 | if (!chat) return null;
259 |
260 | const chatMessages = await db.query.messages.findMany({
261 | where: eq(messages.chatId, id),
262 | orderBy: [messages.createdAt]
263 | });
264 |
265 | return {
266 | ...chat,
267 | messages: chatMessages
268 | };
269 | }
270 |
271 | export async function deleteChat(id: string, userId: string) {
272 | await db.delete(chats).where(
273 | and(
274 | eq(chats.id, id),
275 | eq(chats.userId, userId)
276 | )
277 | );
278 | }
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Constants used throughout the application
3 | */
4 |
5 | // Local storage keys
6 | export const STORAGE_KEYS = {
7 | MCP_SERVERS: "mcp-servers",
8 | SELECTED_MCP_SERVERS: "selected-mcp-servers",
9 | SIDEBAR_STATE: "sidebar-state"
10 | };
--------------------------------------------------------------------------------
/lib/context/mcp-context.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { createContext, useContext, useEffect, useState } from "react";
4 | import { useLocalStorage } from "@/lib/hooks/use-local-storage";
5 | import { STORAGE_KEYS } from "@/lib/constants";
6 |
7 | // Define types for MCP server
8 | export interface KeyValuePair {
9 | key: string;
10 | value: string;
11 | }
12 |
13 | export interface MCPServer {
14 | id: string;
15 | name: string;
16 | url: string;
17 | type: 'sse' | 'stdio';
18 | command?: string;
19 | args?: string[];
20 | env?: KeyValuePair[];
21 | headers?: KeyValuePair[];
22 | description?: string;
23 | }
24 |
25 | // Type for processed MCP server config for API
26 | export interface MCPServerApi {
27 | type: 'sse' | 'stdio';
28 | url: string;
29 | command?: string;
30 | args?: string[];
31 | env?: KeyValuePair[];
32 | headers?: KeyValuePair[];
33 | }
34 |
35 | interface MCPContextType {
36 | mcpServers: MCPServer[];
37 | setMcpServers: (servers: MCPServer[]) => void;
38 | selectedMcpServers: string[];
39 | setSelectedMcpServers: (serverIds: string[]) => void;
40 | mcpServersForApi: MCPServerApi[];
41 | }
42 |
43 | const MCPContext = createContext(undefined);
44 |
45 | export function MCPProvider(props: { children: React.ReactNode }) {
46 | const { children } = props;
47 | const [mcpServers, setMcpServers] = useLocalStorage(
48 | STORAGE_KEYS.MCP_SERVERS,
49 | []
50 | );
51 | const [selectedMcpServers, setSelectedMcpServers] = useLocalStorage(
52 | STORAGE_KEYS.SELECTED_MCP_SERVERS,
53 | []
54 | );
55 | const [mcpServersForApi, setMcpServersForApi] = useState([]);
56 |
57 | // Process MCP servers for API consumption whenever server data changes
58 | useEffect(() => {
59 | if (!selectedMcpServers.length) {
60 | setMcpServersForApi([]);
61 | return;
62 | }
63 |
64 | const processedServers: MCPServerApi[] = selectedMcpServers
65 | .map(id => mcpServers.find(server => server.id === id))
66 | .filter((server): server is MCPServer => Boolean(server))
67 | .map(server => ({
68 | type: server.type,
69 | url: server.url,
70 | command: server.command,
71 | args: server.args,
72 | env: server.env,
73 | headers: server.headers
74 | }));
75 |
76 | setMcpServersForApi(processedServers);
77 | }, [mcpServers, selectedMcpServers]);
78 |
79 | return (
80 |
89 | {children}
90 |
91 | );
92 | }
93 |
94 | export function useMCP() {
95 | const context = useContext(MCPContext);
96 | if (context === undefined) {
97 | throw new Error("useMCP must be used within an MCPProvider");
98 | }
99 | return context;
100 | }
--------------------------------------------------------------------------------
/lib/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/neon-serverless";
2 | import { Pool } from "@neondatabase/serverless";
3 | import * as schema from "./schema";
4 |
5 | // Initialize the connection pool
6 | const pool = new Pool({
7 | connectionString: process.env.DATABASE_URL,
8 | });
9 |
10 | // Initialize Drizzle with the connection pool and schema
11 | export const db = drizzle(pool, { schema });
--------------------------------------------------------------------------------
/lib/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { timestamp, pgTable, text, primaryKey, json } from "drizzle-orm/pg-core";
2 | import { nanoid } from "nanoid";
3 |
4 | // Message role enum type
5 | export enum MessageRole {
6 | USER = "user",
7 | ASSISTANT = "assistant",
8 | TOOL = "tool"
9 | }
10 |
11 | export const chats = pgTable('chats', {
12 | id: text('id').primaryKey().notNull().$defaultFn(() => nanoid()),
13 | userId: text('user_id').notNull(),
14 | title: text('title').notNull().default('New Chat'),
15 | createdAt: timestamp('created_at').defaultNow().notNull(),
16 | updatedAt: timestamp('updated_at').defaultNow().notNull(),
17 | });
18 |
19 | export const messages = pgTable('messages', {
20 | id: text('id').primaryKey().notNull().$defaultFn(() => nanoid()),
21 | chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
22 | role: text('role').notNull(), // user, assistant, or tool
23 | parts: json('parts').notNull(), // Store parts as JSON in the database
24 | createdAt: timestamp('created_at').defaultNow().notNull(),
25 | });
26 |
27 | // Types for structured message content
28 | export type MessagePart = {
29 | type: string;
30 | text?: string;
31 | toolCallId?: string;
32 | toolName?: string;
33 | args?: any;
34 | result?: any;
35 | [key: string]: any;
36 | };
37 |
38 | export type Attachment = {
39 | type: string;
40 | [key: string]: any;
41 | };
42 |
43 | export type Chat = typeof chats.$inferSelect;
44 | export type Message = typeof messages.$inferSelect;
45 | export type DBMessage = {
46 | id: string;
47 | chatId: string;
48 | role: string;
49 | parts: MessagePart[];
50 | createdAt: Date;
51 | };
--------------------------------------------------------------------------------
/lib/hooks/use-chats.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2 | import { type Chat } from '@/lib/db/schema';
3 | import { toast } from 'sonner';
4 |
5 | export function useChats(userId: string) {
6 | const queryClient = useQueryClient();
7 |
8 | // Main query to fetch chats
9 | const {
10 | data: chats = [],
11 | isLoading,
12 | error,
13 | refetch
14 | } = useQuery({
15 | queryKey: ['chats', userId],
16 | queryFn: async () => {
17 | if (!userId) return [];
18 |
19 | const response = await fetch('/api/chats', {
20 | headers: {
21 | 'x-user-id': userId
22 | }
23 | });
24 |
25 | if (!response.ok) {
26 | throw new Error('Failed to fetch chats');
27 | }
28 |
29 | return response.json();
30 | },
31 | enabled: !!userId, // Only run query if userId exists
32 | staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
33 | refetchOnWindowFocus: true, // Refetch when window regains focus
34 | });
35 |
36 | // Mutation to delete a chat
37 | const deleteChat = useMutation({
38 | mutationFn: async (chatId: string) => {
39 | const response = await fetch(`/api/chats/${chatId}`, {
40 | method: 'DELETE',
41 | headers: {
42 | 'x-user-id': userId
43 | }
44 | });
45 |
46 | if (!response.ok) {
47 | throw new Error('Failed to delete chat');
48 | }
49 |
50 | return chatId;
51 | },
52 | onSuccess: (deletedChatId) => {
53 | // Update cache by removing the deleted chat
54 | queryClient.setQueryData(['chats', userId], (oldChats = []) =>
55 | oldChats.filter(chat => chat.id !== deletedChatId)
56 | );
57 |
58 | toast.success('Chat deleted');
59 | },
60 | onError: (error) => {
61 | console.error('Error deleting chat:', error);
62 | toast.error('Failed to delete chat');
63 | }
64 | });
65 |
66 | // Function to invalidate chats cache for refresh
67 | const refreshChats = () => {
68 | queryClient.invalidateQueries({ queryKey: ['chats', userId] });
69 | };
70 |
71 | return {
72 | chats,
73 | isLoading,
74 | error,
75 | deleteChat: deleteChat.mutate,
76 | isDeleting: deleteChat.isPending,
77 | refreshChats,
78 | refetch
79 | };
80 | }
--------------------------------------------------------------------------------
/lib/hooks/use-copy.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | export function useCopy(timeout = 2000) {
4 | const [copied, setCopied] = useState(false);
5 |
6 | const copy = useCallback(
7 | async (text: string) => {
8 | if (!navigator.clipboard) {
9 | console.error('Clipboard API not available');
10 | return false;
11 | }
12 |
13 | try {
14 | await navigator.clipboard.writeText(text);
15 | setCopied(true);
16 |
17 | setTimeout(() => {
18 | setCopied(false);
19 | }, timeout);
20 |
21 | return true;
22 | } catch (error) {
23 | console.error('Failed to copy text:', error);
24 | return false;
25 | }
26 | },
27 | [timeout]
28 | );
29 |
30 | return { copied, copy };
31 | }
--------------------------------------------------------------------------------
/lib/hooks/use-local-storage.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 |
3 | type SetValue = T | ((val: T) => T);
4 |
5 | /**
6 | * Custom hook for persistent localStorage state with SSR support
7 | * @param key The localStorage key
8 | * @param initialValue The initial value if no value exists in localStorage
9 | * @returns A stateful value and a function to update it
10 | */
11 | export function useLocalStorage(key: string, initialValue: T) {
12 | // State to store our value
13 | // Pass initial state function to useState so logic is only executed once
14 | const [storedValue, setStoredValue] = useState(initialValue);
15 |
16 | // Check if we're in the browser environment
17 | const isBrowser = typeof window !== 'undefined';
18 |
19 | // Initialize state from localStorage or use initialValue
20 | useEffect(() => {
21 | if (!isBrowser) return;
22 |
23 | try {
24 | const item = window.localStorage.getItem(key);
25 | if (item) {
26 | setStoredValue(parseJSON(item));
27 | }
28 | } catch (error) {
29 | console.error(`Error reading localStorage key "${key}":`, error);
30 | }
31 | }, [key, isBrowser]);
32 |
33 | // Return a wrapped version of useState's setter function that
34 | // persists the new value to localStorage.
35 | const setValue = useCallback((value: SetValue) => {
36 | if (!isBrowser) return;
37 |
38 | try {
39 | // Allow value to be a function so we have same API as useState
40 | const valueToStore =
41 | value instanceof Function ? value(storedValue) : value;
42 |
43 | // Save state
44 | setStoredValue(valueToStore);
45 |
46 | // Save to localStorage
47 | if (valueToStore === undefined) {
48 | window.localStorage.removeItem(key);
49 | } else {
50 | window.localStorage.setItem(key, JSON.stringify(valueToStore));
51 | }
52 | } catch (error) {
53 | console.error(`Error setting localStorage key "${key}":`, error);
54 | }
55 | }, [key, storedValue, isBrowser]);
56 |
57 | return [storedValue, setValue] as const;
58 | }
59 |
60 | // Helper function to parse JSON with error handling
61 | function parseJSON(value: string): T {
62 | try {
63 | return JSON.parse(value);
64 | } catch {
65 | console.error('Error parsing JSON from localStorage');
66 | return {} as T;
67 | }
68 | }
69 |
70 | /**
71 | * A hook to get a value from localStorage (read-only) with SSR support
72 | * @param key The localStorage key
73 | * @param defaultValue The default value if the key doesn't exist
74 | * @returns The value from localStorage or the default value
75 | */
76 | export function useLocalStorageValue(key: string, defaultValue: T): T {
77 | const [value] = useLocalStorage(key, defaultValue);
78 | return value;
79 | }
--------------------------------------------------------------------------------
/lib/hooks/use-scroll-to-bottom.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, type RefObject } from 'react';
2 |
3 | export function useScrollToBottom(): [
4 | RefObject,
5 | RefObject,
6 | ] {
7 | const containerRef = useRef(null);
8 | const endRef = useRef(null);
9 | const isUserScrollingRef = useRef(false);
10 |
11 | useEffect(() => {
12 | const container = containerRef.current;
13 | const end = endRef.current;
14 |
15 | if (!container || !end) return;
16 |
17 | // Initial scroll to bottom
18 | setTimeout(() => {
19 | end.scrollIntoView({ behavior: 'instant', block: 'end' });
20 | }, 100);
21 |
22 | // Track if user has manually scrolled up
23 | const handleScroll = () => {
24 | if (!container) return;
25 |
26 | const { scrollTop, scrollHeight, clientHeight } = container;
27 | const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
28 |
29 | // If user is scrolled up, mark as manually scrolling
30 | isUserScrollingRef.current = distanceFromBottom > 100;
31 | };
32 |
33 | // Handle mutations
34 | const observer = new MutationObserver((mutations) => {
35 | if (!container || !end) return;
36 |
37 | // Check if mutation is related to expand/collapse
38 | const isToggleSection = mutations.some(mutation => {
39 | // Check if the target or parent is a motion-div (expanded content)
40 | let target = mutation.target as HTMLElement;
41 | let isExpand = false;
42 |
43 | while (target && target !== container) {
44 | if (target.classList?.contains('motion-div')) {
45 | isExpand = true;
46 | break;
47 | }
48 | target = target.parentElement as HTMLElement;
49 | }
50 | return isExpand;
51 | });
52 |
53 | // Don't scroll for expand/collapse actions
54 | if (isToggleSection) return;
55 |
56 | // Only auto-scroll if user hasn't manually scrolled up
57 | if (!isUserScrollingRef.current) {
58 | // For new messages, use smooth scrolling
59 | end.scrollIntoView({ behavior: 'smooth', block: 'end' });
60 | }
61 | });
62 |
63 | observer.observe(container, {
64 | childList: true,
65 | subtree: true,
66 | });
67 |
68 | // Add scroll event listener
69 | container.addEventListener('scroll', handleScroll);
70 |
71 | return () => {
72 | observer.disconnect();
73 | container.removeEventListener('scroll', handleScroll);
74 | };
75 | }, []);
76 |
77 | return [containerRef, endRef] as [RefObject, RefObject];
78 | }
--------------------------------------------------------------------------------
/lib/user-id.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid';
2 |
3 | const USER_ID_KEY = 'ai-chat-user-id';
4 |
5 | export function getUserId(): string {
6 | // Only run this on the client side
7 | if (typeof window === 'undefined') return '';
8 |
9 | let userId = localStorage.getItem(USER_ID_KEY);
10 |
11 | if (!userId) {
12 | // Generate a new user ID and store it
13 | userId = nanoid();
14 | localStorage.setItem(USER_ID_KEY, userId);
15 | }
16 |
17 | return userId;
18 | }
19 |
20 | export function updateUserId(newUserId: string): void {
21 | if (typeof window === 'undefined') return;
22 | localStorage.setItem(USER_ID_KEY, newUserId);
23 | }
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mcp-chat",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build --turbopack",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:generate": "drizzle-kit generate",
11 | "db:migrate": "drizzle-kit migrate",
12 | "db:push": "drizzle-kit push",
13 | "db:studio": "drizzle-kit studio"
14 | },
15 | "dependencies": {
16 | "@ai-sdk/anthropic": "^1.2.10",
17 | "@ai-sdk/cohere": "^1.2.9",
18 | "@ai-sdk/google": "^1.2.12",
19 | "@ai-sdk/groq": "^1.2.8",
20 | "@ai-sdk/openai": "^1.3.16",
21 | "@ai-sdk/react": "^1.2.9",
22 | "@ai-sdk/xai": "^1.2.14",
23 | "@neondatabase/serverless": "^1.0.0",
24 | "@radix-ui/react-accordion": "^1.2.7",
25 | "@radix-ui/react-avatar": "^1.1.6",
26 | "@radix-ui/react-dialog": "^1.1.10",
27 | "@radix-ui/react-dropdown-menu": "^2.1.11",
28 | "@radix-ui/react-label": "^2.1.3",
29 | "@radix-ui/react-popover": "^1.1.10",
30 | "@radix-ui/react-scroll-area": "^1.2.5",
31 | "@radix-ui/react-select": "^2.1.7",
32 | "@radix-ui/react-separator": "^1.1.4",
33 | "@radix-ui/react-slot": "^1.2.0",
34 | "@radix-ui/react-tooltip": "^1.2.3",
35 | "@tanstack/react-query": "^5.74.4",
36 | "@vercel/otel": "^1.11.0",
37 | "ai": "^4.3.9",
38 | "class-variance-authority": "^0.7.1",
39 | "clsx": "^2.1.1",
40 | "drizzle-orm": "^0.42.0",
41 | "fast-deep-equal": "^3.1.3",
42 | "framer-motion": "^12.7.4",
43 | "groq-sdk": "^0.19.0",
44 | "lucide-react": "^0.488.0",
45 | "motion": "^12.7.3",
46 | "nanoid": "^5.1.5",
47 | "next": "^15.3.1",
48 | "next-auth": "^4.24.11",
49 | "next-themes": "^0.4.6",
50 | "or": "^0.2.0",
51 | "pg": "^8.14.1",
52 | "react": "^19.1.0",
53 | "react-dom": "^19.1.0",
54 | "react-markdown": "^10.1.0",
55 | "remark-gfm": "^4.0.1",
56 | "sonner": "^2.0.3",
57 | "tailwind-merge": "^3.2.0",
58 | "tailwindcss-animate": "^1.0.7",
59 | "zod": "^3.24.2"
60 | },
61 | "devDependencies": {
62 | "@eslint/eslintrc": "^3.3.1",
63 | "@tailwindcss/postcss": "^4.1.4",
64 | "@types/node": "^22.14.1",
65 | "@types/pg": "^8.11.13",
66 | "@types/react": "^19.1.2",
67 | "@types/react-dom": "^19.1.2",
68 | "dotenv": "^16.5.0",
69 | "drizzle-kit": "^0.31.0",
70 | "esbuild": ">=0.25.0",
71 | "eslint": "^9.24.0",
72 | "eslint-config-next": "15.3.0",
73 | "pg-pool": "^3.8.0",
74 | "tailwindcss": "^4.1.4",
75 | "typescript": "^5.8.3"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/scira.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zaidmukaddam/scira-mcp-chat/a476a7eaa714f3952d7ad07cca8227e0c898b18e/public/scira.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/railpack.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.railpack.com",
3 | "provider": "node",
4 | "buildAptPackages": [
5 | "git",
6 | "curl"
7 | ],
8 | "packages": {
9 | "node": "22",
10 | "python": "3.12.7"
11 | },
12 | "deploy": {
13 | "aptPackages": [
14 | "git",
15 | "curl"
16 | ]
17 | }
18 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------