armand0e commited on
Commit
250bf31
·
1 Parent(s): 1e2d0e8

gradio -> docker

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules
2
+ .next
3
+ .gradio
.gradio/cached_examples/21/log.csv DELETED
@@ -1,4 +0,0 @@
1
- Chatbot,timestamp
2
- "[[""Hello there! How are you doing?"", ""Hello! I'm doing great, thanks for asking. How can I assist you today?""]]",2025-12-05 10:35:54.190200
3
- "[[""Can you write a Python program that adds two numbers?"", ""**Number Addition Program**\n==========================\n\n### Overview\n\nThis program takes two numbers as input and returns their sum.\n\n### Code\n\n```python\ndef add_numbers(num1: float, num2: float) -> float:\n \""\""\""\n Adds two numbers and returns the result.\n\n Args:\n num1 (float): The first number.\n num2 (float): The second number.\n\n Returns:\n float: The sum of num1 and num2.\n \""\""\""\n return num1 + num2\n\ndef main():\n # Example usage\n num1 = float(input(\""Enter the first number: \""))\n num2 = float(input(\""Enter the second number: \""))\n result = add_numbers(num1, num2)\n print(f\""The sum of {num1} and {num2} is {result}\"")\n\nif __name__ == \""__main__\"":\n main()\n```\n\n### Explanation\n\n1. The `add_numbers` function takes two `float` arguments, `num1` and `num2`, and returns their sum.\n2. The `main` function demonstrates how to use the `add_numbers` function by taking user input for the two numbers, calling the function, and printing the result.\n3. The `if __name__ == \""__main__\"":` block ensures that the `main` function is only executed when the script is run directly, not when it's imported as a module.\n\n### Example Use Case\n\n```\nEnter the first number: 5\nEnter the second number: 7\nThe sum of 5.0 and 7.0 is 12.0\n```""]]",2025-12-05 10:35:56.849988
4
- "[[""Which one of these mountains is not located in Europe? Hoverla, Mont-Blanc, Gran Paradiso, Everest"", ""Everest is not located in Europe. It is the highest mountain in the world, located in the Himalayas on the border between Nepal and Tibet, China.""]]",2025-12-05 10:35:57.207845
 
 
 
 
 
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS base
2
+
3
+ # Install dependencies only when needed
4
+ FROM base AS deps
5
+ RUN apk add --no-cache libc6-compat
6
+ WORKDIR /app
7
+
8
+ COPY package.json ./
9
+ RUN npm install
10
+
11
+ # Rebuild the source code only when needed
12
+ FROM base AS builder
13
+ WORKDIR /app
14
+ COPY --from=deps /app/node_modules ./node_modules
15
+ COPY . .
16
+
17
+ ENV NEXT_TELEMETRY_DISABLED=1
18
+ RUN npm run build
19
+
20
+ # Production image, copy all the files and run next
21
+ FROM base AS runner
22
+ WORKDIR /app
23
+
24
+ ENV NODE_ENV=production
25
+ ENV NEXT_TELEMETRY_DISABLED=1
26
+
27
+ RUN addgroup --system --gid 1001 nodejs
28
+ RUN adduser --system --uid 1001 nextjs
29
+
30
+ COPY --from=builder /app/public ./public
31
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
32
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
33
+
34
+ USER nextjs
35
+
36
+ EXPOSE 7860
37
+ ENV PORT=7860
38
+ ENV HOSTNAME="0.0.0.0"
39
+
40
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -3,12 +3,38 @@ title: Qwen3 Claude Opus
3
  emoji: 🚀
4
  colorFrom: yellow
5
  colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.42.0
8
- app_file: app.py
9
  pinned: false
10
- hf_oauth: true
11
- hf_oauth_scopes:
12
- - inference-api
13
  license: apache-2.0
14
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  emoji: 🚀
4
  colorFrom: yellow
5
  colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
 
 
 
9
  license: apache-2.0
