From d2b8ab8fe8b5b786c5a5d764cbdd297e2b73db49 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 10 Feb 2026 18:45:22 -0500 Subject: [PATCH] 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 --- alembic/versions/0005_add_chat_tables.py | 68 ++++ docker-compose.yml | 1 + frontend/src/App.vue | 27 +- frontend/src/api/client.ts | 52 +++ frontend/src/components/AppHeader.vue | 21 + frontend/src/components/ChatMessage.vue | 113 ++++++ frontend/src/components/ChatPanel.vue | 273 +++++++++++++ frontend/src/router/index.ts | 10 + frontend/src/stores/chat.ts | 187 +++++++++ frontend/src/types/chat.ts | 26 ++ frontend/src/views/ChatView.vue | 443 +++++++++++++++++++++ src/fabledassistant/app.py | 15 + src/fabledassistant/config.py | 1 + src/fabledassistant/models/__init__.py | 1 + src/fabledassistant/models/conversation.py | 71 ++++ src/fabledassistant/routes/chat.py | 175 ++++++++ src/fabledassistant/services/chat.py | 155 +++++++ src/fabledassistant/services/llm.py | 194 +++++++++ summary.md | 108 +++-- 19 files changed, 1906 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/0005_add_chat_tables.py create mode 100644 frontend/src/components/ChatMessage.vue create mode 100644 frontend/src/components/ChatPanel.vue create mode 100644 frontend/src/stores/chat.ts create mode 100644 frontend/src/types/chat.ts create mode 100644 frontend/src/views/ChatView.vue create mode 100644 src/fabledassistant/models/conversation.py create mode 100644 src/fabledassistant/routes/chat.py create mode 100644 src/fabledassistant/services/chat.py create mode 100644 src/fabledassistant/services/llm.py diff --git a/alembic/versions/0005_add_chat_tables.py b/alembic/versions/0005_add_chat_tables.py new file mode 100644 index 0000000..2cf7e12 --- /dev/null +++ b/alembic/versions/0005_add_chat_tables.py @@ -0,0 +1,68 @@ +"""add chat tables + +Revision ID: 0005 +Revises: 0004 +Create Date: 2025-01-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "conversations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.Text(), nullable=False, server_default=""), + sa.Column("model", sa.Text(), nullable=False, server_default=""), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + op.create_table( + "messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "conversation_id", + sa.Integer(), + sa.ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("content", sa.Text(), nullable=False, server_default=""), + sa.Column( + "context_note_id", + sa.Integer(), + sa.ForeignKey("notes.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + op.create_index("ix_messages_conversation_id", "messages", ["conversation_id"]) + + +def downgrade() -> None: + op.drop_index("ix_messages_conversation_id", table_name="messages") + op.drop_table("messages") + op.drop_table("conversations") diff --git a/docker-compose.yml b/docker-compose.yml index 7da3517..3d0d465 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" OLLAMA_URL: "http://ollama:11434" + OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" db: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f37049a..5c3602e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,13 +1,38 @@ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d799386..24fddf1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -48,3 +48,55 @@ export async function apiDelete(path: string): Promise { throw new Error(`API error: ${res.status}`); } } + +export async function apiStreamPost( + path: string, + body: unknown, + onChunk: (data: Record) => void +): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + // Keep the last (possibly incomplete) line in the buffer + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("data: ")) { + try { + const data = JSON.parse(trimmed.slice(6)); + onChunk(data); + } catch { + // Skip malformed JSON lines + } + } + } + } + + // Process any remaining buffer + if (buffer.trim().startsWith("data: ")) { + try { + const data = JSON.parse(buffer.trim().slice(6)); + onChunk(data); + } catch { + // Skip malformed JSON + } + } +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 88f0ac6..7dc332c 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -2,6 +2,10 @@ import { useTheme } from "@/composables/useTheme"; const { theme, toggleTheme } = useTheme(); + +const emit = defineEmits<{ + toggleChatPanel: []; +}>();