Add model selection, dashboard chat input, and model warming

- 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>
This commit is contained in:
2026-02-14 18:49:06 -05:00
parent 987ec56dc3
commit 953eaf2feb
13 changed files with 710 additions and 306 deletions
+53 -4
View File
@@ -15,7 +15,7 @@ from fabledassistant.services.chat import (
list_conversations,
save_response_as_note,
summarize_conversation_as_note,
update_conversation_title,
update_conversation,
)
from fabledassistant.services.generation_buffer import (
GenerationState,
@@ -83,9 +83,10 @@ async def update_conversation_route(conv_id: int):
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title")
if title is None:
return jsonify({"error": "title is required"}), 400
conv = await update_conversation_title(uid, conv_id, title)
model = data.get("model")
if title is None and model is None:
return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return jsonify(conv.to_dict())
@@ -233,6 +234,54 @@ async def summarize_conversation_route(conv_id: int):
return jsonify({"error": str(e)}), 400
@chat_bp.route("/ps", methods=["GET"])
@login_required
async def running_models_route():
"""Return currently loaded (hot) models from Ollama."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
resp.raise_for_status()
data = resp.json()
models = [
{
"name": m["name"],
"size": m.get("size", 0),
"size_vram": m.get("size_vram", 0),
"expires_at": m.get("expires_at", ""),
}
for m in data.get("models", [])
]
return jsonify({"models": models})
except Exception as e:
logger.debug("Failed to query Ollama /api/ps: %s", e)
return jsonify({"models": []})
@chat_bp.route("/warm", methods=["POST"])
@login_required
async def warm_model_route():
"""Pre-load a model into Ollama memory."""
data = await request.get_json(force=True, silent=True) or {}
model = data.get("model", "").strip()
if not model:
return jsonify({"error": "model is required"}), 400
async def _warm():
try:
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": "30m"},
)
logger.info("Warmed model %s", model)
except Exception as e:
logger.warning("Failed to warm model %s: %s", model, e)
asyncio.create_task(_warm())
return jsonify({"status": "warming"}), 202
@chat_bp.route("/status", methods=["GET"])
@login_required
async def chat_status_route():
+15 -3
View File
@@ -103,8 +103,11 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
return True
async def update_conversation_title(
user_id: int, conversation_id: int, title: str
async def update_conversation(
user_id: int,
conversation_id: int,
title: str | None = None,
model: str | None = None,
) -> Conversation | None:
async with async_session() as session:
result = await session.execute(
@@ -116,13 +119,22 @@ async def update_conversation_title(
conv = result.scalars().first()
if conv is None:
return None
conv.title = title
if title is not None:
conv.title = title
if model is not None:
conv.model = model
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(conv)
return conv
async def update_conversation_title(
user_id: int, conversation_id: int, title: str
) -> Conversation | None:
return await update_conversation(user_id, conversation_id, title=title)
async def add_message(
conversation_id: int,
role: str,
+2 -2
View File
@@ -80,7 +80,7 @@ async def stream_chat(
payload: dict = {"model": model, "messages": messages, "stream": True}
if options:
payload["options"] = options
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
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",
@@ -100,7 +100,7 @@ async def stream_chat(
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(300.0, connect=10.0)) as client:
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},