953eaf2feb
- Add GET /api/chat/ps and POST /api/chat/warm endpoints for hot model visibility and pre-loading - Extend PATCH /api/chat/conversations/:id to accept model in addition to title - Add ModelSelector component with hot/cold indicators from Ollama /api/ps - Add DashboardChatInput component (model selector + note picker + textarea) replacing the simple "New Chat" button on the dashboard - Add model selector dropdown to ChatView header, persisted per-conversation - Warm default model on dashboard mount via fire-and-forget background task - Configure Ollama with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m - Always-visible edit buttons on NoteCard/TaskCard (remove hover-only behavior) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
import json
|
|
import logging
|
|
import re
|
|
from collections.abc import AsyncGenerator
|
|
|
|
import httpx
|
|
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.notes import get_note, search_notes_for_context
|
|
from fabledassistant.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STOP_WORDS = frozenset({
|
|
"a", "an", "the", "is", "it", "to", "in", "for", "of", "and", "or",
|
|
"on", "at", "by", "with", "from", "as", "be", "was", "were", "been",
|
|
"are", "am", "do", "does", "did", "have", "has", "had", "will", "would",
|
|
"can", "could", "shall", "should", "may", "might", "must", "that",
|
|
"this", "these", "those", "i", "me", "my", "you", "your", "he", "she",
|
|
"we", "they", "them", "his", "her", "its", "our", "their", "what",
|
|
"which", "who", "whom", "how", "when", "where", "why", "not", "no",
|
|
"but", "if", "so", "than", "too", "very", "just", "about", "up",
|
|
})
|
|
|
|
|
|
async def get_installed_models() -> set[str]:
|
|
"""Return set of installed Ollama model names (with and without :latest)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
names: set[str] = set()
|
|
for m in data.get("models", []):
|
|
name = m["name"]
|
|
names.add(name)
|
|
if name.endswith(":latest"):
|
|
names.add(name.removesuffix(":latest"))
|
|
return names
|
|
except Exception:
|
|
logger.warning("Failed to fetch installed Ollama models")
|
|
return set()
|
|
|
|
|
|
async def ensure_model(model: str) -> None:
|
|
"""Check if model exists in Ollama, pull if missing."""
|
|
try:
|
|
installed = await get_installed_models()
|
|
if model in installed or f"{model}:latest" in installed:
|
|
logger.info("Model '%s' already available", model)
|
|
return
|
|
except Exception:
|
|
logger.warning("Failed to check Ollama models, attempting pull anyway")
|
|
|
|
logger.info("Pulling model '%s' from Ollama...", model)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
|
async with client.stream(
|
|
"POST",
|
|
f"{Config.OLLAMA_URL}/api/pull",
|
|
json={"name": model},
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
async for line in resp.aiter_lines():
|
|
if line.strip():
|
|
status = json.loads(line)
|
|
if "status" in status:
|
|
logger.info("Pull %s: %s", model, status["status"])
|
|
logger.info("Model '%s' pulled successfully", model)
|
|
except Exception:
|
|
logger.warning("Failed to pull model '%s' — chat may not work", model, exc_info=True)
|
|
|
|
|
|
async def stream_chat(
|
|
messages: list[dict],
|
|
model: str,
|
|
options: dict | None = None,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Stream chat completion from Ollama, yielding content chunks."""
|
|
payload: dict = {"model": model, "messages": messages, "stream": True}
|
|
if options:
|
|
payload["options"] = options
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client:
|
|
async with client.stream(
|
|
"POST",
|
|
f"{Config.OLLAMA_URL}/api/chat",
|
|
json=payload,
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
async for line in resp.aiter_lines():
|
|
if not line.strip():
|
|
continue
|
|
data = json.loads(line)
|
|
chunk = data.get("message", {}).get("content", "")
|
|
if chunk:
|
|
yield chunk
|
|
if data.get("done"):
|
|
break
|
|
|
|
|
|
async def generate_completion(messages: list[dict], model: str) -> str:
|
|
"""Non-streaming chat completion, returns full response text."""
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client:
|
|
resp = await client.post(
|
|
f"{Config.OLLAMA_URL}/api/chat",
|
|
json={"model": model, "messages": messages, "stream": False},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("message", {}).get("content", "")
|
|
|
|
|
|
async def fetch_url_content(url: str) -> str:
|
|
"""Fetch a URL and return text content (HTML tags stripped)."""
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
timeout=15.0, follow_redirects=True, headers={"User-Agent": "FabledAssistant/1.0"}
|
|
) as client:
|
|
resp = await client.get(url)
|
|
resp.raise_for_status()
|
|
text = resp.text
|
|
# Strip HTML tags
|
|
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
# Collapse whitespace
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
# Truncate to reasonable size
|
|
if len(text) > 4000:
|
|
text = text[:4000] + "..."
|
|
return text
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch URL %s: %s", url, e)
|
|
return f"[Failed to fetch URL: {url}]"
|
|
|
|
|
|
def _extract_keywords(text: str) -> list[str]:
|
|
"""Extract meaningful keywords from text for note search."""
|
|
words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower())
|
|
keywords = [w for w in words if w not in STOP_WORDS]
|
|
# Deduplicate while preserving order
|
|
seen: set[str] = set()
|
|
unique = []
|
|
for w in keywords:
|
|
if w not in seen:
|
|
seen.add(w)
|
|
unique.append(w)
|
|
return unique[:5]
|
|
|
|
|
|
def _find_urls(text: str) -> list[str]:
|
|
"""Find URLs in text."""
|
|
return re.findall(r"https?://[^\s<>\"')\]]+", text)
|
|
|
|
|
|
async def build_context(
|
|
user_id: int,
|
|
history: list[dict],
|
|
current_note_id: int | None,
|
|
user_message: str,
|
|
exclude_note_ids: list[int] | None = None,
|
|
) -> tuple[list[dict], dict]:
|
|
"""Build messages array for Ollama with system prompt and context.
|
|
|
|
Returns (messages, context_meta) where context_meta contains info about
|
|
which notes were included as context.
|
|
"""
|
|
exclude_set = set(exclude_note_ids or [])
|
|
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
|
system_parts = [
|
|
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
|
"Help users with their notes, tasks, and general questions. "
|
|
"When note context is provided, use it to give relevant answers."
|
|
]
|
|
|
|
context_meta: dict = {
|
|
"context_note_id": None,
|
|
"context_note_title": None,
|
|
"auto_notes": [],
|
|
}
|
|
|
|
# Include current note context if provided
|
|
if current_note_id:
|
|
note = await get_note(user_id, current_note_id)
|
|
if note:
|
|
context_meta["context_note_id"] = note.id
|
|
context_meta["context_note_title"] = note.title
|
|
system_parts.append(
|
|
f"\n\n--- Current Note ---\n"
|
|
f"Title: {note.title}\n"
|
|
f"Content:\n{note.body}\n"
|
|
f"--- End Note ---"
|
|
)
|
|
|
|
# Search notes by keywords from user message — single OR query
|
|
keywords = _extract_keywords(user_message)
|
|
if keywords:
|
|
search_exclude = set(exclude_set)
|
|
if current_note_id:
|
|
search_exclude.add(current_note_id)
|
|
try:
|
|
notes = await search_notes_for_context(
|
|
user_id, keywords, exclude_ids=search_exclude or None, limit=3
|
|
)
|
|
snippets: list[str] = []
|
|
for n in notes:
|
|
body_preview = n.body[:2000] if n.body else ""
|
|
snippets.append(f"- {n.title}: {body_preview}")
|
|
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
|
|
if snippets:
|
|
system_parts.append(
|
|
"\n\n--- Related Notes ---\n"
|
|
+ "\n".join(snippets)
|
|
+ "\n--- End Related Notes ---"
|
|
)
|
|
except Exception:
|
|
logger.warning("Failed to search notes for context", exc_info=True)
|
|
|
|
# Fetch URL content from user message
|
|
urls = _find_urls(user_message)
|
|
for url in urls[:2]: # Limit to 2 URLs
|
|
content = await fetch_url_content(url)
|
|
if content and not content.startswith("[Failed"):
|
|
system_parts.append(
|
|
f"\n\n--- Content from {url} ---\n{content}\n--- End URL Content ---"
|
|
)
|
|
|
|
messages = [{"role": "system", "content": "".join(system_parts)}]
|
|
messages.extend(history)
|
|
messages.append({"role": "user", "content": user_message})
|
|
return messages, context_meta
|