# app.py # ✅ HTMX + FastAPI — light + fast # ✅ Kurdish (Badini) texts kept exactly # ✅ Faster preset: # MAX_MSGS = 12 # thinking_level="minimal" # max_output_tokens=512 # # Docker CMD example: # uvicorn app:app --host 0.0.0.0 --port 7860 import os import time import uuid import html as _html from typing import Dict, List, Any from fastapi import FastAPI, Request, Response, Form from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse from google import genai from google.genai import types # =================== API SETUP =================== API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") if not API_KEY: raise RuntimeError("❌ GOOGLE_API_KEY or GEMINI_API_KEY not set") client = genai.Client(api_key=API_KEY) MODEL_ID = "gemini-3-flash-preview" # =================== BADINI SYSTEM PROMPT =================== BADINI_SYSTEM = """ تو یارمەتیدەرێکی زیرەک و پیشەیی یێ زمانێ کوردیی بادینی (دهۆکێ) یی. گرنگ: 1) هەموو وەڵامان تەنێ بە شێوەزارێ بادینی و بە نووسینا کوردی-عەرەبی بدە. 2) قەدەغەیە بە زمانی تر وەڵام بدەی. 3) قەدەغەیە پەیڤێن سۆرانی یێن وەک (دەبێت، دەکرێت، جێبەجێکردن، هەروەها، بەڵام) بکاربێنیت. 4) ناڤێن تایبەت، URL، ئیمەیڵ، ژمارەکان و کۆد وەک خۆیان بهێڵە. 5) چ سلاڤ و دەستخۆشییان، چ لێدوانێ زێدە د وەڵامدا نەنڤێسە. """.strip() # ✅ faster context window MAX_MSGS = 12 # =================== FASTAPI APP =================== app = FastAPI() # In-memory sessions (simple + fast). For production, swap to Redis/DB. SESSIONS: Dict[str, Dict[str, Any]] = {} # sid -> {"history":[...], "created":...} def _escape(s: str) -> str: return _html.escape(s or "") def _nl2br_escaped(s: str) -> str: return _escape(s).replace("\n", "
") def get_sid(req: Request) -> str: sid = req.cookies.get("sid") if not sid: sid = uuid.uuid4().hex if sid not in SESSIONS: SESSIONS[sid] = {"history": [], "created": time.time()} return sid def trim_history(history: List[dict]) -> List[dict]: # keep last MAX_MSGS user+assistant pairs keep = MAX_MSGS * 2 if len(history) > keep: return history[-keep:] return history def build_context_for_model(history: List[dict], new_user_input: str) -> str: # only send last MAX_MSGS messages into prompt (faster) context_messages = (history or [])[-MAX_MSGS:] lines = [] for msg in context_messages: role = msg.get("role", "") content = (msg.get("content", "") or "").strip() if not content: continue if role == "user": lines.append(f"User: {content}") elif role == "assistant": lines.append(f"Assistant: {content}") lines.append(f"User: {new_user_input}") lines.append("Assistant:") return "\n".join(lines) # =================== UI (HTML + CSS + minimal JS) =================== CSS = """ :root{ --bg:#0b0f14; --panel: rgba(16,20,28,.86); --border: rgba(255,255,255,.10); --text:#e6edf3; --muted: rgba(230,237,243,.60); --acc:#10a37f; --acc2: rgba(16,163,127,.14); --u: rgba(255,255,255,.07); } html, body { height:100%; overflow:hidden; background:var(--bg); color:var(--text); font-family: 'Noto Kufi Arabic', sans-serif; } *{ box-sizing:border-box; } a{ color:inherit; } #app{ position: fixed; inset:0; display:flex; justify-content:center; background: radial-gradient(1100px 560px at 45% -10%, rgba(16,163,127,.22), transparent 60%), radial-gradient(900px 520px at 95% 10%, rgba(255,255,255,.06), transparent 55%), var(--bg); } #shell{ width:min(980px,100%); height:100%; display:flex; flex-direction:column; } #topbar{ height:64px; display:flex; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid var(--border); background: var(--panel); backdrop-filter: blur(10px); } .brand .t{ font-weight:950; font-size:1.10rem; } .brand .s{ color:var(--muted); font-size:.90rem; margin-top:2px; } .actions{ display:flex; gap:10px; align-items:center; } .btn{ background: transparent; border:1px solid var(--border); color: var(--text); border-radius: 12px; padding: 8px 12px; font-weight: 900; cursor:pointer; } .btn.primary{ background: var(--acc); border:none; color:#00140d; border-radius: 16px; padding: 12px 16px; min-height:52px; font-weight:1000; } #chatwrap{ flex:1; position:relative; overflow:hidden; } #chat-scroll{ position:absolute; inset:0; overflow-y:auto; overflow-x:hidden; padding: 16px 14px 18px 14px; } .chat-inner{ display:flex; flex-direction:column; gap:14px; padding-bottom:14px; } .row{ width:100%; display:flex; gap:10px; align-items:flex-end; } .row.user{ justify-content:flex-end; } .row.bot{ justify-content:flex-start; } .avatar{ width:34px; height:34px; border-radius:12px; background: rgba(255,255,255,.08); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; font-weight: 950; } .bubble{ max-width:78%; border:1px solid var(--border); border-radius:18px; padding: 12px 14px; line-height:1.55; font-size:1.02rem; word-break: break-word; box-shadow: 0 10px 26px rgba(0,0,0,.25); } .bubble.user{ background: var(--u); } .bubble.bot{ background: var(--acc2); border-color: rgba(16,163,127,.35); } #bottombar{ padding: 12px 14px; border-top:1px solid var(--border); background: var(--panel); backdrop-filter: blur(10px); } #composer{ display:flex; gap:10px; align-items:flex-end; } textarea{ width:100%; background: rgba(255,255,255,.06); border:1px solid var(--border); border-radius:16px; color: var(--text); padding:12px; font-size:1.02rem; min-height:52px; line-height:1.45; resize:none; outline:none; } #downbtn{ position:absolute; right: 16px; bottom: 96px; z-index: 50; display:none; } #downbtn button{ background: rgba(16,163,127,.96); border:none; color:#00140d; border-radius: 999px; padding: 10px 12px; font-weight: 1000; box-shadow: 0 14px 30px rgba(0,0,0,.35); cursor:pointer; } .notranslate, .notranslate * { -webkit-translate: no !important; translate: no !important; } """ JS = r""" """ def page_html() -> str: return f""" KurdGPT - AI Tutor
KurdGPT
Gemini 3 Preview • وەڵام تەنێ بە بادینی
{JS} """ def user_row_html(text: str) -> str: safe = _nl2br_escaped(text) return f"""
{safe}
""" def bot_row_html(msg_id: str, initial: str = "") -> str: safe = _nl2br_escaped(initial) return f"""
G
{safe}
""" def sse_connector_html(sid: str, msg_id: str) -> str: return f"""
""" # =================== ROUTES =================== @app.get("/", response_class=HTMLResponse) def home(request: Request, response: Response): sid = get_sid(request) response.set_cookie("sid", sid, httponly=True, samesite="lax") return page_html() @app.post("/clear", response_class=HTMLResponse) def clear(request: Request): sid = get_sid(request) SESSIONS[sid]["history"] = [] return HTMLResponse('
') @app.get("/export") def export_txt(request: Request): sid = get_sid(request) history = SESSIONS[sid]["history"] assistant_texts = [ m.get("content", "") for m in (history or []) if m.get("role") == "assistant" and (m.get("content", "") or "").strip() ] content = "\n\n".join(assistant_texts).strip() filename = f"badini_export_{int(time.time())}.txt" headers = {"Content-Disposition": f'attachment; filename="{filename}"'} return PlainTextResponse(content, headers=headers) @app.post("/send", response_class=HTMLResponse) def send(request: Request, msg: str = Form(default="")): sid = get_sid(request) msg = (msg or "").strip() if not msg: return HTMLResponse("") history = SESSIONS[sid]["history"] msg_id = uuid.uuid4().hex history.append({"role": "user", "content": msg}) history.append({"role": "assistant", "content": ""}) # ✅ trim stored history too (faster over time) SESSIONS[sid]["history"] = trim_history(history) # ✅ show placeholder immediately (UI feels instant) html = ( user_row_html(msg) + bot_row_html(msg_id, "چاڤەرێبە...") + sse_connector_html(sid, msg_id) + '\n
' ) return HTMLResponse(html) @app.get("/stream") def stream(request: Request, sid: str, msg_id: str): if sid not in SESSIONS: SESSIONS[sid] = {"history": [], "created": time.time()} history = SESSIONS[sid]["history"] if len(history) < 2: def empty_gen(): yield "event: message\ndata: \n\n" return StreamingResponse(empty_gen(), media_type="text/event-stream") user_input = history[-2].get("content", "") ctx = build_context_for_model(history[:-2], user_input) def sse_pack_oob(html_fragment: str) -> str: data = html_fragment.replace("\n", " ") return f"event: message\ndata: {data}\n\n" def gen(): full = "" def push_full(): frag = f'
{_nl2br_escaped(full)}
' return sse_pack_oob(frag) try: # ✅ faster config cfg_fast = types.GenerateContentConfig( system_instruction=BADINI_SYSTEM, temperature=0.3, top_p=0.9, max_output_tokens=512, ) # Some SDK/envs may not accept thinking_level; fallback safely. try: stream_it = client.models.generate_content_stream( model=MODEL_ID, contents=ctx, config=cfg_fast, ) except Exception: cfg_fallback = types.GenerateContentConfig( system_instruction=BADINI_SYSTEM, temperature=0.2, top_p=0.8, max_output_tokens=2048, ) stream_it = client.models.generate_content_stream( model=MODEL_ID, contents=ctx, config=cfg_fallback, ) buffer = "" n = 0 for chunk in stream_it: t = getattr(chunk, "text", "") or "" if not t: continue full += t buffer += t n += 1 history[-1]["content"] = full if len(buffer) >= 24 or n % 3 == 0: yield push_full() buffer = "" # final update + remove SSE connector kill_sse = f'
' anchor = '
' yield sse_pack_oob( f'
{_nl2br_escaped(full)}
' + kill_sse + anchor ) except Exception as e: err = f"❌ کێشە: {str(e)}" history[-1]["content"] = err kill_sse = f'
' frag = f'
{_nl2br_escaped(err)}
' yield sse_pack_oob(frag + kill_sse) return StreamingResponse(gen(), media_type="text/event-stream")