Files
Heimgeist/backend/ollama_client.py

373 lines
13 KiB
Python
Raw Normal View History

2026-06-15 15:02:39 +02:00
import asyncio
2025-08-22 23:42:34 +02:00
import httpx
feat: Add streaming chat + scroll persistence; improve markdown & links Backend - /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged. - ChatRequest: add stream flag (default false). - GenerateTitleRequest: add model and use it instead of hardcoded llama3. - ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats). - Remove response_model from /chat to allow streaming; non-stream still returns { reply }. Electron - Open external links in system browser (setWindowOpenHandler, shell.openExternal). - New IPC: update-settings, open-external-link. - Set minimum window size; preload exposes updateSettings and openExternalLink. Frontend (React) - Streaming UI with live chunking; sticky-bottom only when user at bottom. - Per-session scroll persistence and robust restore. - New message tip to jump to latest reply when scrolled up. - Disable Send while sending; spinner. - General Settings: stream output toggle; propagate model/stream changes. - Apply color scheme at boot; extract colorSchemes helper. - Sidebar UX tweaks and unread badges. Markdown/rendering - Code blocks: language title bar and wrapper. - Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment. - Headings: remove blank line after h1-h4. - <hr>: handle after tables; strip following whitespace. - Links: target=_blank with icon and URL tooltip. Styles - Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar. API notes / breaking changes - /chat accepts stream=true and returns text/plain streamed chunks. - generate-title now requires a model. - Non-stream /chat response shape unchanged.
2025-08-23 16:45:46 +02:00
import json
import re
import time
from dataclasses import dataclass, field
from typing import Dict, Any, List, AsyncGenerator, Optional, Tuple
2025-08-22 23:42:34 +02:00
from .app_settings import get_ollama_api_url
2025-08-22 23:42:34 +02:00
_MODEL_DETAILS_CACHE: Dict[Tuple[str, str], Tuple[float, Dict[str, Any]]] = {}
_MODEL_DETAILS_TTL_S = 15.0
@dataclass
class OllamaToolCall:
name: str
arguments: Dict[str, Any]
raw: Dict[str, Any] = field(default_factory=dict)
@dataclass
class OllamaUsage:
prompt_eval_count: int = 0
eval_count: int = 0
total_duration: int = 0
load_duration: int = 0
prompt_eval_duration: int = 0
eval_duration: int = 0
@dataclass
class OllamaChatResult:
content: str = ""
thinking: str = ""
tool_calls: List[OllamaToolCall] = field(default_factory=list)
usage: OllamaUsage = field(default_factory=OllamaUsage)
done_reason: Optional[str] = None
raw: Dict[str, Any] = field(default_factory=dict)
@dataclass
class OllamaStreamChunk:
content: str = ""
thinking: str = ""
tool_calls: List[OllamaToolCall] = field(default_factory=list)
done: bool = False
usage: OllamaUsage = field(default_factory=OllamaUsage)
raw: Dict[str, Any] = field(default_factory=dict)
def _cache_key(model: str) -> Tuple[str, str]:
ollama_url = get_ollama_api_url()
return (ollama_url.rstrip('/'), str(model or '').strip())
def _get_cached_model_details(model: str) -> Dict[str, Any]:
cached = _MODEL_DETAILS_CACHE.get(_cache_key(model))
return cached[1] if cached else {}
def _string_tokens(value: Any) -> List[str]:
if isinstance(value, str):
trimmed = value.strip()
return [trimmed] if trimmed else []
if isinstance(value, dict):
out: List[str] = []
for key, item in value.items():
out.extend(_string_tokens(key))
out.extend(_string_tokens(item))
return out
if isinstance(value, (list, tuple, set)):
out: List[str] = []
for item in value:
out.extend(_string_tokens(item))
return out
return []
def _normalize_capabilities(model_data: Dict[str, Any]) -> List[str]:
out = []
for item in model_data.get("capabilities") or []:
text = str(item).strip().lower()
if text and text not in out:
out.append(text)
return out
def _combined_model_tokens(name: str, model_data: Dict[str, Any], tag_item: Dict[str, Any]) -> str:
return " ".join(
token.lower()
for token in _string_tokens(name) + _string_tokens(tag_item.get("details")) + _string_tokens(model_data)
)
def _is_embedding_model(name: str, model_data: Dict[str, Any], tag_item: Dict[str, Any]) -> bool:
capabilities = set(_normalize_capabilities(model_data))
if "embedding" in capabilities or "embeddings" in capabilities:
return True
lowered_tokens = _combined_model_tokens(name, model_data, tag_item)
return any(
marker in lowered_tokens
for marker in (
" embed ",
" embedding ",
"embed-",
"-embed",
"nomic-embed",
"mxbai-embed",
"snowflake-arctic-embed",
"bge-m3",
"bge ",
)
) or lowered_tokens.startswith("bge")
def _is_rerank_model(name: str, model_data: Dict[str, Any], tag_item: Dict[str, Any]) -> bool:
lowered_tokens = _combined_model_tokens(name, model_data, tag_item)
return (
_is_embedding_model(name, model_data, tag_item)
or "rerank" in lowered_tokens
or "cross-encoder" in lowered_tokens
)
def _supports_vision_fast(name: str, model_data: Dict[str, Any], tag_item: Dict[str, Any]) -> bool:
if supports_vision(model_data):
return True
lowered_tokens = _combined_model_tokens(name, model_data, tag_item)
return any(
marker in lowered_tokens
for marker in (
" vision ",
"-vision",
" vision-",
"vision:",
"llava",
"bakllava",
"moondream",
"minicpm-v",
"minicpmv",
"pixtral",
"qwen-vl",
"qwen2vl",
"qwen2.5vl",
"qwen2.5-omni",
"granite3.2-vision",
"llama3.2-vision",
"gemma3",
"gemma4",
"-vl",
" vl ",
)
)
def _build_model_catalog_entry(tag_item: Dict[str, Any], model_data: Dict[str, Any]) -> Dict[str, Any]:
name = str((tag_item or {}).get("name") or "").strip()
capabilities = _normalize_capabilities(model_data)
is_embedding = _is_embedding_model(name, model_data, tag_item)
is_rerank = _is_rerank_model(name, model_data, tag_item)
has_vision = _supports_vision_fast(name, model_data, tag_item)
lowered_tokens = _combined_model_tokens(name, model_data, tag_item)
is_non_chat = is_embedding or "rerank" in lowered_tokens or "cross-encoder" in lowered_tokens
return {
"name": name,
"capabilities": capabilities,
"supports_vision": has_vision,
"is_embedding": is_embedding,
"can_chat": not is_non_chat,
"can_rerank": is_rerank,
}
2025-08-22 23:42:34 +02:00
async def list_models() -> Dict[str, Any]:
ollama_url = get_ollama_api_url()
2025-08-22 23:42:34 +02:00
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{ollama_url}/api/tags")
2025-08-22 23:42:34 +02:00
r.raise_for_status()
data = r.json()
# Normalize to a simple list of names
models = [m.get('name') for m in data.get('models', [])]
return {"models": models}
async def list_model_catalog() -> Dict[str, Any]:
ollama_url = get_ollama_api_url()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{ollama_url}/api/tags")
r.raise_for_status()
payload = r.json()
raw_models = payload.get("models", []) or []
models = [
_build_model_catalog_entry(
item or {},
_get_cached_model_details(str((item or {}).get("name") or "").strip()),
)
for item in raw_models
if str((item or {}).get("name") or "").strip()
]
return {
"models": models,
"chat_models": [model["name"] for model in models if model["can_chat"]],
"embedding_models": [model["name"] for model in models if model["is_embedding"]],
"vision_models": [model["name"] for model in models if model["supports_vision"]],
"reranking_models": [model["name"] for model in models if model["can_rerank"]],
}
async def show_model(model: str, *, refresh: bool = False) -> Dict[str, Any]:
ollama_url = get_ollama_api_url()
cache_key = (ollama_url.rstrip('/'), str(model or '').strip())
cached = _MODEL_DETAILS_CACHE.get(cache_key)
now = time.monotonic()
if not refresh and cached and (now - cached[0]) < _MODEL_DETAILS_TTL_S:
return cached[1]
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{ollama_url}/api/show", json={"model": model})
r.raise_for_status()
data = r.json()
_MODEL_DETAILS_CACHE[cache_key] = (now, data)
return data
def supports_vision(model_data: Dict[str, Any]) -> bool:
capabilities = model_data.get("capabilities") or []
if any(str(item).strip().lower() == "vision" for item in capabilities):
return True
model_info = model_data.get("model_info") or {}
if isinstance(model_info, dict):
for key in model_info.keys():
lowered = str(key).strip().lower()
if ".vision." in lowered or lowered.endswith(".vision"):
return True
if lowered.endswith("tokens_per_image") or re.search(r"\bmm\b", lowered):
return True
return False
def _parse_tool_calls(value: Any) -> List[OllamaToolCall]:
calls: List[OllamaToolCall] = []
for item in value or []:
if not isinstance(item, dict):
continue
function = item.get("function") if isinstance(item.get("function"), dict) else item
name = str(function.get("name") or "").strip()
arguments = function.get("arguments") or {}
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except Exception:
arguments = {}
if name and isinstance(arguments, dict):
calls.append(OllamaToolCall(name=name, arguments=arguments, raw=item))
return calls
def _usage_from_payload(data: Dict[str, Any]) -> OllamaUsage:
return OllamaUsage(
prompt_eval_count=int(data.get("prompt_eval_count") or 0),
eval_count=int(data.get("eval_count") or 0),
total_duration=int(data.get("total_duration") or 0),
load_duration=int(data.get("load_duration") or 0),
prompt_eval_duration=int(data.get("prompt_eval_duration") or 0),
eval_duration=int(data.get("eval_duration") or 0),
)
def _chat_payload(
model: str,
messages: List[Dict[str, Any]],
*,
stream: bool,
options: Optional[Dict[str, Any]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
format: Optional[Any] = None,
think: Optional[bool] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {"model": model, "messages": messages, "stream": stream}
if options:
payload["options"] = options
if tools:
payload["tools"] = tools
if format is not None:
payload["format"] = format
if think is not None:
payload["think"] = bool(think)
return payload
async def chat_typed(
model: str,
messages: List[Dict[str, Any]],
*,
options: Dict[str, Any] | None = None,
tools: Optional[List[Dict[str, Any]]] = None,
format: Optional[Any] = None,
think: Optional[bool] = None,
cancellation_event: Optional[Any] = None,
) -> OllamaChatResult:
ollama_url = get_ollama_api_url()
if cancellation_event is not None and cancellation_event.is_set():
raise asyncio.CancelledError()
payload = _chat_payload(model, messages, stream=False, options=options, tools=tools, format=format, think=think)
2025-08-22 23:42:34 +02:00
async with httpx.AsyncClient(timeout=600.0) as client:
r = await client.post(f"{ollama_url}/api/chat", json=payload)
2025-08-22 23:42:34 +02:00
r.raise_for_status()
data = r.json()
message = data.get("message") if isinstance(data.get("message"), dict) else {}
if not message and data.get("messages"):
message = data["messages"][-1] or {}
return OllamaChatResult(
content=str(message.get("content") or data.get("content") or ""),
thinking=str(message.get("thinking") or data.get("thinking") or ""),
tool_calls=_parse_tool_calls(message.get("tool_calls") or data.get("tool_calls")),
usage=_usage_from_payload(data),
done_reason=data.get("done_reason"),
raw=data,
)
feat: Add streaming chat + scroll persistence; improve markdown & links Backend - /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged. - ChatRequest: add stream flag (default false). - GenerateTitleRequest: add model and use it instead of hardcoded llama3. - ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats). - Remove response_model from /chat to allow streaming; non-stream still returns { reply }. Electron - Open external links in system browser (setWindowOpenHandler, shell.openExternal). - New IPC: update-settings, open-external-link. - Set minimum window size; preload exposes updateSettings and openExternalLink. Frontend (React) - Streaming UI with live chunking; sticky-bottom only when user at bottom. - Per-session scroll persistence and robust restore. - New message tip to jump to latest reply when scrolled up. - Disable Send while sending; spinner. - General Settings: stream output toggle; propagate model/stream changes. - Apply color scheme at boot; extract colorSchemes helper. - Sidebar UX tweaks and unread badges. Markdown/rendering - Code blocks: language title bar and wrapper. - Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment. - Headings: remove blank line after h1-h4. - <hr>: handle after tables; strip following whitespace. - Links: target=_blank with icon and URL tooltip. Styles - Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar. API notes / breaking changes - /chat accepts stream=true and returns text/plain streamed chunks. - generate-title now requires a model. - Non-stream /chat response shape unchanged.
2025-08-23 16:45:46 +02:00
async def chat_stream_typed(
model: str,
messages: List[Dict[str, Any]],
*,
options: Optional[Dict[str, Any]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
format: Optional[Any] = None,
think: Optional[bool] = None,
cancellation_event: Optional[Any] = None,
) -> AsyncGenerator[OllamaStreamChunk, None]:
ollama_url = get_ollama_api_url()
payload = _chat_payload(model, messages, stream=True, options=options, tools=tools, format=format, think=think)
feat: Add streaming chat + scroll persistence; improve markdown & links Backend - /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged. - ChatRequest: add stream flag (default false). - GenerateTitleRequest: add model and use it instead of hardcoded llama3. - ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats). - Remove response_model from /chat to allow streaming; non-stream still returns { reply }. Electron - Open external links in system browser (setWindowOpenHandler, shell.openExternal). - New IPC: update-settings, open-external-link. - Set minimum window size; preload exposes updateSettings and openExternalLink. Frontend (React) - Streaming UI with live chunking; sticky-bottom only when user at bottom. - Per-session scroll persistence and robust restore. - New message tip to jump to latest reply when scrolled up. - Disable Send while sending; spinner. - General Settings: stream output toggle; propagate model/stream changes. - Apply color scheme at boot; extract colorSchemes helper. - Sidebar UX tweaks and unread badges. Markdown/rendering - Code blocks: language title bar and wrapper. - Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment. - Headings: remove blank line after h1-h4. - <hr>: handle after tables; strip following whitespace. - Links: target=_blank with icon and URL tooltip. Styles - Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar. API notes / breaking changes - /chat accepts stream=true and returns text/plain streamed chunks. - generate-title now requires a model. - Non-stream /chat response shape unchanged.
2025-08-23 16:45:46 +02:00
async with httpx.AsyncClient(timeout=600.0) as client:
async with client.stream("POST", f"{ollama_url}/api/chat", json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if cancellation_event is not None and cancellation_event.is_set():
raise asyncio.CancelledError()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
message = data.get("message") if isinstance(data.get("message"), dict) else {}
yield OllamaStreamChunk(
content=str(message.get("content") or data.get("content") or ""),
thinking=str(message.get("thinking") or data.get("thinking") or ""),
tool_calls=_parse_tool_calls(message.get("tool_calls") or data.get("tool_calls")),
done=bool(data.get("done")),
usage=_usage_from_payload(data),
raw=data,
)
async def chat(
model: str,
messages: List[Dict[str, Any]],
*,
options: Dict[str, Any] | None = None,
) -> str:
return (await chat_typed(model, messages, options=options)).content
async def chat_stream(model: str, messages: List[Dict[str, Any]]) -> AsyncGenerator[str, None]:
async for chunk in chat_stream_typed(model, messages):
if chunk.content:
yield chunk.content