diff --git a/web/src/api/chat.ts b/web/src/api/chat.ts new file mode 100644 index 0000000..af9d108 --- /dev/null +++ b/web/src/api/chat.ts @@ -0,0 +1,167 @@ +import { request } from './client' + +export type MessageRole = 'user' | 'assistant' +export type MessageStatus = 'pending' | 'ok' | 'error' | 'cancelled' +export type TemplateScope = 'system' | 'user' + +export interface Conversation { + id: string + user_id: string + project_id?: string | null + workspace_id?: string | null + issue_id?: string | null + model_id: string + system_prompt: string + title?: string | null + title_generated_at?: string | null + created_at: string + updated_at: string +} + +export interface Attachment { + id: string + filename: string + mime_type: string + size_bytes: number + sha256: string +} + +export interface ChatMessage { + id: number + conversation_id: string + role: MessageRole + content: string + thinking?: string | null + status: MessageStatus + error_message?: string | null + prompt_tokens?: number | null + completion_tokens?: number | null + thinking_tokens?: number | null + attachments?: Attachment[] + created_at: string + updated_at: string +} + +export interface PromptTemplate { + id: string + name: string + content: string + scope: TemplateScope + owner_id?: string | null + created_at: string + updated_at: string +} + +export interface ConversationDetail { + conversation: Conversation + messages: ChatMessage[] +} + +export interface SendMessageResp { + user_message_id: number + assistant_message_id: number + stream_url: string +} + +export interface RetryMessageResp { + assistant_message_id: number + stream_url: string +} + +export interface ListConversationsParams { + project_id?: string + workspace_id?: string + issue_id?: string + q?: string + limit?: number +} + +export interface CreateConversationBody { + model_id: string + template_id?: string + system_prompt?: string + project_id?: string + workspace_id?: string + issue_id?: string +} + +export interface UsageDailyItem { + day: string + prompt_tokens: number + completion_tokens: number + thinking_tokens: number + cost_usd: number +} + +const enc = (v: string) => encodeURIComponent(v) + +function buildQuery(params: object): string { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue + q.set(k, String(v)) + } + const s = q.toString() + return s ? '?' + s : '' +} + +export const chatApi = { + // ===== Conversations ===== + listConversations: (params: ListConversationsParams = {}) => + request<{ items: Conversation[] }>(`/api/v1/conversations${buildQuery(params)}`).then( + (r) => r.items, + ), + createConversation: (body: CreateConversationBody) => + request('/api/v1/conversations', { method: 'POST', body }), + getConversation: (id: string, opts: { before?: number; limit?: number } = {}) => + request( + `/api/v1/conversations/${enc(id)}${buildQuery({ + before: opts.before, + limit: opts.limit ?? 50, + })}`, + ), + updateConversationTitle: (id: string, title: string) => + request(`/api/v1/conversations/${enc(id)}`, { + method: 'PATCH', + body: { title }, + }), + deleteConversation: (id: string) => + request(`/api/v1/conversations/${enc(id)}`, { method: 'DELETE' }), + + // ===== Messages ===== + sendMessage: ( + conversationId: string, + body: { content: string; attachment_ids?: string[] }, + ) => + request(`/api/v1/conversations/${enc(conversationId)}/messages`, { + method: 'POST', + body, + }), + cancelMessage: (messageId: number) => + request(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }), + retryMessage: (messageId: number) => + request(`/api/v1/messages/${messageId}/retry`, { method: 'POST' }), + + // ===== Uploads ===== + uploadAttachment: (file: File) => { + const fd = new FormData() + fd.append('file', file) + return request('/api/v1/uploads', { method: 'POST', body: fd }) + }, + + // ===== Templates ===== + listTemplates: () => + request<{ items: PromptTemplate[] }>('/api/v1/prompt-templates').then((r) => r.items), + createTemplate: (body: { name: string; content: string; scope: TemplateScope }) => + request('/api/v1/prompt-templates', { method: 'POST', body }), + updateTemplate: (id: string, body: { name?: string; content?: string }) => + request(`/api/v1/prompt-templates/${enc(id)}`, { method: 'PATCH', body }), + deleteTemplate: (id: string) => + request(`/api/v1/prompt-templates/${enc(id)}`, { method: 'DELETE' }), + + // ===== Personal usage ===== + myDailyUsage: (from: string, to: string) => + request<{ items: UsageDailyItem[] }>( + `/api/v1/users/me/usage${buildQuery({ from, to })}`, + ).then((r) => r.items), +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ee09b1b..bceee3f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -21,10 +21,11 @@ export function configure(opts: { } export async function request(path: string, opts: RequestOptions = {}): Promise { + const isForm = opts.body instanceof FormData const headers: Record = { - 'Content-Type': 'application/json', 'X-Client-Request-ID': crypto.randomUUID(), } + if (!isForm) headers['Content-Type'] = 'application/json' const token = authTokenProvider() if (token) headers['Authorization'] = `Bearer ${token}` @@ -39,10 +40,16 @@ export async function request(path: string, opts: RequestOptions = {}): Promi } try { + const body = + opts.body === undefined + ? undefined + : isForm + ? (opts.body as FormData) + : JSON.stringify(opts.body) const resp = await fetch(BASE + path, { method: opts.method || 'GET', headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + body, signal: controller.signal, }) diff --git a/web/src/api/llmAdmin.ts b/web/src/api/llmAdmin.ts new file mode 100644 index 0000000..56ffe15 --- /dev/null +++ b/web/src/api/llmAdmin.ts @@ -0,0 +1,122 @@ +import { request } from './client' + +export type LLMProvider = 'anthropic' | 'openai' | 'gemini' + +export interface LLMEndpoint { + id: string + provider: LLMProvider + display_name: string + base_url: string + created_at: string + updated_at: string +} + +export interface ModelCapabilities { + image: boolean + pdf: boolean + audio: boolean + reasoning: boolean + tools: boolean +} + +export interface LLMModel { + id: string + endpoint_id: string + model_id: string + display_name: string + capabilities: ModelCapabilities + context_window: number + max_output_tokens: number + prompt_price_per_million_usd: number + completion_price_per_million_usd: number + thinking_price_per_million_usd: number + is_title_generator: boolean + enabled: boolean + sort_order: number + created_at: string + updated_at: string +} + +export interface CreateEndpointBody { + provider: LLMProvider + display_name: string + base_url: string + api_key: string +} + +export interface UpdateEndpointBody { + display_name?: string + base_url?: string + api_key?: string +} + +export interface CreateModelBody { + endpoint_id: string + model_id: string + display_name: string + capabilities?: Partial + context_window: number + max_output_tokens: number + prompt_price_per_million_usd?: number + completion_price_per_million_usd?: number + thinking_price_per_million_usd?: number + is_title_generator?: boolean + enabled?: boolean + sort_order?: number +} + +export type UpdateModelBody = Partial> + +export type AdminUsageGroupBy = 'user' | 'model' | 'day' + +export interface AdminUsageItem { + key: string + prompt_tokens: number + completion_tokens: number + thinking_tokens: number + cost_usd: number +} + +const enc = (v: string) => encodeURIComponent(v) + +function buildQuery(params: object): string { + const q = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === '') continue + q.set(k, String(v)) + } + const s = q.toString() + return s ? '?' + s : '' +} + +export const llmAdminApi = { + // ===== Endpoints ===== + listEndpoints: () => + request<{ items: LLMEndpoint[] }>('/api/v1/admin/llm-endpoints').then((r) => r.items), + createEndpoint: (body: CreateEndpointBody) => + request('/api/v1/admin/llm-endpoints', { method: 'POST', body }), + updateEndpoint: (id: string, body: UpdateEndpointBody) => + request(`/api/v1/admin/llm-endpoints/${enc(id)}`, { method: 'PATCH', body }), + deleteEndpoint: (id: string) => + request(`/api/v1/admin/llm-endpoints/${enc(id)}`, { method: 'DELETE' }), + testEndpoint: (id: string) => + request(`/api/v1/admin/llm-endpoints/${enc(id)}/test`, { method: 'POST' }), + + // ===== Models ===== + listModels: (onlyEnabled = false) => + request<{ items: LLMModel[] }>( + `/api/v1/admin/llm-models${buildQuery({ only_enabled: onlyEnabled })}`, + ).then((r) => r.items), + createModel: (body: CreateModelBody) => + request('/api/v1/admin/llm-models', { method: 'POST', body }), + updateModel: (id: string, body: UpdateModelBody) => + request(`/api/v1/admin/llm-models/${enc(id)}`, { method: 'PATCH', body }), + deleteModel: (id: string) => + request(`/api/v1/admin/llm-models/${enc(id)}`, { method: 'DELETE' }), + + // ===== Usage ===== + adminUsage: (from: string, to: string, groupBy: AdminUsageGroupBy) => + request<{ items: AdminUsageItem[] }>( + `/api/v1/admin/usage${buildQuery({ from, to, group_by: groupBy })}`, + ), +}