refactor: Phase 10 — Ollama service, image cache, config, frontend orphans

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 19:10:25 -04:00
parent b3fca3ced4
commit 4806c34a3c
14 changed files with 42 additions and 764 deletions
+1 -1
View File
@@ -1 +1 @@
604006
632037
+26
View File
@@ -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."
)
-33
View File
@@ -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:
+2 -30
View File
@@ -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:
+2 -30
View File
@@ -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:
-318
View File
@@ -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<string, unknown> | 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<JournalConfig> {
return apiGet<JournalConfig>('/api/journal/config');
}
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
await apiPut('/api/journal/config', config);
}
export async function getJournalToday(): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>('/api/journal/today');
}
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
}
export async function getJournalDays(): Promise<string[]> {
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<string, unknown>;
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<CuratorRunResult> {
return apiPost<CuratorRunResult>(`/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<string, unknown>; // the curator's proposed args
current_snapshot: Record<string, unknown>; // 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<Record<string, unknown>> {
return apiPost<Record<string, unknown>>(`/api/journal/pending/${actionId}/approve`, {});
}
export async function rejectPendingAction(actionId: number): Promise<Record<string, unknown>> {
return apiPost<Record<string, unknown>>(`/api/journal/pending/${actionId}/reject`, {});
}
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
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<JournalMoment>): Promise<JournalMoment> {
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
}
export async function deleteJournalMoment(id: number): Promise<void> {
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<VoiceStatusResult>('/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<Blob> {
// 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<string, unknown> = { 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<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/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<Task>(`/api/tasks/${id}/consolidate`, {})
// ── Note Versions (pinning) ──────────────────────────────────────────────────
+1 -2
View File
@@ -333,8 +333,7 @@ const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const profile = ref<UserProfile>({
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)
+7 -46
View File
@@ -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<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
@@ -485,33 +466,16 @@ useEditorGuards(dirty, save);
<!-- Main column -->
<div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<!-- Auto-summary banner when consolidation has run on this task. -->
<div v-if="isBodyAutoMaintained" class="auto-summary-banner-editor">
<span class="auto-summary-icon" aria-hidden="true"></span>
Auto-summarized from work logs.
<button
type="button"
class="btn-reconsolidate"
:disabled="reconsolidating"
@click="reconsolidate"
>
{{ reconsolidating ? "Re-consolidating…" : "Re-consolidate" }}
</button>
</div>
<!-- Write / Preview tabs + toolbar sit above the editor.
Write tab hidden when body is machine-maintained use Re-consolidate
or edit work logs instead. -->
<!-- Write / Preview tabs + toolbar sit above the editor. -->
<div class="body-tabs-row">
<div class="editor-tabs">
<button
v-if="!isBodyAutoMaintained"
:class="['tab', { active: !showPreview }]"
@click="showPreview = false"
>Write</button>
<button :class="['tab', { active: showPreview || isBodyAutoMaintained }]" @click="showPreview = true">Preview</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview && !isBodyAutoMaintained && assist.state.value === 'idle'" :editor="tiptapEditor" />
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Streaming preview -->
@@ -525,11 +489,8 @@ useEditorGuards(dirty, save);
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal: editor or preview. When body is machine-maintained,
always render the preview (read-only) never the editor. -->
<template v-else>
<div
v-if="!isBodyAutoMaintained"
v-show="!showPreview"
class="body-editor-wrap"
>
@@ -543,7 +504,7 @@ useEditorGuards(dirty, save);
/>
</div>
<div
v-show="showPreview || isBodyAutoMaintained"
v-show="showPreview"
class="preview-pane prose"
v-html="renderedPreview"
/>
-2
View File
@@ -11,7 +11,6 @@ from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.task_logs import task_logs_bp
from fabledassistant.routes.projects import projects_bp
@@ -69,7 +68,6 @@ def create_app() -> Quart:
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
+3 -46
View File
@@ -22,30 +22,11 @@ class Config:
"DATABASE_URL_FILE",
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
# Lightweight model for background tasks (title generation, tag suggestions,
# project summaries). Using a separate model keeps the
# main model's KV cache intact between user messages, enabling prefix cache hits.
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
# Ollama keep_alive — how long a model stays resident in VRAM after its last
# request. Main model gets a longer window since it's used interactively;
# the background model is called sporadically and doesn't need to camp VRAM.
# Format matches Ollama's duration strings: "30m", "10m", "1h", "0s", "-1" (forever).
OLLAMA_KEEP_ALIVE_MAIN: str = os.environ.get("OLLAMA_KEEP_ALIVE_MAIN", "30m")
OLLAMA_KEEP_ALIVE_BACKGROUND: str = os.environ.get("OLLAMA_KEEP_ALIVE_BACKGROUND", "10m")
# KV cache context window for generation. Keep this as small as practical —
# a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
# 16384 covers ~30+ message conversations with our system prompt comfortably.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
# Embedding model for semantic note search (served by Ollama)
EMBEDDING_MODEL: str = os.environ.get("EMBEDDING_MODEL", "nomic-embed-text")
# SMTP defaults (overridden by DB settings when configured via admin UI)
SMTP_HOST: str = os.environ.get("SMTP_HOST", "")
SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587"))
@@ -64,25 +45,11 @@ class Config:
OIDC_SCOPES: str = os.environ.get("OIDC_SCOPES", "openid profile email")
LOCAL_AUTH_ENABLED: bool = os.environ.get("LOCAL_AUTH_ENABLED", "true").lower() not in ("0", "false", "no")
# SearXNG web search (external instance)
# SearXNG web search (external instance). Currently only surfaced via
# /api/settings/search for the Integrations tab's status indicator —
# the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Image cache — images fetched from the web are stored here and served locally
IMAGE_CACHE_DIR: str = os.environ.get("IMAGE_CACHE_DIR", "/data/images")
# Maximum size of a single image to cache (default 5 MB)
IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
# VAPID keys for browser push notifications
VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
# Voice (Speech-to-Speech) feature
VOICE_ENABLED: bool = os.environ.get("VOICE_ENABLED", "").lower() in ("1", "true", "yes")
STT_BACKEND: str = os.environ.get("STT_BACKEND", "faster-whisper")
STT_MODEL: str = os.environ.get("STT_MODEL", "base.en")
TTS_BACKEND: str = os.environ.get("TTS_BACKEND", "kokoro")
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
@@ -95,12 +62,8 @@ class Config:
def validate(cls) -> None:
"""Validate critical config values at startup. Raises ValueError on misconfiguration."""
errors: list[str] = []
if cls.OLLAMA_NUM_CTX < 512:
errors.append(f"OLLAMA_NUM_CTX={cls.OLLAMA_NUM_CTX} is too small (minimum 512)")
if cls.LOG_RETENTION_DAYS < 1:
errors.append(f"LOG_RETENTION_DAYS={cls.LOG_RETENTION_DAYS} must be >= 1")
if cls.IMAGE_MAX_BYTES < 1024:
errors.append(f"IMAGE_MAX_BYTES={cls.IMAGE_MAX_BYTES} must be >= 1024")
if not (1 <= cls.SMTP_PORT <= 65535):
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
@@ -110,11 +73,5 @@ class Config:
"SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates "
"a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting."
)
_valid_stt_models = {"tiny.en", "base.en", "small.en", "medium.en"}
if cls.VOICE_ENABLED and cls.STT_MODEL not in _valid_stt_models:
errors.append(
f"STT_MODEL='{cls.STT_MODEL}' is not supported. "
f"Valid values: {', '.join(sorted(_valid_stt_models))}"
)
if errors:
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
-1
View File
@@ -26,7 +26,6 @@ from fabledassistant.models.app_log import AppLog # noqa: E402, F401
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
-25
View File
@@ -1,25 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class ImageCache(Base):
"""Metadata for an image fetched from the web and stored locally on disk."""
__tablename__ = "image_cache"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
url_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True)
original_url: Mapped[str] = mapped_column(Text, nullable=False)
source_domain: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
content_type: Mapped[str] = mapped_column(Text, nullable=False, default="image/jpeg")
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
file_ext: Mapped[str] = mapped_column(Text, nullable=False, default="jpg")
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
-32
View File
@@ -1,32 +0,0 @@
"""Serve locally-cached images."""
import logging
from quart import Blueprint, jsonify, send_file
from fabledassistant.auth import login_required
from fabledassistant.services.images import get_image_path, get_image_record
logger = logging.getLogger(__name__)
images_bp = Blueprint("images", __name__, url_prefix="/api/images")
@images_bp.route("/<int:image_id>", methods=["GET"])
@login_required
async def serve_image(image_id: int):
"""Serve a locally-cached image by its DB ID."""
record = await get_image_record(image_id)
if record is None:
return jsonify({"error": "Image not found"}), 404
file_path = get_image_path(record)
if not file_path.exists():
logger.warning("Image file missing on disk for id=%d (%s)", image_id, file_path)
return jsonify({"error": "Image file not found"}), 404
return await send_file(
file_path,
mimetype=record.content_type,
cache_timeout=86400,
)
-198
View File
@@ -1,198 +0,0 @@
"""Image cache service: fetch from web, store on disk, serve locally.
Images are stored in IMAGE_CACHE_DIR (default /data/images) and tracked in
the image_cache DB table. The original URL is preserved for citation.
Deduplication is by SHA-256 of the original URL — the same image requested
twice shares one file and one DB row.
"""
import hashlib
import ipaddress
import logging
import socket
from pathlib import Path
from urllib.parse import urlparse
import httpx
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.image_cache import ImageCache
logger = logging.getLogger(__name__)
def _is_safe_image_url(url: str) -> bool:
"""Return True only if the URL is a public http/https address (SSRF guard)."""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname
if not host:
return False
if host.lower() in ("localhost", "::1"):
return False
addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
for entry in addr_info:
ip = ipaddress.ip_address(entry[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return False
return True
except Exception:
return False # Block on resolution failure
_ALLOWED_TYPES = {
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
}
_EXT_MAP = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/avif": "avif",
}
def _cache_dir() -> Path:
d = Path(Config.IMAGE_CACHE_DIR)
d.mkdir(parents=True, exist_ok=True)
return d
def _url_hash(url: str) -> str:
return hashlib.sha256(url.encode()).hexdigest()
async def fetch_and_store_image(
url: str,
title: str | None = None,
source_domain: str | None = None,
) -> ImageCache | None:
"""Fetch an image URL, write it to disk, persist metadata to DB.
Returns the ImageCache record (new or existing).
Returns None if the URL is unreachable, not a valid image type, or
exceeds IMAGE_MAX_BYTES.
"""
if not _is_safe_image_url(url):
logger.warning("Blocked image fetch of private/internal URL: %s", url[:80])
return None
url_hash = _url_hash(url)
# Return existing record if already cached
async with async_session() as session:
existing = (
await session.execute(
select(ImageCache).where(ImageCache.url_hash == url_hash)
)
).scalar_one_or_none()
if existing:
logger.debug("Image cache hit id=%d for %s", existing.id, url[:80])
return existing
# Infer source domain from URL if not supplied
if not source_domain:
try:
source_domain = urlparse(url).netloc or None
except Exception:
source_domain = None
# Fetch from origin
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
headers = {
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0)",
}
# Provide a same-origin Referer to bypass basic hotlink protection
if source_domain:
headers["Referer"] = f"https://{source_domain}/"
resp = await client.get(url, headers=headers)
resp.raise_for_status()
ct = resp.headers.get("content-type", "").split(";")[0].strip().lower()
if ct not in _ALLOWED_TYPES:
logger.warning("Skipping non-image content-type '%s' for %s", ct, url[:80])
return None
# Reject oversized images before buffering all bytes
cl = resp.headers.get("content-length")
if cl and int(cl) > Config.IMAGE_MAX_BYTES:
logger.warning("Image too large (%s bytes, limit %d) at %s", cl, Config.IMAGE_MAX_BYTES, url[:80])
return None
data = resp.content
if len(data) > Config.IMAGE_MAX_BYTES:
logger.warning("Image too large (%d bytes) at %s", len(data), url[:80])
return None
if not data:
return None
except Exception:
logger.warning("Failed to fetch image from %s", url[:80], exc_info=True)
return None
# Write to disk
file_ext = _EXT_MAP.get(ct, "jpg")
file_path = _cache_dir() / f"{url_hash}.{file_ext}"
try:
file_path.write_bytes(data)
except Exception:
logger.warning("Failed to write image to disk: %s", file_path, exc_info=True)
return None
# Persist metadata — handle the rare race-condition duplicate gracefully
record = ImageCache(
url_hash=url_hash,
original_url=url,
source_domain=source_domain,
title=title,
content_type=ct,
file_size=len(data),
file_ext=file_ext,
)
async with async_session() as session:
try:
session.add(record)
await session.commit()
await session.refresh(record)
except IntegrityError:
await session.rollback()
existing = (
await session.execute(
select(ImageCache).where(ImageCache.url_hash == url_hash)
)
).scalar_one_or_none()
if existing:
return existing
logger.warning("Failed to persist image cache record for %s", url[:80], exc_info=True)
return None
logger.info(
"Cached image id=%d (%s, %d bytes) from %s",
record.id, ct, len(data), url[:80],
)
return record
async def get_image_record(image_id: int) -> ImageCache | None:
"""Return the ImageCache row for a given ID, or None."""
async with async_session() as session:
return (
await session.execute(
select(ImageCache).where(ImageCache.id == image_id)
)
).scalar_one_or_none()
def get_image_path(record: ImageCache) -> Path:
"""Filesystem path for a cached image record."""
return _cache_dir() / f"{record.url_hash}.{record.file_ext}"