Files
synapbus/web/src/lib/api/client.ts
T
Algis DumbrisandClaude Opus 4.6 78735bb8ee feat: implement Web UI with Svelte 5 SPA and REST API
Add complete Web UI infrastructure:

Go backend:
- REST API handlers for messages, agents, channels (internal/api/)
- SSE hub for real-time event streaming
- Session-to-owner middleware bridging auth sessions to API context
- SPA file server with go:embed for static assets
- GetMessageByID on MessagingService, RevokeKey on AgentService
- Updated router with RouterConfig for full service wiring

Svelte 5 SPA (web/):
- SvelteKit with static adapter for SPA mode
- Tailwind CSS with dark mode (class-based, localStorage persisted)
- API client with auto-redirect on 401
- SSE client with exponential backoff reconnect
- Pages: Login, Dashboard, Conversations, Channels, Agents, Settings
- Components: Sidebar, Header, MessageList, ComposeForm, AgentCard, TraceViewer
- Responsive layout with mobile sidebar toggle

Build:
- Placeholder index.html in internal/web/dist/ for go:embed compilation
- Updated Makefile web target to copy build output
- All existing Go tests pass, CGO_ENABLED=0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:34:36 +02:00

105 lines
4.2 KiB
TypeScript

/** API client for SynapBus REST API */
class ApiError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
this.name = 'ApiError';
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const opts: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin'
};
if (body) {
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 401) {
// Redirect to login on auth failure
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = `/login?return=${encodeURIComponent(window.location.pathname)}`;
}
throw new ApiError(401, 'unauthorized', 'Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'unknown', message: res.statusText }));
throw new ApiError(res.status, err.error || 'unknown', err.message || err.error_description || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export const auth = {
login: (username: string, password: string) =>
request<{ id: number; username: string; display_name: string }>('POST', '/auth/login', { username, password }),
logout: () => request<void>('POST', '/auth/logout'),
me: () => request<{ id: number; username: string; display_name: string; role: string }>('GET', '/auth/me'),
changePassword: (current_password: string, new_password: string) =>
request<{ status: string }>('PUT', '/auth/password', { current_password, new_password })
};
// Messages
export const messages = {
list: (params?: { limit?: number; status?: string; agent?: string }) => {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.status) qs.set('status', params.status);
if (params?.agent) qs.set('agent', params.agent);
const q = qs.toString();
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
send: (body: { from?: string; to: string; body: string; priority?: number; subject?: string; channel_id?: number }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, limit?: number) => {
const qs = new URLSearchParams({ q });
if (limit) qs.set('limit', String(limit));
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
}
};
// Conversations
export const conversations = {
list: () => request<{ conversations: any[] }>('GET', '/api/conversations'),
get: (id: number) => request<{ conversation: any; messages: any[] }>('GET', `/api/conversations/${id}`)
};
// Agents
export const agents = {
list: () => request<{ agents: any[] }>('GET', '/api/agents'),
get: (name: string) => request<{ agent: any; traces: any[] }>('GET', `/api/agents/${encodeURIComponent(name)}`),
register: (body: { name: string; display_name?: string; type?: string; capabilities?: object }) =>
request<{ agent: any; api_key: string }>('POST', '/api/agents', body),
delete: (name: string) => request<{ status: string }>('DELETE', `/api/agents/${encodeURIComponent(name)}`),
revokeKey: (name: string) =>
request<{ agent: any; api_key: string }>('POST', `/api/agents/${encodeURIComponent(name)}/revoke-key`)
};
// Channels
export const channels = {
list: () => request<{ channels: any[] }>('GET', '/api/channels'),
get: (name: string) => request<{ channel: any; members: any[] }>('GET', `/api/channels/${encodeURIComponent(name)}`),
create: (body: { name: string; description?: string; topic?: string; type?: string; is_private?: boolean }) =>
request<any>('POST', '/api/channels', body),
join: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/join`, agent ? { agent } : {}),
leave: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/leave`, agent ? { agent } : {})
};
export { ApiError };