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:
+74
-34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user