diff --git a/desktop/src/renderer/src/api/client.ts b/desktop/src/renderer/src/api/client.ts index c8d54345..eb47db61 100644 --- a/desktop/src/renderer/src/api/client.ts +++ b/desktop/src/renderer/src/api/client.ts @@ -1,38 +1,74 @@ -import type { ConfigData, ChannelInfo, SkillInfo, ToolInfo, MemoryItem, SchedulerTask, Attachment } from '../types' +import type { + ConfigData, + ChannelInfo, + ChannelAction, + SkillInfo, + ToolInfo, + MemoryItem, + MemoryCategory, + MemoryPage, + SchedulerTask, + Attachment, + SessionsPage, + HistoryPage, + ModelsData, + ModelsAction, + KnowledgeList, + KnowledgeGraph, + KnowledgeAction, +} from '../types' + +interface ApiResult { + status: string + message?: string +} class ApiClient { - private baseUrl: string = 'http://127.0.0.1:9899' + private baseUrl = 'http://127.0.0.1:9899' setBaseUrl(url: string) { this.baseUrl = url } + getBaseUrl() { + return this.baseUrl + } + private async request(path: string, options?: RequestInit): Promise { const res = await fetch(`${this.baseUrl}${path}`, { ...options, + // Send cookies for future web_password auth support + credentials: 'include', headers: { 'Content-Type': 'application/json', ...options?.headers, }, }) - if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`) } - return res.json() } - // Chat + // --------------------------------------------------------- + // Chat / messages + // --------------------------------------------------------- + async sendMessage( sessionId: string, message: string, - stream: boolean = true, - attachments?: Attachment[] - ): Promise<{ status: string; request_id: string; stream: boolean }> { + 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, attachments }), + body: JSON.stringify({ + session_id: sessionId, + message, + stream: opts?.stream ?? true, + attachments: opts?.attachments, + is_voice: opts?.isVoice ?? false, + lang: opts?.lang, + }), }) } @@ -49,10 +85,38 @@ class ApiClient { }) } + 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 }), + }) + } + createSSEStream(requestId: string): EventSource { return new EventSource(`${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 @@ -62,49 +126,138 @@ class ApiClient { }> { const formData = new FormData() formData.append('file', file) - if (sessionId) { - formData.append('session_id', sessionId) - } - + if (sessionId) formData.append('session_id', sessionId) const res = await fetch(`${this.baseUrl}/upload`, { method: 'POST', body: formData, + credentials: 'include', }) - return res.json() } - // Config - async getConfig(): Promise { - const data = await this.request<{ status: string } & ConfigData>('/config') - return data + getFileUrl(previewUrl: string): string { + if (/^https?:\/\//.test(previewUrl)) return previewUrl + return `${this.baseUrl}${previewUrl}` } - async updateConfig(updates: Partial): Promise<{ status: string; applied: Record }> { + getServeFileUrl(absPath: string): string { + return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}` + } + + // --------------------------------------------------------- + // Sessions + // --------------------------------------------------------- + + async getSessions(page = 1, pageSize = 50): Promise { + return this.request<{ status: string } & SessionsPage>(`/api/sessions?page=${page}&page_size=${pageSize}`) + } + + async deleteSession(sessionId: string): Promise { + return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }) + } + + async renameSession(sessionId: string, title: string): Promise { + 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 { + return this.request<{ status: string } & HistoryPage>( + `/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=${pageSize}` + ) + } + + // --------------------------------------------------------- + // Config + // --------------------------------------------------------- + + async getConfig(): Promise { + return this.request<{ status: string } & ConfigData>('/config') + } + + async updateConfig(updates: Record): Promise<{ status: string; applied: Record }> { return this.request('/config', { method: 'POST', body: JSON.stringify({ updates }), }) } + // --------------------------------------------------------- + // Models console + // --------------------------------------------------------- + + async getModels(): Promise { + return this.request<{ status: string } & ModelsData>('/api/models') + } + + async modelsAction(action: ModelsAction): Promise & { status: string }> { + return this.request('/api/models', { + method: 'POST', + body: JSON.stringify(action), + }) + } + + // --------------------------------------------------------- // Channels + // --------------------------------------------------------- + async getChannels(): Promise { const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels') return data.channels } async channelAction( - action: 'save' | 'connect' | 'disconnect', + action: ChannelAction, channel: string, config?: Record - ): Promise<{ status: string }> { + ): Promise & { status: string }> { return this.request('/api/channels', { method: 'POST', body: JSON.stringify({ action, channel, config }), }) } - // Tools & Skills + // 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 & { 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 & { status: string }> { + return this.request('/api/feishu/register', { + method: 'POST', + body: JSON.stringify({ action: 'poll' }), + }) + } + + // --------------------------------------------------------- + // Tools & skills + // --------------------------------------------------------- + async getTools(): Promise { const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools') return data.tools @@ -115,60 +268,136 @@ class ApiClient { return data.skills } - async toggleSkill(name: string, action: 'open' | 'close'): Promise<{ status: string }> { + async toggleSkill(name: string, action: 'open' | 'close'): Promise { return this.request('/api/skills', { method: 'POST', body: JSON.stringify({ action, name }), }) } + // --------------------------------------------------------- // Memory - async getMemoryList(page: number = 1, pageSize: number = 20): Promise<{ list: MemoryItem[]; total: number }> { - const data = await this.request<{ status: string; list: MemoryItem[]; total: number }>( - `/api/memory?page=${page}&page_size=${pageSize}` + // --------------------------------------------------------- + + async getMemoryList(page = 1, pageSize = 20, category: MemoryCategory = 'memory'): Promise { + return this.request<{ status: string } & MemoryPage>( + `/api/memory?page=${page}&page_size=${pageSize}&category=${category}` ) - return { list: data.list, total: data.total } } - async getMemoryContent(filename: string): Promise { + async getMemoryContent(filename: string, category: MemoryCategory = 'memory'): Promise { const data = await this.request<{ status: string; content: string }>( - `/api/memory/content?filename=${encodeURIComponent(filename)}` + `/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}` ) return data.content } + // --------------------------------------------------------- + // Knowledge + // --------------------------------------------------------- + + async getKnowledgeList(): Promise { + 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 { + return this.request('/api/knowledge/graph') + } + + async knowledgeAction(req: KnowledgeAction): Promise & { status: string }> { + return this.request('/api/knowledge/action', { + method: 'POST', + body: JSON.stringify(req), + }) + } + + // --------------------------------------------------------- // Scheduler + // --------------------------------------------------------- + async getSchedulerTasks(): Promise { const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler') return data.tasks } - // History - async getHistory( - sessionId: string, - page: number = 1, - pageSize: number = 20 - ): Promise<{ messages: ChatMessage[]; has_more: boolean }> { - const data = await this.request<{ status: string; messages: ChatMessage[]; has_more: boolean }>( - `/api/history?session_id=${sessionId}&page=${page}&page_size=${pageSize}` - ) - return { messages: data.messages, has_more: data.has_more } + 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 }), + }) } - // Logs SSE + async updateTask(taskId: string, updates: Partial>): 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 { + 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.baseUrl}/api/logs`) } - getFileUrl(previewUrl: string): string { - return `${this.baseUrl}${previewUrl}` + async getVersion(): Promise { + const data = await this.request<{ version: string }>('/api/version') + return data.version } -} -interface ChatMessage { - role: string - content: string - timestamp: number + // --------------------------------------------------------- + // 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 { + return this.request('/auth/login', { + method: 'POST', + body: JSON.stringify({ password }), + }) + } + + async authLogout(): Promise { + return this.request('/auth/logout', { method: 'POST' }) + } } export const apiClient = new ApiClient() diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 9679970e..6966f2ca 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -1,3 +1,7 @@ +// ============================================================ +// Electron bridge +// ============================================================ + export interface ElectronAPI { getBackendPort: () => Promise getBackendStatus: () => Promise @@ -6,6 +10,11 @@ export interface ElectronAPI { selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise onBackendStatus: (callback: (data: BackendStatusEvent) => void) => void onBackendLog: (callback: (line: string) => void) => void + windowMinimize: () => Promise + windowMaximize: () => Promise + windowClose: () => Promise + windowIsMaximized: () => Promise + onMaximizeChange: (callback: (maximized: boolean) => void) => void platform: string } @@ -15,32 +24,166 @@ export interface BackendStatusEvent { error?: string } +// ============================================================ +// Chat / messages / streaming +// ============================================================ + +export type Role = 'user' | 'assistant' + +/** A single ordered step inside an assistant turn (matches backend history). */ +export interface MessageStep { + type: 'thinking' | 'content' | 'tool' + content?: string + // tool step fields + id?: string + name?: string + arguments?: Record + result?: string + is_error?: boolean + status?: string + execution_time?: number +} + +/** Local UI message model (superset of backend history message). */ export interface ChatMessage { id: string - role: 'user' | 'assistant' + role: Role content: string + /** Unix seconds. Backend history uses `created_at`; we normalize to `timestamp`. */ timestamp: number attachments?: Attachment[] + /** Ordered steps (thinking / content / tool). Preferred over legacy toolCalls. */ + steps?: MessageStep[] + /** Legacy live-stream tool events (kept for backward compat during streaming). */ toolCalls?: ToolCall[] + /** Reasoning text streamed via `reasoning` SSE events. */ + reasoning?: string + /** Sequence numbers from backend (for delete/regenerate). */ + userSeq?: number + botSeq?: number + /** Self-evolution bubble flag. */ + kind?: 'evolution' + extras?: Record isStreaming?: boolean + isCancelled?: boolean + error?: string } export interface Attachment { file_path: string file_name: string - file_type: 'image' | 'video' | 'file' + file_type: 'image' | 'video' | 'file' | 'directory' preview_url?: string } +/** Live tool event during SSE streaming. */ export interface ToolCall { - type: 'tool_start' | 'tool_end' + type: 'tool_start' | 'tool_end' | 'tool_progress' tool: string + tool_call_id?: string arguments?: Record result?: string status?: string execution_time?: number } +/** All SSE event types emitted on /stream. */ +export type StreamEventType = + | 'delta' + | 'reasoning' + | 'tool_start' + | 'tool_progress' + | 'tool_end' + | 'message_end' + | 'phase' + | 'file_to_send' + | 'image' + | 'video' + | 'file' + | 'text' + | 'done' + | 'cancelled' + | 'voice_attach' + | 'error' + +export interface StreamEvent { + type: StreamEventType + content?: string + tool?: string + tool_call_id?: string + arguments?: Record + status?: string + result?: string + execution_time?: number + has_tool_calls?: boolean + path?: string + file_name?: string + file_type?: string + web_url?: string + audio_url?: string + request_id?: string + timestamp?: number + user_seq?: number + bot_seq?: number + message?: string +} + +// ============================================================ +// Sessions / history +// ============================================================ + +export interface SessionItem { + session_id: string + title: string + created_at: number + last_active: number + msg_count: number +} + +export interface SessionsPage { + sessions: SessionItem[] + total: number + page: number + page_size: number + has_more: boolean +} + +/** Backend history message (as returned by /api/history). */ +export interface HistoryMessage { + role: Role + content: string + created_at: number + steps?: MessageStep[] + tool_calls?: Array<{ id?: string; name: string; arguments?: Record; result?: string }> + reasoning?: string + kind?: 'evolution' + extras?: Record + /** Per-message sequence number used by delete/regenerate APIs. */ + _seq?: number +} + +export interface HistoryPage { + messages: HistoryMessage[] + total: number + page: number + page_size: number + has_more: boolean + context_start_seq?: number +} + +// ============================================================ +// Config +// ============================================================ + +export interface ProviderMeta { + label: string + models: string[] + api_base_key?: string + api_base_default?: string + api_key_field?: string + [k: string]: unknown +} + export interface ConfigData { use_agent: boolean title: string @@ -51,54 +194,190 @@ export interface ConfigData { agent_max_context_tokens: number agent_max_context_turns: number agent_max_steps: number + enable_thinking?: boolean + self_evolution_enabled?: boolean api_bases: Record api_keys: Record - providers: Record + providers: Record + web_password_masked?: string +} + +// ============================================================ +// Models console (/api/models) +// ============================================================ + +export interface ModelProvider { + id: string + label: string + configured: boolean + is_custom: boolean + custom_id?: string + active?: boolean + api_key_masked?: string + api_base?: string + models: string[] +} + +export type CapabilityKey = 'chat' | 'vision' | 'asr' | 'tts' | 'embedding' | 'image' | 'search' + +export interface CapabilityState { + current_provider?: string + current_model?: string + providers?: string[] + provider_models?: Record + strategy?: string + fallback_provider?: string + fallback_model?: string + [k: string]: unknown +} + +export interface ModelsData { + providers: ModelProvider[] + capabilities: Record +} + +export type ModelsAction = + | { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string } + | { action: 'delete_provider'; provider_id: string } + | { action: 'set_custom_provider'; name: string; id?: string; api_base: string; api_key?: string; model?: string; make_active?: boolean } + | { action: 'delete_custom_provider'; id: string } + | { action: 'set_active_custom_provider'; id: string } + | { action: 'set_capability'; capability: CapabilityKey; provider_id: string; model: string; voice?: string; strategy?: string; provider?: string } + | { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' } + | { action: 'set_search_credential'; api_key: string } + +// ============================================================ +// Channels +// ============================================================ + +export interface ChannelField { + key: string + label: string + type: 'text' | 'secret' | 'number' | 'bool' + value?: string | number | boolean + default?: string | number | boolean } export interface ChannelInfo { name: string - label: Record + label: { zh: string; en: string } icon: string color: string active: boolean fields: ChannelField[] + login_status?: string } -export interface ChannelField { - key: string - label: Record - type: string - required?: boolean - placeholder?: Record +export type ChannelAction = 'save' | 'connect' | 'disconnect' + +// ============================================================ +// Tools / skills +// ============================================================ + +export interface ToolInfo { + name: string + description: string } export interface SkillInfo { name: string - display_name: string description: string + source?: string enabled: boolean + category?: string } -export interface ToolInfo { - name: string - display_name: string - description: string -} +// ============================================================ +// Memory +// ============================================================ + +export type MemoryCategory = 'memory' | 'dream' | 'evolution' export interface MemoryItem { filename: string - modified: number + type: string // global | daily | dream | evolution size: number + updated_at: string +} + +export interface MemoryPage { + list: MemoryItem[] + total: number + page: number + page_size: number +} + +// ============================================================ +// Knowledge +// ============================================================ + +export interface KnowledgeNode { + name: string + path: string + type: 'category' | 'file' + children?: KnowledgeNode[] + count?: number +} + +export interface KnowledgeList { + root_files?: KnowledgeNode[] + tree: KnowledgeNode[] + stats: { pages: number; size: number } + enabled: boolean +} + +export interface KnowledgeGraph { + nodes: Array<{ id: string; label: string; category?: string }> + links: Array<{ source: string; target: string }> +} + +export type KnowledgeAction = + | { action: 'create_category'; payload: { path: string } } + | { action: 'rename_category'; payload: { path: string; new_path: string } } + | { action: 'delete_category'; payload: { path: string; confirm?: boolean } } + | { action: 'delete_documents'; payload: { paths: string[] } } + | { action: 'move_documents'; payload: { paths: string[]; target_category: string } } + +// ============================================================ +// Scheduler +// ============================================================ + +export interface TaskSchedule { + type: 'cron' | 'interval' | 'once' + expression?: string + seconds?: number + run_at?: string +} + +export interface TaskAction { + type: 'send_message' | 'agent_task' + content?: string + task_description?: string + receiver?: string + receiver_name?: string + is_group?: boolean + channel_type?: string } export interface SchedulerTask { id: string name: string - cron: string enabled: boolean - last_run?: string - next_run?: string + created_at: string + updated_at: string + schedule: TaskSchedule + action: TaskAction + next_run_at?: string +} + +// ============================================================ +// Logs +// ============================================================ + +export interface LogEvent { + type: 'init' | 'line' | 'error' + content?: string + message?: string } declare global {