Upload 18 files
Browse files- app.py +112 -27
- public/app.js +197 -106
- public/index.html +18 -11
- public/styles.css +29 -1
app.py
CHANGED
@@ -47,35 +47,70 @@ class ChatResponse(BaseModel):
|
|
47 |
# Global model variables
|
48 |
tokenizer = None
|
49 |
model = None
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
-
|
52 |
-
|
53 |
-
|
|
|
54 |
|
55 |
try:
|
56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
|
58 |
logger.info(f"Loading model: {model_name}")
|
59 |
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
except Exception as e:
|
69 |
-
logger.error(f"Error loading model: {e}")
|
70 |
# For development/testing, use a fallback
|
71 |
logger.warning("Using fallback model response")
|
72 |
-
|
73 |
-
def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
|
74 |
"""Generate response from the model"""
|
75 |
try:
|
|
|
|
|
|
|
|
|
76 |
if model is None or tokenizer is None:
|
77 |
# Fallback response for development
|
78 |
-
return "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
|
79 |
|
80 |
# Format messages for the model
|
81 |
formatted_messages = []
|
@@ -92,6 +127,12 @@ def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max
|
|
92 |
# Tokenize
|
93 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
# Generate
|
96 |
with torch.no_grad():
|
97 |
outputs = model.generate(
|
@@ -110,12 +151,16 @@ def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max
|
|
110 |
logger.error(f"Error generating response: {e}")
|
111 |
return f"I apologize, but I encountered an error while processing your request: {str(e)}"
|
112 |
|
113 |
-
def generate_streaming_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
|
114 |
"""Generate streaming response from the model"""
|
115 |
try:
|
|
|
|
|
|
|
|
|
116 |
if model is None or tokenizer is None:
|
117 |
# Fallback streaming response
|
118 |
-
response = "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
|
119 |
for char in response:
|
120 |
yield f"data: {json.dumps({'choices': [{'delta': {'content': char}}]})}\n\n"
|
121 |
time.sleep(0.05)
|
@@ -138,6 +183,11 @@ def generate_streaming_response(messages: List[ChatMessage], temperature: float
|
|
138 |
# Tokenize
|
139 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
140 |
|
|
|
|
|
|
|
|
|
|
|
141 |
# Setup streaming
|
142 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
143 |
|
@@ -169,12 +219,16 @@ def generate_streaming_response(messages: List[ChatMessage], temperature: float
|
|
169 |
yield f"data: {json.dumps({'choices': [{'finish_reason': 'stop'}]})}\n\n"
|
170 |
yield "data: [DONE]\n\n"
|
171 |
|
172 |
-
def generate_plain_text_stream(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048):
|
173 |
"""Plain text streaming generator used by /chat compatibility endpoint (no SSE)."""
|
174 |
try:
|
|
|
|
|
|
|
|
|
175 |
if model is None or tokenizer is None:
|
176 |
# Fallback streaming: plain text (no SSE)
|
177 |
-
response = "I'm a Qwen AI assistant. The model is currently loading, please try again in a moment."
|
178 |
for ch in response:
|
179 |
yield ch
|
180 |
time.sleep(0.02)
|
@@ -193,6 +247,11 @@ def generate_plain_text_stream(messages: List[ChatMessage], temperature: float =
|
|
193 |
# Tokenize
|
194 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
195 |
|
|
|
|
|
|
|
|
|
|
|
196 |
# Setup streaming (plain text)
|
197 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
198 |
generation_kwargs = {
|
@@ -256,7 +315,17 @@ async def list_models():
|
|
256 |
"id": "qwen-coder-3-30b",
|
257 |
"object": "model",
|
258 |
"created": int(time.time()),
|
259 |
-
"owned_by": "qwen"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
260 |
}
|
261 |
]
|
262 |
}
|
@@ -265,12 +334,19 @@ async def list_models():
|
|
265 |
async def chat_completion(request: ChatRequest):
|
266 |
"""OpenAI compatible chat completion endpoint"""
|
267 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
268 |
if request.stream:
|
269 |
return StreamingResponse(
|
270 |
generate_streaming_response(
|
271 |
request.messages,
|
272 |
request.temperature or 0.7,
|
273 |
-
request.max_tokens or 2048
|
|
|
274 |
),
|
275 |
media_type="text/plain"
|
276 |
)
|
@@ -278,13 +354,14 @@ async def chat_completion(request: ChatRequest):
|
|
278 |
response_content = generate_response(
|
279 |
request.messages,
|
280 |
request.temperature or 0.7,
|
281 |
-
request.max_tokens or 2048
|
|
|
282 |
)
|
283 |
|
284 |
return ChatResponse(
|
285 |
id=f"chatcmpl-{int(time.time())}",
|
286 |
created=int(time.time()),
|
287 |
-
model=
|
288 |
choices=[{
|
289 |
"index": 0,
|
290 |
"message": {
|
@@ -299,6 +376,7 @@ async def chat_completion(request: ChatRequest):
|
|
299 |
logger.error(f"Error in chat completion: {e}")
|
300 |
raise HTTPException(status_code=500, detail=str(e))
|
301 |
|
|
|
302 |
@app.post("/v1/chat/completions")
|
303 |
async def openai_chat_completion(request: ChatRequest):
|
304 |
"""OpenAI API compatible endpoint"""
|
@@ -310,6 +388,11 @@ async def chat_stream_compat(payload: Dict[str, Any]):
|
|
310 |
try:
|
311 |
message = str(payload.get("message", "") or "").strip()
|
312 |
history_raw = payload.get("history", []) or []
|
|
|
|
|
|
|
|
|
|
|
313 |
|
314 |
history_msgs: List[ChatMessage] = []
|
315 |
for item in history_raw:
|
@@ -325,7 +408,8 @@ async def chat_stream_compat(payload: Dict[str, Any]):
|
|
325 |
generate_plain_text_stream(
|
326 |
history_msgs,
|
327 |
temperature=0.7,
|
328 |
-
max_tokens=2048
|
|
|
329 |
),
|
330 |
media_type="text/plain; charset=utf-8"
|
331 |
)
|
@@ -336,12 +420,13 @@ async def chat_stream_compat(payload: Dict[str, Any]):
|
|
336 |
# Mount static files AFTER API routes
|
337 |
app.mount("/", StaticFiles(directory="public", html=True), name="static")
|
338 |
|
|
|
339 |
# Startup event
|
340 |
@app.on_event("startup")
|
341 |
async def startup_event():
|
342 |
-
"""Initialize the model on startup"""
|
343 |
-
# Load model in background thread to avoid blocking startup
|
344 |
-
thread = Thread(target=load_model)
|
345 |
thread.daemon = True
|
346 |
thread.start()
|
347 |
|
|
|
47 |
# Global model variables
|
48 |
tokenizer = None
|
49 |
model = None
|
50 |
+
current_model_name = None
|
51 |
+
available_models = {
|
52 |
+
"qwen-coder-3-30b": "Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
53 |
+
"qwen-4b-thinking": "Qwen/Qwen3-4B-Thinking-2507"
|
54 |
+
}
|
55 |
|
56 |
+
|
57 |
+
def load_model(model_id: str = "qwen-coder-3-30b"):
|
58 |
+
"""Load the specified Qwen model and tokenizer"""
|
59 |
+
global tokenizer, model, current_model_name
|
60 |
|
61 |
try:
|
62 |
+
if model_id not in available_models:
|
63 |
+
raise ValueError(f"Unknown model ID: {model_id}")
|
64 |
+
|
65 |
+
model_name = available_models[model_id]
|
66 |
+
|
67 |
+
# If the same model is already loaded, skip
|
68 |
+
if current_model_name == model_name:
|
69 |
+
logger.info(f"Model {model_name} is already loaded")
|
70 |
+
return
|
71 |
+
|
72 |
+
# Clear previous model from memory
|
73 |
+
if model is not None:
|
74 |
+
del model
|
75 |
+
torch.cuda.empty_cache() if torch.cuda.is_available() else None
|
76 |
|
77 |
logger.info(f"Loading model: {model_name}")
|
78 |
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
79 |
+
|
80 |
+
# Use different settings for the lighter model
|
81 |
+
if model_id == "qwen-4b-thinking":
|
82 |
+
model = AutoModelForCausalLM.from_pretrained(
|
83 |
+
model_name,
|
84 |
+
torch_dtype=torch.float16,
|
85 |
+
device_map="auto",
|
86 |
+
trust_remote_code=True,
|
87 |
+
low_cpu_mem_usage=True
|
88 |
+
)
|
89 |
+
else:
|
90 |
+
model = AutoModelForCausalLM.from_pretrained(
|
91 |
+
model_name,
|
92 |
+
torch_dtype=torch.float16,
|
93 |
+
device_map="auto",
|
94 |
+
trust_remote_code=True
|
95 |
+
)
|
96 |
+
|
97 |
+
current_model_name = model_name
|
98 |
+
logger.info(f"Model {model_name} loaded successfully")
|
99 |
|
100 |
except Exception as e:
|
101 |
+
logger.error(f"Error loading model {model_id}: {e}")
|
102 |
# For development/testing, use a fallback
|
103 |
logger.warning("Using fallback model response")
|
104 |
+
def generate_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
|
|
|
105 |
"""Generate response from the model"""
|
106 |
try:
|
107 |
+
# Load model if not loaded or different model requested
|
108 |
+
if model is None or current_model_name != available_models.get(model_id):
|
109 |
+
load_model(model_id)
|
110 |
+
|
111 |
if model is None or tokenizer is None:
|
112 |
# Fallback response for development
|
113 |
+
return f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
|
114 |
|
115 |
# Format messages for the model
|
116 |
formatted_messages = []
|
|
|
127 |
# Tokenize
|
128 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
129 |
|
130 |
+
# Adjust generation parameters for different models
|
131 |
+
if model_id == "qwen-4b-thinking":
|
132 |
+
# Use more conservative settings for the smaller model
|
133 |
+
max_tokens = min(max_tokens, 1024)
|
134 |
+
temperature = min(temperature, 0.8)
|
135 |
+
|
136 |
# Generate
|
137 |
with torch.no_grad():
|
138 |
outputs = model.generate(
|
|
|
151 |
logger.error(f"Error generating response: {e}")
|
152 |
return f"I apologize, but I encountered an error while processing your request: {str(e)}"
|
153 |
|
154 |
+
def generate_streaming_response(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
|
155 |
"""Generate streaming response from the model"""
|
156 |
try:
|
157 |
+
# Load model if not loaded or different model requested
|
158 |
+
if model is None or current_model_name != available_models.get(model_id):
|
159 |
+
load_model(model_id)
|
160 |
+
|
161 |
if model is None or tokenizer is None:
|
162 |
# Fallback streaming response
|
163 |
+
response = f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
|
164 |
for char in response:
|
165 |
yield f"data: {json.dumps({'choices': [{'delta': {'content': char}}]})}\n\n"
|
166 |
time.sleep(0.05)
|
|
|
183 |
# Tokenize
|
184 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
185 |
|
186 |
+
# Adjust generation parameters for different models
|
187 |
+
if model_id == "qwen-4b-thinking":
|
188 |
+
max_tokens = min(max_tokens, 1024)
|
189 |
+
temperature = min(temperature, 0.8)
|
190 |
+
|
191 |
# Setup streaming
|
192 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
193 |
|
|
|
219 |
yield f"data: {json.dumps({'choices': [{'finish_reason': 'stop'}]})}\n\n"
|
220 |
yield "data: [DONE]\n\n"
|
221 |
|
222 |
+
def generate_plain_text_stream(messages: List[ChatMessage], temperature: float = 0.7, max_tokens: int = 2048, model_id: str = "qwen-coder-3-30b"):
|
223 |
"""Plain text streaming generator used by /chat compatibility endpoint (no SSE)."""
|
224 |
try:
|
225 |
+
# Load model if not loaded or different model requested
|
226 |
+
if model is None or current_model_name != available_models.get(model_id):
|
227 |
+
load_model(model_id)
|
228 |
+
|
229 |
if model is None or tokenizer is None:
|
230 |
# Fallback streaming: plain text (no SSE)
|
231 |
+
response = f"I'm a Qwen AI assistant ({model_id}). The model is currently loading, please try again in a moment."
|
232 |
for ch in response:
|
233 |
yield ch
|
234 |
time.sleep(0.02)
|
|
|
247 |
# Tokenize
|
248 |
inputs = tokenizer(text, return_tensors="pt").to(model.device)
|
249 |
|
250 |
+
# Adjust parameters for lighter model
|
251 |
+
if model_id == "qwen-4b-thinking":
|
252 |
+
max_tokens = min(max_tokens, 1024)
|
253 |
+
temperature = min(temperature, 0.8)
|
254 |
+
|
255 |
# Setup streaming (plain text)
|
256 |
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
257 |
generation_kwargs = {
|
|
|
315 |
"id": "qwen-coder-3-30b",
|
316 |
"object": "model",
|
317 |
"created": int(time.time()),
|
318 |
+
"owned_by": "qwen",
|
319 |
+
"name": "Qwen 3 Coder 30B",
|
320 |
+
"description": "Výkonný model pro programování"
|
321 |
+
},
|
322 |
+
{
|
323 |
+
"id": "qwen-4b-thinking",
|
324 |
+
"object": "model",
|
325 |
+
"created": int(time.time()),
|
326 |
+
"owned_by": "qwen",
|
327 |
+
"name": "Qwen 4B Thinking",
|
328 |
+
"description": "Rychlejší odlehčený model"
|
329 |
}
|
330 |
]
|
331 |
}
|
|
|
334 |
async def chat_completion(request: ChatRequest):
|
335 |
"""OpenAI compatible chat completion endpoint"""
|
336 |
try:
|
337 |
+
model_id = request.model or "qwen-coder-3-30b"
|
338 |
+
|
339 |
+
# Validate model ID
|
340 |
+
if model_id not in available_models:
|
341 |
+
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
342 |
+
|
343 |
if request.stream:
|
344 |
return StreamingResponse(
|
345 |
generate_streaming_response(
|
346 |
request.messages,
|
347 |
request.temperature or 0.7,
|
348 |
+
request.max_tokens or 2048,
|
349 |
+
model_id
|
350 |
),
|
351 |
media_type="text/plain"
|
352 |
)
|
|
|
354 |
response_content = generate_response(
|
355 |
request.messages,
|
356 |
request.temperature or 0.7,
|
357 |
+
request.max_tokens or 2048,
|
358 |
+
model_id
|
359 |
)
|
360 |
|
361 |
return ChatResponse(
|
362 |
id=f"chatcmpl-{int(time.time())}",
|
363 |
created=int(time.time()),
|
364 |
+
model=model_id,
|
365 |
choices=[{
|
366 |
"index": 0,
|
367 |
"message": {
|
|
|
376 |
logger.error(f"Error in chat completion: {e}")
|
377 |
raise HTTPException(status_code=500, detail=str(e))
|
378 |
|
379 |
+
|
380 |
@app.post("/v1/chat/completions")
|
381 |
async def openai_chat_completion(request: ChatRequest):
|
382 |
"""OpenAI API compatible endpoint"""
|
|
|
388 |
try:
|
389 |
message = str(payload.get("message", "") or "").strip()
|
390 |
history_raw = payload.get("history", []) or []
|
391 |
+
model_id = payload.get("model", "qwen-coder-3-30b")
|
392 |
+
|
393 |
+
# Validate model ID
|
394 |
+
if model_id not in available_models:
|
395 |
+
model_id = "qwen-coder-3-30b" # fallback
|
396 |
|
397 |
history_msgs: List[ChatMessage] = []
|
398 |
for item in history_raw:
|
|
|
408 |
generate_plain_text_stream(
|
409 |
history_msgs,
|
410 |
temperature=0.7,
|
411 |
+
max_tokens=2048,
|
412 |
+
model_id=model_id
|
413 |
),
|
414 |
media_type="text/plain; charset=utf-8"
|
415 |
)
|
|
|
420 |
# Mount static files AFTER API routes
|
421 |
app.mount("/", StaticFiles(directory="public", html=True), name="static")
|
422 |
|
423 |
+
# Startup event
|
424 |
# Startup event
|
425 |
@app.on_event("startup")
|
426 |
async def startup_event():
|
427 |
+
"""Initialize the default model on startup"""
|
428 |
+
# Load default model in background thread to avoid blocking startup
|
429 |
+
thread = Thread(target=load_model, args=("qwen-coder-3-30b",))
|
430 |
thread.daemon = True
|
431 |
thread.start()
|
432 |
|
public/app.js
CHANGED
@@ -79,14 +79,53 @@ class ChatState {
|
|
79 |
|
80 |
// Generate conversation title from first message
|
81 |
generateTitle(content) {
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
|
89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
90 |
}
|
91 |
|
92 |
// Save state to localStorage
|
@@ -171,8 +210,12 @@ class APIManager {
|
|
171 |
}
|
172 |
}
|
173 |
|
174 |
-
|
175 |
async sendMessage(message, history = []) {
|
|
|
|
|
|
|
|
|
176 |
const response = await this.makeRequest("/chat", {
|
177 |
method: "POST",
|
178 |
headers: {
|
@@ -184,6 +227,7 @@ class APIManager {
|
|
184 |
role: msg.role,
|
185 |
content: msg.content,
|
186 |
})),
|
|
|
187 |
}),
|
188 |
});
|
189 |
|
@@ -384,15 +428,49 @@ class MessageRenderer {
|
|
384 |
|
385 |
// Format message content (handle markdown, code, etc.)
|
386 |
formatContent(content) {
|
387 |
-
//
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
394 |
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
395 |
-
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
|
|
|
|
|
396 |
}
|
397 |
|
398 |
// Format timestamp
|
@@ -544,95 +622,71 @@ class ChatApp {
|
|
544 |
const modelDropdown = this.elements.modelDropdown;
|
545 |
if (!modelDropdown) return;
|
546 |
|
547 |
-
const
|
548 |
-
const
|
549 |
-
const
|
550 |
-
|
551 |
-
if (!trigger || !menu) return;
|
552 |
-
|
553 |
-
// Available models mapping
|
554 |
-
const modelMap = {
|
555 |
-
"Qwen 3 Coder 30B": "qwen-coder-3-30b",
|
556 |
-
"Qwen 3 Coder (Default)": "qwen-coder-3-default",
|
557 |
-
};
|
558 |
-
|
559 |
-
// Set current model from state or default
|
560 |
-
const currentModel =
|
561 |
-
this.state.getCurrentConversation()?.model || "Qwen 3 Coder (Default)";
|
562 |
-
trigger.childNodes[0].textContent = currentModel;
|
563 |
-
|
564 |
-
// Handle model selection
|
565 |
-
modelButtons.forEach((button) => {
|
566 |
-
// Skip info/status buttons
|
567 |
-
if (!button.textContent.includes("Qwen")) return;
|
568 |
-
|
569 |
-
button.addEventListener("click", (e) => {
|
570 |
-
e.stopPropagation();
|
571 |
-
const selectedModel = button.textContent.trim();
|
572 |
|
|
|
|
|
|
|
|
|
|
|
|
|
573 |
// Update UI
|
574 |
-
|
575 |
-
|
576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
577 |
// Update current conversation model
|
578 |
const conversation = this.state.getCurrentConversation();
|
579 |
if (conversation) {
|
580 |
-
conversation.model =
|
581 |
-
conversation.modelId =
|
582 |
-
modelMap[selectedModel] || "qwen-coder-3-default";
|
583 |
this.state.saveToStorage();
|
584 |
}
|
585 |
-
|
586 |
// Update chat header
|
587 |
-
this.updateChatHeader(
|
588 |
-
|
589 |
-
|
590 |
-
|
591 |
-
|
592 |
-
|
|
|
|
|
|
|
|
|
593 |
});
|
594 |
});
|
595 |
}
|
596 |
|
597 |
// Show model change notification
|
|
|
598 |
showModelChangeNotification(modelName) {
|
599 |
-
|
600 |
-
|
601 |
-
|
602 |
-
"fixed top-20 right-4 z-50 px-4 py-2 rounded-lg bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 text-sm font-medium transition-all duration-300 transform translate-x-full";
|
603 |
-
notification.innerHTML = `
|
604 |
-
<div class="flex items-center gap-2">
|
605 |
-
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
606 |
-
<path d="M9 12l2 2 4-4"/>
|
607 |
-
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
|
608 |
-
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
|
609 |
-
<path d="M3 12h6m6 0h6"/>
|
610 |
-
</svg>
|
611 |
-
Model switched to ${modelName}
|
612 |
-
</div>
|
613 |
-
`;
|
614 |
-
|
615 |
-
document.body.appendChild(notification);
|
616 |
-
|
617 |
-
// Animate in
|
618 |
-
requestAnimationFrame(() => {
|
619 |
-
notification.classList.remove("translate-x-full");
|
620 |
-
});
|
621 |
-
|
622 |
-
// Animate out after 3 seconds
|
623 |
-
setTimeout(() => {
|
624 |
-
notification.classList.add("translate-x-full");
|
625 |
-
setTimeout(() => {
|
626 |
-
document.body.removeChild(notification);
|
627 |
-
}, 300);
|
628 |
-
}, 3000);
|
629 |
}
|
630 |
|
631 |
-
// Update chat header with current model
|
632 |
-
updateChatHeader(modelName) {
|
633 |
const chatTitle = document.getElementById("chat-title");
|
634 |
const chatSubtitle = chatTitle?.nextElementSibling;
|
635 |
|
|
|
|
|
|
|
|
|
|
|
|
|
636 |
if (chatSubtitle) {
|
637 |
chatSubtitle.textContent = `Using ${modelName} • Ready to help`;
|
638 |
}
|
@@ -737,17 +791,23 @@ class ChatApp {
|
|
737 |
this.renderMessage(userMessage);
|
738 |
}
|
739 |
|
|
|
|
|
|
|
|
|
|
|
|
|
740 |
// Show typing indicator
|
741 |
this.renderer.showTyping();
|
742 |
|
743 |
// Prepare conversation history for API
|
744 |
-
const
|
745 |
-
const history =
|
746 |
-
|
747 |
-
|
748 |
-
|
749 |
-
|
750 |
-
|
751 |
|
752 |
// Use the streaming chat endpoint
|
753 |
const response = await this.api.sendMessage(
|
@@ -925,6 +985,10 @@ class ChatApp {
|
|
925 |
this.renderWelcomeScreen();
|
926 |
this.updateChatHistory();
|
927 |
this.state.saveToStorage();
|
|
|
|
|
|
|
|
|
928 |
console.log("Created new conversation:", conversation.id);
|
929 |
}
|
930 |
|
@@ -1044,6 +1108,9 @@ class ChatApp {
|
|
1044 |
this.updateChatHistory();
|
1045 |
this.state.saveToStorage();
|
1046 |
|
|
|
|
|
|
|
1047 |
console.log("Loaded chat session:", sessionId);
|
1048 |
}
|
1049 |
|
@@ -1724,19 +1791,43 @@ class AccessibilityManager {
|
|
1724 |
}: ${message.content}`;
|
1725 |
}
|
1726 |
}
|
|
|
|
|
1727 |
}
|
1728 |
|
1729 |
-
//
|
1730 |
-
|
1731 |
-
|
1732 |
-
|
1733 |
-
|
1734 |
-
|
1735 |
-
|
1736 |
-
|
1737 |
-
|
1738 |
-
|
1739 |
-
|
1740 |
-
|
1741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1742 |
};
|
|
|
79 |
|
80 |
// Generate conversation title from first message
|
81 |
generateTitle(content) {
|
82 |
+
// Remove extra whitespace and line breaks
|
83 |
+
const cleanContent = content.trim().replace(/\s+/g, ' ');
|
84 |
+
|
85 |
+
// Generate a more intelligent title
|
86 |
+
let title;
|
87 |
+
|
88 |
+
// Check for common patterns
|
89 |
+
if (cleanContent.toLowerCase().includes('napište') || cleanContent.toLowerCase().includes('napiš')) {
|
90 |
+
// Extract what user wants to write
|
91 |
+
const match = cleanContent.match(/napi[šs]\w*\s+(.+)/i);
|
92 |
+
title = match ? `Napsat: ${match[1]}` : cleanContent;
|
93 |
+
} else if (cleanContent.toLowerCase().includes('vytvořte') || cleanContent.toLowerCase().includes('vytvoř')) {
|
94 |
+
// Extract what user wants to create
|
95 |
+
const match = cleanContent.match(/vytvo[řr]\w*\s+(.+)/i);
|
96 |
+
title = match ? `Vytvořit: ${match[1]}` : cleanContent;
|
97 |
+
} else if (cleanContent.toLowerCase().includes('pomozte') || cleanContent.toLowerCase().includes('pomoc')) {
|
98 |
+
// Help requests
|
99 |
+
title = `Pomoc: ${cleanContent.replace(/pomozte\s*mi\s*/i, '').replace(/pomoc\s*s\s*/i, '')}`;
|
100 |
+
} else if (cleanContent.toLowerCase().includes('vysvětlete') || cleanContent.toLowerCase().includes('vysvětli')) {
|
101 |
+
// Explanations
|
102 |
+
const match = cleanContent.match(/vysvětl\w*\s+(.+)/i);
|
103 |
+
title = match ? `Vysvětlit: ${match[1]}` : cleanContent;
|
104 |
+
} else if (cleanContent.toLowerCase().includes('oprav')) {
|
105 |
+
// Fixes
|
106 |
+
const match = cleanContent.match(/oprav\w*\s+(.+)/i);
|
107 |
+
title = match ? `Opravit: ${match[1]}` : cleanContent;
|
108 |
+
} else {
|
109 |
+
// Default: use first meaningful words
|
110 |
+
const words = cleanContent.split(" ");
|
111 |
+
const meaningfulWords = words.filter(word =>
|
112 |
+
word.length > 2 &&
|
113 |
+
!['jak', 'kde', 'kdy', 'proč', 'která', 'který', 'které'].includes(word.toLowerCase())
|
114 |
+
);
|
115 |
+
|
116 |
+
title = meaningfulWords.slice(0, 4).join(" ");
|
117 |
+
if (title.length < 10 && words.length > 4) {
|
118 |
+
title = words.slice(0, 6).join(" ");
|
119 |
+
}
|
120 |
+
}
|
121 |
|
122 |
+
// Cleanup and limit length
|
123 |
+
title = title.replace(/[^\w\s.,!?-áčďéěíňóřšťúůýž]/gi, '').trim();
|
124 |
+
if (title.length > 50) {
|
125 |
+
title = title.substring(0, 47) + "...";
|
126 |
+
}
|
127 |
+
|
128 |
+
return title || "New Chat";
|
129 |
}
|
130 |
|
131 |
// Save state to localStorage
|
|
|
210 |
}
|
211 |
}
|
212 |
|
213 |
+
// Send chat message using streaming endpoint
|
214 |
async sendMessage(message, history = []) {
|
215 |
+
// Get current model from conversation or default
|
216 |
+
const conversation = this.state.getCurrentConversation();
|
217 |
+
const modelId = conversation?.model || "qwen-coder-3-30b";
|
218 |
+
|
219 |
const response = await this.makeRequest("/chat", {
|
220 |
method: "POST",
|
221 |
headers: {
|
|
|
227 |
role: msg.role,
|
228 |
content: msg.content,
|
229 |
})),
|
230 |
+
model: modelId
|
231 |
}),
|
232 |
});
|
233 |
|
|
|
428 |
|
429 |
// Format message content (handle markdown, code, etc.)
|
430 |
formatContent(content) {
|
431 |
+
// Enhanced formatting with code block support
|
432 |
+
let formatted = content;
|
433 |
+
|
434 |
+
// First handle code blocks (triple backticks)
|
435 |
+
formatted = formatted.replace(
|
436 |
+
/```(\w+)?\n?([\s\S]*?)```/g,
|
437 |
+
(match, language, code) => {
|
438 |
+
const lang = language ? ` data-language="${language}"` : '';
|
439 |
+
const escapedCode = code
|
440 |
+
.replace(/&/g, '&')
|
441 |
+
.replace(/</g, '<')
|
442 |
+
.replace(/>/g, '>')
|
443 |
+
.replace(/"/g, '"')
|
444 |
+
.replace(/'/g, ''');
|
445 |
+
|
446 |
+
return `<div class="code-block my-4">
|
447 |
+
<div class="code-header bg-zinc-100 dark:bg-zinc-800 px-3 py-2 text-xs text-zinc-600 dark:text-zinc-400 border-b border-zinc-200 dark:border-zinc-700 rounded-t-md flex items-center justify-between">
|
448 |
+
<span>${language || 'Code'}</span>
|
449 |
+
<button class="copy-code-btn text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" onclick="copyToClipboard(this)" title="Kopírovat kód">
|
450 |
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
451 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
452 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
453 |
+
</svg>
|
454 |
+
</button>
|
455 |
+
</div>
|
456 |
+
<pre class="code-content bg-zinc-50 dark:bg-zinc-900 p-4 rounded-b-md overflow-x-auto text-sm border border-zinc-200 dark:border-zinc-700"${lang}><code>${escapedCode}</code></pre>
|
457 |
+
</div>`;
|
458 |
+
}
|
459 |
+
);
|
460 |
+
|
461 |
+
// Then handle inline code (single backticks)
|
462 |
+
formatted = formatted.replace(
|
463 |
+
/`([^`]+)`/g,
|
464 |
+
'<code class="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-sm font-mono">$1</code>'
|
465 |
+
);
|
466 |
+
|
467 |
+
// Handle other markdown formatting
|
468 |
+
formatted = formatted
|
469 |
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
470 |
+
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
471 |
+
.replace(/\n/g, "<br>");
|
472 |
+
|
473 |
+
return formatted;
|
474 |
}
|
475 |
|
476 |
// Format timestamp
|
|
|
622 |
const modelDropdown = this.elements.modelDropdown;
|
623 |
if (!modelDropdown) return;
|
624 |
|
625 |
+
const modelOptions = modelDropdown.querySelectorAll('.model-option');
|
626 |
+
const currentModelName = document.getElementById('current-model-name');
|
627 |
+
const responseTimeElement = document.getElementById('model-response-time');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
628 |
|
629 |
+
modelOptions.forEach(option => {
|
630 |
+
option.addEventListener('click', (e) => {
|
631 |
+
e.preventDefault();
|
632 |
+
const modelId = option.dataset.modelId;
|
633 |
+
const modelName = option.querySelector('.font-medium').textContent;
|
634 |
+
|
635 |
// Update UI
|
636 |
+
if (currentModelName) {
|
637 |
+
currentModelName.textContent = modelName;
|
638 |
+
}
|
639 |
+
|
640 |
+
// Update response time estimate
|
641 |
+
if (responseTimeElement) {
|
642 |
+
if (modelId === 'qwen-4b-thinking') {
|
643 |
+
responseTimeElement.textContent = 'Response time: ~1-3s';
|
644 |
+
} else {
|
645 |
+
responseTimeElement.textContent = 'Response time: ~2-5s';
|
646 |
+
}
|
647 |
+
}
|
648 |
+
|
649 |
// Update current conversation model
|
650 |
const conversation = this.state.getCurrentConversation();
|
651 |
if (conversation) {
|
652 |
+
conversation.model = modelId;
|
|
|
|
|
653 |
this.state.saveToStorage();
|
654 |
}
|
655 |
+
|
656 |
// Update chat header
|
657 |
+
this.updateChatHeader(modelName);
|
658 |
+
|
659 |
+
// Show model change notification
|
660 |
+
this.showModelChangeNotification(modelName);
|
661 |
+
|
662 |
+
// Close dropdown
|
663 |
+
const menu = modelDropdown.querySelector('[data-dd-menu]');
|
664 |
+
if (menu) {
|
665 |
+
menu.classList.add('hidden');
|
666 |
+
}
|
667 |
});
|
668 |
});
|
669 |
}
|
670 |
|
671 |
// Show model change notification
|
672 |
+
// Show model change notification
|
673 |
showModelChangeNotification(modelName) {
|
674 |
+
if (this.showNotification) {
|
675 |
+
this.showNotification(`Přepnuto na model: ${modelName}`, "success");
|
676 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
677 |
}
|
678 |
|
679 |
+
// Update chat header with current model and conversation title
|
680 |
+
updateChatHeader(modelName, conversationTitle = null) {
|
681 |
const chatTitle = document.getElementById("chat-title");
|
682 |
const chatSubtitle = chatTitle?.nextElementSibling;
|
683 |
|
684 |
+
// Update the conversation title if provided
|
685 |
+
if (conversationTitle && chatTitle) {
|
686 |
+
chatTitle.textContent = conversationTitle;
|
687 |
+
}
|
688 |
+
|
689 |
+
// Update the subtitle with model info
|
690 |
if (chatSubtitle) {
|
691 |
chatSubtitle.textContent = `Using ${modelName} • Ready to help`;
|
692 |
}
|
|
|
791 |
this.renderMessage(userMessage);
|
792 |
}
|
793 |
|
794 |
+
// Update chat title in header if this was the first user message
|
795 |
+
const conversation = this.state.getCurrentConversation();
|
796 |
+
if (conversation && conversation.messages.filter(m => m.role === "user").length === 1) {
|
797 |
+
this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title);
|
798 |
+
}
|
799 |
+
|
800 |
// Show typing indicator
|
801 |
this.renderer.showTyping();
|
802 |
|
803 |
// Prepare conversation history for API
|
804 |
+
const conversationForHistory = this.state.getCurrentConversation();
|
805 |
+
const history = conversationForHistory
|
806 |
+
? conversation.messages.map((msg) => ({
|
807 |
+
role: msg.role,
|
808 |
+
content: msg.content,
|
809 |
+
}))
|
810 |
+
: [];
|
811 |
|
812 |
// Use the streaming chat endpoint
|
813 |
const response = await this.api.sendMessage(
|
|
|
985 |
this.renderWelcomeScreen();
|
986 |
this.updateChatHistory();
|
987 |
this.state.saveToStorage();
|
988 |
+
|
989 |
+
// Update chat header with default title
|
990 |
+
this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", "New Chat");
|
991 |
+
|
992 |
console.log("Created new conversation:", conversation.id);
|
993 |
}
|
994 |
|
|
|
1108 |
this.updateChatHistory();
|
1109 |
this.state.saveToStorage();
|
1110 |
|
1111 |
+
// Update chat header with conversation title
|
1112 |
+
this.updateChatHeader(conversation.model || "Qwen 3 Coder (Default)", conversation.title);
|
1113 |
+
|
1114 |
console.log("Loaded chat session:", sessionId);
|
1115 |
}
|
1116 |
|
|
|
1791 |
}: ${message.content}`;
|
1792 |
}
|
1793 |
}
|
1794 |
+
|
1795 |
+
|
1796 |
}
|
1797 |
|
1798 |
+
// Global function for copying code to clipboard
|
1799 |
+
window.copyToClipboard = function(button) {
|
1800 |
+
const codeBlock = button.closest('.code-block');
|
1801 |
+
const codeContent = codeBlock.querySelector('code').textContent;
|
1802 |
+
|
1803 |
+
navigator.clipboard.writeText(codeContent).then(() => {
|
1804 |
+
// Změníme ikonu na checkmark
|
1805 |
+
button.innerHTML = `
|
1806 |
+
<svg class="h-4 w-4 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
1807 |
+
<path d="M9 12l2 2 4-4"/>
|
1808 |
+
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"/>
|
1809 |
+
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"/>
|
1810 |
+
</svg>
|
1811 |
+
`;
|
1812 |
+
|
1813 |
+
// Zobrazíme notifikaci
|
1814 |
+
if (window.chatApp && window.chatApp.showNotification) {
|
1815 |
+
window.chatApp.showNotification("Kód byl zkopírován do schránky", "success");
|
1816 |
+
}
|
1817 |
+
|
1818 |
+
// Vraťme ikonu zpět po 2 sekundách
|
1819 |
+
setTimeout(() => {
|
1820 |
+
button.innerHTML = `
|
1821 |
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
1822 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
1823 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
1824 |
+
</svg>
|
1825 |
+
`;
|
1826 |
+
}, 2000);
|
1827 |
+
}).catch(err => {
|
1828 |
+
console.error('Failed to copy code:', err);
|
1829 |
+
if (window.chatApp && window.chatApp.showNotification) {
|
1830 |
+
window.chatApp.showNotification("Chyba při kopírování kódu", "error");
|
1831 |
+
}
|
1832 |
+
});
|
1833 |
};
|
public/index.html
CHANGED
@@ -45,24 +45,30 @@
|
|
45 |
<!-- Model Dropdown -->
|
46 |
<div class="relative" id="model-dd">
|
47 |
<button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800" data-dd-trigger>
|
48 |
-
Qwen 3 Coder
|
49 |
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
50 |
</button>
|
51 |
-
<div class="absolute right-0 z-40 mt-2 hidden w-
|
52 |
-
<div class="px-2 py-1 text-xs font-medium text-zinc-500">
|
53 |
-
<button class="flex w-full items-center gap-
|
54 |
-
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
55 |
-
|
|
|
|
|
|
|
56 |
</button>
|
57 |
-
<button class="flex w-full items-center gap-
|
58 |
-
<span class="w-2 h-2 bg-
|
59 |
-
|
|
|
|
|
|
|
60 |
</button>
|
61 |
<div class="my-1 h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
62 |
<div class="px-2 py-1 text-xs text-zinc-400">
|
63 |
<div class="flex items-center gap-1">
|
64 |
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
65 |
-
Response time: ~2-5s
|
66 |
</div>
|
67 |
</div>
|
68 |
</div>
|
@@ -96,7 +102,7 @@
|
|
96 |
</div>
|
97 |
<div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
98 |
<div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
|
99 |
-
<!-- chat list
|
100 |
<button class="group flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
101 |
<svg class="h-4 w-4 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12c2 0 4 1 5 3-1 2-3 3-5 3s-4-1-5-3c1-2 3-3 5-3Z"/><circle cx="12" cy="8" r="3"/></svg>
|
102 |
<div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Build Tailwind Navbar</div><div class="truncate text-xs text-zinc-500">Today</div></div>
|
@@ -112,6 +118,7 @@
|
|
112 |
<div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Write SQL for analytics</div><div class="truncate text-xs text-zinc-500">Yesterday</div></div>
|
113 |
<svg class="h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
114 |
</button>
|
|
|
115 |
</div>
|
116 |
<div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
117 |
<div class="flex items-center justify-between p-3">
|
|
|
45 |
<!-- Model Dropdown -->
|
46 |
<div class="relative" id="model-dd">
|
47 |
<button class="inline-flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-1.5 text-sm hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800" data-dd-trigger>
|
48 |
+
<span id="current-model-name">Qwen 3 Coder 30B</span>
|
49 |
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
50 |
</button>
|
51 |
+
<div class="absolute right-0 z-40 mt-2 hidden w-64 overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-sm shadow-lg dark:border-zinc-800 dark:bg-zinc-900" data-dd-menu>
|
52 |
+
<div class="px-2 py-1 text-xs font-medium text-zinc-500">Dostupné modely</div>
|
53 |
+
<button class="model-option flex w-full items-center gap-3 rounded-md px-2 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-800" data-model-id="qwen-coder-3-30b">
|
54 |
+
<span class="w-2 h-2 bg-green-500 rounded-full flex-shrink-0"></span>
|
55 |
+
<div class="flex-1 text-left">
|
56 |
+
<div class="font-medium">Qwen 3 Coder 30B</div>
|
57 |
+
<div class="text-xs text-zinc-500">Výkonný model pro programování</div>
|
58 |
+
</div>
|
59 |
</button>
|
60 |
+
<button class="model-option flex w-full items-center gap-3 rounded-md px-2 py-2 hover:bg-zinc-100 dark:hover:bg-zinc-800" data-model-id="qwen-4b-thinking">
|
61 |
+
<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
62 |
+
<div class="flex-1 text-left">
|
63 |
+
<div class="font-medium">Qwen 4B Thinking</div>
|
64 |
+
<div class="text-xs text-zinc-500">Rychlejší odlehčený model</div>
|
65 |
+
</div>
|
66 |
</button>
|
67 |
<div class="my-1 h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
68 |
<div class="px-2 py-1 text-xs text-zinc-400">
|
69 |
<div class="flex items-center gap-1">
|
70 |
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
71 |
+
<span id="model-response-time">Response time: ~2-5s</span>
|
72 |
</div>
|
73 |
</div>
|
74 |
</div>
|
|
|
102 |
</div>
|
103 |
<div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
104 |
<div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
|
105 |
+
<!-- chat list
|
106 |
<button class="group flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
107 |
<svg class="h-4 w-4 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12c2 0 4 1 5 3-1 2-3 3-5 3s-4-1-5-3c1-2 3-3 5-3Z"/><circle cx="12" cy="8" r="3"/></svg>
|
108 |
<div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Build Tailwind Navbar</div><div class="truncate text-xs text-zinc-500">Today</div></div>
|
|
|
118 |
<div class="min-w-0 flex-1"><div class="truncate text-sm font-medium">Write SQL for analytics</div><div class="truncate text-xs text-zinc-500">Yesterday</div></div>
|
119 |
<svg class="h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
120 |
</button>
|
121 |
+
-->
|
122 |
</div>
|
123 |
<div class="h-px bg-zinc-200 dark:bg-zinc-800"></div>
|
124 |
<div class="flex items-center justify-between p-3">
|
public/styles.css
CHANGED
@@ -323,4 +323,32 @@ body {
|
|
323 |
/* Dark mode toggle improvements */
|
324 |
.dark-mode-transition {
|
325 |
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
326 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
323 |
/* Dark mode toggle improvements */
|
324 |
.dark-mode-transition {
|
325 |
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
326 |
+
}
|
327 |
+
|
328 |
+
.scrollbar-thin{scrollbar-width:thin} .scrollbar-thin::-webkit-scrollbar{height:8px;width:8px} .scrollbar-thin::-webkit-scrollbar-thumb{border-radius:9999px;background-color:#c7c7d0} .scrollbar-thin::-webkit-scrollbar-track{background-color:transparent}
|
329 |
+
|
330 |
+
/* Code block styles */
|
331 |
+
.code-block {
|
332 |
+
font-family: 'Fira Code', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
|
333 |
+
}
|
334 |
+
|
335 |
+
.code-content {
|
336 |
+
line-height: 1.5;
|
337 |
+
font-size: 0.875rem;
|
338 |
+
}
|
339 |
+
|
340 |
+
.code-content code {
|
341 |
+
font-family: inherit;
|
342 |
+
background: none !important;
|
343 |
+
padding: 0 !important;
|
344 |
+
border-radius: 0 !important;
|
345 |
+
}
|
346 |
+
|
347 |
+
/* Copy button hover effect */
|
348 |
+
.copy-code-btn {
|
349 |
+
transition: all 0.2s ease;
|
350 |
+
}
|
351 |
+
|
352 |
+
.copy-code-btn:hover {
|
353 |
+
transform: scale(1.1);
|
354 |
+
}
|