Add LLM chat integration with streaming responses via Ollama

Phase 4: Full chat system with SSE streaming, note-aware context, and
conversation persistence.

Backend:
- Migration 0005: conversations + messages tables with FKs and indexes
- Conversation/Message SQLAlchemy models with relationships
- LLM service: ensure_model (auto-pull on startup), stream_chat (NDJSON),
  generate_completion, fetch_url_content (HTML stripping), build_context
  (keyword extraction, related note search, URL content injection)
- Chat service: conversation CRUD, save_response_as_note,
  summarize_conversation_as_note
- Chat routes blueprint: 9 endpoints including SSE streaming for messages,
  save/summarize as note, Ollama model listing
- Auto-pull llama3.1 model on app startup (non-blocking)

Frontend:
- apiStreamPost: SSE client using fetch + ReadableStream
- Chat Pinia store with streaming state management
- ChatView: dedicated /chat page with conversation sidebar + message thread
- ChatPanel: slide-out panel with contextNoteId from current route
- ChatMessage: markdown-rendered message bubble with "Save as Note" action
- Updated AppHeader with Chat nav link + panel toggle button
- Updated App.vue to mount ChatPanel with route-derived context
- Added /chat and /chat/:id routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 18:45:22 -05:00
parent 807cde30be
commit d2b8ab8fe8
19 changed files with 1906 additions and 35 deletions
+1
View File
@@ -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
@@ -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(),
}