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
+155
View File
@@ -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()