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:
@@ -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()
|
||||
Reference in New Issue
Block a user