feat(web): chat + llmAdmin api clients with FormData support

This commit is contained in:
2026-05-04 19:48:26 +08:00
parent 8effeee063
commit 00daad07a9
3 changed files with 298 additions and 2 deletions
+167
View File
@@ -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<Conversation>('/api/v1/conversations', { method: 'POST', body }),
getConversation: (id: string, opts: { before?: number; limit?: number } = {}) =>
request<ConversationDetail>(
`/api/v1/conversations/${enc(id)}${buildQuery({
before: opts.before,
limit: opts.limit ?? 50,
})}`,
),
updateConversationTitle: (id: string, title: string) =>
request<void>(`/api/v1/conversations/${enc(id)}`, {
method: 'PATCH',
body: { title },
}),
deleteConversation: (id: string) =>
request<void>(`/api/v1/conversations/${enc(id)}`, { method: 'DELETE' }),
// ===== Messages =====
sendMessage: (
conversationId: string,
body: { content: string; attachment_ids?: string[] },
) =>
request<SendMessageResp>(`/api/v1/conversations/${enc(conversationId)}/messages`, {
method: 'POST',
body,
}),
cancelMessage: (messageId: number) =>
request<void>(`/api/v1/messages/${messageId}/cancel`, { method: 'POST' }),
retryMessage: (messageId: number) =>
request<RetryMessageResp>(`/api/v1/messages/${messageId}/retry`, { method: 'POST' }),
// ===== Uploads =====
uploadAttachment: (file: File) => {
const fd = new FormData()
fd.append('file', file)
return request<Attachment>('/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<PromptTemplate>('/api/v1/prompt-templates', { method: 'POST', body }),
updateTemplate: (id: string, body: { name?: string; content?: string }) =>
request<void>(`/api/v1/prompt-templates/${enc(id)}`, { method: 'PATCH', body }),
deleteTemplate: (id: string) =>
request<void>(`/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),
}
+9 -2
View File
@@ -21,10 +21,11 @@ export function configure(opts: {
} }
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> { export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const isForm = opts.body instanceof FormData
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-Request-ID': crypto.randomUUID(), 'X-Client-Request-ID': crypto.randomUUID(),
} }
if (!isForm) headers['Content-Type'] = 'application/json'
const token = authTokenProvider() const token = authTokenProvider()
if (token) headers['Authorization'] = `Bearer ${token}` if (token) headers['Authorization'] = `Bearer ${token}`
@@ -39,10 +40,16 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
} }
try { try {
const body =
opts.body === undefined
? undefined
: isForm
? (opts.body as FormData)
: JSON.stringify(opts.body)
const resp = await fetch(BASE + path, { const resp = await fetch(BASE + path, {
method: opts.method || 'GET', method: opts.method || 'GET',
headers, headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, body,
signal: controller.signal, signal: controller.signal,
}) })
+122
View File
@@ -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<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
}
export type UpdateModelBody = Partial<Omit<CreateModelBody, 'endpoint_id'>>
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<LLMEndpoint>('/api/v1/admin/llm-endpoints', { method: 'POST', body }),
updateEndpoint: (id: string, body: UpdateEndpointBody) =>
request<void>(`/api/v1/admin/llm-endpoints/${enc(id)}`, { method: 'PATCH', body }),
deleteEndpoint: (id: string) =>
request<void>(`/api/v1/admin/llm-endpoints/${enc(id)}`, { method: 'DELETE' }),
testEndpoint: (id: string) =>
request<void>(`/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<LLMModel>('/api/v1/admin/llm-models', { method: 'POST', body }),
updateModel: (id: string, body: UpdateModelBody) =>
request<void>(`/api/v1/admin/llm-models/${enc(id)}`, { method: 'PATCH', body }),
deleteModel: (id: string) =>
request<void>(`/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 })}`,
),
}