Semnykcz commited on
Commit
b295e6b
·
verified ·
1 Parent(s): 04ef616

Upload 5 files

Browse files
Files changed (5) hide show
  1. app.py +261 -0
  2. app/index.html +151 -0
  3. app/script.js +125 -0
  4. app/styles.css +8 -0
  5. requirements.txt +8 -0
app.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Qwen3 Coder – FastAPI server (OpenAI-compatible /v1/chat/completions)
4
+
5
+ Refaktorováno do čisté, přehledné struktury a sloučeno s konfigurací z config.py.
6
+ - Konfigurace přes env s rozumnými defaulty (viz třída AppConfig)
7
+ - Deterministické načítání modelu/tokenizeru s volitelným prewarm přes snapshot_download
8
+ - Oddělené sekce: konfigurace, model, API schémata, routy
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ import logging
16
+ from dataclasses import dataclass
17
+ from typing import List, Optional, Dict, Any
18
+
19
+ import torch
20
+ from fastapi import FastAPI
21
+ from fastapi.responses import FileResponse, HTMLResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+ from pydantic import BaseModel
24
+ from transformers import AutoTokenizer, AutoModelForCausalLM
25
+
26
+ # =============================
27
+ # Konfigurace
28
+ # =============================
29
+
30
+ @dataclass(frozen=True)
31
+ class AppConfig:
32
+ """Aplikační konfigurace s env fallbacky.
33
+
34
+ Default hodnoty vycházejí z původního config.py:
35
+ - APP_NAME = "Qwen3 Coder"
36
+ - APP_LANG = "en"
37
+ - MODEL_ID = "Qwen/Qwen3-Coder-30B-A3B-Instruct"
38
+ - MODEL_ALIAS = "qwen3"
39
+ - PERSISTENT_DIR = "data"
40
+ Navíc:
41
+ - SNAPSHOT_DOWNLOAD ("1" / "0")
42
+ - PORT (výchozí 7860)
43
+ """
44
+
45
+ app_name: str
46
+ app_lang: str
47
+ model_id: str
48
+ model_alias: str
49
+ persistent_dir: str
50
+ snapshot_download: bool
51
+ port: int
52
+
53
+
54
+ def _env(key: str, default: Optional[str] = None) -> str:
55
+ v = os.getenv(key)
56
+ return v if v is not None else (default or "")
57
+
58
+
59
+ def make_config() -> AppConfig:
60
+ # Podpora obou názvů proměnných kvůli zpětné kompatibilitě: PERSISTENT_HOME i PERSISTENT_DIR
61
+ persistent_dir = (
62
+ os.getenv("PERSISTENT_HOME")
63
+ or os.getenv("PERSISTENT_DIR")
64
+ or "data" # default z config.py
65
+ )
66
+
67
+ return AppConfig(
68
+ app_name=_env("APP_NAME", "Qwen3 Coder"),
69
+ app_lang=_env("APP_LANG", "en"),
70
+ model_id=_env("MODEL_ID", "Qwen/Qwen3-Coder-30B-A3B-Instruct"),
71
+ model_alias=_env("MODEL_ALIAS", "qwen3"),
72
+ persistent_dir=persistent_dir,
73
+ snapshot_download=_env("SNAPSHOT_DOWNLOAD", "0") == "1",
74
+ port=int(_env("PORT", "7860") or 7860),
75
+ )
76
+
77
+
78
+ CONFIG: AppConfig = make_config()
79
+
80
+ # Logování
81
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
82
+ LOGGER = logging.getLogger("qwen3-coder")
83
+
84
+ # Absolutní cesty
85
+ PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
86
+ FRONTEND_DIR = os.path.join(PROJECT_DIR, "app")
87
+ CACHE_DIR = os.path.abspath(CONFIG.persistent_dir)
88
+ os.makedirs(CACHE_DIR, exist_ok=True)
89
+
90
+ # =============================
91
+ # Načtení modelu a tokenizeru
92
+ # =============================
93
+
94
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
95
+
96
+
97
+ def maybe_snapshot_download(model_id: str, cache_dir: str, enabled: bool) -> None:
98
+ """Volitelně stáhne snapshot modelu do cache (prewarm)."""
99
+ if not enabled:
100
+ return
101
+ try:
102
+ from huggingface_hub import snapshot_download # import on-demand
103
+
104
+ LOGGER.info("Prewarming model cache via snapshot_download …")
105
+ snapshot_download(repo_id=model_id, cache_dir=cache_dir, local_files_only=False)
106
+ LOGGER.info("Snapshot download completed")
107
+ except Exception as e:
108
+ LOGGER.warning("snapshot_download failed: %s", e)
109
+
110
+
111
+ def load_models(model_id: str, cache_dir: str):
112
+ """Načte tokenizer a model. U 30B variant vyžaduje výkonné GPU; CPU je velmi pomalé."""
113
+ LOGGER.info("Loading tokenizer '%s' (cache: %s)", model_id, cache_dir)
114
+ tokenizer = AutoTokenizer.from_pretrained(
115
+ model_id,
116
+ cache_dir=cache_dir,
117
+ trust_remote_code=True,
118
+ )
119
+
120
+ LOGGER.info("Loading model '%s' on device=%s", model_id, DEVICE)
121
+ model = AutoModelForCausalLM.from_pretrained(
122
+ model_id,
123
+ cache_dir=cache_dir,
124
+ device_map="auto" if DEVICE == "cuda" else None,
125
+ torch_dtype=(torch.float16 if DEVICE == "cuda" else torch.float32),
126
+ trust_remote_code=True,
127
+ )
128
+ if DEVICE != "cuda":
129
+ model.to(DEVICE)
130
+ model.eval()
131
+
132
+ return tokenizer, model
133
+
134
+
135
+ # Prewarm (volitelně)
136
+ maybe_snapshot_download(CONFIG.model_id, CACHE_DIR, CONFIG.snapshot_download)
137
+
138
+ # Načtení modelu/tokenizeru (synchronně při startu – zachováno jako původní chování)
139
+ TOKENIZER, MODEL = load_models(CONFIG.model_id, CACHE_DIR)
140
+
141
+ # =============================
142
+ # API schémata (OpenAI-compatible)
143
+ # =============================
144
+
145
+ class Message(BaseModel):
146
+ role: str # "system" | "user" | "assistant"
147
+ content: str
148
+
149
+
150
+ class ChatCompletionsRequest(BaseModel):
151
+ model: Optional[str] = None
152
+ messages: List[Message]
153
+ temperature: Optional[float] = 0.2
154
+ top_p: Optional[float] = 0.95
155
+ max_tokens: Optional[int] = 1024
156
+ stream: Optional[bool] = False # stream není implementován
157
+ stop: Optional[List[str]] = None
158
+
159
+
160
+ # =============================
161
+ # FastAPI aplikace a routy
162
+ # =============================
163
+
164
+ app = FastAPI(title=f"{CONFIG.app_name} ({CONFIG.model_alias})")
165
+
166
+ # Statické soubory a frontend ve složce ./app
167
+ if os.path.isdir(FRONTEND_DIR):
168
+ app.mount("/app", StaticFiles(directory=FRONTEND_DIR), name="app")
169
+
170
+
171
+ @app.get("/", response_class=HTMLResponse)
172
+ def serve_index():
173
+ """Vrátí app/index.html, pokud existuje; jinak zobrazí jednoduché info."""
174
+ index_path = os.path.join(FRONTEND_DIR, "index.html")
175
+ if os.path.exists(index_path):
176
+ return FileResponse(index_path)
177
+ return HTMLResponse(
178
+ """
179
+ <h1>Qwen3 Coder</h1>
180
+ <p>Vlož prosím frontend do složky <code>/app</code> (soubor <code>index.html</code>).</p>
181
+ """,
182
+ status_code=200,
183
+ )
184
+
185
+
186
+ @app.get("/healthz")
187
+ def healthz() -> Dict[str, Any]:
188
+ return {
189
+ "ok": True,
190
+ "app_name": CONFIG.app_name,
191
+ "lang": CONFIG.app_lang,
192
+ "model": CONFIG.model_id,
193
+ "alias": CONFIG.model_alias,
194
+ "device": DEVICE,
195
+ "cache_dir": CACHE_DIR,
196
+ }
197
+
198
+
199
+ @app.get("/v1/models")
200
+ def list_models():
201
+ return {"object": "list", "data": [{"id": CONFIG.model_id, "object": "model"}]}
202
+
203
+
204
+ @app.post("/v1/chat/completions")
205
+ def chat_completions(req: ChatCompletionsRequest):
206
+ """OpenAI-compatible Chat Completions (bez streamu)."""
207
+ # Převod zpráv na formát očekávaný chat šablonou
208
+ msgs = [{"role": m.role, "content": m.content} for m in req.messages]
209
+
210
+ input_ids = TOKENIZER.apply_chat_template(
211
+ msgs,
212
+ tokenize=True,
213
+ add_generation_prompt=True,
214
+ return_tensors="pt",
215
+ ).to(MODEL.device)
216
+
217
+ outputs = MODEL.generate(
218
+ input_ids=input_ids,
219
+ max_new_tokens=req.max_tokens or 1024,
220
+ do_sample=(req.temperature or 0) > 0,
221
+ temperature=req.temperature or 0.2,
222
+ top_p=req.top_p or 0.95,
223
+ pad_token_id=TOKENIZER.eos_token_id,
224
+ eos_token_id=TOKENIZER.eos_token_id,
225
+ use_cache=True,
226
+ )
227
+
228
+ # Nově vygenerovaná část za promptem
229
+ gen_ids = outputs[0][input_ids.shape[-1] :]
230
+ text = TOKENIZER.decode(gen_ids, skip_special_tokens=True).strip()
231
+
232
+ now = int(time.time())
233
+ usage = {
234
+ "prompt_tokens": int(input_ids.numel()),
235
+ "completion_tokens": int(gen_ids.numel()),
236
+ "total_tokens": int(input_ids.numel() + gen_ids.numel()),
237
+ }
238
+
239
+ return {
240
+ "id": f"chatcmpl-{now}",
241
+ "object": "chat.completion",
242
+ "created": now,
243
+ "model": req.model or CONFIG.model_id,
244
+ "choices": [
245
+ {
246
+ "index": 0,
247
+ "message": {"role": "assistant", "content": text},
248
+ "finish_reason": "stop",
249
+ }
250
+ ],
251
+ "usage": usage,
252
+ }
253
+
254
+
255
+ # =============================
256
+ # Lokální běh (HF Spaces spouští automaticky)
257
+ # =============================
258
+ if __name__ == "__main__":
259
+ import uvicorn
260
+
261
+ uvicorn.run(app, host="0.0.0.0", port=CONFIG.port)
app/index.html ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" class="h-full scroll-smooth">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Minimal AI Chat — Tailwind</title>
7
+
8
+ <!-- Tailwind Play CDN (compatible with Tailwind v3/v4 classes) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <link rel="stylesheet" href="styles.css">
11
+ </head>
12
+
13
+ <body class="min-h-dvh bg-neutral-50 text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
14
+ <!-- App Shell -->
15
+ <div class="grid grid-rows-[auto,1fr,auto] min-h-dvh">
16
+ <!-- Header -->
17
+ <header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:supports-[backdrop-filter]:bg-neutral-900/60 bg-white/90 dark:bg-neutral-90/90 border-b border-neutral-200/70 dark:border-neutral-800/70">
18
+ <div class="mx-auto max-w-3xl px-4 sm:px-6 pt-safe">
19
+ <div class="flex items-center justify-between pb-3 gap-3">
20
+ <div class="flex items-center gap-3 min-w-0">
21
+ <div class="size-7 rounded-xl grid place-items-center bg-neutral-900 text-white dark:bg-white dark:text-neutral-900 shadow-soft">
22
+ <!-- Dot logo -->
23
+ <svg viewBox="0 0 24 24" class="size-4" fill="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="7"/></svg>
24
+ </div>
25
+ <div class="truncate">
26
+ <h1 class="text-sm font-medium tracking-tight text-neutral-900 dark:text-neutral-100">Minimal AI Chat</h1>
27
+ <p class="text-xs text-neutral-500 dark:text-neutral-400 truncate">OriginUI‑inspired · Light / Dark</p>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="flex items-center gap-2">
32
+ <!-- New chat -->
33
+ <button id="newChatBtn" class="hidden sm:inline-flex items-center gap-2 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-xs font-medium shadow-soft hover:bg-neutral-50 dark:hover:bg-neutral-800 transition" aria-label="New chat">
34
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
35
+ New
36
+ </button>
37
+
38
+ <!-- Theme toggle -->
39
+ <button id="themeToggle" class="inline-flex items-center justify-center rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 size-9 shadow-soft hover:bg-neutral-50 dark:hover:bg-neutral-800 transition" aria-label="Toggle theme">
40
+ <svg id="iconSun" class="size-4 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
41
+ <svg id="iconMoon" class="size-4 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </header>
47
+
48
+ <!-- Messages -->
49
+ <main id="scrollArea" class="no-scrollbar overflow-y-auto">
50
+ <div class="mx-auto max-w-3xl w-full px-4 sm:px-6 py-4 sm:py-6 pb-40">
51
+ <!-- Tip / examples card -->
52
+ <section class="mb-4 sm:mb-6">
53
+ <div class="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/70 dark:bg-neutral-900/70 backdrop-blur p-4 sm:p-5 shadow-soft">
54
+ <div class="flex items-start gap-3">
55
+ <div class="shrink-0 size-7 rounded-lg grid place-items-center bg-neutral-100 dark:bg-neutral-800">
56
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
57
+ </div>
58
+ <div class="text-sm leading-6 text-neutral-700 dark:text-neutral-300">
59
+ <p class="font-medium text-neutral-900 dark:text-neutral-100">Try prompts</p>
60
+ <ul class="mt-1 list-disc pl-5 space-y-1">
61
+ <li>"Summarize this text in 3 bullet points."</li>
62
+ <li>"Explain quantum tunneling like I'm 12."</li>
63
+ <li>"Draft a polite email declining a meeting."</li>
64
+ </ul>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </section>
69
+
70
+ <div id="messages" class="space-y-3 sm:space-y-4">
71
+ <!-- Assistant message (sample) -->
72
+ <article class="group">
73
+ <div class="flex items-start gap-3">
74
+ <div class="shrink-0 size-7 rounded-lg grid place-items-center bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
75
+ <!-- Bot icon -->
76
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="8" width="18" height="10" rx="2"/><path d="M8 8V6a4 4 0 1 1 8 0v2"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
77
+ </div>
78
+ <div class="max-w-[80ch] w-full">
79
+ <div class="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
80
+ <p class="text-sm leading-7">Hi! I’m your AI assistant. Ask me anything — your messages will appear here, and I’ll reply below. This UI is built with Tailwind and inspired by OriginUI’s clean, minimal aesthetic.</p>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </article>
85
+
86
+ <!-- User message (sample) -->
87
+ <article class="group">
88
+ <div class="flex items-start gap-3 justify-end">
89
+ <div class="max-w-[80ch] w-full">
90
+ <div class="rounded-2xl border border-neutral-200 bg-neutral-900 text-neutral-50 dark:bg-white dark:text-neutral-900 p-4 shadow-soft">
91
+ <p class="text-sm leading-7">Give me a short productivity tip.</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </article>
96
+
97
+ <!-- Assistant message (sample) -->
98
+ <article class="group">
99
+ <div class="flex items-start gap-3">
100
+ <div class="shrink-0 size-7 rounded-lg grid place-items-center bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
101
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="8" width="18" height="10" rx="2"/><path d="M8 8V6a4 4 0 1 1 8 0v2"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
102
+ </div>
103
+ <div class="max-w-[80ch] w-full">
104
+ <div class="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
105
+ <ul class="text-sm leading-7 list-disc pl-5 space-y-1">
106
+ <li>Time‑box tasks to 25 minutes.</li>
107
+ <li>Mute non‑urgent notifications.</li>
108
+ <li>Write the next step before you stop.</li>
109
+ </ul>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </article>
114
+ </div>
115
+ </div>
116
+ </main>
117
+
118
+ <!-- Composer -->
119
+ <footer class="fixed bottom-0 inset-x-0 z-40">
120
+ <div class="pointer-events-none bg-gradient-to-t from-white/95 to-white/0 dark:from-neutral-950/95 dark:to-neutral-950/0">
121
+ <div class="mx-auto max-w-3xl px-4 sm:px-6 pb-safe">
122
+ <form id="composer" class="pointer-events-auto" autocomplete="off">
123
+ <div class="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-soft p-2 sm:p-2.5">
124
+ <div class="flex items-end gap-2">
125
+ <button type="button" id="attachBtn" class="shrink-0 grid place-items-center size-9 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition" aria-label="Attach">
126
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-8.49 8.49a5 5 0 1 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.19 9.19a2 2 0 1 1-2.83-2.83l8.49-8.49"/></svg>
127
+ </button>
128
+
129
+ <textarea id="input" rows="1" placeholder="Message…" class="flex-1 resize-none bg-transparent focus:outline-none placeholder:text-neutral-400 text-sm leading-6 max-h-40 p-2"></textarea>
130
+
131
+ <div class="flex items-center gap-2">
132
+ <button type="button" id="queueBtn" class="hidden sm:grid place-items-center size-9 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition" aria-label="Add to queue">
133
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M3 12h12M3 18h6"/></svg>
134
+ </button>
135
+
136
+ <button type="submit" class="grid place-items-center size-9 rounded-xl bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 transition" aria-label="Send">
137
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </form>
143
+ <p class="pointer-events-auto mt-2 text-[11px] text-neutral-500 dark:text-neutral-400 text-center">This is a static UI. Wire it to your API to make it talk.</p>
144
+ </div>
145
+ </div>
146
+ </footer>
147
+ </div>
148
+
149
+ <script src="script.js"></script>
150
+ </body>
151
+ </html>
app/script.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---- Theme: auto + toggle ----
2
+ const root = document.documentElement;
3
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
4
+ const savedTheme = localStorage.getItem('theme');
5
+ const isDark = () => root.classList.contains('dark');
6
+
7
+ function applyTheme(theme) {
8
+ if (theme === 'dark' || (theme === 'auto' && prefersDark.matches)) {
9
+ root.classList.add('dark');
10
+ } else {
11
+ root.classList.remove('dark');
12
+ }
13
+ updateThemeIcon();
14
+ }
15
+
16
+ function updateThemeIcon () {
17
+ const sun = document.getElementById('iconSun');
18
+ const moon = document.getElementById('iconMoon');
19
+ if (isDark()) { moon.classList.add('hidden'); sun.classList.remove('hidden'); }
20
+ else { sun.classList.add('hidden'); moon.classList.remove('hidden'); }
21
+ }
22
+
23
+ applyTheme(savedTheme || 'auto');
24
+ prefersDark.addEventListener('change', () => applyTheme(localStorage.getItem('theme') || 'auto'));
25
+ document.getElementById('themeToggle').addEventListener('click', () => {
26
+ const next = isDark() ? 'light' : 'dark';
27
+ localStorage.setItem('theme', next);
28
+ applyTheme(next);
29
+ });
30
+
31
+ // ---- Composer logic (static demo) ----
32
+ const form = document.getElementById('composer');
33
+ const input = document.getElementById('input');
34
+ const messages = document.getElementById('messages');
35
+ const scrollArea = document.getElementById('scrollArea');
36
+
37
+ function autoResizeTextarea(el) {
38
+ el.style.height = '0px';
39
+ const h = Math.min(el.scrollHeight, 320);
40
+ el.style.height = h + 'px';
41
+ }
42
+ input.addEventListener('input', () => autoResizeTextarea(input));
43
+ autoResizeTextarea(input);
44
+
45
+ function scrollToBottom() {
46
+ requestAnimationFrame(() => scrollArea.scrollTo({ top: scrollArea.scrollHeight, behavior: 'smooth' }));
47
+ }
48
+
49
+ function bubble({ role, html }) {
50
+ const wrapper = document.createElement('article');
51
+ wrapper.className = 'group';
52
+ if (role === 'user') {
53
+ wrapper.innerHTML = `
54
+ <div class="flex items-start gap-3 justify-end">
55
+ <div class="max-w-[80ch] w-full">
56
+ <div class="rounded-2xl border border-neutral-200 bg-neutral-900 text-neutral-50 dark:bg-white dark:text-neutral-900 p-4 shadow-soft">
57
+ <div class="text-sm leading-7">${html}</div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ `;
62
+ } else {
63
+ wrapper.innerHTML = `
64
+ <div class="flex items-start gap-3">
65
+ <div class="shrink-0 size-7 rounded-lg grid place-items-center bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
66
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="8" width="18" height="10" rx="2"/><path d="M8 8V6a4 4 0 1 1 8 0v2"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
67
+ </div>
68
+ <div class="max-w-[80ch] w-full">
69
+ <div class="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
70
+ <div class="text-sm leading-7">${html}</div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ `;
75
+ }
76
+ return wrapper;
77
+ }
78
+
79
+ function fakeAssistantResponse(prompt) {
80
+ // Very small, friendly canned reply to prove UI flow.
81
+ const ideas = [
82
+ 'Noted! You can connect this UI to your backend by POSTing messages to your API and streaming token updates into the last assistant bubble.',
83
+ 'Tip: Press Ctrl/Cmd + Enter to send quickly.',
84
+ 'You can store theme preference in localStorage (already done here).',
85
+ 'OriginUI vibes achieved with soft borders, neutral palette and subtle shadows.'
86
+ ];
87
+ const pick = ideas[Math.floor(Math.random() * ideas.length)];
88
+ return `You said: <em>${prompt.replace(/</g,'<')}</em><br/>${pick}`;
89
+ }
90
+
91
+ form.addEventListener('submit', (e) => {
92
+ e.preventDefault();
93
+ const text = input.value.trim();
94
+ if (!text) return;
95
+
96
+ messages.appendChild(bubble({ role: 'user', html: text.replace(/</g,'<') }));
97
+ input.value = '';
98
+ autoResizeTextarea(input);
99
+ scrollToBottom();
100
+
101
+ const typing = bubble({ role: 'assistant', html: '<span class=\'inline-flex items-center gap-2\'><span class=\'size-2 rounded-full bg-current animate-pulse\'></span><span class=\'text-neutral-500 dark:text-neutral-400\'>Thinking…</span></span>' });
102
+ messages.appendChild(typing);
103
+ scrollToBottom();
104
+
105
+ setTimeout(() => {
106
+ typing.remove();
107
+ messages.appendChild(bubble({ role: 'assistant', html: fakeAssistantResponse(text) }));
108
+ scrollToBottom();
109
+ }, 550);
110
+ });
111
+
112
+ // Keyboard shortcut: Cmd/Ctrl+Enter to send
113
+ input.addEventListener('keydown', (e) => {
114
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
115
+ form.requestSubmit();
116
+ }
117
+ });
118
+
119
+ // New chat clears messages (keeps tips)
120
+ document.getElementById('newChatBtn')?.addEventListener('click', () => {
121
+ const items = [...messages.querySelectorAll('article')];
122
+ // Keep only the very first assistant tip block (index 0..2 are demo). Remove others.
123
+ items.slice(3).forEach(n => n.remove());
124
+ scrollArea.scrollTo({ top: 0, behavior: 'smooth' });
125
+ });
app/styles.css ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /* Respect iOS notch / safe-area on mobile */
2
+ .pb-safe { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
3
+ .pt-safe { padding-top: max(0.75rem, env(safe-area-inset-top)); }
4
+ /* Hide scrollbar on WebKit (messages list looks cleaner) */
5
+ .no-scrollbar::-webkit-scrollbar { display: none; }
6
+ .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
7
+ /* Smooth textarea autoresize transitions */
8
+ textarea { transition: height 120ms ease; }
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.116.1
2
+ uvicorn==0.35.0
3
+ transformers>=4.55.3
4
+ torch==2.4.0
5
+ accelerate>=0.33.0
6
+ einops
7
+ safetensors
8
+ # bitsandbytes>=0.43.1 # jen pokud chceš 4-bit kvantizaci