Add settings page, model management, and chat UX improvements
- Settings infrastructure: key-value settings table, GET/PUT API, Pinia store - Configurable assistant name (default "Fable") in settings and LLM system prompt - Model catalog with 18 models in 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with download/select/remove functionality - Move Ollama status indicator from chat views to global nav bar - Chat bubble layout: user messages right-aligned, assistant left-aligned - Floating dark input bar with auto-focus and circular send button - Fix HTML entity rendering (' apostrophe issue in marked/DOMPurify pipeline) - Fix new chat button navigation (fetchConversation before router.push) - Recent chats section on home page with "New Chat" button - Update summary.md with Phase 4.5 changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from fabledassistant.config import Config
|
||||
from fabledassistant.routes.api import api
|
||||
from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
from fabledassistant.routes.tasks import tasks_bp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
@@ -20,6 +21,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
|
||||
@app.before_serving
|
||||
|
||||
@@ -13,3 +13,4 @@ class Base(DeclarativeBase):
|
||||
|
||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"key": self.key, "value": self.value}
|
||||
@@ -15,7 +15,8 @@ from fabledassistant.services.chat import (
|
||||
summarize_conversation_as_note,
|
||||
update_conversation_title,
|
||||
)
|
||||
from fabledassistant.services.llm import build_context, stream_chat
|
||||
from fabledassistant.services.llm import build_context, ensure_model, stream_chat
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -97,7 +98,7 @@ async def send_message_route(conv_id: int):
|
||||
# Build context with note search, URL fetching, etc.
|
||||
messages = await build_context(history, context_note_id, content)
|
||||
|
||||
model = conv.model or Config.OLLAMA_MODEL
|
||||
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
async def generate():
|
||||
full_response = []
|
||||
@@ -149,7 +150,7 @@ async def summarize_conversation_route(conv_id: int):
|
||||
conv = await get_conversation(conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
model = conv.model or Config.OLLAMA_MODEL
|
||||
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||
try:
|
||||
note = await summarize_conversation_as_note(conv_id, model)
|
||||
return jsonify(note), 201
|
||||
@@ -160,7 +161,7 @@ async def summarize_conversation_route(conv_id: int):
|
||||
@chat_bp.route("/status", methods=["GET"])
|
||||
async def chat_status_route():
|
||||
"""Check Ollama availability and model readiness."""
|
||||
default_model = Config.OLLAMA_MODEL
|
||||
default_model = await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||
result = {
|
||||
"ollama": "unavailable",
|
||||
"model": "not_found",
|
||||
@@ -196,3 +197,41 @@ async def list_models_route():
|
||||
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"])
|
||||
async def pull_model_route():
|
||||
"""Pull a model from Ollama. Runs in the background."""
|
||||
data = await request.get_json()
|
||||
model_name = data.get("model", "").strip()
|
||||
if not model_name:
|
||||
return jsonify({"error": "model is required"}), 400
|
||||
|
||||
import asyncio
|
||||
asyncio.create_task(ensure_model(model_name))
|
||||
return jsonify({"status": "pulling", "model": model_name}), 202
|
||||
|
||||
|
||||
@chat_bp.route("/models/delete", methods=["POST"])
|
||||
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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.services.settings import get_all_settings, set_setting
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
|
||||
@settings_bp.route("", methods=["GET"])
|
||||
async def get_settings_route():
|
||||
settings = await get_all_settings()
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("", methods=["PUT"])
|
||||
async def update_settings_route():
|
||||
data = await request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Expected a JSON object"}), 400
|
||||
for key, value in data.items():
|
||||
await set_setting(key, str(value))
|
||||
settings = await get_all_settings()
|
||||
return jsonify(settings)
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.notes import get_note, list_notes
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,8 +140,9 @@ async def build_context(
|
||||
user_message: str,
|
||||
) -> list[dict]:
|
||||
"""Build messages array for Ollama with system prompt and context."""
|
||||
assistant_name = await get_setting("assistant_name", "Fable")
|
||||
system_parts = [
|
||||
"You are a helpful assistant integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||
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."
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
|
||||
async def get_setting(key: str, default: str = "") -> str:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else default
|
||||
|
||||
|
||||
async def set_setting(key: str, value: str) -> None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
session.add(Setting(key=key, value=value))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_all_settings() -> dict[str, str]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Setting))
|
||||
return {s.key: s.value for s in result.scalars().all()}
|
||||
Reference in New Issue
Block a user