refactor: Phase 8 — backend deletion (chat / voice / push / journal / curator)
Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.
Deleted (services/):
chat, generation_buffer, generation_log, generation_task, llm, tools/
(entire package), stt, tts, voice_config, voice_library, push,
journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
journal_search, curator, curator_scheduler, consolidation,
tag_suggestions, research, weather, article_fetcher, pending_actions,
moments, assist, wikipedia.
Deleted (routes/):
chat, voice, push, journal, quick_capture, fable_mcp_dist.
Deleted (models/):
conversation, generation_tool_log, push_subscription,
pending_curator_action, moment, weather_cache.
Deleted (tests/):
test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
test_notes_consolidation_trigger, test_record_moment_guards,
test_research_pipeline, test_tools_*, test_tool_use_fixes,
test_voice_library, test_weather_service, test_calendar_tool_tz,
test_wikipedia.
Deleted (top-level):
fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
also removed from Dockerfile).
app.py:
- blueprint registrations for the 6 deleted routes
- startup hook trimmed: no more Ollama warmup, KV-cache priming,
journal/curator schedulers, voice model loading
- shutdown hook simplified
- httpx import dropped (was for Ollama calls)
pyproject.toml:
- removed deps: pywebpush, feedparser, html2text, trafilatura
- removed [voice] extras entirely
- description updated for the MCP-first architecture
Dockerfile:
- removed faster-whisper / piper-tts install steps
- removed bundled piper voice download stage
- removed fable-mcp wheel build stage
Surviving-file edits:
- services/auth.py: drop Conversation table claim on first-user setup
- services/backup.py: drop conversation / push-subscription export+restore;
v1/v2 restore now silently skip pre-pivot conversation data
- services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
drop _maybe_trigger_project_summary (LLM auto-summary)
- services/projects.py: drop generate_project_summary + backfill_project_summaries
(both LLM-driven)
- services/user_profile.py: drop append_observations / consolidate /
clear_learned_data (curator-tied) and build_profile_context
(was LLM system-prompt builder)
- services/notifications.py: stub out _fire_push_notif (was send_push_notification)
- services/event_scheduler.py: drop event-reminder push + chat-retention
cleanup job; keep CalDAV pull-sync + reminders job (in-app)
- services/diagnostics.py: _curator_busy() always False
- routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
- routes/tasks.py: drop /<id>/consolidate endpoint
- routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
(was in services/llm.py)
- routes/admin.py: drop /voice, /voice/reload endpoints
- routes/profile.py: drop /consolidate, /observations (GET, DELETE)
- models/__init__.py: drop the 6 dead model imports
Frontend cascade:
- stores/push.ts: deleted entirely (no callers after Phase 7)
- stores/settings.ts: drop checkVoiceStatus + voice-status state
- views/SettingsView.vue: drop Locations section + journalConfig state
(was tied to /api/journal/config); drop JournalConfig + journal/voice
api/client imports
- frontend/api/client.ts: orphaned voice/journal/profile-observation/
fable-mcp-dist exports are left as dead but harmless (call them and
they 404; type-check is clean).
Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,6 @@ from fabledassistant.services.backup import (
|
||||
restore_full_backup,
|
||||
)
|
||||
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
|
||||
from fabledassistant.services.voice_config import get_voice_config
|
||||
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
|
||||
from fabledassistant.services.notifications import send_invitation_email
|
||||
from fabledassistant.services.settings import set_setting, set_settings_batch
|
||||
@@ -205,44 +204,6 @@ async def update_base_url():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/voice", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_voice_config_route():
|
||||
config = await get_voice_config()
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@admin_bp.route("/voice", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_voice_config():
|
||||
data = await request.get_json()
|
||||
uid = get_current_user_id()
|
||||
valid_models = {"tiny.en", "base.en", "small.en", "medium.en"}
|
||||
settings: dict[str, str] = {}
|
||||
if "voice_enabled" in data:
|
||||
settings["voice_enabled"] = "true" if data["voice_enabled"] else "false"
|
||||
if "voice_stt_model" in data:
|
||||
model = str(data["voice_stt_model"])
|
||||
if model not in valid_models:
|
||||
return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400
|
||||
settings["voice_stt_model"] = model
|
||||
if settings:
|
||||
await set_settings_batch(uid, settings)
|
||||
await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/voice/reload", methods=["POST"])
|
||||
@admin_required
|
||||
async def reload_voice_models():
|
||||
"""Reload STT and TTS models in the background without a server restart."""
|
||||
from fabledassistant.services.stt import reload_stt_model
|
||||
from fabledassistant.services.tts import reload_tts_model
|
||||
asyncio.create_task(reload_stt_model())
|
||||
asyncio.create_task(reload_tts_model())
|
||||
return jsonify({"status": "loading"})
|
||||
|
||||
|
||||
@admin_bp.route("/invitations", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_invite():
|
||||
|
||||
@@ -1,548 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.chat import (
|
||||
add_message,
|
||||
bulk_delete_conversations,
|
||||
create_conversation,
|
||||
delete_conversation,
|
||||
get_conversation,
|
||||
list_conversations,
|
||||
save_response_as_note,
|
||||
summarize_conversation_as_note,
|
||||
update_conversation,
|
||||
)
|
||||
from fabledassistant.services.generation_buffer import (
|
||||
GenerationState,
|
||||
create_buffer,
|
||||
get_buffer,
|
||||
)
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.notes import get_notes_by_ids
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
||||
|
||||
|
||||
@chat_bp.route("/conversations", methods=["GET"])
|
||||
@login_required
|
||||
async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
limit, offset = parse_pagination()
|
||||
conv_type = request.args.get("type", "chat")
|
||||
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
|
||||
return jsonify({
|
||||
"conversations": conversations,
|
||||
"total": total,
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
|
||||
@login_required
|
||||
async def bulk_delete_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
ids = data.get("ids", []) if isinstance(data, dict) else []
|
||||
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
|
||||
return jsonify({"error": "ids must be a list of integers"}), 400
|
||||
count = await bulk_delete_conversations(uid, ids)
|
||||
return jsonify({"deleted": count})
|
||||
|
||||
|
||||
@chat_bp.route("/conversations", methods=["POST"])
|
||||
@login_required
|
||||
async def create_conversation_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
title = data.get("title", "")
|
||||
model = data.get("model", Config.OLLAMA_MODEL)
|
||||
conversation_type = data.get("conversation_type", "chat")
|
||||
# Only allow known types to prevent accidental misuse
|
||||
if conversation_type not in ("chat", "mcp", "voice"):
|
||||
conversation_type = "chat"
|
||||
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
|
||||
return jsonify(conv.to_dict()), 201
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
result = conv.to_dict()
|
||||
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
|
||||
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
|
||||
result["messages"] = []
|
||||
for m in conv.messages:
|
||||
msg_dict = m.to_dict()
|
||||
if m.context_note_id and m.context_note_id in note_map:
|
||||
msg_dict["context_note_title"] = note_map[m.context_note_id].title
|
||||
result["messages"].append(msg_dict)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_conversation(uid, conv_id)
|
||||
if not deleted:
|
||||
return not_found("Conversation")
|
||||
return "", 204
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_conversation_route(conv_id: int):
|
||||
from fabledassistant.services.chat import _UNSET
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title")
|
||||
model = data.get("model")
|
||||
rag_project_id = data.get("rag_project_id", _UNSET)
|
||||
if title is None and model is None and rag_project_id is _UNSET:
|
||||
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
|
||||
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
return jsonify(conv.to_dict())
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
|
||||
@login_required
|
||||
async def send_message_route(conv_id: int):
|
||||
"""Start generation: save user message, launch background task, return 202."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
|
||||
data = await request.get_json()
|
||||
content = data.get("content", "").strip()
|
||||
if not content:
|
||||
return jsonify({"error": "content is required"}), 400
|
||||
context_note_id = data.get("context_note_id")
|
||||
include_note_ids = data.get("include_note_ids") or []
|
||||
excluded_note_ids = data.get("excluded_note_ids") or []
|
||||
think = bool(data.get("think", False))
|
||||
rag_project_id = data.get("rag_project_id") or None
|
||||
workspace_project_id = data.get("workspace_project_id") or None
|
||||
user_timezone = data.get("user_timezone") or None
|
||||
if not user_timezone:
|
||||
user_timezone = await get_setting(uid, "user_timezone") or None
|
||||
effective_rag_project_id = workspace_project_id or rag_project_id
|
||||
|
||||
# Reject if generation already running for this conversation
|
||||
existing = get_buffer(conv_id)
|
||||
if existing and existing.state == GenerationState.RUNNING:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Save user message
|
||||
await add_message(conv_id, "user", content, context_note_id=context_note_id)
|
||||
|
||||
# Create placeholder assistant message
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
|
||||
try:
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
except RuntimeError:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Build history from existing messages (excluding system and the placeholder)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||
|
||||
# Launch background generation task (context building happens inside the task).
|
||||
#
|
||||
# Wrap in a top-level guard: `run_generation` is fire-and-forget via
|
||||
# asyncio.create_task. Without a wrapper, an unhandled exception inside
|
||||
# the coroutine is swallowed by the event loop, the buffer stays in
|
||||
# GenerationState.RUNNING forever, and every subsequent POST to this
|
||||
# conversation returns 409 — locking the user out of the chat surface
|
||||
# with no log trail. Observed in production 2026-05-22 where the
|
||||
# generation hung before any internal log line could fire.
|
||||
async def _run_generation_guarded():
|
||||
try:
|
||||
await run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title, content,
|
||||
context_note_id=context_note_id,
|
||||
include_note_ids=include_note_ids,
|
||||
excluded_note_ids=excluded_note_ids,
|
||||
think=think,
|
||||
rag_project_id=effective_rag_project_id,
|
||||
workspace_project_id=workspace_project_id,
|
||||
user_timezone=user_timezone,
|
||||
voice_mode=(conv.conversation_type == "voice"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"run_generation crashed for conv %d msg %d (model=%s); "
|
||||
"transitioning buffer to FAILED so the route can be used again",
|
||||
conv_id, assistant_msg.id, model,
|
||||
)
|
||||
try:
|
||||
buf.state = GenerationState.ERRORED
|
||||
buf.append_event(
|
||||
"done",
|
||||
{"done": True, "error": "Generation crashed; see server logs"},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id)
|
||||
try:
|
||||
from fabledassistant.services.generation_task import _update_message
|
||||
await _update_message(
|
||||
assistant_msg.id,
|
||||
"",
|
||||
"error",
|
||||
tool_calls=None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to mark assistant message %d as error after crash",
|
||||
assistant_msg.id,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_generation_guarded())
|
||||
|
||||
return jsonify({
|
||||
"assistant_message_id": assistant_msg.id,
|
||||
"status": "generating",
|
||||
}), 202
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
|
||||
@login_required
|
||||
async def generation_stream_route(conv_id: int):
|
||||
"""SSE endpoint that tails the generation buffer for a conversation."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None:
|
||||
return jsonify({"error": "No active generation"}), 404
|
||||
|
||||
# Determine starting point from Last-Event-ID header or query param
|
||||
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
||||
try:
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
except (ValueError, TypeError):
|
||||
last_id = -1
|
||||
|
||||
async def stream():
|
||||
cursor = last_id
|
||||
while True:
|
||||
# Replay any buffered events past the cursor
|
||||
pending = buf.events_after(cursor)
|
||||
for event in pending:
|
||||
yield buf.format_sse(event)
|
||||
cursor = event.index
|
||||
|
||||
# If generation is done and all events delivered, close stream
|
||||
if buf.state != GenerationState.RUNNING:
|
||||
break
|
||||
|
||||
# Wait for new events or send keepalive on timeout
|
||||
got_new = await buf.wait_for_event(cursor, timeout=15.0)
|
||||
if not got_new:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
# Final drain: deliver any events appended between the last yield
|
||||
# and the state check above (e.g. the 'done' event).
|
||||
for event in buf.events_after(cursor):
|
||||
yield buf.format_sse(event)
|
||||
|
||||
return Response(
|
||||
stream(),
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
|
||||
@login_required
|
||||
async def confirm_generation_route(conv_id: int):
|
||||
"""Resolve a pending tool confirmation (accept or decline)."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None or buf.state != GenerationState.RUNNING:
|
||||
return jsonify({"error": "No active generation"}), 404
|
||||
|
||||
if buf.confirmation_future is None or buf.confirmation_future.done():
|
||||
return jsonify({"error": "No pending tool confirmation"}), 409
|
||||
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
decision = bool(data.get("confirmed", False))
|
||||
buf.confirmation_future.set_result(decision)
|
||||
return jsonify({"status": "ok", "confirmed": decision})
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
|
||||
@login_required
|
||||
async def cancel_generation_route(conv_id: int):
|
||||
"""Cancel an active generation for a conversation."""
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
|
||||
buf = get_buffer(conv_id)
|
||||
if buf is None or buf.state != GenerationState.RUNNING:
|
||||
return jsonify({"error": "No active generation"}), 404
|
||||
|
||||
buf.cancel_event.set()
|
||||
return jsonify({"status": "cancelled"})
|
||||
|
||||
|
||||
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
|
||||
@login_required
|
||||
async def save_message_as_note_route(message_id: int):
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
note = await save_response_as_note(uid, message_id)
|
||||
return jsonify(note), 201
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
|
||||
@login_required
|
||||
async def summarize_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||
try:
|
||||
note = await summarize_conversation_as_note(uid, conv_id, model)
|
||||
return jsonify(note), 201
|
||||
except ValueError as e:
|
||||
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():
|
||||
from fabledassistant.services.llm import keep_alive_for
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/generate",
|
||||
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
|
||||
)
|
||||
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():
|
||||
"""Check Ollama availability, model installation, and model load state."""
|
||||
uid = get_current_user_id()
|
||||
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
# Guard against empty-string rows written by older code when user selected "default"
|
||||
if not default_model:
|
||||
default_model = Config.OLLAMA_MODEL
|
||||
result = {
|
||||
"ollama": "unavailable",
|
||||
"model": "not_found",
|
||||
"default_model": default_model,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Query installed models and loaded models in parallel
|
||||
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
|
||||
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
|
||||
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
|
||||
|
||||
if isinstance(tags_resp, Exception):
|
||||
logger.debug("Ollama /api/tags failed: %s", tags_resp)
|
||||
else:
|
||||
tags_resp.raise_for_status()
|
||||
result["ollama"] = "available"
|
||||
model_names = {m["name"] for m in tags_resp.json().get("models", [])}
|
||||
base = default_model.removesuffix(":latest")
|
||||
if default_model in model_names or f"{base}:latest" in model_names or base in model_names:
|
||||
# Installed — now check if currently loaded in memory
|
||||
result["model"] = "cold"
|
||||
if not isinstance(ps_resp, Exception):
|
||||
try:
|
||||
ps_resp.raise_for_status()
|
||||
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
||||
if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names:
|
||||
result["model"] = "loaded"
|
||||
except Exception:
|
||||
logger.debug("Ollama /api/ps check failed", exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("Ollama status check failed", exc_info=True)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@chat_bp.route("/models", methods=["GET"])
|
||||
@login_required
|
||||
async def list_models_route():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
|
||||
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
|
||||
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
|
||||
|
||||
loaded_names: set[str] = set()
|
||||
if not isinstance(ps_resp, Exception):
|
||||
try:
|
||||
ps_resp.raise_for_status()
|
||||
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models = []
|
||||
if not isinstance(tags_resp, Exception):
|
||||
tags_resp.raise_for_status()
|
||||
for m in tags_resp.json().get("models", []):
|
||||
models.append({
|
||||
"name": m["name"],
|
||||
"size": m.get("size", 0),
|
||||
"modified_at": m.get("modified_at", ""),
|
||||
"loaded": m["name"] in loaded_names,
|
||||
})
|
||||
return jsonify({"models": models})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list Ollama models: %s", e)
|
||||
return jsonify({"models": [], "error": str(e)}), 200
|
||||
|
||||
|
||||
@chat_bp.route("/models/pull", methods=["POST"])
|
||||
@admin_required
|
||||
async def pull_model_route():
|
||||
"""Pull a model from Ollama, streaming progress via SSE."""
|
||||
data = await request.get_json()
|
||||
model_name = data.get("model", "").strip()
|
||||
if not model_name:
|
||||
return jsonify({"error": "model is required"}), 400
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=1800.0) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{Config.OLLAMA_URL}/api/pull",
|
||||
json={"name": model_name},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
progress = json.loads(line)
|
||||
event_data = json.dumps(progress)
|
||||
yield f"data: {event_data}\n\n"
|
||||
yield f"data: {json.dumps({'status': 'success'})}\n\n"
|
||||
except Exception as e:
|
||||
logger.exception("Error pulling model %s", model_name)
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@chat_bp.route("/models/delete", methods=["POST"])
|
||||
@admin_required
|
||||
async def delete_model_route():
|
||||
"""Delete a model from Ollama."""
|
||||
data = await request.get_json()
|
||||
model_name = data.get("model", "").strip()
|
||||
if not model_name:
|
||||
return jsonify({"error": "model is required"}), 400
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
f"{Config.OLLAMA_URL}/api/delete",
|
||||
json={"name": model_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return jsonify({"status": "deleted", "model": model_name})
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Failed to delete model %s: %s", model_name, e)
|
||||
return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete model %s: %s", model_name, e)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Serve the fable-mcp distribution wheel built into the Docker image."""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, send_file
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp")
|
||||
|
||||
# Wheel is built into the image at this path (see Dockerfile)
|
||||
_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist"))
|
||||
|
||||
|
||||
def _find_wheel() -> Path | None:
|
||||
"""Return the newest fable_mcp wheel in the dist dir, or None."""
|
||||
wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True)
|
||||
return wheels[0] if wheels else None
|
||||
|
||||
|
||||
@fable_mcp_dist_bp.route("/info", methods=["GET"])
|
||||
@login_required
|
||||
async def fable_mcp_info():
|
||||
"""Return availability and filename of the bundled fable-mcp wheel."""
|
||||
wheel = _find_wheel()
|
||||
return jsonify({
|
||||
"available": wheel is not None,
|
||||
"filename": wheel.name if wheel else None,
|
||||
})
|
||||
|
||||
|
||||
@fable_mcp_dist_bp.route("/download", methods=["GET"])
|
||||
@login_required
|
||||
async def download_fable_mcp():
|
||||
"""Serve the fable-mcp wheel file as a download."""
|
||||
wheel = _find_wheel()
|
||||
if wheel is None:
|
||||
return jsonify({"error": "Package not built into this image"}), 404
|
||||
return await send_file(wheel, as_attachment=True)
|
||||
@@ -1,519 +0,0 @@
|
||||
"""HTTP endpoints for the Journal feature.
|
||||
|
||||
Includes the conversational journal endpoints (config / today / day / days /
|
||||
trigger-prep / moments) plus the ambient-context surface lifted from the
|
||||
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
|
||||
The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
``journal_config`` user setting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.journal_scheduler import (
|
||||
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
||||
update_user_schedule,
|
||||
)
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.moments import delete_moment, update_moment
|
||||
from fabledassistant.services.settings import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
|
||||
|
||||
|
||||
def _resolve_tz(tz_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
if now.hour < day_rollover_hour:
|
||||
return (now - datetime.timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
async def _user_timezone(user_id: int) -> str:
|
||||
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
|
||||
|
||||
|
||||
async def _resolve_config(user_id: int) -> dict:
|
||||
raw = await get_setting(user_id, "journal_config", "")
|
||||
config: dict = {}
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
logger.warning("Invalid journal_config for user %d", user_id)
|
||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
||||
|
||||
|
||||
def _valid_location_keys(cfg: dict) -> set[str]:
|
||||
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
|
||||
(orphaned cache rows, locations the user typed but didn't geocode) is
|
||||
excluded so it can't render as a fake site in the UI."""
|
||||
locations = cfg.get("locations") or {}
|
||||
return {
|
||||
key for key, loc in locations.items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
|
||||
|
||||
@journal_bp.get("/config")
|
||||
@login_required
|
||||
async def get_config():
|
||||
user_id = get_current_user_id()
|
||||
return jsonify(await _resolve_config(user_id))
|
||||
|
||||
|
||||
@journal_bp.put("/config")
|
||||
@login_required
|
||||
async def put_config():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "config must be an object"}), 400
|
||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||
await update_user_schedule(user_id)
|
||||
|
||||
# Trigger a background weather refresh for any newly-saved location with
|
||||
# valid lat/lon. Without this, the cache row for the location doesn't
|
||||
# exist (or stays stale) until the user clicks the manual refresh button,
|
||||
# so the journal weather panel renders empty for newly-entered sites.
|
||||
valid_locs = [
|
||||
(key, loc)
|
||||
for key, loc in (body.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
]
|
||||
if valid_locs:
|
||||
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _refresh_locations_in_background(
|
||||
user_id: int, locations: list[tuple[str, dict]]
|
||||
) -> None:
|
||||
for key, loc in locations:
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Post-save weather refresh failed for user %d / %s",
|
||||
user_id, key, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.get("/today")
|
||||
@login_required
|
||||
async def get_today():
|
||||
user_id = get_current_user_id()
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
|
||||
await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=today, user_timezone=tz_str
|
||||
)
|
||||
return await _day_payload(user_id=user_id, day_date=today)
|
||||
|
||||
|
||||
@journal_bp.get("/day/<iso_date>")
|
||||
@login_required
|
||||
async def get_day(iso_date: str):
|
||||
user_id = get_current_user_id()
|
||||
try:
|
||||
day = datetime.date.fromisoformat(iso_date)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid date"}), 400
|
||||
return await _day_payload(user_id=user_id, day_date=day)
|
||||
|
||||
|
||||
@journal_bp.get("/days")
|
||||
@login_required
|
||||
async def list_days():
|
||||
user_id = get_current_user_id()
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Conversation.day_date)
|
||||
.where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date.is_not(None),
|
||||
)
|
||||
.order_by(Conversation.day_date.desc())
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return jsonify({"days": [d.isoformat() for d in rows]})
|
||||
|
||||
|
||||
@journal_bp.post("/curator/run/<int:conv_id>")
|
||||
@login_required
|
||||
async def trigger_curator_run(conv_id: int):
|
||||
"""Manually run the journal curator over a conversation.
|
||||
|
||||
The curator reads recent messages and fires tool calls (record_moment,
|
||||
update_task, etc.) the chat model can't (chat models have tools=[]).
|
||||
Returns a summary of what was captured.
|
||||
|
||||
See services/curator.py for the architectural background.
|
||||
"""
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Confirm the conversation belongs to this user (curator runs against
|
||||
# arbitrary conv_ids would otherwise leak data across tenants).
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_res = await _sess.execute(
|
||||
_select(_Conversation).where(
|
||||
_Conversation.id == conv_id,
|
||||
_Conversation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if _res.scalar_one_or_none() is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
from fabledassistant.services.curator import (
|
||||
is_curator_running,
|
||||
run_curator_for_conversation,
|
||||
)
|
||||
# The curator typically runs on a large model (30b-70b on CPU); we
|
||||
# serialize runs globally via a module-level lock. Reject rather
|
||||
# than block when busy — blocking would tie up an HTTP worker for
|
||||
# minutes. The user can retry in a moment.
|
||||
if is_curator_running():
|
||||
return jsonify({
|
||||
"error": "Curator is currently running. Please try again in a moment.",
|
||||
"busy": True,
|
||||
}), 409
|
||||
result = await run_curator_for_conversation(conv_id)
|
||||
|
||||
# Stamp last_curator_run_at on success so the scheduler doesn't
|
||||
# immediately re-process the same conversation on its next sweep.
|
||||
# Errored runs intentionally leave the timestamp alone so the
|
||||
# scheduler retries them. Persist the curator's summary too when
|
||||
# non-empty (Phase 3 feedback loop) — empty summary keeps the
|
||||
# existing one rather than clobbering useful context.
|
||||
if not result.error:
|
||||
import datetime as _dt
|
||||
from sqlalchemy import update as _update
|
||||
_values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)}
|
||||
if result.summary:
|
||||
_values["curator_summary"] = result.summary.strip()[:240]
|
||||
async with _async_session() as _sess:
|
||||
await _sess.execute(
|
||||
_update(_Conversation).where(_Conversation.id == conv_id).values(**_values)
|
||||
)
|
||||
await _sess.commit()
|
||||
|
||||
return jsonify(result.to_dict())
|
||||
|
||||
|
||||
@journal_bp.post("/trigger-prep")
|
||||
@login_required
|
||||
async def trigger_prep():
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json(silent=True) or {}
|
||||
iso_date = body.get("date")
|
||||
config = await _resolve_config(user_id)
|
||||
tz_str = await _user_timezone(user_id)
|
||||
day = (
|
||||
datetime.date.fromisoformat(iso_date)
|
||||
if iso_date
|
||||
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
|
||||
)
|
||||
msg = await ensure_daily_prep_message(
|
||||
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
|
||||
)
|
||||
return jsonify({"ok": True, "message_id": msg.id})
|
||||
|
||||
|
||||
@journal_bp.get("/moments")
|
||||
@login_required
|
||||
async def list_moments():
|
||||
user_id = get_current_user_id()
|
||||
args = request.args
|
||||
df = args.get("date_from")
|
||||
dt = args.get("date_to")
|
||||
person_id = args.get("person_id", type=int)
|
||||
place_id = args.get("place_id", type=int)
|
||||
tag = args.get("tag")
|
||||
query = args.get("query")
|
||||
pinned_only = args.get("pinned_only", "false").lower() == "true"
|
||||
limit = args.get("limit", default=50, type=int)
|
||||
|
||||
results = await search_journal(
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
person_id=person_id,
|
||||
place_id=place_id,
|
||||
tag=tag,
|
||||
date_from=datetime.date.fromisoformat(df) if df else None,
|
||||
date_to=datetime.date.fromisoformat(dt) if dt else None,
|
||||
limit=limit,
|
||||
)
|
||||
if pinned_only:
|
||||
results = [r for r in results if r.get("pinned")]
|
||||
return jsonify({"moments": results})
|
||||
|
||||
|
||||
@journal_bp.get("/pending")
|
||||
@login_required
|
||||
async def list_pending_actions():
|
||||
"""List curator-proposed mutations awaiting the user's review."""
|
||||
user_id = get_current_user_id()
|
||||
from fabledassistant.services.pending_actions import list_pending
|
||||
pending = await list_pending(user_id)
|
||||
return jsonify({"pending": pending, "count": len(pending)})
|
||||
|
||||
|
||||
@journal_bp.post("/pending/<int:action_id>/approve")
|
||||
@login_required
|
||||
async def approve_pending_action(action_id: int):
|
||||
"""Approve a proposed action — replays the underlying tool call.
|
||||
|
||||
Returns the tool result on success. If the replay errors (e.g., the
|
||||
target was deleted in the meantime), the action stays pending so the
|
||||
user can re-try or reject explicitly.
|
||||
"""
|
||||
user_id = get_current_user_id()
|
||||
from fabledassistant.services.pending_actions import approve
|
||||
result = await approve(action_id, user_id)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@journal_bp.post("/pending/<int:action_id>/reject")
|
||||
@login_required
|
||||
async def reject_pending_action(action_id: int):
|
||||
"""Reject a proposed action — marks rejected without executing anything."""
|
||||
user_id = get_current_user_id()
|
||||
from fabledassistant.services.pending_actions import reject
|
||||
result = await reject(action_id, user_id)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@journal_bp.patch("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def patch_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "body must be an object"}), 400
|
||||
moment = await update_moment(
|
||||
user_id=user_id,
|
||||
moment_id=moment_id,
|
||||
content=body.get("content"),
|
||||
tags=body.get("tags"),
|
||||
pinned=body.get("pinned"),
|
||||
person_ids=body.get("person_ids"),
|
||||
place_ids=body.get("place_ids"),
|
||||
task_ids=body.get("task_ids"),
|
||||
note_ids=body.get("note_ids"),
|
||||
)
|
||||
if moment is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(moment.to_dict())
|
||||
|
||||
|
||||
@journal_bp.delete("/moments/<int:moment_id>")
|
||||
@login_required
|
||||
async def remove_moment(moment_id: int):
|
||||
user_id = get_current_user_id()
|
||||
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
# Ambient endpoints (lifted from the old briefing surface).
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _journal_temp_unit(user_id: int) -> str:
|
||||
cfg = await _resolve_config(user_id)
|
||||
unit = cfg.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
|
||||
|
||||
|
||||
def _is_stale(cache_row) -> bool:
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return True
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
return age > _STALE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
|
||||
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
|
||||
config has no usable lat/lon for a given location_key."""
|
||||
cfg = await _resolve_config(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
for key in stale_keys:
|
||||
loc = locations.get(key)
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
|
||||
# Kick off a best-effort background refresh for stale rows so the next page
|
||||
# load gets fresh data; we still serve whatever's currently cached now.
|
||||
stale_keys = {row.location_key for row in rows if _is_stale(row)}
|
||||
if stale_keys:
|
||||
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
|
||||
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.get("/weather/current")
|
||||
@login_required
|
||||
async def get_current_weather():
|
||||
"""Live current temperature + conditions for the user's primary location."""
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
return jsonify({"error": "No location configured"}), 404
|
||||
|
||||
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
temp = current["temperature"]
|
||||
if temp is not None and temp_unit == "F":
|
||||
temp = temp * 9 / 5 + 32
|
||||
current["temperature"] = round(temp) if temp is not None else None
|
||||
current["temp_unit"] = temp_unit
|
||||
current["location"] = loc.get("label") or "Home"
|
||||
return jsonify(current)
|
||||
|
||||
|
||||
@journal_bp.post("/weather/refresh")
|
||||
@login_required
|
||||
async def refresh_weather():
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
for key, loc in (cfg.get("locations") or {}).items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.post("/weather/geocode")
|
||||
@login_required
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
async def _day_payload(*, user_id: int, day_date: datetime.date):
|
||||
async with async_session() as session:
|
||||
conv_stmt = select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == day_date,
|
||||
)
|
||||
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
|
||||
if conv is None:
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": None,
|
||||
"messages": [],
|
||||
})
|
||||
|
||||
msgs_stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = (await session.execute(msgs_stmt)).scalars().all()
|
||||
# conv.to_dict() recomputes message_count from the `messages`
|
||||
# relationship, which isn't eager-loaded here, so it would report 0.
|
||||
# We already have the real list — override with the known count, same
|
||||
# convention the chat-list path uses (services/chat.py).
|
||||
conv_dict = conv.to_dict()
|
||||
conv_dict["message_count"] = len(messages)
|
||||
return jsonify({
|
||||
"day_date": day_date.isoformat(),
|
||||
"conversation": conv_dict,
|
||||
"messages": [m.to_dict() for m in messages],
|
||||
})
|
||||
@@ -4,18 +4,10 @@ import re
|
||||
|
||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.assist import build_assist_messages
|
||||
from fabledassistant.services.generation_buffer import (
|
||||
GenerationState,
|
||||
create_assist_buffer,
|
||||
get_assist_buffer,
|
||||
)
|
||||
from fabledassistant.services.generation_task import run_assist_generation
|
||||
from fabledassistant.services.notes import (
|
||||
build_note_graph,
|
||||
convert_note_to_task,
|
||||
@@ -31,8 +23,6 @@ from fabledassistant.services.notes import (
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||
from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
|
||||
from fabledassistant.services.note_versions import list_versions, get_version
|
||||
|
||||
@@ -140,18 +130,6 @@ async def list_tags_route():
|
||||
return jsonify({"tags": tags})
|
||||
|
||||
|
||||
@notes_bp.route("/suggest-tags", methods=["POST"])
|
||||
@login_required
|
||||
async def suggest_tags_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title", "")
|
||||
body = data.get("body", "")
|
||||
current_tags = data.get("current_tags", [])
|
||||
tags = await suggest_tags(uid, title, body, current_tags=current_tags)
|
||||
return jsonify({"suggested_tags": tags})
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
|
||||
@login_required
|
||||
async def append_tag_route(note_id: int):
|
||||
@@ -318,124 +296,6 @@ async def get_backlinks_route(note_id: int):
|
||||
return jsonify({"backlinks": links})
|
||||
|
||||
|
||||
@notes_bp.route("/assist", methods=["POST"])
|
||||
@login_required
|
||||
async def assist_route():
|
||||
"""Launch a background assist generation task."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
|
||||
body = data.get("body", "")
|
||||
target_section = data.get("target_section", "")
|
||||
instruction = data.get("instruction", "")
|
||||
whole_doc = bool(data.get("whole_doc", False))
|
||||
project_id = data.get("project_id")
|
||||
note_id = data.get("note_id")
|
||||
|
||||
if not whole_doc and not target_section:
|
||||
return jsonify({"error": "target_section is required for section mode"}), 400
|
||||
if not instruction:
|
||||
return jsonify({"error": "instruction is required"}), 400
|
||||
|
||||
# Fetch related project notes for context (up to 5, excluding current note).
|
||||
# Glossary-tagged notes are prioritised so the model knows canonical definitions.
|
||||
context_notes: list[dict] = []
|
||||
if project_id:
|
||||
try:
|
||||
pid = int(project_id)
|
||||
# 1) Glossary notes first (up to 3)
|
||||
glossary_notes, _ = await list_notes(
|
||||
uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3
|
||||
)
|
||||
seen_ids: set[int] = set()
|
||||
for n in glossary_notes:
|
||||
if n.id == note_id:
|
||||
continue
|
||||
seen_ids.add(n.id)
|
||||
context_notes.append({
|
||||
"title": n.title or "Untitled",
|
||||
"tags": n.tags or [],
|
||||
"body": n.body or "",
|
||||
})
|
||||
# 2) Fill remaining slots with recently-updated notes
|
||||
if len(context_notes) < 5:
|
||||
recent_notes, _ = await list_notes(
|
||||
uid, project_id=pid, sort="updated_at", order="desc",
|
||||
limit=5 - len(context_notes) + len(seen_ids) + 1,
|
||||
)
|
||||
for n in recent_notes:
|
||||
if n.id == note_id or n.id in seen_ids:
|
||||
continue
|
||||
if len(context_notes) >= 5:
|
||||
break
|
||||
seen_ids.add(n.id)
|
||||
context_notes.append({
|
||||
"title": n.title or "Untitled",
|
||||
"tags": n.tags or [],
|
||||
"body": n.body or "",
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch project context notes for assist", exc_info=True)
|
||||
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
|
||||
messages = build_assist_messages(
|
||||
body, target_section, instruction,
|
||||
whole_doc=whole_doc,
|
||||
context_notes=context_notes or None,
|
||||
)
|
||||
|
||||
buf = create_assist_buffer(uid)
|
||||
asyncio.create_task(run_assist_generation(buf, messages, model))
|
||||
|
||||
return jsonify({"status": "started"}), 202
|
||||
|
||||
|
||||
@notes_bp.route("/assist/stream", methods=["GET"])
|
||||
@login_required
|
||||
async def assist_stream_route():
|
||||
"""SSE endpoint that tails the assist generation buffer."""
|
||||
uid = get_current_user_id()
|
||||
|
||||
buf = get_assist_buffer(uid)
|
||||
if buf is None:
|
||||
return jsonify({"error": "No active assist generation"}), 404
|
||||
|
||||
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
|
||||
try:
|
||||
last_id = int(last_id_str) if last_id_str is not None else -1
|
||||
except (ValueError, TypeError):
|
||||
last_id = -1
|
||||
|
||||
async def stream():
|
||||
cursor = last_id
|
||||
while True:
|
||||
pending = buf.events_after(cursor)
|
||||
for event in pending:
|
||||
yield buf.format_sse(event)
|
||||
cursor = event.index
|
||||
|
||||
if buf.state != GenerationState.RUNNING:
|
||||
break
|
||||
|
||||
got_new = await buf.wait_for_event(cursor, timeout=15.0)
|
||||
if not got_new:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
# Final drain: deliver any events appended between the last yield
|
||||
# and the state check above (e.g. the 'done' event).
|
||||
for event in buf.events_after(cursor):
|
||||
yield buf.format_sse(event)
|
||||
|
||||
return Response(
|
||||
stream(),
|
||||
content_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Link suggestions ─────────────────────────────────────────────────────────
|
||||
|
||||
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
|
||||
|
||||
@@ -5,8 +5,6 @@ from fabledassistant.services.user_profile import (
|
||||
VALID_EXPERTISE,
|
||||
VALID_STYLES,
|
||||
VALID_TONES,
|
||||
clear_learned_data,
|
||||
consolidate_observations,
|
||||
get_profile,
|
||||
update_profile,
|
||||
)
|
||||
@@ -43,29 +41,3 @@ async def update_profile_route():
|
||||
|
||||
profile = await update_profile(uid, data)
|
||||
return jsonify(profile.to_dict())
|
||||
|
||||
|
||||
@profile_bp.route("/consolidate", methods=["POST"])
|
||||
@login_required
|
||||
async def trigger_consolidate():
|
||||
uid = get_current_user_id()
|
||||
summary = await consolidate_observations(uid)
|
||||
return jsonify({"status": "ok", "learned_summary": summary})
|
||||
|
||||
|
||||
@profile_bp.route("/observations", methods=["DELETE"])
|
||||
@login_required
|
||||
async def clear_observations():
|
||||
uid = get_current_user_id()
|
||||
await clear_learned_data(uid)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@profile_bp.route("/observations", methods=["GET"])
|
||||
@login_required
|
||||
async def list_observations():
|
||||
uid = get_current_user_id()
|
||||
profile = await get_profile(uid)
|
||||
raw = list(profile.observations_raw or [])
|
||||
# Newest first, last 14 entries
|
||||
return jsonify({"observations": list(reversed(raw[-14:]))})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Push notification subscription routes."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id, admin_required
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
push_bp = Blueprint("push", __name__, url_prefix="/api/push")
|
||||
|
||||
|
||||
@push_bp.route("/vapid-public-key", methods=["GET"])
|
||||
@login_required
|
||||
async def get_vapid_key():
|
||||
if not vapid_enabled():
|
||||
return jsonify({"error": "Push notifications not configured"}), 503
|
||||
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY})
|
||||
|
||||
|
||||
@push_bp.route("/subscribe", methods=["POST"])
|
||||
@login_required
|
||||
async def subscribe():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
if not data or not data.get("endpoint"):
|
||||
return jsonify({"error": "Invalid subscription data"}), 400
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
sub = await save_subscription(uid, data, user_agent=user_agent)
|
||||
return jsonify({"id": sub.id}), 201
|
||||
|
||||
|
||||
@push_bp.route("/subscribe", methods=["DELETE"])
|
||||
@login_required
|
||||
async def unsubscribe():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
endpoint = (data or {}).get("endpoint", "")
|
||||
if not endpoint:
|
||||
return jsonify({"error": "endpoint is required"}), 400
|
||||
await delete_subscription(uid, endpoint)
|
||||
return "", 204
|
||||
|
||||
|
||||
@push_bp.route("/reset-vapid", methods=["POST"])
|
||||
@admin_required
|
||||
async def reset_vapid():
|
||||
"""Regenerate VAPID keys and clear all push subscriptions."""
|
||||
ok = await regenerate_vapid_keys()
|
||||
if ok:
|
||||
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
|
||||
return jsonify({"error": "Key regeneration failed"}), 500
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Quick-capture endpoint for mobile/external clients.
|
||||
|
||||
POST /api/quick-capture — sends text through the main LLM tool-calling pipeline
|
||||
and returns a single synchronous JSON response. No SSE, no conversation ID.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.llm import stream_chat_with_tools
|
||||
from fabledassistant.services.tools import execute_tool, get_tools_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
|
||||
|
||||
# Tools offered to the quick-capture endpoint. Excludes destructive ops,
|
||||
# read-only queries, and conversational-only tools.
|
||||
_CAPTURE_TOOL_NAMES = {"create_note", "create_event", "update_note", "research_topic"}
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
Today is {today}. You are a quick-capture assistant. The user has sent a short \
|
||||
snippet from their mobile device. Create the appropriate item — note, task, or \
|
||||
calendar event — using the available tools. Always call a tool; never reply \
|
||||
conversationally."""
|
||||
|
||||
|
||||
@quick_capture_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def quick_capture_route():
|
||||
"""Classify text via native tool-calling and create the appropriate item."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
|
||||
from fabledassistant.services.settings import get_setting
|
||||
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
all_tools = await get_tools_for_user(uid)
|
||||
capture_tools = [
|
||||
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
|
||||
]
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
|
||||
# Quick capture is a fast classification path — never think.
|
||||
tool_calls: list[dict] = []
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=False, num_ctx=4096):
|
||||
if chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
tool_calls = chunk.tool_calls
|
||||
except Exception:
|
||||
logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True)
|
||||
|
||||
if tool_calls:
|
||||
tc = tool_calls[0]
|
||||
tool_name = tc.get("function", {}).get("name", "")
|
||||
arguments = tc.get("function", {}).get("arguments", {})
|
||||
|
||||
if tool_name == "research_topic" and Config.searxng_enabled():
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
topic = arguments.get("topic", text)
|
||||
try:
|
||||
note = await run_research_pipeline(topic, uid, model)
|
||||
logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": "note",
|
||||
"message": f"Research note created: {note.title}",
|
||||
"data": {"id": note.id, "title": note.title},
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception("Quick-capture research failed: %s", topic)
|
||||
return jsonify({"error": f"Research failed: {exc}"}), 500
|
||||
|
||||
result = await execute_tool(uid, tool_name, arguments)
|
||||
if result.get("success"):
|
||||
item_type = result.get("type", "note")
|
||||
title = (result.get("data") or {}).get("title", "")
|
||||
logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": item_type,
|
||||
"message": f"{item_type.capitalize()}: {title}",
|
||||
"data": result.get("data"),
|
||||
})
|
||||
logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error"))
|
||||
|
||||
# Fallback: create a plain note with the raw text
|
||||
result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text})
|
||||
if result.get("success"):
|
||||
title = (result.get("data") or {}).get("title", "")
|
||||
logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"type": "note",
|
||||
"message": f"Note created: {title}",
|
||||
"data": result.get("data"),
|
||||
"fallback": True,
|
||||
})
|
||||
|
||||
return jsonify({"error": "Failed to create item"}), 500
|
||||
@@ -1,49 +1,46 @@
|
||||
import asyncio
|
||||
"""User settings + integrations (CalDAV, SearXNG status).
|
||||
|
||||
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
|
||||
hooks were removed in Phase 8 alongside the chat/journal subsystems.
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
||||
from fabledassistant.services.llm import get_installed_models, _is_private_url
|
||||
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
|
||||
"""Fire-and-forget: prime Ollama's KV cache with the user's system prompt."""
|
||||
import httpx
|
||||
from fabledassistant.services.llm import build_context, pick_num_ctx
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
def _is_private_url(url: str) -> bool:
|
||||
"""SSRF-blocking helper: returns True for URLs that resolve to private,
|
||||
loopback, or link-local addresses. Inlined here after services/llm.py
|
||||
(the original home) was removed in Phase 8."""
|
||||
try:
|
||||
messages, _ = await build_context(
|
||||
user_id=user_id,
|
||||
history=[],
|
||||
current_note_id=None,
|
||||
user_message=" ",
|
||||
)
|
||||
# Size the prime to match what real chat requests will use, including
|
||||
# tool schemas — otherwise Ollama reloads the model on the first real
|
||||
# request and throws away the cache we just built.
|
||||
tools = await get_tools_for_user(user_id)
|
||||
num_ctx = pick_num_ctx(messages, tools=tools)
|
||||
from fabledassistant.services.llm import keep_alive_for
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 1, "num_ctx": num_ctx},
|
||||
"keep_alive": keep_alive_for(model),
|
||||
},
|
||||
)
|
||||
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
|
||||
host = urlparse(url).hostname
|
||||
if not host:
|
||||
return True
|
||||
# Resolve to all addresses; reject if any is private/loopback/link-local.
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
for family, *_rest, sockaddr in infos:
|
||||
ip_str = sockaddr[0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
|
||||
# Conservative: if we can't resolve, treat as private (reject).
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
@@ -64,29 +61,10 @@ async def update_settings_route():
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Expected a JSON object"}), 400
|
||||
|
||||
# Normalize model names to lowercase before validation. Ollama's /api/tags
|
||||
# preserves whatever casing was used at pull time, but /api/chat rejects
|
||||
# mixed-case tags — lowercasing here keeps the two paths consistent and
|
||||
# means the stored setting is always in a form Ollama will actually accept.
|
||||
_MODEL_KEYS = frozenset({"default_model", "background_model"})
|
||||
for _key in _MODEL_KEYS:
|
||||
if _key in data and data[_key]:
|
||||
data[_key] = str(data[_key]).lower()
|
||||
|
||||
if "default_model" in data:
|
||||
installed = await get_installed_models()
|
||||
if data["default_model"]:
|
||||
model = str(data["default_model"])
|
||||
if installed and model not in installed:
|
||||
return jsonify({"error": f"Model '{model}' is not installed"}), 400
|
||||
|
||||
# Empty string for model keys means "reset to system default".
|
||||
# Delete the DB row so get_setting() falls back to Config defaults
|
||||
# rather than returning "" and breaking model resolution everywhere.
|
||||
to_save = {}
|
||||
for k, v in data.items():
|
||||
str_v = str(v)
|
||||
if k in _MODEL_KEYS and not str_v:
|
||||
if not str_v:
|
||||
await delete_setting(uid, k)
|
||||
else:
|
||||
to_save[k] = str_v
|
||||
@@ -94,29 +72,10 @@ async def update_settings_route():
|
||||
if to_save:
|
||||
await set_settings_batch(uid, to_save)
|
||||
|
||||
# Live-reschedule the journal daily-prep job when the timezone changes.
|
||||
if "user_timezone" in to_save:
|
||||
from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule
|
||||
await _update_journal_schedule(uid)
|
||||
|
||||
if "default_model" in to_save and to_save["default_model"]:
|
||||
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
|
||||
|
||||
settings = await get_all_settings(uid)
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("/models", methods=["GET"])
|
||||
@login_required
|
||||
async def get_models_route():
|
||||
"""Return installed Ollama models and the configured defaults."""
|
||||
models = sorted(await get_installed_models())
|
||||
return jsonify({
|
||||
"models": models,
|
||||
"default_chat_model": Config.OLLAMA_MODEL,
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["GET"])
|
||||
@login_required
|
||||
async def get_caldav():
|
||||
@@ -166,12 +125,7 @@ async def test_caldav():
|
||||
@settings_bp.route("/search", methods=["GET"])
|
||||
@login_required
|
||||
async def test_search():
|
||||
"""Test SearXNG connectivity and preview results for a query."""
|
||||
"""Report SearXNG configuration status (used by the Integrations tab)."""
|
||||
if not Config.searxng_enabled():
|
||||
return jsonify({"configured": False, "results": [], "searxng_url": ""})
|
||||
q = request.args.get("q", "").strip()
|
||||
if not q:
|
||||
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
results = await _search_searxng(q)
|
||||
return jsonify({"configured": True, "results": results, "query": q, "searxng_url": Config.SEARXNG_URL})
|
||||
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
|
||||
|
||||
@@ -284,23 +284,3 @@ async def delete_task_route(task_id: int):
|
||||
return "", 204
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>/consolidate", methods=["POST"])
|
||||
@login_required
|
||||
async def consolidate_task_route(task_id: int):
|
||||
"""Manually trigger a consolidation pass for a task.
|
||||
|
||||
Bypasses the auto_consolidate_tasks setting (the user is asking
|
||||
explicitly). Returns the task's updated state including the freshly-
|
||||
written body and consolidated_at timestamp.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(uid, task_id)
|
||||
|
||||
note = await get_note(uid, task_id)
|
||||
if note is None:
|
||||
return not_found("Task")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Voice (Speech-to-Speech) routes at /api/voice."""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import admin_required, login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice")
|
||||
|
||||
|
||||
@voice_bp.route("/status", methods=["GET"])
|
||||
@login_required
|
||||
async def voice_status():
|
||||
"""Return availability of STT and TTS services."""
|
||||
from fabledassistant.services.voice_config import get_voice_config
|
||||
from fabledassistant.services.stt import stt_available
|
||||
from fabledassistant.services.tts import tts_available
|
||||
|
||||
config = await get_voice_config()
|
||||
enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
if not enabled:
|
||||
return jsonify({"enabled": False, "stt": False, "tts": False})
|
||||
|
||||
return jsonify({
|
||||
"enabled": True,
|
||||
"stt": stt_available(),
|
||||
"tts": tts_available(),
|
||||
"stt_model": config.get("voice_stt_model", "base.en"),
|
||||
"tts_backend": "piper",
|
||||
})
|
||||
|
||||
|
||||
@voice_bp.route("/voices", methods=["GET"])
|
||||
@login_required
|
||||
async def list_voices():
|
||||
"""Return available piper voice IDs and metadata.
|
||||
|
||||
Scans /opt/piper-voices (bundled) + /data/voices (admin-downloaded)
|
||||
on every call so newly-downloaded voices show up without a restart.
|
||||
Does NOT require tts_available() — even if the active voice failed
|
||||
to load, the catalog is still useful for picking a different one.
|
||||
"""
|
||||
from fabledassistant.services.voice_config import is_voice_enabled
|
||||
if not await is_voice_enabled():
|
||||
return jsonify({"error": "Voice feature is disabled"}), 503
|
||||
|
||||
from fabledassistant.services.tts import list_voices
|
||||
|
||||
return jsonify({"voices": list_voices()})
|
||||
|
||||
|
||||
@voice_bp.route("/transcribe", methods=["POST"])
|
||||
@login_required
|
||||
async def transcribe_audio():
|
||||
"""Accept a multipart audio file and return the transcript.
|
||||
|
||||
Request: multipart/form-data with field 'audio' (WebM/Opus blob)
|
||||
Response: {"transcript": "...", "duration_ms": 123}
|
||||
"""
|
||||
from fabledassistant.services.voice_config import is_voice_enabled
|
||||
if not await is_voice_enabled():
|
||||
return jsonify({"error": "Voice feature is disabled"}), 503
|
||||
|
||||
from fabledassistant.services.stt import stt_available, transcribe
|
||||
|
||||
if not stt_available():
|
||||
return jsonify({"error": "STT not available — model may still be loading"}), 503
|
||||
|
||||
files = await request.files
|
||||
audio_file = files.get("audio")
|
||||
if audio_file is None:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
audio_bytes = audio_file.read()
|
||||
if not audio_bytes:
|
||||
return jsonify({"error": "Empty audio file"}), 400
|
||||
|
||||
if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap
|
||||
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
|
||||
|
||||
mime_type = audio_file.content_type or "audio/webm"
|
||||
form = await request.form
|
||||
context = (form.get("context") or "").strip() or None
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context)
|
||||
except Exception:
|
||||
logger.exception("STT transcription failed")
|
||||
return jsonify({"error": "Transcription failed"}), 500
|
||||
|
||||
duration_ms = round((time.monotonic() - t0) * 1000)
|
||||
return jsonify({"transcript": transcript, "duration_ms": duration_ms})
|
||||
|
||||
|
||||
@voice_bp.route("/synthesise", methods=["POST"])
|
||||
@login_required
|
||||
async def synthesise_speech():
|
||||
"""Convert text to speech and return WAV bytes.
|
||||
|
||||
Request body: {"text": "...", "voice": "af_heart", "speed": 1.0}
|
||||
Response: audio/wav bytes
|
||||
"""
|
||||
from fabledassistant.services.voice_config import is_voice_enabled
|
||||
if not await is_voice_enabled():
|
||||
return jsonify({"error": "Voice feature is disabled"}), 503
|
||||
|
||||
from fabledassistant.services.tts import synthesise, tts_available
|
||||
|
||||
if not tts_available():
|
||||
return jsonify({"error": "TTS not available — model may still be loading"}), 503
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
text = str(data.get("text", "")).strip()
|
||||
if not text:
|
||||
return jsonify({"error": "text is required"}), 400
|
||||
|
||||
char_count = len(text)
|
||||
if char_count > 8000:
|
||||
logger.warning(
|
||||
"TTS request rejected: text too long (%d chars, limit 8000). Preview: %r",
|
||||
char_count, text[:120],
|
||||
)
|
||||
return jsonify({"error": "text too long (max 8000 characters)"}), 400
|
||||
|
||||
# Piper voice file basename (e.g. "en_US-amy-medium"). Default is read
|
||||
# from user settings if not in the request body.
|
||||
voice = str(data.get("voice", "")) or "en_US-amy-medium"
|
||||
try:
|
||||
speed = float(data.get("speed", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
|
||||
# Pull saved settings only when caller didn't override.
|
||||
if "voice" not in data and "speed" not in data:
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.auth import get_current_user_id
|
||||
try:
|
||||
uid = get_current_user_id()
|
||||
saved_voice = await get_setting(uid, "voice_tts_voice", "")
|
||||
if saved_voice:
|
||||
voice = saved_voice
|
||||
saved_speed = await get_setting(uid, "voice_tts_speed", "")
|
||||
if saved_speed:
|
||||
try:
|
||||
speed = float(saved_speed)
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, voice, speed)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
wav_bytes = await synthesise(text, voice=voice, speed=speed)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
|
||||
char_count, voice, text[:120],
|
||||
)
|
||||
return jsonify({"error": "Synthesis failed"}), 500
|
||||
|
||||
duration_ms = round((time.monotonic() - t0) * 1000)
|
||||
if not wav_bytes:
|
||||
logger.warning(
|
||||
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
|
||||
char_count, voice, duration_ms, text[:120],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
|
||||
char_count, len(wav_bytes), duration_ms, voice,
|
||||
)
|
||||
|
||||
from quart import Response
|
||||
return Response(wav_bytes, mimetype="audio/wav")
|
||||
|
||||
|
||||
# ── Voice library (admin only) ──────────────────────────────────────────────
|
||||
# Browse the piper-voices catalog, download new voices into /data/voices,
|
||||
# remove user-installed voices. Bundled voices in /opt/piper-voices are
|
||||
# read-only and cannot be touched via these endpoints.
|
||||
# These are admin-only because installs consume shared disk and affect
|
||||
# every user on the instance (voices are picked per-user, but the files
|
||||
# themselves are shared).
|
||||
|
||||
|
||||
@voice_bp.route("/voices/library", methods=["GET"])
|
||||
@admin_required
|
||||
async def voice_library():
|
||||
"""Return the piper-voices catalog with install state annotations.
|
||||
|
||||
Query params:
|
||||
?refresh=1 — force a fresh fetch from HuggingFace (bypass the 24h
|
||||
in-memory cache). Use sparingly; HF doesn't appreciate hammering.
|
||||
"""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
force = (request.args.get("refresh") or "").lower() in ("1", "true", "yes")
|
||||
try:
|
||||
catalog = await lib.fetch_catalog(force_refresh=force)
|
||||
except Exception:
|
||||
logger.exception("Voice catalog fetch failed")
|
||||
return jsonify({"error": "Failed to fetch voice catalog"}), 502
|
||||
voices = lib.shape_catalog_for_ui(catalog)
|
||||
return jsonify({"voices": voices, "count": len(voices)})
|
||||
|
||||
|
||||
@voice_bp.route("/voices/install", methods=["POST"])
|
||||
@admin_required
|
||||
async def install_voice_route():
|
||||
"""Download a voice into /data/voices.
|
||||
|
||||
Body: {"voice_id": "en_US-amy-medium"}
|
||||
Idempotent — already-installed voices return {"skipped": true} without
|
||||
re-downloading.
|
||||
"""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
data = await request.get_json()
|
||||
voice_id = str((data or {}).get("voice_id") or "").strip()
|
||||
if not voice_id:
|
||||
return jsonify({"error": "voice_id is required"}), 400
|
||||
try:
|
||||
result = await lib.install_voice(voice_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except KeyError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
except Exception:
|
||||
logger.exception("Voice install failed: %s", voice_id)
|
||||
return jsonify({"error": "Voice install failed"}), 500
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@voice_bp.route("/voices/<voice_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def uninstall_voice_route(voice_id: str):
|
||||
"""Remove a /data/voices voice. Bundled voices return 403."""
|
||||
from fabledassistant.services import voice_library as lib
|
||||
|
||||
try:
|
||||
result = await lib.uninstall_voice(voice_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except PermissionError as e:
|
||||
return jsonify({"error": str(e)}), 403
|
||||
except Exception:
|
||||
logger.exception("Voice uninstall failed: %s", voice_id)
|
||||
return jsonify({"error": "Voice uninstall failed"}), 500
|
||||
return jsonify(result)
|
||||
Reference in New Issue
Block a user