Spaces:
Sleeping
Sleeping
gradio -> docker
Browse files- .gitignore +3 -0
- .gradio/cached_examples/21/log.csv +0 -4
- Dockerfile +40 -0
- README.md +33 -7
- app.py +0 -378
- next-env.d.ts +5 -0
- next.config.js +6 -0
- package-lock.json +0 -0
- package.json +35 -0
- postcss.config.js +6 -0
- public/.gitkeep +0 -0
- requirements.txt +0 -4
- src/app/api/chat/route.ts +277 -0
- src/app/globals.css +283 -0
- src/app/layout.tsx +22 -0
- src/app/page.tsx +11 -0
- src/components/chat.tsx +421 -0
- src/components/markdown.tsx +61 -0
- src/components/reasoning-block.tsx +94 -0
- src/components/tool-invocation.tsx +127 -0
- src/components/ui/button.tsx +49 -0
- src/lib/utils.ts +6 -0
- tailwind.config.ts +32 -0
- tsconfig.json +22 -0
.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:
|
| 7 |
-
|
| 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("&", "&")
|
| 112 |
-
.replace("<", "<")
|
| 113 |
-
.replace(">", ">")
|
| 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 |
+
}
|