Notes
Tasks
+
Chat
+
@@ -51,6 +59,19 @@ const { theme, toggleTheme } = useTheme();
.nav-link:hover {
color: var(--color-primary);
}
+.btn-chat-panel {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ padding: 0.25rem 0.5rem;
+ cursor: pointer;
+ font-size: 1rem;
+ color: var(--color-text);
+ line-height: 1;
+}
+.btn-chat-panel:hover {
+ background: var(--color-bg-card);
+}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue
new file mode 100644
index 0000000..91c3d31
--- /dev/null
+++ b/frontend/src/components/ChatMessage.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+ Note #{{ message.context_note_id }}
+
+
+
+
+
+
diff --git a/frontend/src/components/ChatPanel.vue b/frontend/src/components/ChatPanel.vue
new file mode 100644
index 0000000..fb42a22
--- /dev/null
+++ b/frontend/src/components/ChatPanel.vue
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index e764f4c..87bd93b 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -49,6 +49,16 @@ const router = createRouter({
name: "task-edit",
component: () => import("@/views/TaskEditorView.vue"),
},
+ {
+ path: "/chat",
+ name: "chat",
+ component: () => import("@/views/ChatView.vue"),
+ },
+ {
+ path: "/chat/:id",
+ name: "chat-conversation",
+ component: () => import("@/views/ChatView.vue"),
+ },
],
});
diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts
new file mode 100644
index 0000000..5c9d511
--- /dev/null
+++ b/frontend/src/stores/chat.ts
@@ -0,0 +1,187 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+import {
+ apiGet,
+ apiPost,
+ apiPatch,
+ apiDelete,
+ apiStreamPost,
+} from "@/api/client";
+import type {
+ Conversation,
+ ConversationDetail,
+ Message,
+ OllamaModel,
+} from "@/types/chat";
+
+export const useChatStore = defineStore("chat", () => {
+ const conversations = ref
([]);
+ const currentConversation = ref(null);
+ const total = ref(0);
+ const loading = ref(false);
+ const streaming = ref(false);
+ const streamingContent = ref("");
+ const models = ref([]);
+
+ async function fetchConversations(limit = 50, offset = 0) {
+ loading.value = true;
+ try {
+ const data = await apiGet<{ conversations: Conversation[]; total: number }>(
+ `/api/chat/conversations?limit=${limit}&offset=${offset}`
+ );
+ conversations.value = data.conversations;
+ total.value = data.total;
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function createConversation(
+ title = "",
+ model = ""
+ ): Promise {
+ const conv = await apiPost("/api/chat/conversations", {
+ title,
+ model,
+ });
+ conversations.value.unshift(conv);
+ return conv;
+ }
+
+ async function fetchConversation(id: number) {
+ loading.value = true;
+ try {
+ currentConversation.value = await apiGet(
+ `/api/chat/conversations/${id}`
+ );
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function deleteConversation(id: number) {
+ await apiDelete(`/api/chat/conversations/${id}`);
+ conversations.value = conversations.value.filter((c) => c.id !== id);
+ if (currentConversation.value?.id === id) {
+ currentConversation.value = null;
+ }
+ }
+
+ async function updateTitle(id: number, title: string) {
+ const updated = await apiPatch(
+ `/api/chat/conversations/${id}`,
+ { title }
+ );
+ const idx = conversations.value.findIndex((c) => c.id === id);
+ if (idx !== -1) {
+ conversations.value[idx] = { ...conversations.value[idx], ...updated };
+ }
+ if (currentConversation.value?.id === id) {
+ currentConversation.value.title = updated.title;
+ }
+ }
+
+ async function sendMessage(content: string, contextNoteId?: number | null) {
+ if (!currentConversation.value) return;
+
+ const convId = currentConversation.value.id;
+
+ // Add optimistic user message
+ const userMsg: Message = {
+ id: -Date.now(), // Temporary ID
+ conversation_id: convId,
+ role: "user",
+ content,
+ context_note_id: contextNoteId ?? null,
+ created_at: new Date().toISOString(),
+ };
+ currentConversation.value.messages.push(userMsg);
+
+ streaming.value = true;
+ streamingContent.value = "";
+
+ try {
+ await apiStreamPost(
+ `/api/chat/conversations/${convId}/messages`,
+ { content, context_note_id: contextNoteId },
+ (data) => {
+ if (data.chunk) {
+ streamingContent.value += data.chunk as string;
+ }
+ if (data.done) {
+ // Add the final assistant message
+ const assistantMsg: Message = {
+ id: data.message_id as number,
+ conversation_id: convId,
+ role: "assistant",
+ content: streamingContent.value,
+ context_note_id: null,
+ created_at: new Date().toISOString(),
+ };
+ if (currentConversation.value?.id === convId) {
+ currentConversation.value.messages.push(assistantMsg);
+ }
+ streamingContent.value = "";
+ streaming.value = false;
+
+ // Update conversation in list
+ const idx = conversations.value.findIndex((c) => c.id === convId);
+ if (idx !== -1) {
+ conversations.value[idx].message_count += 2;
+ conversations.value[idx].updated_at = new Date().toISOString();
+ }
+ }
+ if (data.error) {
+ streaming.value = false;
+ streamingContent.value = "";
+ }
+ }
+ );
+ } catch {
+ streaming.value = false;
+ streamingContent.value = "";
+ }
+ }
+
+ async function saveMessageAsNote(messageId: number) {
+ return await apiPost>(
+ `/api/chat/messages/${messageId}/save-as-note`,
+ {}
+ );
+ }
+
+ async function summarizeAsNote(conversationId: number) {
+ return await apiPost>(
+ `/api/chat/conversations/${conversationId}/summarize`,
+ {}
+ );
+ }
+
+ async function fetchModels() {
+ try {
+ const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
+ models.value = data.models;
+ } catch {
+ models.value = [];
+ }
+ }
+
+ return {
+ conversations,
+ currentConversation,
+ total,
+ loading,
+ streaming,
+ streamingContent,
+ models,
+ fetchConversations,
+ createConversation,
+ fetchConversation,
+ deleteConversation,
+ updateTitle,
+ sendMessage,
+ saveMessageAsNote,
+ summarizeAsNote,
+ fetchModels,
+ };
+});
diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts
new file mode 100644
index 0000000..c9ae3f8
--- /dev/null
+++ b/frontend/src/types/chat.ts
@@ -0,0 +1,26 @@
+export interface Message {
+ id: number;
+ conversation_id: number;
+ role: "system" | "user" | "assistant";
+ content: string;
+ context_note_id: number | null;
+ created_at: string;
+}
+
+export interface Conversation {
+ id: number;
+ title: string;
+ model: string;
+ message_count: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ConversationDetail extends Conversation {
+ messages: Message[];
+}
+
+export interface OllamaModel {
+ name: string;
+ size: number;
+}
diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue
new file mode 100644
index 0000000..1625fbd
--- /dev/null
+++ b/frontend/src/views/ChatView.vue
@@ -0,0 +1,443 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Send a message to start the conversation.
+
+
+
+
+
+
+
+
+
+
+
Select a conversation or start a new chat.
+
+
+
+
+
+
+
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 6b1ec17..9ca9d8b 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -5,6 +5,7 @@ from quart import Quart, jsonify, make_response, request, send_from_directory
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.tasks import tasks_bp
@@ -17,9 +18,23 @@ def create_app() -> Quart:
app.secret_key = Config.SECRET_KEY
app.register_blueprint(api)
+ app.register_blueprint(chat_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(tasks_bp)
+ @app.before_serving
+ async def startup():
+ from fabledassistant.services.llm import ensure_model
+
+ try:
+ await ensure_model(Config.OLLAMA_MODEL)
+ except Exception:
+ logger.warning(
+ "Failed to ensure model '%s' on startup — chat may not work until model is available",
+ Config.OLLAMA_MODEL,
+ exc_info=True,
+ )
+
@app.route("/")
async def serve_index():
resp = await make_response(
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index dc8aafa..1aaf939 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -7,4 +7,5 @@ class Config:
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
+ OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me")
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index cf98cbe..8b74abf 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -12,3 +12,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
diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py
new file mode 100644
index 0000000..2b27300
--- /dev/null
+++ b/src/fabledassistant/models/conversation.py
@@ -0,0 +1,71 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from fabledassistant.models import Base
+
+
+class Conversation(Base):
+ __tablename__ = "conversations"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(Text, default="")
+ model: Mapped[str] = mapped_column(Text, default="")
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+ messages: Mapped[list["Message"]] = relationship(
+ back_populates="conversation",
+ cascade="all, delete-orphan",
+ order_by="Message.created_at",
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "title": self.title,
+ "model": self.model,
+ "message_count": len(self.messages) if self.messages else 0,
+ "created_at": self.created_at.isoformat(),
+ "updated_at": self.updated_at.isoformat(),
+ }
+
+
+class Message(Base):
+ __tablename__ = "messages"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ conversation_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("conversations.id", ondelete="CASCADE")
+ )
+ role: Mapped[str] = mapped_column(Text)
+ content: Mapped[str] = mapped_column(Text, default="")
+ context_note_id: Mapped[int | None] = mapped_column(
+ Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+
+ conversation: Mapped["Conversation"] = relationship(back_populates="messages")
+
+ __table_args__ = (
+ Index("ix_messages_conversation_id", "conversation_id"),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "conversation_id": self.conversation_id,
+ "role": self.role,
+ "content": self.content,
+ "context_note_id": self.context_note_id,
+ "created_at": self.created_at.isoformat(),
+ }
diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py
new file mode 100644
index 0000000..a35a103
--- /dev/null
+++ b/src/fabledassistant/routes/chat.py
@@ -0,0 +1,175 @@
+import json
+import logging
+
+from quart import Blueprint, Response, jsonify, request
+
+from fabledassistant.config import Config
+from fabledassistant.services.chat import (
+ add_message,
+ create_conversation,
+ delete_conversation,
+ get_conversation,
+ list_conversations,
+ save_response_as_note,
+ summarize_conversation_as_note,
+ update_conversation_title,
+)
+from fabledassistant.services.llm import build_context, stream_chat
+
+logger = logging.getLogger(__name__)
+
+chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
+
+
+@chat_bp.route("/conversations", methods=["GET"])
+async def list_conversations_route():
+ limit = request.args.get("limit", 50, type=int)
+ offset = request.args.get("offset", 0, type=int)
+ conversations, total = await list_conversations(limit=limit, offset=offset)
+ return jsonify({
+ "conversations": [c.to_dict() for c in conversations],
+ "total": total,
+ })
+
+
+@chat_bp.route("/conversations", methods=["POST"])
+async def create_conversation_route():
+ data = await request.get_json(force=True, silent=True) or {}
+ title = data.get("title", "")
+ model = data.get("model", Config.OLLAMA_MODEL)
+ conv = await create_conversation(title=title, model=model)
+ return jsonify(conv.to_dict()), 201
+
+
+@chat_bp.route("/conversations/", methods=["GET"])
+async def get_conversation_route(conv_id: int):
+ conv = await get_conversation(conv_id)
+ if conv is None:
+ return jsonify({"error": "Conversation not found"}), 404
+ result = conv.to_dict()
+ result["messages"] = [m.to_dict() for m in conv.messages]
+ return jsonify(result)
+
+
+@chat_bp.route("/conversations/", methods=["DELETE"])
+async def delete_conversation_route(conv_id: int):
+ deleted = await delete_conversation(conv_id)
+ if not deleted:
+ return jsonify({"error": "Conversation not found"}), 404
+ return "", 204
+
+
+@chat_bp.route("/conversations/", methods=["PATCH"])
+async def update_conversation_route(conv_id: int):
+ 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(conv_id, title)
+ if conv is None:
+ return jsonify({"error": "Conversation not found"}), 404
+ return jsonify(conv.to_dict())
+
+
+@chat_bp.route("/conversations//messages", methods=["POST"])
+async def send_message_route(conv_id: int):
+ """Send a user message, stream the LLM response via SSE."""
+ conv = await get_conversation(conv_id)
+ if conv is None:
+ return jsonify({"error": "Conversation not found"}), 404
+
+ 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")
+
+ # Save user message
+ await add_message(conv_id, "user", content, context_note_id=context_note_id)
+
+ # Build history from existing messages (excluding system)
+ history = []
+ for msg in conv.messages:
+ if msg.role != "system":
+ history.append({"role": msg.role, "content": msg.content})
+
+ # Build context with note search, URL fetching, etc.
+ messages = await build_context(history, context_note_id, content)
+
+ model = conv.model or Config.OLLAMA_MODEL
+
+ async def generate():
+ full_response = []
+ try:
+ async for chunk in stream_chat(messages, model):
+ full_response.append(chunk)
+ event_data = json.dumps({"chunk": chunk})
+ yield f"data: {event_data}\n\n"
+
+ # Save complete assistant message
+ full_text = "".join(full_response)
+ msg = await add_message(conv_id, "assistant", full_text)
+
+ # Auto-generate title from first user message if conversation has no title
+ if not conv.title:
+ title = content[:80]
+ if len(content) > 80:
+ title += "..."
+ await update_conversation_title(conv_id, title)
+
+ done_data = json.dumps({"done": True, "message_id": msg.id})
+ yield f"data: {done_data}\n\n"
+ except Exception as e:
+ logger.exception("Error streaming LLM response")
+ error_data = json.dumps({"error": str(e)})
+ yield f"data: {error_data}\n\n"
+
+ return Response(
+ generate(),
+ content_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@chat_bp.route("/messages//save-as-note", methods=["POST"])
+async def save_message_as_note_route(message_id: int):
+ try:
+ note = await save_response_as_note(message_id)
+ return jsonify(note), 201
+ except ValueError as e:
+ return jsonify({"error": str(e)}), 400
+
+
+@chat_bp.route("/conversations//summarize", methods=["POST"])
+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
+ try:
+ note = await summarize_conversation_as_note(conv_id, model)
+ return jsonify(note), 201
+ except ValueError as e:
+ return jsonify({"error": str(e)}), 400
+
+
+@chat_bp.route("/models", methods=["GET"])
+async def list_models_route():
+ import httpx
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
+ resp.raise_for_status()
+ data = resp.json()
+ models = [
+ {"name": m["name"], "size": m.get("size", 0)}
+ for m in data.get("models", [])
+ ]
+ return jsonify({"models": models})
+ except Exception as e:
+ logger.warning("Failed to list Ollama models: %s", e)
+ return jsonify({"models": [], "error": str(e)}), 200
diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py
new file mode 100644
index 0000000..e0db761
--- /dev/null
+++ b/src/fabledassistant/services/chat.py
@@ -0,0 +1,155 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import selectinload
+
+from fabledassistant.models import async_session
+from fabledassistant.models.conversation import Conversation, Message
+from fabledassistant.services.llm import generate_completion
+from fabledassistant.services.notes import create_note
+
+
+async def create_conversation(title: str = "", model: str = "") -> Conversation:
+ async with async_session() as session:
+ conv = Conversation(title=title, model=model)
+ session.add(conv)
+ await session.commit()
+ await session.refresh(conv)
+ # Initialize empty messages list for to_dict()
+ conv.messages = []
+ return conv
+
+
+async def get_conversation(conversation_id: int) -> Conversation | None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(Conversation)
+ .options(selectinload(Conversation.messages))
+ .where(Conversation.id == conversation_id)
+ )
+ return result.scalars().first()
+
+
+async def list_conversations(
+ limit: int = 50, offset: int = 0
+) -> tuple[list[Conversation], int]:
+ async with async_session() as session:
+ total = await session.scalar(
+ select(func.count(Conversation.id))
+ ) or 0
+
+ result = await session.execute(
+ select(Conversation)
+ .options(selectinload(Conversation.messages))
+ .order_by(Conversation.updated_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ conversations = list(result.scalars().all())
+ return conversations, total
+
+
+async def delete_conversation(conversation_id: int) -> bool:
+ async with async_session() as session:
+ conv = await session.get(Conversation, conversation_id)
+ if conv is None:
+ return False
+ await session.delete(conv)
+ await session.commit()
+ return True
+
+
+async def update_conversation_title(
+ conversation_id: int, title: str
+) -> Conversation | None:
+ async with async_session() as session:
+ conv = await session.get(Conversation, conversation_id)
+ if conv is None:
+ return None
+ conv.title = title
+ conv.updated_at = datetime.now(timezone.utc)
+ await session.commit()
+ await session.refresh(conv)
+ return conv
+
+
+async def add_message(
+ conversation_id: int,
+ role: str,
+ content: str,
+ context_note_id: int | None = None,
+) -> Message:
+ async with async_session() as session:
+ msg = Message(
+ conversation_id=conversation_id,
+ role=role,
+ content=content,
+ context_note_id=context_note_id,
+ )
+ session.add(msg)
+ # Touch conversation updated_at
+ conv = await session.get(Conversation, conversation_id)
+ if conv:
+ conv.updated_at = datetime.now(timezone.utc)
+ await session.commit()
+ await session.refresh(msg)
+ return msg
+
+
+async def get_message(message_id: int) -> Message | None:
+ async with async_session() as session:
+ return await session.get(Message, message_id)
+
+
+async def save_response_as_note(message_id: int) -> dict:
+ """Create a note from an assistant message. Returns the new note dict."""
+ msg = await get_message(message_id)
+ if msg is None:
+ raise ValueError("Message not found")
+ if msg.role != "assistant":
+ raise ValueError("Can only save assistant messages as notes")
+
+ # Use first line as title, rest as body
+ lines = msg.content.strip().split("\n", 1)
+ title = lines[0].strip().lstrip("# ")[:100]
+ body = msg.content
+
+ note = await create_note(title=title, body=body)
+ return note.to_dict()
+
+
+async def summarize_conversation_as_note(
+ conversation_id: int, model: str
+) -> dict:
+ """Summarize a conversation using the LLM and save as a note."""
+ conv = await get_conversation(conversation_id)
+ if conv is None:
+ raise ValueError("Conversation not found")
+
+ # Build the conversation text
+ conv_text = []
+ for msg in conv.messages:
+ if msg.role == "system":
+ continue
+ label = "User" if msg.role == "user" else "Assistant"
+ conv_text.append(f"{label}: {msg.content}")
+
+ prompt_messages = [
+ {
+ "role": "system",
+ "content": (
+ "Summarize the following conversation into a concise note. "
+ "Include key points, decisions, and any action items. "
+ "Format the summary in markdown."
+ ),
+ },
+ {"role": "user", "content": "\n\n".join(conv_text)},
+ ]
+
+ summary = await generate_completion(prompt_messages, model)
+
+ title = conv.title or "Conversation Summary"
+ title = f"Summary: {title}"
+
+ note = await create_note(title=title, body=summary)
+ return note.to_dict()
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
new file mode 100644
index 0000000..19bd5f2
--- /dev/null
+++ b/src/fabledassistant/services/llm.py
@@ -0,0 +1,194 @@
+import json
+import logging
+import re
+from collections.abc import AsyncGenerator
+
+import httpx
+
+from fabledassistant.config import Config
+from fabledassistant.services.notes import get_note, list_notes
+
+logger = logging.getLogger(__name__)
+
+STOP_WORDS = frozenset({
+ "a", "an", "the", "is", "it", "to", "in", "for", "of", "and", "or",
+ "on", "at", "by", "with", "from", "as", "be", "was", "were", "been",
+ "are", "am", "do", "does", "did", "have", "has", "had", "will", "would",
+ "can", "could", "shall", "should", "may", "might", "must", "that",
+ "this", "these", "those", "i", "me", "my", "you", "your", "he", "she",
+ "we", "they", "them", "his", "her", "its", "our", "their", "what",
+ "which", "who", "whom", "how", "when", "where", "why", "not", "no",
+ "but", "if", "so", "than", "too", "very", "just", "about", "up",
+})
+
+
+async def ensure_model(model: str) -> None:
+ """Check if model exists in Ollama, pull if missing."""
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
+ resp.raise_for_status()
+ data = resp.json()
+ names = {m["name"] for m in data.get("models", [])}
+ # Check both with and without :latest tag
+ if model in names or f"{model}:latest" in names:
+ logger.info("Model '%s' already available", model)
+ return
+ except Exception:
+ logger.warning("Failed to check Ollama models, attempting pull anyway")
+
+ logger.info("Pulling model '%s' from Ollama...", model)
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ async with client.stream(
+ "POST",
+ f"{Config.OLLAMA_URL}/api/pull",
+ json={"name": model},
+ ) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if line.strip():
+ status = json.loads(line)
+ if "status" in status:
+ logger.info("Pull %s: %s", model, status["status"])
+ logger.info("Model '%s' pulled successfully", model)
+ except Exception:
+ logger.warning("Failed to pull model '%s' — chat may not work", model, exc_info=True)
+
+
+async def stream_chat(
+ messages: list[dict], model: str
+) -> AsyncGenerator[str, None]:
+ """Stream chat completion from Ollama, yielding content chunks."""
+ async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
+ async with client.stream(
+ "POST",
+ f"{Config.OLLAMA_URL}/api/chat",
+ json={"model": model, "messages": messages, "stream": True},
+ ) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if not line.strip():
+ continue
+ data = json.loads(line)
+ chunk = data.get("message", {}).get("content", "")
+ if chunk:
+ yield chunk
+ if data.get("done"):
+ break
+
+
+async def generate_completion(messages: list[dict], model: str) -> str:
+ """Non-streaming chat completion, returns full response text."""
+ async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
+ resp = await client.post(
+ f"{Config.OLLAMA_URL}/api/chat",
+ json={"model": model, "messages": messages, "stream": False},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return data.get("message", {}).get("content", "")
+
+
+async def fetch_url_content(url: str) -> str:
+ """Fetch a URL and return text content (HTML tags stripped)."""
+ try:
+ async with httpx.AsyncClient(
+ timeout=15.0, follow_redirects=True, headers={"User-Agent": "FabledAssistant/1.0"}
+ ) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ text = resp.text
+ # Strip HTML tags
+ text = re.sub(r"", "", text, flags=re.DOTALL)
+ text = re.sub(r"", "", text, flags=re.DOTALL)
+ text = re.sub(r"<[^>]+>", " ", text)
+ # Collapse whitespace
+ text = re.sub(r"\s+", " ", text).strip()
+ # Truncate to reasonable size
+ if len(text) > 4000:
+ text = text[:4000] + "..."
+ return text
+ except Exception as e:
+ logger.warning("Failed to fetch URL %s: %s", url, e)
+ return f"[Failed to fetch URL: {url}]"
+
+
+def _extract_keywords(text: str) -> list[str]:
+ """Extract meaningful keywords from text for note search."""
+ words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower())
+ keywords = [w for w in words if w not in STOP_WORDS]
+ # Deduplicate while preserving order
+ seen: set[str] = set()
+ unique = []
+ for w in keywords:
+ if w not in seen:
+ seen.add(w)
+ unique.append(w)
+ return unique[:5]
+
+
+def _find_urls(text: str) -> list[str]:
+ """Find URLs in text."""
+ return re.findall(r"https?://[^\s<>\"')\]]+", text)
+
+
+async def build_context(
+ history: list[dict],
+ current_note_id: int | None,
+ user_message: str,
+) -> list[dict]:
+ """Build messages array for Ollama with system prompt and context."""
+ system_parts = [
+ "You are a helpful assistant 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."
+ ]
+
+ # Include current note context if provided
+ if current_note_id:
+ note = await get_note(current_note_id)
+ if note:
+ system_parts.append(
+ f"\n\n--- Current Note ---\n"
+ f"Title: {note.title}\n"
+ f"Content:\n{note.body}\n"
+ f"--- End Note ---"
+ )
+
+ # Search notes by keywords from user message
+ keywords = _extract_keywords(user_message)
+ if keywords:
+ search_q = " ".join(keywords[:3])
+ try:
+ notes, _ = await list_notes(q=search_q, limit=3)
+ if notes:
+ snippets = []
+ for n in notes:
+ # Skip the current note (already included)
+ if current_note_id and n.id == current_note_id:
+ continue
+ body_preview = n.body[:300] if n.body else ""
+ snippets.append(f"- {n.title}: {body_preview}")
+ if snippets:
+ system_parts.append(
+ "\n\n--- Related Notes ---\n"
+ + "\n".join(snippets)
+ + "\n--- End Related Notes ---"
+ )
+ except Exception:
+ pass
+
+ # Fetch URL content from user message
+ urls = _find_urls(user_message)
+ for url in urls[:2]: # Limit to 2 URLs
+ content = await fetch_url_content(url)
+ if content and not content.startswith("[Failed"):
+ system_parts.append(
+ f"\n\n--- Content from {url} ---\n{content}\n--- End URL Content ---"
+ )
+
+ messages = [{"role": "system", "content": "".join(system_parts)}]
+ messages.extend(history)
+ messages.append({"role": "user", "content": user_message})
+ return messages
diff --git a/summary.md b/summary.md
index a79c3e7..d3f8d1e 100644
--- a/summary.md
+++ b/summary.md
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
-2026-02-10 — Phase 3.6 complete (Merged tasks into notes — a task is just a note with task attributes)
+2026-02-10 — Phase 4 complete (LLM Chat Integration — streaming chat with Ollama, note-aware context, save/summarize as note)
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -56,6 +56,15 @@ for AI-assisted features.
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes.
+- **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream`
+ response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we
+ need to POST the message body. Simpler than WebSockets; Quart supports async
+ generators natively for SSE.
+- **Context building server-side:** Backend fetches URL content and searches notes —
+ frontend just sends the message text + optional note ID. Keyword extraction uses
+ simple word splitting with stopword filtering (no embeddings).
+- **Auto-pull model on startup:** Non-blocking, logs warning on failure so the app
+ still starts even if Ollama isn't ready.
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
`[[Title]]` patterns referencing the given note. Results include `type: "note"` or
`type: "task"` based on whether the linking note has `status IS NOT NULL`.
@@ -114,9 +123,18 @@ for AI-assisted features.
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
- Task body field is `body` (not `description`) — standardized with notes
-### LLM Interactions (Phase 4)
-- Summarize notes, generate task breakdowns, search/query across notes,
- chat-style assistant within the app
+### Conversations
+- `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at`
+- Has many messages (cascade delete)
+- Title auto-generated from first user message if not set
+- `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at`
+
+### Messages
+- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
+ `content` (str), `context_note_id` (nullable FK to notes, SET NULL), `created_at`
+- Index on `conversation_id`
+- `context_note_id` tracks which note was attached as context when message was sent
+- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `context_note_id`, `created_at`
## Project Structure (Current)
```
@@ -132,23 +150,28 @@ fabledassistant/
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
-│ └── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
+│ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
+│ └── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│ ├── config.py # Config from env vars
│ ├── models/
-│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority
-│ │ └── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
+│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message
+│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
+│ │ └── conversation.py # Conversation + Message models with relationships and to_dict()
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
+│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
│ ├── services/
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
-│ │ └── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
+│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
+│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search, URL fetching)
+│ │ └── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -158,26 +181,29 @@ fabledassistant/
├── vite.config.ts
├── tsconfig.json
├── src/
- │ ├── App.vue # Shell: AppHeader + router-view + ToastNotification
+ │ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
│ ├── api/
- │ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers
+ │ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
+ │ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
+ │ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel interfaces
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
│ ├── views/
+ │ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
@@ -186,7 +212,9 @@ fabledassistant/
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ ├── components/
- │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle
+ │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat links, chat panel toggle, theme toggle
+ │ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop)
+ │ │ ├── ChatMessage.vue # Message bubble: markdown rendering, role label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
@@ -197,7 +225,7 @@ fabledassistant/
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container
│ └── router/
- │ └── index.ts # Routes: /, /notes/*, /tasks/*
+ │ └── index.ts # Routes: /, /notes/*, /tasks/*, /chat, /chat/:id
└── public/
```
@@ -223,6 +251,15 @@ fabledassistant/
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
+| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
+| POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) |
+| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
+| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
+| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
+| POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?}`) |
+| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note |
+| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
+| GET | `/api/chat/models` | List available Ollama models |
## Alembic Migrations
@@ -233,7 +270,7 @@ container startup.
### Migration Chain
```
-0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py
+0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py
```
### How Migrations Run
@@ -363,12 +400,18 @@ When adding a new migration, follow these conventions:
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
-### Phase 4 — LLM Integration (next)
-- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp)
-- [ ] API endpoints for LLM features (summarize note, generate tasks from note,
- freeform chat)
-- [ ] Vue components for LLM interactions (chat panel, summarize button, etc.)
-- [ ] Configurable LLM endpoint + model selection in app settings
+### Phase 4 — LLM Chat Integration ✓
+- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
+- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars)
+- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
+- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
+- [x] **Save as note:** Save any assistant message as a new note (first line as title)
+- [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note
+- [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
+- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
+- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
+- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
+- [x] **Auto-title:** Conversation title auto-set from first user message content
### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD)
@@ -378,7 +421,6 @@ When adding a new migration, follow these conventions:
- [ ] Error handling, logging, monitoring
### Future / Stretch
-- WebSocket streaming for LLM responses
- Tagging/labeling system with LLM-suggested tags
- Calendar/timeline view for tasks
- Import/export (Markdown files, JSON)
@@ -394,19 +436,17 @@ When adding a new migration, follow these conventions:
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
-- Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5?
## Current Status
-**Phase:** Phase 3.6 complete. Tasks merged into notes — unified single-table model.
-- Single `notes` table with optional `status`, `priority`, `due_date` columns
-- A note **is a task** when `status IS NOT NULL`
-- Convert between note ↔ task by setting/clearing task attributes (same ID preserved)
-- No companion notes, no cascade deletes, no bidirectional sync
-- Task body standardized as `body` (not `description`)
-- `services/tasks.py` is a thin wrapper around `services/notes.py`
-- Frontend `Task` type is an alias for `Note`
-- Full notes CRUD with markdown editing, rendering, and wikilinks
-- Full tasks CRUD with status/priority enums and filters
-- Backlinks, wikilink auto-create, tag autocomplete all work across unified model
-- Dark/light theme with status/priority/wikilink color variables
-- Ready for Phase 4: LLM Integration
+**Phase:** Phase 4 complete. LLM chat integration with streaming responses.
+- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
+- Unified note/task model (a task is a note with `status IS NOT NULL`)
+- LLM chat via Ollama with SSE streaming responses
+- Note-aware context: auto-includes current note + searches related notes by keyword
+- URL content fetching: detects URLs in messages, fetches and includes content
+- Save assistant messages as notes; summarize entire conversations as notes
+- Dedicated `/chat` page with conversation sidebar + message thread
+- Slide-out chat panel accessible from any page, auto-includes note/task context
+- Auto-pull default model (`llama3.1`) on startup
+- Dark/light theme with CSS custom properties
+- Ready for Phase 5: Polish & Production Hardening