10
+ ---
11
+
12
+ # Qwen3 Claude Opus Chat
13
+
14
+ A minimal, modern chat interface for the Qwen3-4B-Thinking-2507-Claude-4.5-Opus model with:
15
+
16
+ - **Collapsible thinking blocks** - See the model's reasoning process
17
+ - **Web search** - Toggle to enable real-time web search via MCP
18
+ - **Streaming responses** - Real-time token streaming
19
+ - **Clean UI** - Modern dark theme with smooth animations
20
+
21
+ ## Environment Variables
22
+
23
+ Set these in HF Spaces Settings > Secrets:
24
+
25
+ - `OPENAI_API_KEY` - API key for the inference server (required)
26
+ - `BASE_URL` - OpenAI-compatible API base URL (default: `https://llama.gptbox.dev/v1`)
27
+ - `MODEL_ID` - Model identifier (default: `qwen3-4b-thinking-2507-claude-4.5-opus-distill`)
28
+
29
+ ## Local Development
30
+
31
+ ```bash
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ ## Docker Build
37
+
38
+ ```bash
39
+ docker build -t qwen3-chat .
40
+ docker run -p 7860:7860 -e OPENAI_API_KEY=your_key qwen3-chat
app.py DELETED
@@ -1,378 +0,0 @@
1
- import os
2
- import re
3
- import json
4
- import asyncio
5
- from typing import Generator
6
-
7
- import gradio as gr
8
- from openai import OpenAI
9
- from mcp import ClientSession, StdioServerParameters
10
- from mcp.client.stdio import stdio_client
11
-
12
- # =============================================================================
13
- # Configuration
14
- # =============================================================================
15
- MODEL_ID = os.environ.get("MODEL_ID", "qwen3-4b-thinking-2507-claude-4.5-opus-distill")
16
- BASE_URL = os.environ.get("BASE_URL", "https://llama.gptbox.dev/v1")
17
- API_KEY = os.environ.get("OPENAI_API_KEY") # Set via HF Spaces Secrets
18
-
19
- if not API_KEY:
20
- raise ValueError(
21
- "OPENAI_API_KEY environment variable is required. Set it in HF Spaces Settings > Secrets."
22
- )
23
-
24
- client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
25
-
26
- # MCP SearXNG server config
27
- SEARXNG_SERVER_PARAMS = StdioServerParameters(
28
- command="npx",
29
- args=["-y", "@kevinwatt/mcp-server-searxng"],
30
- env={
31
- **os.environ,
32
- "SEARXNG_INSTANCES": "https://searxng.gptbox.dev",
33
- "SEARXNG_USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
34
- "NODE_TLS_REJECT_UNAUTHORIZED": "0",
35
- },
36
- )
37
-
38
- # OpenAI-style tool definition for web_search
39
- WEB_SEARCH_TOOL = {
40
- "type": "function",
41
- "function": {
42
- "name": "web_search",
43
- "description": "Search the web using SearXNG metasearch engine. Use this to find current information.",
44
- "parameters": {
45
- "type": "object",
46
- "properties": {
47
- "query": {"type": "string", "description": "Search query"},
48
- "page": {"type": "integer", "description": "Page number (default 1)"},
49
- "language": {
50
- "type": "string",
51
- "description": "Language code e.g. 'en', 'all'",
52
- },
53
- "categories": {
54
- "type": "array",
55
- "items": {"type": "string"},
56
- "description": "Categories: general, news, science, images, videos, etc.",
57
- },
58
- "time_range": {
59
- "type": "string",
60
- "description": "day, week, month, year",
61
- },
62
- "safesearch": {
63
- "type": "integer",
64
- "description": "0=off, 1=moderate, 2=strict",
65
- },
66
- },
67
- "required": ["query"],
68
- },
69
- },
70
- }
71
-
72
-
73
- # =============================================================================
74
- # MCP Tool Execution
75
- # =============================================================================
76
- async def execute_web_search(arguments: dict) -> str:
77
- """Execute web_search tool via MCP SearXNG server."""
78
- try:
79
- async with stdio_client(SEARXNG_SERVER_PARAMS) as (read, write):
80
- async with ClientSession(read, write) as session:
81
- await session.initialize()
82
- result = await session.call_tool("web_search", arguments)
83
- # Extract text content from result
84
- texts = []
85
- for content in result.content:
86
- if hasattr(content, "text"):
87
- texts.append(content.text)
88
- return "\n".join(texts) if texts else "No results found."
89
- except Exception as e:
90
- return f"Search error: {str(e)}"
91
-
92
-
93
- def run_web_search(arguments: dict) -> str:
94
- """Sync wrapper for async MCP call."""
95
- return asyncio.run(execute_web_search(arguments))
96
-
97
-
98
- # =============================================================================
99
- # Thinking Block Parser
100
- # =============================================================================
101
- def format_thinking(text: str) -> str:
102
- """
103
- Convert <think>...</think> blocks into collapsible HTML details.
104
- """
105
- pattern = r"<think>(.*?)</think>"
106
-
107
- def replacer(match):
108
- thinking_content = match.group(1).strip()
109
- # Escape HTML in thinking content
110
- thinking_content = (
111
- thinking_content.replace("&", "&amp;")
112
- .replace("<", "&lt;")
113
- .replace(">", "&gt;")
114
- )
115
- thinking_content = thinking_content.replace("\n", "<br>")
116
- return f'<details><summary>💭 Thinking...</summary><div style="padding:8px;background:#1a1a2e;border-radius:4px;margin-top:4px;font-size:0.9em;color:#aaa;">{thinking_content}</div></details>'
117
-
118
- return re.sub(pattern, replacer, text, flags=re.DOTALL)
119
-
120
-
121
- # =============================================================================
122
- # Chat Function with Tool Calling
123
- # =============================================================================
124
- def chat_fn(
125
- message: str,
126
- history: list,
127
- system_message: str,
128
- max_tokens: int,
129
- temperature: float,
130
- top_p: float,
131
- search_enabled: bool,
132
- ) -> Generator[list, None, None]:
133
- """Main chat function with streaming and optional tool calling."""
134
-
135
- # Build messages
136
- messages = [{"role": "system", "content": system_message}]
137
- for msg in history:
138
- messages.append(msg)
139
- messages.append({"role": "user", "content": message})
140
-
141
- # Prepare tools if search enabled
142
- tools = [WEB_SEARCH_TOOL] if search_enabled else None
143
-
144
- # Append user message to history for display
145
- new_history = history + [{"role": "user", "content": message}]
146
- yield new_history
147
-
148
- max_tool_rounds = 5
149
- for _ in range(max_tool_rounds):
150
- # Call model
151
- response = client.chat.completions.create(
152
- model=MODEL_ID,
153
- messages=messages,
154
- max_tokens=max_tokens,
155
- temperature=temperature,
156
- top_p=top_p,
157
- tools=tools,
158
- stream=True,
159
- )
160
-
161
- # Accumulate streamed response
162
- assistant_content = ""
163
- tool_calls_acc = {} # id -> {name, arguments}
164
- current_tool_call_id = None
165
-
166
- for chunk in response:
167
- delta = chunk.choices[0].delta if chunk.choices else None
168
- if not delta:
169
- continue
170
-
171
- # Handle content
172
- if delta.content:
173
- assistant_content += delta.content
174
- display_content = format_thinking(assistant_content)
175
- updated_history = new_history + [
176
- {"role": "assistant", "content": display_content}
177
- ]
178
- yield updated_history
179
-
180
- # Handle tool calls
181
- if delta.tool_calls:
182
- for tc in delta.tool_calls:
183
- if tc.id:
184
- current_tool_call_id = tc.id
185
- tool_calls_acc[tc.id] = {
186
- "name": tc.function.name if tc.function else "",
187
- "arguments": "",
188
- }
189
- if tc.function:
190
- if tc.function.name and current_tool_call_id:
191
- tool_calls_acc[current_tool_call_id][
192
- "name"
193
- ] = tc.function.name
194
- if tc.function.arguments and current_tool_call_id:
195
- tool_calls_acc[current_tool_call_id][
196
- "arguments"
197
- ] += tc.function.arguments
198
-
199
- # Check finish reason
200
- finish_reason = chunk.choices[0].finish_reason if chunk.choices else None
201
-
202
- # If tool calls were made, execute them
203
- if tool_calls_acc and finish_reason == "tool_calls":
204
- # Add assistant message with tool calls to messages
205
- tool_calls_list = [
206
- {
207
- "id": tid,
208
- "type": "function",
209
- "function": {"name": tc["name"], "arguments": tc["arguments"]},
210
- }
211
- for tid, tc in tool_calls_acc.items()
212
- ]
213
- messages.append(
214
- {
215
- "role": "assistant",
216
- "content": assistant_content if assistant_content else None,
217
- "tool_calls": tool_calls_list,
218
- }
219
- )
220
-
221
- # Execute each tool and add results
222
- for tid, tc in tool_calls_acc.items():
223
- if tc["name"] == "web_search":
224
- # Show searching status
225
- search_status = f"🔍 Searching: {tc['arguments']}..."
226
- yield new_history + [
227
- {"role": "assistant", "content": search_status}
228
- ]
229
-
230
- try:
231
- args = json.loads(tc["arguments"])
232
- except json.JSONDecodeError:
233
- args = {"query": tc["arguments"]}
234
-
235
- result = run_web_search(args)
236
- messages.append(
237
- {
238
- "role": "tool",
239
- "tool_call_id": tid,
240
- "content": result,
241
- }
242
- )
243
- # Continue loop to get final response
244
- continue
245
- else:
246
- # No tool calls or finished - we're done
247
- final_content = format_thinking(assistant_content)
248
- final_history = new_history + [
249
- {"role": "assistant", "content": final_content}
250
- ]
251
- yield final_history
252
- return
253
-
254
- # Fallback if max rounds exceeded
255
- yield new_history + [
256
- {"role": "assistant", "content": "Max tool call rounds exceeded."}
257
- ]
258
-
259
-
260
- # =============================================================================
261
- # Gradio UI
262
- # =============================================================================
263
- CSS = """
264
- .search-btn {
265
- min-width: 40px !important;
266
- width: 40px !important;
267
- height: 40px !important;
268
- padding: 8px !important;
269
- border-radius: 50% !important;
270
- }
271
- .search-btn.active {
272
- background: #2563eb !important;
273
- color: white !important;
274
- }
275
- .chat-container {
276
- height: 500px !important;
277
- }
278
- """
279
-
280
- with gr.Blocks(css=CSS, title="Claude-Opus-Qwen3 Chat") as demo:
281
- # State for search toggle
282
- search_state = gr.State(value=False)
283
-
284
- gr.Markdown("# 🚀 Claude-Opus-Qwen3 Chat")
285
-
286
- with gr.Row():
287
- with gr.Column(scale=4):
288
- chatbot = gr.Chatbot(
289
- label="Chat",
290
- type="messages",
291
- elem_classes=["chat-container"],
292
- show_copy_button=True,
293
- render_markdown=True,
294
- sanitize_html=False,
295
- )
296
-
297
- with gr.Row():
298
- search_btn = gr.Button(
299
- value="🌐",
300
- elem_classes=["search-btn"],
301
- variant="secondary",
302
- size="sm",
303
- )
304
- msg = gr.Textbox(
305
- placeholder="How can I help you today?",
306
- show_label=False,
307
- scale=10,
308
- container=False,
309
- )
310
- send_btn = gr.Button("Send", variant="primary", scale=1)
311
- clear_btn = gr.Button("Clear", scale=1)
312
-
313
- with gr.Column(scale=1):
314
- with gr.Accordion("Settings", open=False):
315
- system_msg = gr.Textbox(
316
- value="You are a helpful AI assistant. You can use <think>...</think> tags to show your reasoning process.",
317
- label="System Message",
318
- lines=3,
319
- )
320
- max_tokens = gr.Slider(
321
- minimum=256, maximum=8192, value=2048, step=64, label="Max Tokens"
322
- )
323
- temperature = gr.Slider(
324
- minimum=0.0, maximum=2.0, value=0.7, step=0.1, label="Temperature"
325
- )
326
- top_p = gr.Slider(
327
- minimum=0.0, maximum=1.0, value=0.95, step=0.05, label="Top-p"
328
- )
329
-
330
- search_status = gr.Markdown("🌐 Web Search: **OFF**")
331
-
332
- # Toggle search
333
- def toggle_search(current_state):
334
- new_state = not current_state
335
- status = "🌐 Web Search: **ON**" if new_state else "🌐 Web Search: **OFF**"
336
- btn_variant = "primary" if new_state else "secondary"
337
- return new_state, status, gr.update(variant=btn_variant)
338
-
339
- search_btn.click(
340
- toggle_search,
341
- inputs=[search_state],
342
- outputs=[search_state, search_status, search_btn],
343
- )
344
-
345
- # Submit handlers
346
- def user_submit(message, history):
347
- if not message.strip():
348
- return history, ""
349
- return history, ""
350
-
351
- def bot_respond(
352
- message, history, system_msg, max_tokens, temperature, top_p, search_enabled
353
- ):
354
- if not message.strip():
355
- yield history
356
- return
357
- for updated_history in chat_fn(
358
- message, history, system_msg, max_tokens, temperature, top_p, search_enabled
359
- ):
360
- yield updated_history
361
-
362
- # Wire up events
363
- msg.submit(
364
- bot_respond,
365
- inputs=[msg, chatbot, system_msg, max_tokens, temperature, top_p, search_state],
366
- outputs=[chatbot],
367
- ).then(lambda: "", outputs=[msg])
368
-
369
- send_btn.click(
370
- bot_respond,
371
- inputs=[msg, chatbot, system_msg, max_tokens, temperature, top_p, search_state],
372
- outputs=[chatbot],
373
- ).then(lambda: "", outputs=[msg])
374
-
375
- clear_btn.click(lambda: [], outputs=[chatbot])
376
-
377
- if __name__ == "__main__":
378
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
next.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ output: 'standalone',
4
+ };
5
+
6
+ module.exports = nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "qwen3-claude-opus-chat",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start -p 7860"
9
+ },
10
+ "dependencies": {
11
+ "@ai-sdk/openai-compatible": "^1.0.0",
12
+ "@modelcontextprotocol/sdk": "^1.0.0",
13
+ "@radix-ui/react-collapsible": "^1.1.0",
14
+ "@radix-ui/react-slot": "^1.1.0",
15
+ "ai": "^4.0.0",
16
+ "class-variance-authority": "^0.7.0",
17
+ "clsx": "^2.1.0",
18
+ "framer-motion": "^11.0.0",
19
+ "lucide-react": "^0.400.0",
20
+ "next": "^14.2.0",
21
+ "react": "^18.3.0",
22
+ "react-dom": "^18.3.0",
23
+ "react-markdown": "^9.0.0",
24
+ "tailwind-merge": "^2.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.0.0",
28
+ "@types/react": "^18.3.0",
29
+ "@types/react-dom": "^18.3.0",
30
+ "autoprefixer": "^10.4.0",
31
+ "postcss": "^8.4.0",
32
+ "tailwindcss": "^3.4.0",
33
+ "typescript": "^5.4.0"
34
+ }
35
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
public/.gitkeep ADDED
File without changes
requirements.txt DELETED
@@ -1,4 +0,0 @@
1
- transformers==4.51.2
2
- openai
3
- torch
4
- mcp
 
 
 
 
 
