Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled "Custom" option with the raw rule shown read-only — visible but preserved unless the user explicitly picks a preset to replace it. EventUpdatePayload.recurrence is now string | null so we can clear via PATCH; backend service already treats null as "clear" (recurrence is in the nullable set in update_event). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
699 lines
22 KiB
TypeScript
699 lines
22 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 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
// 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 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,
|
|
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}`)
|
|
|
|
// ─── 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 interface VoiceBlendEntry {
|
|
voice: string
|
|
weight: number
|
|
}
|
|
|
|
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, 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,
|
|
voiceBlend?: VoiceBlendEntry[]
|
|
): Promise<Blob> {
|
|
// Only send voice/speed/blend when explicitly provided — omitting them lets
|
|
// the server auto-load the user's saved voice settings (voice, speed, blend).
|
|
const body: Record<string, unknown> = { text }
|
|
if (voiceBlend && voiceBlend.length >= 2) {
|
|
body.voice_blend = voiceBlend
|
|
if (speed !== undefined) body.speed = speed
|
|
} else if (voice !== undefined || speed !== undefined) {
|
|
body.voice = voice ?? 'af_heart'
|
|
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 {
|
|
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')
|