Files
FabledScribe/frontend/src/api/client.ts
T
bvandeusen 4806c34a3c 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>
2026-05-27 19:10:25 -04:00

495 lines
15 KiB
TypeScript

export class ApiError extends Error {
status: number;
body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
const msg = (body.error as string) || `API error: ${status}`;
super(msg);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
// Redirect to login on 401 (except for auth endpoints)
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/register") {
router.push({ name: "login", query: { redirect: currentPath } });
}
}
throw new ApiError(res.status, body);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
return handleResponse<void>(res, path);
}
// ---------------------------------------------------------------------------
// Sharing, Groups, Notifications, User search
// ---------------------------------------------------------------------------
export interface ShareEntry {
id: number
permission: string
shared_with_user_id: number | null
shared_with_group_id: number | null
username?: string
group_name?: string
invited_by: number
created_at: string
}
export interface GroupEntry {
id: number
name: string
description: string | null
created_by: number | null
member_count: number
is_member: boolean
created_at: string
updated_at: string
}
export interface GroupMember {
id: number
group_id: number
user_id: number
role: string
username: string
email: string | null
created_at: string
}
export interface NotificationEntry {
id: number
user_id: number
type: string
payload: Record<string, unknown>
read_at: string | null
created_at: string
}
export interface UserSearchResult {
id: number
username: string
}
// --- User search ---
export const searchUsers = (q: string) =>
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
// --- Groups ---
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
export const createGroup = (name: string, description?: string) =>
apiPost<GroupEntry>('/api/groups', { name, description })
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
export const getGroupDetail = (id: number) =>
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
export const listGroupMembers = (id: number) =>
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
export const addGroupMember = (groupId: number, userId: number, role: string) =>
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
export const removeGroupMember = (groupId: number, userId: number) =>
apiDelete(`/api/groups/${groupId}/members/${userId}`)
// --- Project shares ---
export const listProjectShares = (projectId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
export const deleteProjectShare = (projectId: number, shareId: number) =>
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
// --- Note / task shares ---
export const listNoteShares = (noteId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
export const deleteNoteShare = (noteId: number, shareId: number) =>
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
// --- Shared-with-me ---
export const getSharedWithMe = () =>
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
// --- In-app notifications ---
export const getNotifications = (all = false) =>
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
export const getNotificationCount = () =>
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
done: Promise<void>;
}
export interface SSENamedEvent {
id: number;
event: string;
data: Record<string, unknown>;
}
export function apiSSEStream(
path: string,
onEvent: (event: SSENamedEvent) => void,
options?: { lastEventId?: number; signal?: AbortSignal },
): SSEStreamHandle {
const controller = new AbortController();
const combinedSignal = options?.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
const headers: Record<string, string> = {};
if (options?.lastEventId !== undefined) {
headers["Last-Event-ID"] = String(options.lastEventId);
}
const done = (async () => {
const res = await fetch(path, { headers, signal: combinedSignal });
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, body);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
// SSE field parsing state
let currentId = -1;
let currentEvent = "message";
let currentData = "";
function dispatch() {
if (!currentData) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(currentData);
} catch {
return;
}
onEvent({ id: currentId, event: currentEvent, data: parsed });
// Reset for next event
currentEvent = "message";
currentData = "";
}
function processLine(line: string) {
if (line === "") {
// Empty line = end of event
dispatch();
return;
}
if (line.startsWith(":")) return; // comment / keepalive
const colonIdx = line.indexOf(":");
let field: string;
let value: string;
if (colonIdx === -1) {
field = line;
value = "";
} else {
field = line.slice(0, colonIdx);
value = line.slice(colonIdx + 1);
if (value.startsWith(" ")) value = value.slice(1);
}
switch (field) {
case "id":
currentId = parseInt(value, 10);
break;
case "event":
currentEvent = value;
break;
case "data":
currentData += value;
break;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
// Process remaining buffer
if (buffer) {
processLine(buffer);
}
dispatch();
})().catch(() => {
// Stream closed or aborted — handled by caller via reconnection
});
return { close: () => controller.abort(), done };
}
export async function apiStreamPost(
path: string,
body: unknown,
onChunk: (data: Record<string, unknown>) => void
): Promise<void> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let errBody: Record<string, unknown> = {};
try {
errBody = await res.json();
} catch {
errBody = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, errBody);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
function processLine(line: string) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
data = JSON.parse(trimmed.slice(6));
} catch {
return; // Skip malformed JSON lines
}
onChunk(data);
}
}
function processBuffer() {
const lines = buffer.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
}
} catch {
// Stream may close with a network error after all data was sent.
}
// Flush any remaining complete lines in the buffer
if (buffer.trim()) {
// The buffer may contain one or more unprocessed lines
const remaining = buffer;
buffer = "";
for (const line of remaining.split("\n")) {
processLine(line);
}
}
}
// ---------------------------------------------------------------------------
// Calendar events
// ---------------------------------------------------------------------------
export interface EventEntry {
id: number;
uid: string;
title: string;
start_dt: string;
end_dt: string | null;
all_day: boolean;
description: string;
location: string;
color: string;
recurrence: string | null;
caldav_uid: string;
project_id: number | null;
user_id: number;
created_at: string | null;
updated_at: string | null;
}
export interface EventCreatePayload {
title: string;
start_dt: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export interface EventUpdatePayload {
title?: string;
start_dt?: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string | null;
project_id?: number;
}
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
}
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
return apiPost<EventEntry>('/api/events', payload);
}
export async function getEvent(id: number): Promise<EventEntry> {
return apiGet<EventEntry>(`/api/events/${id}`);
}
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
}
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
}
export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
// ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
/** Mark a note version as manually pinned, optionally with a commit-note
* label. Re-calling with a different label updates the label. */
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
apiPost<NoteVersion>(
`/api/notes/${noteId}/versions/${versionId}/pin`,
{ label: label ?? null },
)
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
* the row — older rows may be FIFO-pruned by the next autosave. */
export const unpinNoteVersion = (noteId: number, versionId: number) =>
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)