Files
chatgpt-on-wechat/desktop/src/renderer/src/api/client.ts

458 lines
15 KiB
TypeScript

import type {
ConfigData,
ChannelInfo,
ChannelAction,
SkillInfo,
ToolInfo,
MemoryItem,
MemoryCategory,
MemoryPage,
SchedulerTask,
Attachment,
SessionsPage,
HistoryPage,
ModelsData,
ModelsAction,
KnowledgeList,
KnowledgeGraph,
KnowledgeAction,
KnowledgeImportPayload,
} from '../types'
interface ApiResult {
status: string
message?: string
}
const AUTH_TOKEN_KEY = 'cow_auth_token'
class ApiClient {
private baseUrl = 'http://127.0.0.1:9876'
// Bearer token for web_password-protected backends. The desktop renderer
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
// aren't sent reliably, so we authenticate via an Authorization header
// instead. Persisted in localStorage so it survives reloads.
private authToken: string | null =
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
setBaseUrl(url: string) {
this.baseUrl = url
}
getBaseUrl() {
return this.baseUrl
}
setAuthToken(token: string | null) {
this.authToken = token
try {
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
else localStorage.removeItem(AUTH_TOKEN_KEY)
} catch {
// localStorage may be unavailable; in-memory token still works this session
}
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
// Cookies still work for browser access; the desktop app relies on the
// Authorization header below.
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
}
// ---------------------------------------------------------
// Chat / messages
// ---------------------------------------------------------
async sendMessage(
sessionId: string,
message: string,
opts?: { stream?: boolean; attachments?: Attachment[]; isVoice?: boolean; lang?: string }
): Promise<{ status: string; request_id: string; stream: boolean; inline_reply?: string }> {
return this.request('/message', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
message,
stream: opts?.stream ?? true,
attachments: opts?.attachments,
is_voice: opts?.isVoice ?? false,
lang: opts?.lang,
}),
})
}
async poll(sessionId: string): Promise<{
status: string
has_content: boolean
content?: string
request_id?: string
timestamp?: number
}> {
return this.request('/poll', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}
async cancel(opts: { requestId?: string; sessionId?: string; lang?: string }): Promise<{ status: string; cancelled: number }> {
return this.request('/cancel', {
method: 'POST',
body: JSON.stringify({ request_id: opts.requestId, session_id: opts.sessionId, lang: opts.lang }),
})
}
// EventSource can't set an Authorization header, so append the auth token as
// a query param for SSE endpoints (the backend accepts it there).
private withToken(url: string): string {
if (!this.authToken) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
}
createSSEStream(requestId: string): EventSource {
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
}
async deleteMessage(opts: {
sessionId: string
userSeq: number
deleteUser?: boolean
cascade?: boolean
}): Promise<{ status: string; deleted: number }> {
return this.request('/api/messages/delete', {
method: 'POST',
body: JSON.stringify({
session_id: opts.sessionId,
user_seq: opts.userSeq,
delete_user: opts.deleteUser ?? true,
cascade: opts.cascade ?? false,
}),
})
}
// ---------------------------------------------------------
// Upload / files
// ---------------------------------------------------------
async uploadFile(file: File, sessionId?: string): Promise<{
status: string
file_path: string
file_name: string
file_type: string
preview_url: string
}> {
const formData = new FormData()
formData.append('file', file)
if (sessionId) formData.append('session_id', sessionId)
const res = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
getFileUrl(previewUrl: string): string {
if (/^https?:\/\//.test(previewUrl)) return previewUrl
// Served via <img src>, which can't set headers — carry the token in the
// query so protected file endpoints load under web_password.
return this.withToken(`${this.baseUrl}${previewUrl}`)
}
getServeFileUrl(absPath: string): string {
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
}
// ---------------------------------------------------------
// Sessions
// ---------------------------------------------------------
async getSessions(page = 1, pageSize = 50): Promise<SessionsPage> {
return this.request<{ status: string } & SessionsPage>(`/api/sessions?page=${page}&page_size=${pageSize}`)
}
async deleteSession(sessionId: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
}
async renameSession(sessionId: string, title: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PUT',
body: JSON.stringify({ title }),
})
}
async generateSessionTitle(sessionId: string, userMessage: string, assistantReply?: string): Promise<{ status: string; title: string }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/generate_title`, {
method: 'POST',
body: JSON.stringify({ user_message: userMessage, assistant_reply: assistantReply }),
})
}
async clearContext(sessionId: string): Promise<{ status: string; context_start_seq: number }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
}
async getHistory(sessionId: string, page = 1, pageSize = 20): Promise<HistoryPage> {
return this.request<{ status: string } & HistoryPage>(
`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=${pageSize}`
)
}
// ---------------------------------------------------------
// Config
// ---------------------------------------------------------
async getConfig(): Promise<ConfigData> {
return this.request<{ status: string } & ConfigData>('/config')
}
async updateConfig(updates: Record<string, unknown>): Promise<{ status: string; applied: Record<string, unknown> }> {
return this.request('/config', {
method: 'POST',
body: JSON.stringify({ updates }),
})
}
// ---------------------------------------------------------
// Models console
// ---------------------------------------------------------
async getModels(): Promise<ModelsData> {
return this.request<{ status: string } & ModelsData>('/api/models')
}
async modelsAction(action: ModelsAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/models', {
method: 'POST',
body: JSON.stringify(action),
})
}
// ---------------------------------------------------------
// Channels
// ---------------------------------------------------------
async getChannels(): Promise<ChannelInfo[]> {
const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels')
return data.channels
}
async channelAction(
action: ChannelAction,
channel: string,
config?: Record<string, unknown>
): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/channels', {
method: 'POST',
body: JSON.stringify({ action, channel, config }),
})
}
// Weixin QR login
async getWeixinQr(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; source?: string; message?: string }> {
return this.request('/api/weixin/qrlogin')
}
async weixinQrAction(action: 'poll' | 'refresh'): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/weixin/qrlogin', {
method: 'POST',
body: JSON.stringify({ action }),
})
}
// Feishu one-click register
async getFeishuRegister(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; expire_in?: number; message?: string }> {
return this.request('/api/feishu/register')
}
async feishuRegisterPoll(): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/feishu/register', {
method: 'POST',
body: JSON.stringify({ action: 'poll' }),
})
}
// ---------------------------------------------------------
// Tools & skills
// ---------------------------------------------------------
async getTools(): Promise<ToolInfo[]> {
const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools')
return data.tools
}
async getSkills(): Promise<SkillInfo[]> {
const data = await this.request<{ status: string; skills: SkillInfo[] }>('/api/skills')
return data.skills
}
async toggleSkill(name: string, action: 'open' | 'close'): Promise<ApiResult> {
return this.request('/api/skills', {
method: 'POST',
body: JSON.stringify({ action, name }),
})
}
// ---------------------------------------------------------
// Memory
// ---------------------------------------------------------
async getMemoryList(page = 1, pageSize = 20, category: MemoryCategory = 'memory'): Promise<MemoryPage> {
return this.request<{ status: string } & MemoryPage>(
`/api/memory?page=${page}&page_size=${pageSize}&category=${category}`
)
}
async getMemoryContent(filename: string, category: MemoryCategory = 'memory'): Promise<string> {
const data = await this.request<{ status: string; content: string }>(
`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`
)
return data.content
}
// ---------------------------------------------------------
// Knowledge
// ---------------------------------------------------------
async getKnowledgeList(): Promise<KnowledgeList> {
return this.request<{ status: string } & KnowledgeList>('/api/knowledge/list')
}
async readKnowledge(path: string): Promise<{ status: string; content: string; path: string }> {
return this.request(`/api/knowledge/read?path=${encodeURIComponent(path)}`)
}
async getKnowledgeGraph(): Promise<KnowledgeGraph> {
return this.request<KnowledgeGraph>('/api/knowledge/graph')
}
async knowledgeAction(req: KnowledgeAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/knowledge/action', {
method: 'POST',
body: JSON.stringify(req),
})
}
// Bulk import: upload .md/.txt files into a target category (multipart).
async importKnowledge(
files: File[],
targetCategory: string
): Promise<{ status: string; message?: string; payload?: KnowledgeImportPayload }> {
const formData = new FormData()
formData.append('target_category', targetCategory)
formData.append('conflict_strategy', 'rename')
files.forEach((file) => formData.append('files', file, file.name))
const res = await fetch(`${this.baseUrl}/api/knowledge/import`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
// ---------------------------------------------------------
// Scheduler
// ---------------------------------------------------------
async getSchedulerTasks(): Promise<SchedulerTask[]> {
const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler')
return data.tasks
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, enabled }),
})
}
async updateTask(taskId: string, updates: Partial<Pick<SchedulerTask, 'name' | 'enabled' | 'schedule' | 'action'>>): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/update', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, ...updates }),
})
}
async deleteTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/delete', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
// ---------------------------------------------------------
// Voice
// ---------------------------------------------------------
async voiceAsr(audio: File | Blob): Promise<{ status: string; text?: string; audio_url?: string; message?: string }> {
const formData = new FormData()
formData.append('file', audio, 'recording.webm')
const res = await fetch(`${this.baseUrl}/api/voice/asr`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
async voiceTts(text: string, sessionId?: string): Promise<{ status: string; audio_url?: string; message?: string }> {
return this.request('/api/voice/tts', {
method: 'POST',
body: JSON.stringify({ text, session_id: sessionId }),
})
}
// ---------------------------------------------------------
// Logs / version
// ---------------------------------------------------------
createLogStream(): EventSource {
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
}
async getVersion(): Promise<string> {
const data = await this.request<{ version: string }>('/api/version')
return data.version
}
// ---------------------------------------------------------
// Auth (web_password) — placeholder for future use
// ---------------------------------------------------------
async authCheck(): Promise<{ status: string; auth_required: boolean; authenticated?: boolean }> {
return this.request('/auth/check')
}
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
if (res.status === 'success' && res.token) {
this.setAuthToken(res.token)
}
return res
}
async authLogout(): Promise<ApiResult> {
this.setAuthToken(null)
return this.request('/auth/logout', { method: 'POST' })
}
}
export const apiClient = new ApiClient()
export default apiClient