4806c34a3c
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>
495 lines
15 KiB
TypeScript
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`)
|