| """ |
| FastAPI сервер — OpenAI-compatible REST API поверх Ollama |
| Самодостаточный файл: весь код провайдера встроен. |
| Эндпоинты: |
| GET /health |
| GET /v1/models |
| POST /v1/chat/completions (tools, stream) |
| POST /v1/embeddings |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import time |
| import uuid |
| from dataclasses import dataclass, field |
| from typing import Any, AsyncGenerator, Dict, List, Optional, Union |
|
|
| import requests |
| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse |
| from pydantic import BaseModel, Field |
|
|
| |
| |
| |
|
|
| |
| OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434") |
| OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2") |
| OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "120")) |
|
|
| |
| |
| |
|
|
| @dataclass |
| class FunctionCall: |
| name: str |
| arguments: str |
|
|
| @dataclass |
| class ToolCall: |
| id: str |
| type: str = "function" |
| function: FunctionCall = None |
|
|
| @dataclass |
| class Message: |
| role: str |
| content: Optional[str] = None |
| tool_calls: Optional[List[ToolCall]] = None |
| tool_call_id: Optional[str] = None |
|
|
| |
| |
| |
|
|
| def _messages_to_ollama(messages: List[dict]) -> List[dict]: |
| result = [] |
| for msg in messages: |
| role = msg["role"] |
| content = msg.get("content") or "" |
|
|
| if role == "tool": |
| result.append({"role": "tool", "content": content}) |
| continue |
|
|
| if role == "assistant" and msg.get("tool_calls"): |
| tcs = [] |
| for tc in msg["tool_calls"]: |
| fn = tc["function"] |
| args = fn["arguments"] |
| tcs.append({"function": { |
| "name": fn["name"], |
| "arguments": json.loads(args) if isinstance(args, str) else args, |
| }}) |
| result.append({"role": "assistant", "content": content, "tool_calls": tcs}) |
| continue |
|
|
| if isinstance(content, list): |
| texts, images = [], [] |
| for block in content: |
| if block.get("type") == "text": |
| texts.append(block["text"]) |
| elif block.get("type") == "image_url": |
| url = block["image_url"].get("url", "") |
| images.append(url.split(",", 1)[1] if url.startswith("data:") else url) |
| entry: dict = {"role": role, "content": " ".join(texts)} |
| if images: |
| entry["images"] = images |
| result.append(entry) |
| continue |
|
|
| result.append({"role": role, "content": content}) |
| return result |
|
|
|
|
| def _tools_to_ollama(tools: Optional[List[dict]]) -> Optional[List[dict]]: |
| if not tools: |
| return None |
| return [ |
| {"type": "function", "function": { |
| "name": t["function"]["name"], |
| "description": t["function"].get("description", ""), |
| "parameters": t["function"].get("parameters", {}), |
| }} |
| for t in tools if t.get("type") == "function" |
| ] |
|
|
|
|
| def _parse_message(msg: dict) -> Message: |
| tcs_raw = msg.get("tool_calls", []) |
| tool_calls = None |
| if tcs_raw: |
| tool_calls = [] |
| for tc in tcs_raw: |
| fn = tc.get("function", {}) |
| args = fn.get("arguments", {}) |
| if isinstance(args, dict): |
| args = json.dumps(args, ensure_ascii=False) |
| tool_calls.append(ToolCall( |
| id=f"call_{uuid.uuid4().hex[:8]}", |
| type="function", |
| function=FunctionCall(name=fn.get("name", ""), arguments=args), |
| )) |
| return Message( |
| role=msg.get("role", "assistant"), |
| content=msg.get("content") or None, |
| tool_calls=tool_calls, |
| ) |
|
|
|
|
| def _finish_reason(msg: dict, done: bool) -> str: |
| return "tool_calls" if msg.get("tool_calls") else ("stop" if done else "length") |
|
|
| |
| |
| |
|
|
| _session = requests.Session() |
| _session.headers.update({"Content-Type": "application/json"}) |
|
|
|
|
| def _post(path: str, payload: dict, stream: bool = False): |
| r = _session.post(f"{OLLAMA_BASE_URL}{path}", json=payload, |
| stream=stream, timeout=OLLAMA_TIMEOUT) |
| r.raise_for_status() |
| return r |
|
|
|
|
| def _get(path: str, timeout: int = 5): |
| |
| r = _session.get(f"{OLLAMA_BASE_URL}{path}", timeout=timeout) |
| r.raise_for_status() |
| return r |
|
|
| |
| |
| |
|
|
| app = FastAPI( |
| title="Ollama OpenAI-Compatible API", |
| description="OpenAI-compatible REST API backed by Ollama", |
| version="1.0.0", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| |
| |
|
|
| class _FunctionDef(BaseModel): |
| name: str |
| description: Optional[str] = None |
| parameters: Optional[Dict[str, Any]] = None |
|
|
| class _Tool(BaseModel): |
| type: str = "function" |
| function: _FunctionDef |
|
|
| class _Message(BaseModel): |
| role: str |
| content: Optional[Union[str, List[Dict[str, Any]]]] = None |
| tool_calls: Optional[List[Dict[str, Any]]] = None |
| tool_call_id: Optional[str] = None |
| name: Optional[str] = None |
|
|
| class ChatRequest(BaseModel): |
| model: str = Field(default=OLLAMA_MODEL) |
| messages: List[_Message] |
| tools: Optional[List[_Tool]] = None |
| tool_choice: Optional[Union[str, Dict[str, Any]]] = None |
| temperature: Optional[float] = None |
| top_p: Optional[float] = None |
| max_tokens: Optional[int] = None |
| stream: bool = False |
| stop: Optional[Union[str, List[str]]] = None |
| seed: Optional[int] = None |
|
|
| class EmbeddingRequest(BaseModel): |
| model: str = Field(default=OLLAMA_MODEL) |
| input: Union[str, List[str]] |
|
|
| |
| |
| |
|
|
| def _msg_to_dict(m: _Message) -> dict: |
| d: dict = {"role": m.role} |
| if m.content is not None: d["content"] = m.content |
| if m.tool_calls: d["tool_calls"] = m.tool_calls |
| if m.tool_call_id: d["tool_call_id"] = m.tool_call_id |
| if m.name: d["name"] = m.name |
| return d |
|
|
| def _tool_to_dict(t: _Tool) -> dict: |
| return {"type": t.type, "function": { |
| "name": t.function.name, |
| "description": t.function.description or "", |
| "parameters": t.function.parameters or {}, |
| }} |
|
|
| async def _sse_stream(model: str, payload: dict) -> AsyncGenerator[str, None]: |
| cid = f"chatcmpl-{uuid.uuid4().hex}" |
| created = int(time.time()) |
| with _session.post(f"{OLLAMA_BASE_URL}/api/chat", |
| json=payload, stream=True, timeout=OLLAMA_TIMEOUT) as resp: |
| resp.raise_for_status() |
| for line in resp.iter_lines(): |
| if not line: |
| continue |
| try: |
| data = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
|
|
| msg_data = data.get("message", {}) |
| done = data.get("done", False) |
| finish_reason = _finish_reason(msg_data, done) if done else None |
| delta: dict = {"role": "assistant", "content": msg_data.get("content") or None} |
|
|
| if msg_data.get("tool_calls"): |
| tcs = [] |
| for i, tc in enumerate(msg_data["tool_calls"]): |
| fn = tc.get("function", {}) |
| args = fn.get("arguments", {}) |
| if isinstance(args, dict): |
| args = json.dumps(args, ensure_ascii=False) |
| tcs.append({ |
| "index": i, "id": f"call_{uuid.uuid4().hex[:8]}", |
| "type": "function", |
| "function": {"name": fn.get("name", ""), "arguments": args}, |
| }) |
| delta["tool_calls"] = tcs |
|
|
| chunk = { |
| "id": cid, "object": "chat.completion.chunk", |
| "created": created, "model": model, |
| "choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}], |
| } |
| yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" |
| if done: |
| break |
| yield "data: [DONE]\n\n" |
|
|
| |
| |
| |
|
|
| @app.get("/health") |
| def health(): |
| try: |
| data = _get("/api/tags").json() |
| return {"status": "ok", "ollama": OLLAMA_BASE_URL, |
| "models_count": len(data.get("models", []))} |
| except Exception as e: |
| raise HTTPException(status_code=503, detail=f"Ollama недоступен: {e}") |
|
|
|
|
| @app.get("/v1/models") |
| def list_models(): |
| try: |
| data = _get("/api/tags").json() |
| return {"object": "list", "data": [ |
| {"id": m["name"], "object": "model", |
| "created": int(time.time()), "owned_by": "ollama"} |
| for m in data.get("models", []) |
| ]} |
| except Exception as e: |
| raise HTTPException(status_code=503, detail=str(e)) |
|
|
|
|
| @app.post("/v1/chat/completions") |
| async def chat_completions(req: ChatRequest): |
| messages = [_msg_to_dict(m) for m in req.messages] |
| tools = [_tool_to_dict(t) for t in req.tools] if req.tools else None |
|
|
| ollama_messages = _messages_to_ollama(messages) |
| ollama_tools = _tools_to_ollama(tools) |
|
|
| options: dict = {} |
| if req.temperature is not None: options["temperature"] = req.temperature |
| if req.top_p is not None: options["top_p"] = req.top_p |
| if req.max_tokens is not None: options["num_predict"] = req.max_tokens |
| if req.stop is not None: options["stop"] = [req.stop] if isinstance(req.stop, str) else req.stop |
| if req.seed is not None: options["seed"] = req.seed |
|
|
| payload: dict = {"model": req.model, "messages": ollama_messages, "stream": req.stream} |
| if options: payload["options"] = options |
| if ollama_tools: payload["tools"] = ollama_tools |
|
|
| if req.stream: |
| return StreamingResponse( |
| _sse_stream(req.model, payload), |
| media_type="text/event-stream", |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, |
| ) |
|
|
| try: |
| resp_data = _post("/api/chat", payload).json() |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=str(e)) |
|
|
| msg_data = resp_data.get("message", {}) |
| message = _parse_message(msg_data) |
| done = resp_data.get("done", True) |
| finish = _finish_reason(msg_data, done) |
|
|
| message_dict: dict = {"role": message.role, "content": message.content} |
| if message.tool_calls: |
| message_dict["tool_calls"] = [ |
| {"id": tc.id, "type": tc.type, |
| "function": {"name": tc.function.name, "arguments": tc.function.arguments}} |
| for tc in message.tool_calls |
| ] |
|
|
| result: dict = { |
| "id": f"chatcmpl-{uuid.uuid4().hex}", |
| "object": "chat.completion", |
| "created": int(time.time()), |
| "model": req.model, |
| "choices": [{"index": 0, "message": message_dict, "finish_reason": finish}], |
| } |
| pt = resp_data.get("prompt_eval_count", 0) |
| ct = resp_data.get("eval_count", 0) |
| if pt or ct: |
| result["usage"] = {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": pt + ct} |
|
|
| return JSONResponse(result) |
|
|
|
|
| @app.post("/v1/embeddings") |
| def embeddings(req: EmbeddingRequest): |
| texts = [req.input] if isinstance(req.input, str) else req.input |
| data_out: list = [] |
| total = 0 |
| try: |
| for i, text in enumerate(texts): |
| r = _post("/api/embed", {"model": req.model, "input": text}).json() |
| vec = r.get("embeddings", [[]])[0] |
| data_out.append({"object": "embedding", "index": i, "embedding": vec}) |
| total += r.get("prompt_eval_count", len(text.split())) |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=str(e)) |
|
|
| return {"object": "list", "data": data_out, "model": req.model, |
| "usage": {"prompt_tokens": total, "total_tokens": total}} |
|
|
|
|
| |
| |
| |
|
|
| from fastapi.responses import HTMLResponse |
|
|
| CHAT_HTML = """<!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Ollama Chat</title> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;400;500;600&display=swap'); |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| :root { |
| --bg: #0d0f14; |
| --surface: #151820; |
| --surface2: #1c2030; |
| --border: #2a2f42; |
| --accent: #7c6af7; |
| --accent2: #56cfb2; |
| --text: #e2e5f0; |
| --muted: #6b7394; |
| --user-bg: #1e2340; |
| --ai-bg: #151820; |
| --danger: #f06080; |
| --radius: 14px; |
| } |
| body { |
| font-family: 'Inter', sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| height: 100dvh; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| /* ── Header ── */ |
| header { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| padding: 14px 24px; |
| background: var(--surface); |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .logo { |
| width: 36px; height: 36px; |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); |
| border-radius: 10px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 18px; |
| } |
| header h1 { font-size: 16px; font-weight: 600; letter-spacing: .3px; } |
| header .sub { font-size: 12px; color: var(--muted); margin-left: 2px; } |
| .header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; } |
| select#modelSelect { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 6px 10px; |
| border-radius: 8px; |
| font-size: 13px; |
| font-family: 'JetBrains Mono', monospace; |
| cursor: pointer; |
| outline: none; |
| } |
| select#modelSelect:focus { border-color: var(--accent); } |
| .status-dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: var(--muted); |
| transition: background .3s; |
| } |
| .status-dot.online { background: var(--accent2); box-shadow: 0 0 6px var(--accent2); } |
| .status-dot.error { background: var(--danger); } |
| /* ── Messages ── */ |
| #messages { |
| flex: 1; |
| overflow-y: auto; |
| padding: 24px; |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| scroll-behavior: smooth; |
| } |
| #messages::-webkit-scrollbar { width: 4px; } |
| #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } |
| .msg { |
| display: flex; |
| gap: 12px; |
| max-width: 820px; |
| animation: fadeUp .25s ease; |
| } |
| @keyframes fadeUp { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .msg.user { align-self: flex-end; flex-direction: row-reverse; } |
| .msg.assistant { align-self: flex-start; } |
| .avatar { |
| width: 32px; height: 32px; border-radius: 10px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 15px; flex-shrink: 0; margin-top: 2px; |
| } |
| .msg.user .avatar { background: linear-gradient(135deg, var(--accent), #a78bfa); } |
| .msg.assistant .avatar { background: linear-gradient(135deg, var(--accent2), #3b9e8a); } |
| .bubble { |
| padding: 12px 16px; |
| border-radius: var(--radius); |
| font-size: 14.5px; |
| line-height: 1.65; |
| max-width: 680px; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| .msg.user .bubble { background: var(--user-bg); border: 1px solid #2d3560; border-bottom-right-radius: 4px; } |
| .msg.assistant .bubble { background: var(--ai-bg); border: 1px solid var(--border); border-bottom-left-radius: 4px; } |
| .bubble code { |
| font-family: 'JetBrains Mono', monospace; |
| background: #0a0c10; |
| padding: 2px 6px; |
| border-radius: 5px; |
| font-size: 13px; |
| color: var(--accent2); |
| } |
| .bubble pre { |
| background: #0a0c10; |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| padding: 14px; |
| overflow-x: auto; |
| margin: 8px 0; |
| } |
| .bubble pre code { background: none; padding: 0; color: #c8d3f5; } |
| .ts { font-size: 11px; color: var(--muted); margin-top: 5px; padding: 0 4px; } |
| .msg.user .ts { text-align: right; } |
| /* thinking dots */ |
| .thinking { display: flex; align-items: center; gap: 5px; padding: 14px 16px; } |
| .thinking span { |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--muted); |
| animation: blink 1.2s infinite; |
| } |
| .thinking span:nth-child(2) { animation-delay: .2s; } |
| .thinking span:nth-child(3) { animation-delay: .4s; } |
| @keyframes blink { |
| 0%,80%,100% { opacity: .3; transform: scale(1); } |
| 40% { opacity: 1; transform: scale(1.3); } |
| } |
| /* ── Input area ── */ |
| footer { |
| padding: 16px 24px 20px; |
| background: var(--surface); |
| border-top: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .input-row { |
| display: flex; |
| gap: 10px; |
| align-items: flex-end; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 10px 12px; |
| transition: border-color .2s; |
| } |
| .input-row:focus-within { border-color: var(--accent); } |
| textarea#input { |
| flex: 1; |
| background: transparent; |
| border: none; |
| outline: none; |
| color: var(--text); |
| font-family: 'Inter', sans-serif; |
| font-size: 14.5px; |
| line-height: 1.5; |
| resize: none; |
| max-height: 160px; |
| min-height: 24px; |
| overflow-y: auto; |
| } |
| textarea#input::placeholder { color: var(--muted); } |
| .btn-send { |
| width: 36px; height: 36px; |
| background: linear-gradient(135deg, var(--accent), #a78bfa); |
| border: none; border-radius: 10px; |
| color: #fff; cursor: pointer; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 16px; |
| transition: opacity .15s, transform .1s; |
| flex-shrink: 0; |
| } |
| .btn-send:hover { opacity: .85; } |
| .btn-send:active { transform: scale(.93); } |
| .btn-send:disabled { opacity: .35; cursor: not-allowed; } |
| .footer-hint { |
| font-size: 11.5px; color: var(--muted); |
| margin-top: 8px; text-align: center; |
| } |
| .btn-clear { |
| background: none; border: 1px solid var(--border); |
| color: var(--muted); padding: 5px 12px; |
| border-radius: 8px; cursor: pointer; font-size: 12px; |
| transition: color .15s, border-color .15s; |
| } |
| .btn-clear:hover { color: var(--danger); border-color: var(--danger); } |
| /* empty state */ |
| #empty { |
| flex: 1; display: flex; flex-direction: column; |
| align-items: center; justify-content: center; gap: 12px; |
| color: var(--muted); |
| } |
| #empty .big { font-size: 48px; } |
| #empty p { font-size: 15px; } |
| #empty small { font-size: 12px; opacity: .6; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="logo">🦙</div> |
| <div> |
| <h1>Ollama Chat</h1> |
| <div class="sub">OpenAI-compatible API</div> |
| </div> |
| <div class="header-right"> |
| <select id="modelSelect"><option>Загрузка...</option></select> |
| <div class="status-dot" id="statusDot" title="Статус соединения"></div> |
| </div> |
| </header> |
| <div id="messages"> |
| <div id="empty"> |
| <div class="big">🦙</div> |
| <p>Начни диалог с моделью</p> |
| <small>Поддерживается Markdown и блоки кода</small> |
| </div> |
| </div> |
| <footer> |
| <div class="input-row"> |
| <textarea id="input" rows="1" placeholder="Напиши сообщение... (Shift+Enter — новая строка)"></textarea> |
| <button class="btn-send" id="sendBtn" title="Отправить">➤</button> |
| </div> |
| <div class="footer-hint" style="display:flex;justify-content:space-between;align-items:center;margin-top:8px"> |
| <span>Enter — отправить · Shift+Enter — новая строка</span> |
| <button class="btn-clear" onclick="clearChat()">🗑 Очистить</button> |
| </div> |
| </footer> |
| <script> |
| const messagesEl = document.getElementById('messages'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('sendBtn'); |
| const modelSel = document.getElementById('modelSelect'); |
| const statusDot = document.getElementById('statusDot'); |
| const emptyEl = document.getElementById('empty'); |
| let history = []; // {role, content}[] |
| let busy = false; |
| |
| // ИСПРАВЛЕНИЕ: Добавлена проверка r.ok и `(e)` в catch для совместимости |
| async function loadModels() { |
| try { |
| const r = await fetch('/v1/models'); |
| if (!r.ok) throw new Error('HTTP ' + r.status); |
| const d = await r.json(); |
| const models = d.data || []; |
| modelSel.innerHTML = models.length |
| ? models.map(m => `<option value="${m.id}">${m.id}</option>`).join('') |
| : '<option value="">Нет моделей</option>'; |
| statusDot.className = 'status-dot online'; |
| } catch (e) { |
| statusDot.className = 'status-dot error'; |
| modelSel.innerHTML = '<option value="">Ошибка API</option>'; |
| } |
| } |
| loadModels(); |
| |
| function now() { |
| return new Date().toLocaleTimeString('ru', {hour:'2-digit', minute:'2-digit'}); |
| } |
| function escapeHtml(s) { |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| function renderMarkdown(raw) { |
| let s = raw.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => |
| `<pre><code class="${lang}">${escapeHtml(code.trim())}</code></pre>` |
| ); |
| s = s.replace(/`([^`]+)`/g, '<code>$1</code>'); |
| s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| s = s.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
| s = s.replace(/\n/g, '<br>'); |
| return s; |
| } |
| function addMessage(role, content, streaming = false) { |
| emptyEl && emptyEl.remove(); |
| const wrap = document.createElement('div'); |
| wrap.className = `msg ${role}`; |
| const avatar = role === 'user' ? '👤' : '🤖'; |
| const bubble = document.createElement('div'); |
| bubble.className = 'bubble'; |
| if (!streaming) bubble.innerHTML = renderMarkdown(content); |
| const ts = document.createElement('div'); |
| ts.className = 'ts'; |
| ts.textContent = now(); |
| wrap.innerHTML = `<div class="avatar">${avatar}</div>`; |
| wrap.appendChild(Object.assign(document.createElement('div'), |
| { style: 'display:flex;flex-direction:column' })); |
| wrap.lastChild.appendChild(bubble); |
| wrap.lastChild.appendChild(ts); |
| messagesEl.appendChild(wrap); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| return bubble; |
| } |
| function addThinking() { |
| const wrap = document.createElement('div'); |
| wrap.className = 'msg assistant'; |
| wrap.id = 'thinking'; |
| wrap.innerHTML = ` |
| <div class="avatar">🤖</div> |
| <div class="bubble thinking"> |
| <span></span><span></span><span></span> |
| </div>`; |
| messagesEl.appendChild(wrap); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } |
| function removeThinking() { |
| document.getElementById('thinking')?.remove(); |
| } |
| |
| async function send() { |
| const text = inputEl.value.trim(); |
| if (!text || busy) return; |
| const model = modelSel.value; |
| if (!model) return; |
| busy = true; |
| sendBtn.disabled = true; |
| inputEl.value = ''; |
| inputEl.style.height = 'auto'; |
| history.push({ role: 'user', content: text }); |
| addMessage('user', text); |
| addThinking(); |
| try { |
| const resp = await fetch('/v1/chat/completions', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| model, |
| messages: history, |
| stream: true, |
| temperature: 0.7, |
| }), |
| }); |
| removeThinking(); |
| const bubble = addMessage('assistant', '', true); |
| const reader = resp.body.getReader(); |
| const dec = new TextDecoder(); |
| let full = ''; |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| const chunk = dec.decode(value); |
| for (const line of chunk.split('\n')) { |
| if (!line.startsWith('data:')) continue; |
| const data = line.slice(5).trim(); |
| if (data === '[DONE]') break; |
| try { |
| const j = JSON.parse(data); |
| const delta = j.choices?.[0]?.delta?.content || ''; |
| full += delta; |
| bubble.innerHTML = renderMarkdown(full); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } catch (err) {} |
| } |
| } |
| history.push({ role: 'assistant', content: full }); |
| } catch (e) { |
| removeThinking(); |
| addMessage('assistant', '⚠️ Ошибка: ' + e.message); |
| } |
| busy = false; |
| sendBtn.disabled = false; |
| inputEl.focus(); |
| } |
| |
| function clearChat() { |
| history = []; |
| messagesEl.innerHTML = ''; |
| const e = document.createElement('div'); |
| e.id = 'empty'; |
| e.innerHTML = '<div class="big">🦙</div><p>Начни диалог с моделью</p><small>Поддерживается Markdown и блоки кода</small>'; |
| messagesEl.appendChild(e); |
| } |
| |
| sendBtn.addEventListener('click', send); |
| inputEl.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } |
| }); |
| inputEl.addEventListener('input', () => { |
| inputEl.style.height = 'auto'; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; |
| }); |
| </script> |
| </body> |
| </html>""" |
|
|
| @app.get("/v1/mess", response_class=HTMLResponse) |
| def chat_ui(): |
| return HTMLResponse(content=CHAT_HTML) |