src/app/api/chat/route.ts ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from "next/server";
2
+
3
+ const BASE_URL = process.env.BASE_URL || "https://llama.gptbox.dev/v1";
4
+ const API_KEY = process.env.OPENAI_API_KEY || "";
5
+ const MODEL_ID = process.env.MODEL_ID || "qwen3-4b-thinking-2507-claude-4.5-opus-distill";
6
+
7
+ const SEARXNG_CONFIG = {
8
+ command: "npx",
9
+ args: ["-y", "@kevinwatt/mcp-server-searxng"],
10
+ env: {
11
+ SEARXNG_INSTANCES: "https://searxng.gptbox.dev",
12
+ SEARXNG_USER_AGENT: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
13
+ NODE_TLS_REJECT_UNAUTHORIZED: "0",
14
+ },
15
+ };
16
+
17
+ const WEB_SEARCH_TOOL = {
18
+ type: "function" as const,
19
+ function: {
20
+ name: "web_search",
21
+ description: "Search the web for current information. Use this when you need up-to-date information.",
22
+ parameters: {
23
+ type: "object",
24
+ properties: {
25
+ query: { type: "string", description: "Search query" },
26
+ },
27
+ required: ["query"],
28
+ },
29
+ },
30
+ };
31
+
32
+ async function executeWebSearch(query: string): Promise<string> {
33
+ try {
34
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
35
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
36
+ const { spawn } = await import("child_process");
37
+
38
+ const serverProcess = spawn(SEARXNG_CONFIG.command, SEARXNG_CONFIG.args, {
39
+ env: { ...process.env, ...SEARXNG_CONFIG.env },
40
+ stdio: ["pipe", "pipe", "pipe"],
41
+ });
42
+
43
+ const transport = new StdioClientTransport({
44
+ reader: serverProcess.stdout,
45
+ writer: serverProcess.stdin,
46
+ });
47
+
48
+ const client = new Client({ name: "chat-client", version: "1.0.0" });
49
+ await client.connect(transport);
50
+
51
+ const result = await client.callTool({
52
+ name: "web_search",
53
+ arguments: { query },
54
+ });
55
+
56
+ await client.close();
57
+ serverProcess.kill();
58
+
59
+ if (result.content && Array.isArray(result.content)) {
60
+ return result.content
61
+ .map((c: { type: string; text?: string }) => (c.type === "text" ? c.text : ""))
62
+ .filter(Boolean)
63
+ .join("\n");
64
+ }
65
+ return "No results found.";
66
+ } catch (error) {
67
+ console.error("Web search error:", error);
68
+ return `Search error: ${error instanceof Error ? error.message : "Unknown error"}`;
69
+ }
70
+ }
71
+
72
+ function parseThinkingBlocks(text: string): { reasoning: string; content: string } {
73
+ const thinkMatch = text.match(/<think>([\s\S]*?)<\/think>/);
74
+ if (thinkMatch) {
75
+ const reasoning = thinkMatch[1].trim();
76
+ const content = text.replace(/<think>[\s\S]*?<\/think>/, "").trim();
77
+ return { reasoning, content };
78
+ }
79
+ return { reasoning: "", content: text };
80
+ }
81
+
82
+ export async function POST(req: NextRequest) {
83
+ const { messages, searchEnabled } = await req.json();
84
+
85
+ const encoder = new TextEncoder();
86
+ const stream = new ReadableStream({
87
+ async start(controller) {
88
+ const send = (data: object) => {
89
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
90
+ };
91
+
92
+ try {
93
+ const systemMessage = {
94
+ role: "system",
95
+ content: "You are a helpful AI assistant.",
96
+ };
97
+
98
+ const apiMessages = [systemMessage, ...messages];
99
+ const tools = searchEnabled ? [WEB_SEARCH_TOOL] : undefined;
100
+
101
+ let maxRounds = 5;
102
+ let currentMessages = [...apiMessages];
103
+
104
+ while (maxRounds > 0) {
105
+ maxRounds--;
106
+
107
+ const response = await fetch(`${BASE_URL}/chat/completions`, {
108
+ method: "POST",
109
+ headers: {
110
+ "Content-Type": "application/json",
111
+ Authorization: `Bearer ${API_KEY}`,
112
+ },
113
+ body: JSON.stringify({
114
+ model: MODEL_ID,
115
+ messages: currentMessages,
116
+ tools,
117
+ stream: true,
118
+ max_tokens: 4096,
119
+ }),
120
+ });
121
+
122
+ if (!response.ok) {
123
+ throw new Error(`API error: ${response.status}`);
124
+ }
125
+
126
+ const reader = response.body?.getReader();
127
+ if (!reader) throw new Error("No reader");
128
+
129
+ const decoder = new TextDecoder();
130
+ let buffer = "";
131
+ let fullContent = "";
132
+ let toolCalls: Array<{
133
+ id: string;
134
+ function: { name: string; arguments: string };
135
+ }> = [];
136
+ let currentToolCall: { id: string; function: { name: string; arguments: string } } | null = null;
137
+ let sentReasoning = false;
138
+ let reasoningBuffer = "";
139
+ let inThinking = false;
140
+
141
+ while (true) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+
145
+ buffer += decoder.decode(value, { stream: true });
146
+ const lines = buffer.split("\n");
147
+ buffer = lines.pop() || "";
148
+
149
+ for (const line of lines) {
150
+ if (!line.startsWith("data: ")) continue;
151
+ const data = line.slice(6).trim();
152
+ if (data === "[DONE]") continue;
153
+
154
+ try {
155
+ const parsed = JSON.parse(data);
156
+ const delta = parsed.choices?.[0]?.delta;
157
+
158
+ if (delta?.content) {
159
+ fullContent += delta.content;
160
+
161
+ // Check for thinking blocks
162
+ if (fullContent.includes("<think>") && !fullContent.includes("</think>")) {
163
+ inThinking = true;
164
+ const thinkStart = fullContent.indexOf("<think>") + 7;
165
+ reasoningBuffer = fullContent.slice(thinkStart);
166
+ send({ type: "reasoning", content: delta.content });
167
+ } else if (inThinking && fullContent.includes("</think>")) {
168
+ inThinking = false;
169
+ sentReasoning = true;
170
+ const thinkEnd = fullContent.indexOf("</think>");
171
+ reasoningBuffer = fullContent.slice(fullContent.indexOf("<think>") + 7, thinkEnd);
172
+ const afterThink = fullContent.slice(thinkEnd + 8);
173
+ if (afterThink) {
174
+ send({ type: "content", content: afterThink });
175
+ }
176
+ } else if (inThinking) {
177
+ send({ type: "reasoning", content: delta.content });
178
+ } else if (sentReasoning || !fullContent.includes("<think>")) {
179
+ send({ type: "content", content: delta.content });
180
+ }
181
+ }
182
+
183
+ // Handle tool calls
184
+ if (delta?.tool_calls) {
185
+ for (const tc of delta.tool_calls) {
186
+ if (tc.id) {
187
+ currentToolCall = {
188
+ id: tc.id,
189
+ function: { name: tc.function?.name || "", arguments: "" },
190
+ };
191
+ toolCalls.push(currentToolCall);
192
+ }
193
+ if (tc.function?.name && currentToolCall) {
194
+ currentToolCall.function.name = tc.function.name;
195
+ }
196
+ if (tc.function?.arguments && currentToolCall) {
197
+ currentToolCall.function.arguments += tc.function.arguments;
198
+ }
199
+ }
200
+ }
201
+ } catch {
202
+ // Ignore parse errors
203
+ }
204
+ }
205
+ }
206
+
207
+ // Process tool calls if any
208
+ if (toolCalls.length > 0) {
209
+ currentMessages.push({
210
+ role: "assistant",
211
+ content: fullContent || null,
212
+ tool_calls: toolCalls.map((tc) => ({
213
+ id: tc.id,
214
+ type: "function",
215
+ function: tc.function,
216
+ })),
217
+ });
218
+
219
+ for (const tc of toolCalls) {
220
+ const args = JSON.parse(tc.function.arguments || "{}");
221
+ send({
222
+ type: "tool_call",
223
+ name: tc.function.name,
224
+ args,
225
+ status: "running",
226
+ });
227
+
228
+ let result = "";
229
+ if (tc.function.name === "web_search") {
230
+ result = await executeWebSearch(args.query);
231
+ }
232
+
233
+ send({
234
+ type: "tool_call",
235
+ name: tc.function.name,
236
+ args,
237
+ status: "complete",
238
+ result: result.slice(0, 2000),
239
+ });
240
+
241
+ currentMessages.push({
242
+ role: "tool",
243
+ tool_call_id: tc.id,
244
+ content: result,
245
+ });
246
+ }
247
+
248
+ // Continue the loop to get final response
249
+ continue;
250
+ }
251
+
252
+ // No tool calls, we're done
253
+ break;
254
+ }
255
+
256
+ send({ type: "done" });
257
+ } catch (error) {
258
+ console.error("Chat error:", error);
259
+ send({
260
+ type: "error",
261
+ message: error instanceof Error ? error.message : "Unknown error",
262
+ });
263
+ } finally {
264
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
265
+ controller.close();
266
+ }
267
+ },
268
+ });
269
+
270
+ return new Response(stream, {
271
+ headers: {
272
+ "Content-Type": "text/event-stream",
273
+ "Cache-Control": "no-cache",
274
+ Connection: "keep-alive",
275
+ },
276
+ });
277
+ }
src/app/globals.css ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --background: 0 0% 100%;
7
+ --foreground: 220 13% 18%;
8
+ --card: 0 0% 100%;
9
+ --card-foreground: 220 13% 18%;
10
+ --popover: 0 0% 100%;
11
+ --popover-foreground: 220 13% 18%;
12
+ --primary: 221 83% 53%;
13
+ --primary-foreground: 210 40% 98%;
14
+ --secondary: 220 14% 96%;
15
+ --secondary-foreground: 220 13% 18%;
16
+ --muted: 220 14% 96%;
17
+ --muted-foreground: 220 9% 46%;
18
+ --accent: 220 14% 96%;
19
+ --accent-foreground: 220 13% 18%;
20
+ --destructive: 0 84% 60%;
21
+ --destructive-foreground: 210 40% 98%;
22
+ --border: 220 13% 91%;
23
+ --input: 220 13% 91%;
24
+ --ring: 221 83% 53%;
25
+ --radius: 0.75rem;
26
+ }
27
+
28
+ .dark {
29
+ --background: 224 71% 4%;
30
+ --foreground: 213 31% 91%;
31
+ --card: 224 71% 4%;
32
+ --card-foreground: 213 31% 91%;
33
+ --popover: 224 71% 4%;
34
+ --popover-foreground: 213 31% 91%;
35
+ --primary: 217 91% 60%;
36
+ --primary-foreground: 222 47% 11%;
37
+ --secondary: 222 47% 11%;
38
+ --secondary-foreground: 213 31% 91%;
39
+ --muted: 223 47% 11%;
40
+ --muted-foreground: 215 20% 65%;
41
+ --accent: 216 34% 17%;
42
+ --accent-foreground: 213 31% 91%;
43
+ --destructive: 0 63% 31%;
44
+ --destructive-foreground: 210 40% 98%;
45
+ --border: 216 34% 17%;
46
+ --input: 216 34% 17%;
47
+ --ring: 224 64% 33%;
48
+ }
49
+
50
+ * {
51
+ box-sizing: border-box;
52
+ padding: 0;
53
+ margin: 0;
54
+ }
55
+
56
+ html {
57
+ scroll-behavior: smooth;
58
+ }
59
+
60
+ html,
61
+ body {
62
+ max-width: 100vw;
63
+ min-height: 100vh;
64
+ overflow-x: hidden;
65
+ font-feature-settings: "rlig" 1, "calt" 1;
66
+ }
67
+
68
+ body {
69
+ color: hsl(var(--foreground));
70
+ background: hsl(var(--background));
71
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
72
+ -webkit-font-smoothing: antialiased;
73
+ -moz-osx-font-smoothing: grayscale;
74
+ }
75
+
76
+ /* Scrollbar styling */
77
+ ::-webkit-scrollbar {
78
+ width: 6px;
79
+ height: 6px;
80
+ }
81
+
82
+ ::-webkit-scrollbar-track {
83
+ background: transparent;
84
+ }
85
+
86
+ ::-webkit-scrollbar-thumb {
87
+ background: hsl(var(--muted-foreground) / 0.2);
88
+ border-radius: 3px;
89
+ }
90
+
91
+ ::-webkit-scrollbar-thumb:hover {
92
+ background: hsl(var(--muted-foreground) / 0.4);
93
+ }
94
+
95
+ /* Selection */
96
+ ::selection {
97
+ background: hsl(var(--primary) / 0.2);
98
+ color: hsl(var(--foreground));
99
+ }
100
+
101
+ /* Focus styles */
102
+ :focus-visible {
103
+ outline: 2px solid hsl(var(--ring));
104
+ outline-offset: 2px;
105
+ }
106
+
107
+ /* Animations */
108
+ @keyframes fadeIn {
109
+ from {
110
+ opacity: 0;
111
+ }
112
+
113
+ to {
114
+ opacity: 1;
115
+ }
116
+ }
117
+
118
+ @keyframes slideUp {
119
+ from {
120
+ opacity: 0;
121
+ transform: translateY(10px);
122
+ }
123
+
124
+ to {
125
+ opacity: 1;
126
+ transform: translateY(0);
127
+ }
128
+ }
129
+
130
+ @keyframes pulse-subtle {
131
+
132
+ 0%,
133
+ 100% {
134
+ opacity: 1;
135
+ }
136
+
137
+ 50% {
138
+ opacity: 0.7;
139
+ }
140
+ }
141
+
142
+ .animate-fade-in {
143
+ animation: fadeIn 0.3s ease-out;
144
+ }
145
+
146
+ .animate-slide-up {
147
+ animation: slideUp 0.4s ease-out;
148
+ }
149
+
150
+ .animate-pulse-subtle {
151
+ animation: pulse-subtle 2s ease-in-out infinite;
152
+ }
153
+
154
+ /* Typography */
155
+ .text-balance {
156
+ text-wrap: balance;
157
+ }
158
+
159
+ /* Glass effect */
160
+ .glass {
161
+ background: hsl(var(--background) / 0.8);
162
+ backdrop-filter: blur(12px);
163
+ -webkit-backdrop-filter: blur(12px);
164
+ }
165
+
166
+ /* Gradient text */
167
+ .gradient-text {
168
+ background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(280 80% 60%) 100%);
169
+ -webkit-background-clip: text;
170
+ -webkit-text-fill-color: transparent;
171
+ background-clip: text;
172
+ }
173
+
174
+ /* Message bubble styles */
175
+ .message-user {
176
+ background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.8) 100%);
177
+ }
178
+
179
+ .message-assistant {
180
+ background: hsl(var(--secondary));
181
+ }
182
+
183
+ /* Input glow effect */
184
+ .input-glow:focus-within {
185
+ box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1), 0 0 20px hsl(var(--primary) / 0.1);
186
+ }
187
+
188
+ /* Markdown styling */
189
+ .prose {
190
+ max-width: none;
191
+ line-height: 1.7;
192
+ }
193
+
194
+ .prose p {
195
+ margin-bottom: 1em;
196
+ }
197
+
198
+ .prose p:last-child {
199
+ margin-bottom: 0;
200
+ }
201
+
202
+ .prose pre {
203
+ background: hsl(var(--muted));
204
+ border: 1px solid hsl(var(--border));
205
+ border-radius: var(--radius);
206
+ padding: 1rem;
207
+ overflow-x: auto;
208
+ font-size: 0.875rem;
209
+ margin: 1rem 0;
210
+ }
211
+
212
+ .prose code {
213
+ background: hsl(var(--muted));
214
+ padding: 0.2rem 0.4rem;
215
+ border-radius: 0.375rem;
216
+ font-size: 0.875em;
217
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
218
+ }
219
+
220
+ .prose pre code {
221
+ background: transparent;
222
+ padding: 0;
223
+ border-radius: 0;
224
+ }
225
+
226
+ .prose ul,
227
+ .prose ol {
228
+ padding-left: 1.5rem;
229
+ margin: 0.75rem 0;
230
+ }
231
+
232
+ .prose li {
233
+ margin: 0.25rem 0;
234
+ }
235
+
236
+ .prose blockquote {
237
+ border-left: 3px solid hsl(var(--primary));
238
+ padding-left: 1rem;
239
+ margin: 1rem 0;
240
+ color: hsl(var(--muted-foreground));
241
+ font-style: italic;
242
+ }
243
+
244
+ .prose h1,
245
+ .prose h2,
246
+ .prose h3,
247
+ .prose h4 {
248
+ font-weight: 600;
249
+ margin-top: 1.5em;
250
+ margin-bottom: 0.5em;
251
+ }
252
+
253
+ .prose h1 {
254
+ font-size: 1.5rem;
255
+ }
256
+
257
+ .prose h2 {
258
+ font-size: 1.25rem;
259
+ }
260
+
261
+ .prose h3 {
262
+ font-size: 1.125rem;
263
+ }
264
+
265
+ .prose a {
266
+ color: hsl(var(--primary));
267
+ text-decoration: underline;
268
+ text-underline-offset: 2px;
269
+ }
270
+
271
+ .prose a:hover {
272
+ text-decoration-thickness: 2px;
273
+ }
274
+
275
+ .prose strong {
276
+ font-weight: 600;
277
+ }
278
+
279
+ .prose hr {
280
+ border: none;
281
+ border-top: 1px solid hsl(var(--border));
282
+ margin: 1.5rem 0;
283
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "Qwen3 Claude Opus Chat",
9
+ description: "Chat with Qwen3-4B-Thinking-2507-Claude-4.5-Opus",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <html lang="en" className="dark">
19
+ <body className={inter.className}>{children}</body>
20
+ </html>
21
+ );
22
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Chat } from "@/components/chat";
4
+
5
+ export default function Home() {
6
+ return (
7
+ <main className="flex min-h-screen flex-col">
8
+ <Chat />
9
+ </main>
10
+ );
11
+ }
src/components/chat.tsx ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { Globe, Send, Loader2, RotateCcw, Sparkles, MessageSquare } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ReasoningBlock } from "@/components/reasoning-block";
7
+ import { ToolInvocation } from "@/components/tool-invocation";
8
+ import { Markdown } from "@/components/markdown";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ interface Message {
12
+ id: string;
13
+ role: "user" | "assistant";
14
+ content: string;
15
+ reasoning?: string;
16
+ reasoningDuration?: number;
17
+ toolInvocations?: Array<{
18
+ toolName: string;
19
+ args: Record<string, unknown>;
20
+ result?: string;
21
+ status: "pending" | "running" | "complete" | "error";
22
+ }>;
23
+ }
24
+
25
+ export function Chat() {
26
+ const [messages, setMessages] = useState<Message[]>([]);
27
+ const [input, setInput] = useState("");
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [searchEnabled, setSearchEnabled] = useState(false);
30
+ const [streamingMessage, setStreamingMessage] = useState<Message | null>(null);
31
+ const messagesEndRef = useRef<HTMLDivElement>(null);
32
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
33
+
34
+ const scrollToBottom = () => {
35
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
36
+ };
37
+
38
+ useEffect(() => {
39
+ scrollToBottom();
40
+ }, [messages, streamingMessage]);
41
+
42
+ // Auto-resize textarea
43
+ useEffect(() => {
44
+ const textarea = textareaRef.current;
45
+ if (textarea) {
46
+ textarea.style.height = "auto";
47
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
48
+ }
49
+ }, [input]);
50
+
51
+ const handleSubmit = async (e: React.FormEvent) => {
52
+ e.preventDefault();
53
+ if (!input.trim() || isLoading) return;
54
+
55
+ const userMessage: Message = {
56
+ id: Date.now().toString(),
57
+ role: "user",
58
+ content: input.trim(),
59
+ };
60
+
61
+ setMessages((prev) => [...prev, userMessage]);
62
+ setInput("");
63
+ setIsLoading(true);
64
+
65
+ const assistantMessage: Message = {
66
+ id: (Date.now() + 1).toString(),
67
+ role: "assistant",
68
+ content: "",
69
+ reasoning: "",
70
+ toolInvocations: [],
71
+ };
72
+ setStreamingMessage(assistantMessage);
73
+
74
+ try {
75
+ const response = await fetch("/api/chat", {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify({
79
+ messages: [...messages, userMessage].map((m) => ({
80
+ role: m.role,
81
+ content: m.content,
82
+ })),
83
+ searchEnabled,
84
+ }),
85
+ });
86
+
87
+ if (!response.ok) throw new Error("Failed to get response");
88
+
89
+ const reader = response.body?.getReader();
90
+ if (!reader) throw new Error("No reader available");
91
+
92
+ const decoder = new TextDecoder();
93
+ let buffer = "";
94
+ let currentReasoning = "";
95
+ let currentContent = "";
96
+ let reasoningStart = 0;
97
+ let inReasoning = false;
98
+
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done) break;
102
+
103
+ buffer += decoder.decode(value, { stream: true });
104
+ const lines = buffer.split("\n");
105
+ buffer = lines.pop() || "";
106
+
107
+ for (const line of lines) {
108
+ if (!line.startsWith("data: ")) continue;
109
+ const data = line.slice(6);
110
+ if (data === "[DONE]") continue;
111
+
112
+ try {
113
+ const parsed = JSON.parse(data);
114
+
115
+ if (parsed.type === "reasoning") {
116
+ if (!inReasoning) {
117
+ inReasoning = true;
118
+ reasoningStart = Date.now();
119
+ }
120
+ currentReasoning += parsed.content;
121
+ setStreamingMessage((prev) =>
122
+ prev ? { ...prev, reasoning: currentReasoning } : prev
123
+ );
124
+ } else if (parsed.type === "content") {
125
+ if (inReasoning) {
126
+ inReasoning = false;
127
+ const duration = (Date.now() - reasoningStart) / 1000;
128
+ setStreamingMessage((prev) =>
129
+ prev ? { ...prev, reasoningDuration: duration } : prev
130
+ );
131
+ }
132
+ currentContent += parsed.content;
133
+ setStreamingMessage((prev) =>
134
+ prev ? { ...prev, content: currentContent } : prev
135
+ );
136
+ } else if (parsed.type === "tool_call") {
137
+ setStreamingMessage((prev) => {
138
+ if (!prev) return prev;
139
+ const existing = prev.toolInvocations || [];
140
+ const existingIndex = existing.findIndex(
141
+ (t) => t.toolName === parsed.name
142
+ );
143
+ if (existingIndex >= 0) {
144
+ const updated = [...existing];
145
+ updated[existingIndex] = {
146
+ ...updated[existingIndex],
147
+ status: parsed.status,
148
+ result: parsed.result,
149
+ };
150
+ return { ...prev, toolInvocations: updated };
151
+ }
152
+ return {
153
+ ...prev,
154
+ toolInvocations: [
155
+ ...existing,
156
+ {
157
+ toolName: parsed.name,
158
+ args: parsed.args,
159
+ status: parsed.status,
160
+ result: parsed.result,
161
+ },
162
+ ],
163
+ };
164
+ });
165
+ }
166
+ } catch {
167
+ // Ignore parse errors
168
+ }
169
+ }
170
+ }
171
+
172
+ // Finalize message
173
+ setStreamingMessage((prev) => {
174
+ if (prev) {
175
+ setMessages((msgs) => [...msgs, prev]);
176
+ }
177
+ return null;
178
+ });
179
+ } catch (error) {
180
+ console.error("Chat error:", error);
181
+ setStreamingMessage(null);
182
+ } finally {
183
+ setIsLoading(false);
184
+ }
185
+ };
186
+
187
+ const handleKeyDown = (e: React.KeyboardEvent) => {
188
+ if (e.key === "Enter" && !e.shiftKey) {
189
+ e.preventDefault();
190
+ handleSubmit(e);
191
+ }
192
+ };
193
+
194
+ const clearChat = () => {
195
+ setMessages([]);
196
+ setStreamingMessage(null);
197
+ };
198
+
199
+ const hasMessages = messages.length > 0 || streamingMessage;
200
+
201
+ return (
202
+ <div className="flex flex-col h-screen bg-background">
203
+ {/* Header */}
204
+ <header className="sticky top-0 z-50 glass border-b border-border/50">
205
+ <div className="max-w-3xl mx-auto px-4 h-14 flex items-center justify-between">
206
+ <div className="flex items-center gap-3">
207
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
208
+ <Sparkles className="w-4 h-4 text-white" />
209
+ </div>
210
+ <div>
211
+ <h1 className="text-sm font-semibold">Qwen3 Claude Opus</h1>
212
+ <p className="text-xs text-muted-foreground">Reasoning Model</p>
213
+ </div>
214
+ </div>
215
+ {hasMessages && (
216
+ <Button
217
+ variant="ghost"
218
+ size="sm"
219
+ onClick={clearChat}
220
+ className="text-muted-foreground hover:text-foreground gap-2"
221
+ >
222
+ <RotateCcw className="h-4 w-4" />
223
+ <span className="hidden sm:inline">New Chat</span>
224
+ </Button>
225
+ )}
226
+ </div>
227
+ </header>
228
+
229
+ {/* Messages Area */}
230
+ <div className="flex-1 overflow-y-auto">
231
+ <div className="max-w-3xl mx-auto px-4 py-6">
232
+ {/* Empty State */}
233
+ {!hasMessages && (
234
+ <div className="flex flex-col items-center justify-center min-h-[60vh] text-center animate-fade-in">
235
+ <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500/20 to-purple-600/20 flex items-center justify-center mb-6">
236
+ <MessageSquare className="w-8 h-8 text-blue-500" />
237
+ </div>
238
+ <h2 className="text-2xl font-semibold mb-2 text-balance">
239
+ How can I help you today?
240
+ </h2>
241
+ <p className="text-muted-foreground max-w-md text-balance">
242
+ I'm a reasoning model that thinks through problems step by step.
243
+ Toggle web search for real-time information.
244
+ </p>
245
+
246
+ {/* Quick Actions */}
247
+ <div className="flex flex-wrap gap-2 mt-8 justify-center">
248
+ {[
249
+ "Explain quantum computing",
250
+ "Write a Python function",
251
+ "What's the latest news?",
252
+ ].map((prompt) => (
253
+ <button
254
+ key={prompt}
255
+ onClick={() => setInput(prompt)}
256
+ className="px-4 py-2 text-sm rounded-full border border-border hover:bg-accent hover:border-accent transition-colors"
257
+ >
258
+ {prompt}
259
+ </button>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ )}
264
+
265
+ {/* Messages */}
266
+ <div className="space-y-6">
267
+ {messages.map((message) => (
268
+ <div
269
+ key={message.id}
270
+ className={cn(
271
+ "animate-slide-up",
272
+ message.role === "user" ? "flex justify-end" : ""
273
+ )}
274
+ >
275
+ {message.role === "user" ? (
276
+ <div className="message-user text-white px-4 py-3 rounded-2xl rounded-br-md max-w-[85%] shadow-lg">
277
+ <p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
278
+ </div>
279
+ ) : (
280
+ <div className="space-y-3 max-w-full">
281
+ {message.reasoning && (
282
+ <ReasoningBlock
283
+ content={message.reasoning}
284
+ duration={message.reasoningDuration}
285
+ />
286
+ )}
287
+ {message.toolInvocations?.map((tool, idx) => (
288
+ <ToolInvocation
289
+ key={idx}
290
+ toolName={tool.toolName}
291
+ args={tool.args}
292
+ result={tool.result}
293
+ status={tool.status}
294
+ />
295
+ ))}
296
+ {message.content && (
297
+ <div className="prose text-foreground">
298
+ <Markdown>{message.content}</Markdown>
299
+ </div>
300
+ )}
301
+ </div>
302
+ )}
303
+ </div>
304
+ ))}
305
+
306
+ {/* Streaming message */}
307
+ {streamingMessage && (
308
+ <div className="space-y-3 animate-fade-in">
309
+ {streamingMessage.reasoning && (
310
+ <ReasoningBlock
311
+ content={streamingMessage.reasoning}
312
+ isStreaming={!streamingMessage.reasoningDuration}
313
+ duration={streamingMessage.reasoningDuration}
314
+ />
315
+ )}
316
+ {streamingMessage.toolInvocations?.map((tool, idx) => (
317
+ <ToolInvocation
318
+ key={idx}
319
+ toolName={tool.toolName}
320
+ args={tool.args}
321
+ result={tool.result}
322
+ status={tool.status}
323
+ />
324
+ ))}
325
+ {streamingMessage.content && (
326
+ <div className="prose text-foreground">
327
+ <Markdown>{streamingMessage.content}</Markdown>
328
+ </div>
329
+ )}
330
+ {!streamingMessage.content && !streamingMessage.reasoning && (
331
+ <div className="flex items-center gap-3 text-muted-foreground py-2">
332
+ <div className="flex gap-1">
333
+ <span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
334
+ <span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
335
+ <span className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
336
+ </div>
337
+ <span className="text-sm">Thinking...</span>
338
+ </div>
339
+ )}
340
+ </div>
341
+ )}
342
+ </div>
343
+
344
+ <div ref={messagesEndRef} className="h-4" />
345
+ </div>
346
+ </div>
347
+
348
+ {/* Input Area */}
349
+ <div className="sticky bottom-0 glass border-t border-border/50">
350
+ <div className="max-w-3xl mx-auto px-4 py-4">
351
+ <form onSubmit={handleSubmit} className="relative">
352
+ <div className={cn(
353
+ "flex items-end gap-2 p-2 rounded-2xl border border-border bg-background/50 transition-all duration-200",
354
+ "input-glow focus-within:border-primary/50"
355
+ )}>
356
+ {/* Web Search Toggle */}
357
+ <button
358
+ type="button"
359
+ onClick={() => setSearchEnabled(!searchEnabled)}
360
+ className={cn(
361
+ "shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-200",
362
+ searchEnabled
363
+ ? "bg-blue-500 text-white shadow-lg shadow-blue-500/25"
364
+ : "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
365
+ )}
366
+ title={searchEnabled ? "Web search enabled" : "Enable web search"}
367
+ >
368
+ <Globe className="w-5 h-5" />
369
+ </button>
370
+
371
+ {/* Textarea */}
372
+ <textarea
373
+ ref={textareaRef}
374
+ value={input}
375
+ onChange={(e) => setInput(e.target.value)}
376
+ onKeyDown={handleKeyDown}
377
+ placeholder="Message Qwen3 Claude Opus..."
378
+ rows={1}
379
+ disabled={isLoading}
380
+ className={cn(
381
+ "flex-1 bg-transparent border-0 resize-none text-sm leading-6",
382
+ "placeholder:text-muted-foreground focus:outline-none focus:ring-0",
383
+ "min-h-[40px] max-h-[200px] py-2 px-1"
384
+ )}
385
+ />
386
+
387
+ {/* Send Button */}
388
+ <button
389
+ type="submit"
390
+ disabled={isLoading || !input.trim()}
391
+ className={cn(
392
+ "shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-200",
393
+ input.trim() && !isLoading
394
+ ? "bg-primary text-primary-foreground shadow-lg shadow-primary/25 hover:opacity-90"
395
+ : "bg-muted text-muted-foreground cursor-not-allowed"
396
+ )}
397
+ >
398
+ {isLoading ? (
399
+ <Loader2 className="w-5 h-5 animate-spin" />
400
+ ) : (
401
+ <Send className="w-5 h-5" />
402
+ )}
403
+ </button>
404
+ </div>
405
+
406
+ {/* Status Bar */}
407
+ <div className="flex items-center justify-center gap-4 mt-3 text-xs text-muted-foreground">
408
+ {searchEnabled && (
409
+ <span className="flex items-center gap-1.5">
410
+ <span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" />
411
+ Web search enabled
412
+ </span>
413
+ )}
414
+ <span>Press Enter to send, Shift+Enter for new line</span>
415
+ </div>
416
+ </form>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ );
421
+ }
src/components/markdown.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import ReactMarkdown from "react-markdown";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface MarkdownProps {
7
+ children: string;
8
+ className?: string;
9
+ }
10
+
11
+ export function Markdown({ children, className }: MarkdownProps) {
12
+ return (
13
+ <ReactMarkdown
14
+ className={cn("prose max-w-none", className)}
15
+ components={{
16
+ pre: ({ children }) => (
17
+ <pre className="bg-muted/50 border border-border rounded-xl p-4 overflow-x-auto my-4 text-sm">
18
+ {children}
19
+ </pre>
20
+ ),
21
+ code: ({ children, className: codeClassName }) => {
22
+ const isInline = !codeClassName;
23
+ if (isInline) {
24
+ return (
25
+ <code className="bg-muted/70 px-1.5 py-0.5 rounded-md text-sm font-mono">
26
+ {children}
27
+ </code>
28
+ );
29
+ }
30
+ return <code className={codeClassName}>{children}</code>;
31
+ },
32
+ p: ({ children }) => <p className="mb-4 last:mb-0 leading-7">{children}</p>,
33
+ ul: ({ children }) => <ul className="list-disc pl-6 mb-4 space-y-1">{children}</ul>,
34
+ ol: ({ children }) => <ol className="list-decimal pl-6 mb-4 space-y-1">{children}</ol>,
35
+ li: ({ children }) => <li className="leading-7">{children}</li>,
36
+ h1: ({ children }) => <h1 className="text-xl font-semibold mb-4 mt-6 first:mt-0">{children}</h1>,
37
+ h2: ({ children }) => <h2 className="text-lg font-semibold mb-3 mt-5 first:mt-0">{children}</h2>,
38
+ h3: ({ children }) => <h3 className="text-base font-semibold mb-2 mt-4 first:mt-0">{children}</h3>,
39
+ blockquote: ({ children }) => (
40
+ <blockquote className="border-l-3 border-primary/50 pl-4 italic my-4 text-muted-foreground">
41
+ {children}
42
+ </blockquote>
43
+ ),
44
+ a: ({ children, href }) => (
45
+ <a
46
+ href={href}
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ className="text-primary hover:underline underline-offset-2"
50
+ >
51
+ {children}
52
+ </a>
53
+ ),
54
+ strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
55
+ hr: () => <hr className="my-6 border-border" />,
56
+ }}
57
+ >
58
+ {children}
59
+ </ReactMarkdown>
60
+ );
61
+ }
src/components/reasoning-block.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { ChevronDown, Brain } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface ReasoningBlockProps {
9
+ content: string;
10
+ isStreaming?: boolean;
11
+ duration?: number;
12
+ }
13
+
14
+ export function ReasoningBlock({
15
+ content,
16
+ isStreaming = false,
17
+ duration,
18
+ }: ReasoningBlockProps) {
19
+ const [isExpanded, setIsExpanded] = useState(isStreaming);
20
+
21
+ useEffect(() => {
22
+ if (!isStreaming && isExpanded) {
23
+ const timer = setTimeout(() => setIsExpanded(false), 800);
24
+ return () => clearTimeout(timer);
25
+ }
26
+ }, [isStreaming, isExpanded]);
27
+
28
+ const formatDuration = (seconds?: number) => {
29
+ if (!seconds) return "a few seconds";
30
+ if (seconds < 1) return "less than a second";
31
+ if (seconds === 1) return "1 second";
32
+ return `${Math.round(seconds)} seconds`;
33
+ };
34
+
35
+ return (
36
+ <div className="mb-4">
37
+ <button
38
+ onClick={() => setIsExpanded(!isExpanded)}
39
+ className={cn(
40
+ "flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all duration-200",
41
+ "hover:bg-accent/50 group",
42
+ isStreaming
43
+ ? "text-blue-500"
44
+ : "text-muted-foreground hover:text-foreground"
45
+ )}
46
+ >
47
+ <div className={cn(
48
+ "w-6 h-6 rounded-md flex items-center justify-center transition-colors",
49
+ isStreaming
50
+ ? "bg-blue-500/10"
51
+ : "bg-muted group-hover:bg-accent"
52
+ )}>
53
+ <Brain className={cn(
54
+ "w-3.5 h-3.5",
55
+ isStreaming && "animate-pulse"
56
+ )} />
57
+ </div>
58
+
59
+ {isStreaming ? (
60
+ <span className="font-medium">Thinking...</span>
61
+ ) : (
62
+ <span className="font-medium">Thought for {formatDuration(duration)}</span>
63
+ )}
64
+
65
+ <ChevronDown
66
+ className={cn(
67
+ "w-4 h-4 ml-auto transition-transform duration-200",
68
+ isExpanded && "rotate-180"
69
+ )}
70
+ />
71
+ </button>
72
+
73
+ <AnimatePresence initial={false}>
74
+ {isExpanded && (
75
+ <motion.div
76
+ initial={{ height: 0, opacity: 0 }}
77
+ animate={{ height: "auto", opacity: 1 }}
78
+ exit={{ height: 0, opacity: 0 }}
79
+ transition={{ duration: 0.2, ease: "easeInOut" }}
80
+ className="overflow-hidden"
81
+ >
82
+ <div className={cn(
83
+ "mt-2 ml-3 pl-4 py-3 border-l-2 text-sm leading-relaxed",
84
+ "text-muted-foreground whitespace-pre-wrap",
85
+ isStreaming ? "border-blue-500/50" : "border-border"
86
+ )}>
87
+ {content || (isStreaming ? "" : "Processing...")}
88
+ </div>
89
+ </motion.div>
90
+ )}
91
+ </AnimatePresence>
92
+ </div>
93
+ );
94
+ }
src/components/tool-invocation.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { ChevronDown, Globe, Loader2, CheckCircle2, XCircle, Search } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface ToolInvocationProps {
9
+ toolName: string;
10
+ args: Record<string, unknown>;
11
+ result?: string;
12
+ status: "pending" | "running" | "complete" | "error";
13
+ }
14
+
15
+ export function ToolInvocation({
16
+ toolName,
17
+ args,
18
+ result,
19
+ status,
20
+ }: ToolInvocationProps) {
21
+ const [isExpanded, setIsExpanded] = useState(status === "running");
22
+
23
+ const getStatusIndicator = () => {
24
+ switch (status) {
25
+ case "pending":
26
+ case "running":
27
+ return (
28
+ <div className="relative">
29
+ <div className="w-5 h-5 rounded-full border-2 border-blue-500/30 border-t-blue-500 animate-spin" />
30
+ </div>
31
+ );
32
+ case "complete":
33
+ return (
34
+ <div className="w-5 h-5 rounded-full bg-green-500/10 flex items-center justify-center">
35
+ <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
36
+ </div>
37
+ );
38
+ case "error":
39
+ return (
40
+ <div className="w-5 h-5 rounded-full bg-red-500/10 flex items-center justify-center">
41
+ <XCircle className="w-3.5 h-3.5 text-red-500" />
42
+ </div>
43
+ );
44
+ }
45
+ };
46
+
47
+ const getDisplayInfo = () => {
48
+ if (toolName === "web_search") {
49
+ const query = args.query as string;
50
+ return {
51
+ icon: <Globe className="w-4 h-4" />,
52
+ title: "Web Search",
53
+ subtitle: query,
54
+ };
55
+ }
56
+ return {
57
+ icon: <Search className="w-4 h-4" />,
58
+ title: toolName,
59
+ subtitle: JSON.stringify(args),
60
+ };
61
+ };
62
+
63
+ const info = getDisplayInfo();
64
+
65
+ return (
66
+ <div className={cn(
67
+ "mb-3 rounded-xl border overflow-hidden transition-all duration-200",
68
+ status === "running"
69
+ ? "border-blue-500/30 bg-blue-500/5"
70
+ : status === "error"
71
+ ? "border-red-500/30 bg-red-500/5"
72
+ : "border-border bg-muted/30"
73
+ )}>
74
+ <button
75
+ onClick={() => setIsExpanded(!isExpanded)}
76
+ className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-accent/30 transition-colors"
77
+ >
78
+ {getStatusIndicator()}
79
+
80
+ <div className={cn(
81
+ "w-8 h-8 rounded-lg flex items-center justify-center",
82
+ status === "running" ? "bg-blue-500/10 text-blue-500" : "bg-muted text-muted-foreground"
83
+ )}>
84
+ {info.icon}
85
+ </div>
86
+
87
+ <div className="flex-1 min-w-0">
88
+ <p className="text-sm font-medium">{info.title}</p>
89
+ <p className="text-xs text-muted-foreground truncate">{info.subtitle}</p>
90
+ </div>
91
+
92
+ <ChevronDown
93
+ className={cn(
94
+ "w-4 h-4 text-muted-foreground transition-transform duration-200 shrink-0",
95
+ isExpanded && "rotate-180"
96
+ )}
97
+ />
98
+ </button>
99
+
100
+ <AnimatePresence initial={false}>
101
+ {isExpanded && (
102
+ <motion.div
103
+ initial={{ height: 0 }}
104
+ animate={{ height: "auto" }}
105
+ exit={{ height: 0 }}
106
+ transition={{ duration: 0.2, ease: "easeInOut" }}
107
+ className="overflow-hidden"
108
+ >
109
+ <div className="px-4 pb-4 pt-2 border-t border-border/50">
110
+ {result && (
111
+ <div className="text-sm text-muted-foreground max-h-48 overflow-y-auto whitespace-pre-wrap rounded-lg bg-background/50 p-3">
112
+ {result}
113
+ </div>
114
+ )}
115
+ {!result && status === "running" && (
116
+ <div className="flex items-center gap-2 text-sm text-blue-500">
117
+ <Loader2 className="w-4 h-4 animate-spin" />
118
+ <span>Fetching results...</span>
119
+ </div>
120
+ )}
121
+ </div>
122
+ </motion.div>
123
+ )}
124
+ </AnimatePresence>
125
+ </div>
126
+ );
127
+ }
src/components/ui/button.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-foreground text-background shadow hover:bg-foreground/90",
12
+ ghost: "hover:bg-accent hover:text-accent-foreground",
13
+ outline: "border border-border bg-transparent hover:bg-accent",
14
+ },
15
+ size: {
16
+ default: "h-9 px-4 py-2",
17
+ sm: "h-8 rounded-md px-3 text-xs",
18
+ lg: "h-10 rounded-md px-8",
19
+ icon: "h-9 w-9",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ size: "default",
25
+ },
26
+ }
27
+ );
28
+
29
+ export interface ButtonProps
30
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
31
+ VariantProps<typeof buttonVariants> {
32
+ asChild?: boolean;
33
+ }
34
+
35
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
36
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
37
+ const Comp = asChild ? Slot : "button";
38
+ return (
39
+ <Comp
40
+ className={cn(buttonVariants({ variant, size, className }))}
41
+ ref={ref}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+ );
47
+ Button.displayName = "Button";
48
+
49
+ export { Button, buttonVariants };
src/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
tailwind.config.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ darkMode: "class",
5
+ content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
6
+ theme: {
7
+ extend: {
8
+ colors: {
9
+ background: "hsl(var(--background))",
10
+ foreground: "hsl(var(--foreground))",
11
+ muted: {
12
+ DEFAULT: "hsl(var(--muted))",
13
+ foreground: "hsl(var(--muted-foreground))",
14
+ },
15
+ accent: {
16
+ DEFAULT: "hsl(var(--accent))",
17
+ foreground: "hsl(var(--accent-foreground))",
18
+ },
19
+ border: "hsl(var(--border))",
20
+ ring: "hsl(var(--ring))",
21
+ },
22
+ borderRadius: {
23
+ lg: "var(--radius)",
24
+ md: "calc(var(--radius) - 2px)",
25
+ sm: "calc(var(--radius) - 4px)",
26
+ },
27
+ },
28
+ },
29
+ plugins: [],
30
+ };
31
+
32
+ export default config;
tsconfig.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "module": "esnext",
10
+ "moduleResolution": "bundler",
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "preserve",
14
+ "incremental": true,
15
+ "plugins": [{ "name": "next" }],
16
+ "paths": {
17
+ "@/*": ["./src/*"]
18
+ }
19
+ },
20
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
21
+ "exclude": ["node_modules"]
22
+ }