From 4806c34a3c8b3491ec26f1bf4d380dae35b83a6d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 19:10:25 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20Phase=2010=20=E2=80=94=20Ollama=20s?= =?UTF-8?q?ervice,=20image=20cache,=20config,=20frontend=20orphans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final cleanup phase of the MCP-first pivot. docker-compose: - docker-compose.yml: drop ollama service + OLLAMA_URL/MODEL env vars + IMAGE_CACHE / VAPID env comments - docker-compose.prod.yml: drop ollama service + Ollama env + GPU reservation - docker-compose.quickstart.yml: drop ollama service + Ollama env + GPU-reservation comment; quickstart instructions now point at the MCP Access tab instead of model-pull Config: - Drop OLLAMA_URL, OLLAMA_MODEL, OLLAMA_BACKGROUND_MODEL, OLLAMA_KEEP_ALIVE_*, OLLAMA_NUM_CTX, EMBEDDING_MODEL (fastembed is hard-coded inside services/embeddings.py) - Drop IMAGE_CACHE_DIR, IMAGE_MAX_BYTES (image cache subsystem deleted) - Drop VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY, VAPID_CLAIMS_SUB (push deleted in phase 8) - Drop VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND (voice deleted in phase 8) - Drop Config.validate() rules for those keys Image cache deletion: - services/images.py, routes/images.py, models/image_cache.py - models/__init__.py: drop ImageCache import - app.py: drop images_bp registration - alembic/versions/0054_drop_image_cache.py: DROP TABLE image_cache Frontend client.ts orphan exports stripped: - getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice, uninstallVoice, transcribeAudio, synthesiseSpeech, VoiceStatusResult / VoiceEntry / VoiceLibraryEntry types - getJournalConfig, saveJournalConfig, getJournalToday/Day/Days, triggerJournalPrep, runJournalCurator, listPendingActions, approvePendingAction, rejectPendingAction, listJournalMoments, updateJournalMoment, deleteJournalMoment, geocodeAddress - JournalConfig / JournalLocation / JournalConversation / JournalMessage / JournalDayPayload / JournalMoment / CuratorRunResult / PendingCuratorAction types - consolidateProfile, clearProfileObservations, listProfileObservations - ProfileObservationEntry, learned_summary/observations_* fields on UserProfile - consolidateTask (cascading update to TaskEditorView) - getFableMcpInfo, getNewsItems, GetNewsItemsParams, NewsItem import TaskEditorView: - Drop the auto-summary banner + Re-consolidate button - Drop isBodyAutoMaintained gate (editor is always user-controlled now) - Drop reconsolidate function + reconsolidating ref SettingsView: - profile ref no longer initialises learned_summary / observations_count / observations_updated_at (those fields are gone from UserProfile type) Surviving frontend composables/components flagged for likely future cleanup but not deleted in this commit (no compile errors, just unreferenced after Phase 7-8): - useAssist, useFloatingAssist, useTagSuggestions, useVad, useListenMode, useOnnxPreloader (composables) - WorkspaceNoteEditor, WorkspaceTaskPanel, WeatherCard, InlineAssistPanel (components) - api/client.ts still references /api/notes/assist/* and /api/notes/suggest-tags via useAssist + useTagSuggestions — those endpoints 404 now but no caller hits them; dead at runtime, harmless. Compose stack collapses to two services: `app` + `db`. No Ollama, no voice models, no fable-mcp wheel build. First-boot install reduces to: docker compose up -d → visit web UI → register → Settings → MCP Access → copy snippet → claude mcp add … → done. Co-Authored-By: Claude Opus 4.7 (1M context) --- .remember/tmp/save-session.pid | 2 +- alembic/versions/0054_drop_image_cache.py | 26 ++ docker-compose.prod.yml | 33 --- docker-compose.quickstart.yml | 32 +-- docker-compose.yml | 32 +-- frontend/src/api/client.ts | 318 ---------------------- frontend/src/views/SettingsView.vue | 3 +- frontend/src/views/TaskEditorView.vue | 53 +--- src/fabledassistant/app.py | 2 - src/fabledassistant/config.py | 49 +--- src/fabledassistant/models/__init__.py | 1 - src/fabledassistant/models/image_cache.py | 25 -- src/fabledassistant/routes/images.py | 32 --- src/fabledassistant/services/images.py | 198 -------------- 14 files changed, 42 insertions(+), 764 deletions(-) create mode 100644 alembic/versions/0054_drop_image_cache.py delete mode 100644 src/fabledassistant/models/image_cache.py delete mode 100644 src/fabledassistant/routes/images.py delete mode 100644 src/fabledassistant/services/images.py diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid index e7d4661..4724b9a 100644 --- a/.remember/tmp/save-session.pid +++ b/.remember/tmp/save-session.pid @@ -1 +1 @@ -604006 +632037 diff --git a/alembic/versions/0054_drop_image_cache.py b/alembic/versions/0054_drop_image_cache.py new file mode 100644 index 0000000..187b402 --- /dev/null +++ b/alembic/versions/0054_drop_image_cache.py @@ -0,0 +1,26 @@ +"""drop image_cache table + +Revision ID: 0054 +Revises: 0053 +Create Date: 2026-05-27 + +The image cache was wired into the LLM image-search tool (removed in Phase 8). +With no producer or consumer left, the table is dropped here. +""" +from alembic import op + + +revision = "0054" +down_revision = "0053" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS image_cache CASCADE") + + +def downgrade() -> None: + raise NotImplementedError( + "No downgrade — hard cutover per the MCP-first pivot spec." + ) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7d0fac1..6150a66 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -4,8 +4,6 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant" SECRET_KEY: "${SECRET_KEY}" - OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" TRUST_PROXY_HEADERS: "true" SECURE_COOKIES: "true" @@ -49,39 +47,8 @@ services: max_attempts: 0 window: 120s - ollama: - image: ollama/ollama - volumes: - - ollama_models:/root/.ollama - networks: - - fabledassistant_backend - environment: - OLLAMA_MAX_LOADED_MODELS: "2" - OLLAMA_KEEP_ALIVE: "30m" - OLLAMA_FLASH_ATTENTION: "1" - healthcheck: - test: ["CMD-SHELL", "ollama list || exit 1"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 30s - deploy: - placement: - constraints: - - node.role == worker - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] - restart_policy: - condition: on-failure - max_attempts: 5 - volumes: pgdata: - ollama_models: networks: fabledassistant_backend: diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 882e80b..0e7e00b 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -6,7 +6,8 @@ # 1. Download this file # 2. docker compose -f docker-compose.quickstart.yml up -d # 3. Open http://localhost:5000 — the first account registered becomes admin -# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points) +# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the +# bearer-token URL shown there. # # Set SECRET_KEY via environment variable or a .env file alongside this file: # SECRET_KEY=your-random-secret-here @@ -19,16 +20,12 @@ services: environment: DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant" SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" - OLLAMA_URL: "http://ollama:11434" - OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" volumes: - app_data:/data depends_on: db: condition: service_healthy - ollama: - condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] @@ -56,31 +53,6 @@ services: start_period: 180s restart: unless-stopped - ollama: - image: ollama/ollama - volumes: - - ollama_models:/root/.ollama - environment: - OLLAMA_MAX_LOADED_MODELS: "2" - OLLAMA_KEEP_ALIVE: "30m" - OLLAMA_FLASH_ATTENTION: "1" - healthcheck: - test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 15s - restart: unless-stopped - # Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit): - # deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] - volumes: app_data: pgdata: - ollama_models: diff --git a/docker-compose.yml b/docker-compose.yml index 2a06564..22602be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,25 +6,16 @@ services: depends_on: db: condition: service_healthy - ollama: - condition: service_started volumes: - app_data:/data # To use a bind mount instead (gives direct host access to all app data): # - ./data:/data 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:-qwen3:8B}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" - # Uncomment and set to enable web research and image search via SearXNG: + # Uncomment if you have a SearXNG instance you want to surface in the + # Integrations tab as a configured web-search backend: # SEARXNG_URL: "http://searxng:8080" - # IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path - # IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed - # Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)") - VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}" - VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}" - VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] interval: 10s @@ -51,25 +42,6 @@ services: retries: 10 start_period: 180s - ollama: - image: ollama/ollama - volumes: - - ollama_models:/root/.ollama - environment: - OLLAMA_MAX_LOADED_MODELS: "2" - OLLAMA_NUM_PARALLEL: "2" - OLLAMA_KEEP_ALIVE: "30m" - OLLAMA_FLASH_ATTENTION: "1" - # GPU reservation commented out — no nvidia-container-toolkit on this host - # deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] - volumes: pgdata: - ollama_models: app_data: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0d5df9f..c0aa114 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -299,188 +299,6 @@ export function apiSSEStream( return { close: () => controller.abort(), done }; } -// --------------------------------------------------------------------------- -// Journal -// --------------------------------------------------------------------------- - -export interface JournalLocation { - label: string; - address: string; - lat?: number; - lon?: number; -} - -export interface JournalConfig { - prep_enabled: boolean; - prep_hour: number; - prep_minute: number; - day_rollover_hour: number; - morning_end_hour?: number; - midday_end_hour?: number; - closeout_enabled?: boolean; - // Ambient-context fields (carried forward from the briefing config schema) - locations?: { home?: JournalLocation; work?: JournalLocation }; - temp_unit?: 'C' | 'F'; - use_caldav_event_locations?: boolean; - enabled?: boolean; - [key: string]: unknown; -} - - -export interface JournalConversation { - id: number; - title: string; - model: string; - conversation_type: string; - day_date: string | null; - rag_project_id: number | null; - message_count: number; - created_at: string; - updated_at: string; -} - -export interface JournalMessage { - id: number; - conversation_id: number; - role: 'user' | 'assistant' | 'system'; - content: string; - status: string; - context_note_id: number | null; - tool_calls: unknown[] | null; - metadata: Record | null; - created_at: string; -} - -export interface JournalDayPayload { - day_date: string; - conversation: JournalConversation | null; - messages: JournalMessage[]; -} - -export interface JournalMoment { - id: number; - user_id: number; - conversation_id: number | null; - source_message_id: number | null; - day_date: string; - occurred_at: string; - recorded_at: string; - content: string; - raw_excerpt: string | null; - tags: string[]; - pinned: boolean; - people: { id: number; title: string }[]; - places: { id: number; title: string }[]; - task_ids: number[]; - note_ids: number[]; - score?: number; -} - -export async function getJournalConfig(): Promise { - return apiGet('/api/journal/config'); -} - -export async function saveJournalConfig(config: JournalConfig): Promise { - await apiPut('/api/journal/config', config); -} - -export async function getJournalToday(): Promise { - return apiGet('/api/journal/today'); -} - -export async function getJournalDay(isoDate: string): Promise { - return apiGet(`/api/journal/day/${isoDate}`); -} - -export async function getJournalDays(): Promise { - const data = await apiGet<{ days: string[] }>('/api/journal/days'); - return data.days; -} - -export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> { - return apiPost('/api/journal/trigger-prep', date ? { date } : {}); -} - -export interface CuratorRunResult { - conv_id: number; - user_id: number; - model: string; - messages_examined: number; - tool_calls: Array<{ - name: string; - arguments: Record; - status: 'success' | 'error' | 'pending'; - error: string | null; - }>; - tools_attempted: number; - tools_succeeded: number; - summary: string; - duration_ms: number; - error: string | null; -} - -export async function runJournalCurator(convId: number): Promise { - return apiPost(`/api/journal/curator/run/${convId}`, {}); -} - -// Curator-proposed mutations awaiting user review (Needs Review panel). -// See routes/journal.py pending_actions endpoints. - -export interface PendingCuratorAction { - id: number; - user_id: number; - conv_id: number | null; - action_type: string; // e.g. "update_note", "delete_note", "update_milestone" - target_type: string | null; // "task" | "note" | "milestone" | "project" | "profile" - target_id: number | null; - target_label: string | null; // human-readable title for the card header - payload: Record; // the curator's proposed args - current_snapshot: Record; // target state at proposal time - status: 'pending' | 'approved' | 'rejected'; - created_at: string; - reviewed_at: string | null; -} - -export async function listPendingActions(): Promise<{ pending: PendingCuratorAction[]; count: number }> { - return apiGet<{ pending: PendingCuratorAction[]; count: number }>('/api/journal/pending'); -} - -export async function approvePendingAction(actionId: number): Promise> { - return apiPost>(`/api/journal/pending/${actionId}/approve`, {}); -} - -export async function rejectPendingAction(actionId: number): Promise> { - return apiPost>(`/api/journal/pending/${actionId}/reject`, {}); -} - -export async function listJournalMoments(params: Record = {}): Promise { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) qs.set(k, String(v)); - const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`); - return data.moments; -} - -export async function updateJournalMoment(id: number, patch: Partial): Promise { - return apiPatch(`/api/journal/moments/${id}`, patch); -} - -export async function deleteJournalMoment(id: number): Promise { - await apiDelete(`/api/journal/moments/${id}`); -} - -export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> { - try { - const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/weather/geocode', { query: address }); - return { lat: r.lat, lon: r.lon, display_name: r.label }; - } catch { - return null; - } -} - -export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> { - return apiGet('/api/fable-mcp/info'); -} - export async function apiStreamPost( path: string, body: unknown, @@ -640,117 +458,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') => export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`) -// ─── News ───────────────────────────────────────────────────────────────────── - -import type { NewsItem } from '@/types/news' - -export interface GetNewsItemsParams { - days?: number - limit?: number - offset?: number - feed_id?: number | null -} - -export function getNewsItems(params: GetNewsItemsParams = {}) { - const p = new URLSearchParams() - if (params.days != null) p.set('days', String(params.days)) - if (params.limit != null) p.set('limit', String(params.limit)) - if (params.offset != null) p.set('offset', String(params.offset)) - if (params.feed_id != null) p.set('feed_id', String(params.feed_id)) - return apiGet<{ items: NewsItem[]; offset: number; limit: number }>( - `/api/briefing/news?${p}` - ) -} - -// ─── Voice ──────────────────────────────────────────────────────────────────── - -export interface VoiceStatusResult { - enabled: boolean - stt: boolean - tts: boolean - stt_model?: string - tts_backend?: string -} - -export interface VoiceEntry { - id: string - label: string - language?: string - quality?: string - sample_rate?: number -} - -export interface VoiceLibraryEntry { - id: string - name: string - language_code: string - language_name: string - country: string - quality: string - num_speakers: number - size_bytes: number - installed: boolean - installed_source: 'user' | 'bundled' | null -} - -export const getVoiceStatus = () => apiGet('/api/voice/status') - -export const getVoiceList = () => - apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices) - -export const getVoiceLibrary = (refresh = false) => - apiGet<{ voices: VoiceLibraryEntry[]; count: number }>( - refresh ? '/api/voice/voices/library?refresh=1' : '/api/voice/voices/library' - ) - -export const installVoice = (voiceId: string) => - apiPost<{ id: string; size_bytes: number; skipped: boolean }>( - '/api/voice/voices/install', - { voice_id: voiceId } - ) - -export const uninstallVoice = (voiceId: string) => - apiDelete(`/api/voice/voices/${encodeURIComponent(voiceId)}`) - -export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> { - const form = new FormData() - form.append('audio', blob, 'audio.webm') - if (context) form.append('context', context) - const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) - throw new ApiError(res.status, err) - } - return res.json() -} - -export async function synthesiseSpeech( - text: string, - voice?: string, - speed?: number, -): Promise { - // Only send voice/speed when explicitly provided — omitting them lets the - // server auto-load the user's saved voice settings. - // Voice blending was removed with the kokoro → piper migration (piper has - // no blend equivalent); callers that previously passed `voiceBlend` should - // pass the first voice's id as `voice` instead. - const body: Record = { text } - if (voice !== undefined || speed !== undefined) { - body.voice = voice ?? 'en_US-amy-medium' - body.speed = speed ?? 1.0 - } - const res = await fetch('/api/voice/synthesise', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) - throw new ApiError(res.status, err) - } - return res.blob() -} - // ── User Profile ───────────────────────────────────────────────────────────── export interface UserProfile { @@ -762,36 +469,11 @@ export interface UserProfile { tone: 'casual' | 'professional' | 'technical' interests: string[] work_schedule: { days?: string[]; start?: string; end?: string } - learned_summary: string - observations_count: number - observations_updated_at: string | null } export const getProfile = () => apiGet('/api/profile') export const updateProfile = (data: Partial) => apiPut('/api/profile', data) -export const consolidateProfile = () => - apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {}) -export const clearProfileObservations = () => apiDelete('/api/profile/observations') - -export interface ProfileObservationEntry { - date: string - bullets: string -} - -export const listProfileObservations = () => - apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations') - - -// ── Tasks ──────────────────────────────────────────────────────────────────── - -import type { Note as Task } from '../types/note' - -/** Manually trigger a consolidation pass for a task. Returns the freshly- - * updated task with new body + consolidated_at. Bypasses the user's - * auto_consolidate_tasks setting. */ -export const consolidateTask = (id: number) => - apiPost(`/api/tasks/${id}/consolidate`, {}) // ── Note Versions (pinning) ────────────────────────────────────────────────── diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 0e95a1e..81d9b57 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -333,8 +333,7 @@ const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const profile = ref({ display_name: '', job_title: '', industry: '', expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual', - interests: [], work_schedule: {}, learned_summary: '', - observations_count: 0, observations_updated_at: null, + interests: [], work_schedule: {}, }) const profileSaving = ref(false) const profileSaved = ref(false) diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index db70b29..179c431 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -109,29 +109,10 @@ async function toggleSubTask(sub: SubTask) { } const showPreview = ref(false); const sidebarOpen = ref(true); -const reconsolidating = ref(false); -// Body is machine-maintained once a consolidation pass has run. The editor -// is gated to read-only in that state; the user can re-consolidate or rely -// on log_work entries flowing into the next auto pass. -const isBodyAutoMaintained = computed(() => consolidatedAt.value !== null); - -async function reconsolidate() { - if (!taskId.value || reconsolidating.value) return; - reconsolidating.value = true; - try { - const { consolidateTask } = await import("@/api/client"); - const updated = await consolidateTask(taskId.value); - body.value = updated.body; - consolidatedAt.value = updated.consolidated_at ?? null; - savedBody = body.value; - toast.show("Task summary refreshed"); - } catch { - toast.show("Failed to re-consolidate", "error"); - } finally { - reconsolidating.value = false; - } -} +// reconsolidate / isBodyAutoMaintained removed in Phase 8 — the curator +// that auto-maintained task bodies is gone, so the body editor is now +// always user-controlled. const editorRef = ref | null>(null); const titleRef = ref(null); const tiptapEditor = computed(() => { @@ -485,33 +466,16 @@ useEditorGuards(dirty, save);
- -
- - Auto-summarized from work logs. - -
- - +
- +
- +
@@ -525,11 +489,8 @@ useEditorGuards(dirty, save); -