dba41879ed
Replaces the freeform briefing-profile note with a DB-backed user_profiles table. Users can edit job/industry/expertise/response preferences/interests/ work schedule via a new Settings → Profile tab. The LLM appends nightly observations; at 14+ entries they are auto-consolidated into a learned_summary. Profile context is injected into both briefing and chat system prompts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
693 lines
21 KiB
TypeScript
693 lines
21 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 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Briefing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface BriefingLocation {
|
|
label: string;
|
|
address: string;
|
|
lat?: number;
|
|
lon?: number;
|
|
}
|
|
|
|
export interface BriefingSlots {
|
|
compilation: boolean;
|
|
morning: boolean;
|
|
midday: boolean;
|
|
afternoon: boolean;
|
|
}
|
|
|
|
export interface BriefingConfig {
|
|
enabled: boolean;
|
|
locations: {
|
|
home?: BriefingLocation;
|
|
work?: BriefingLocation;
|
|
};
|
|
use_caldav_event_locations: boolean;
|
|
work_days: number[];
|
|
slots: BriefingSlots;
|
|
notifications: boolean;
|
|
temp_unit: 'C' | 'F';
|
|
}
|
|
|
|
export interface BriefingFeed {
|
|
id: number;
|
|
title: string;
|
|
url: string;
|
|
category: string | null;
|
|
last_fetched_at: string | null;
|
|
}
|
|
|
|
export interface BriefingConversation {
|
|
id: number;
|
|
title: string;
|
|
briefing_date: string | null;
|
|
message_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface BriefingMessage {
|
|
id: number;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
created_at: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
|
enabled: false,
|
|
locations: {},
|
|
use_caldav_event_locations: false,
|
|
work_days: [1, 2, 3, 4, 5],
|
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
|
notifications: true,
|
|
temp_unit: 'C',
|
|
};
|
|
|
|
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
|
try {
|
|
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
|
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
|
} catch {
|
|
return { ...DEFAULT_BRIEFING_CONFIG };
|
|
}
|
|
}
|
|
|
|
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
|
await apiPut('/api/briefing/config', config);
|
|
}
|
|
|
|
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
|
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
|
return data;
|
|
}
|
|
|
|
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
|
const body: Record<string, string> = { url };
|
|
if (category?.trim()) body.category = category.trim();
|
|
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
|
return { ...data, last_fetched_at: null };
|
|
}
|
|
|
|
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
|
return apiPost('/api/briefing/feeds/refresh', {});
|
|
}
|
|
|
|
export async function deleteBriefingFeed(id: number): Promise<void> {
|
|
await apiDelete(`/api/briefing/feeds/${id}`);
|
|
}
|
|
|
|
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
|
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
|
return data.conversations;
|
|
}
|
|
|
|
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
|
return apiGet('/api/briefing/conversations/today');
|
|
}
|
|
|
|
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
|
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
|
return data.messages;
|
|
}
|
|
|
|
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
|
await apiPost('/api/briefing/trigger', { slot });
|
|
}
|
|
|
|
export async function postRssReaction(
|
|
rssItemId: number,
|
|
reaction: 'up' | 'down'
|
|
): Promise<{ ok: boolean; action: string }> {
|
|
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
|
}
|
|
|
|
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
|
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
|
}
|
|
|
|
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/briefing/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,
|
|
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;
|
|
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}`)
|
|
|
|
// ─── 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
|
|
}
|
|
|
|
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
|
|
|
|
export const getVoiceList = () =>
|
|
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
|
|
|
|
export async function transcribeAudio(blob: Blob): Promise<{ transcript: string; duration_ms: number }> {
|
|
const form = new FormData()
|
|
form.append('audio', blob, 'audio.webm')
|
|
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> {
|
|
const res = await fetch('/api/voice/synthesise', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text, voice: voice ?? 'af_heart', speed: speed ?? 1.0 }),
|
|
})
|
|
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 {
|
|
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 }
|
|
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')
|