import type { AdminDashboardSummary, AdminUserRow, AuthStatus, CapabilityDefinition, CapabilityMap, ChatSaveAnalysis, ChatSaveResult, PolicyDefinition, PolicyMap, SkillDefinition, SkillMap, BillingConfig, HarnessBootstrapResponse, InsufficientBalanceDetails, LedgerEntry, LlmProviderDefinition, LlmProviderKeyRow, LlmGlobalSettings, LlmConnectionTestResult, RechargeOrder, Message, MindSpace, MindSpaceAsset, MindSpaceAgentJob, MindSpaceCleanupItem, MindSpaceConversationPackage, MindSpacePage, MindSpacePageDeletePreview, MindSpacePageDeleteResult, MindSpaceQuota, MindSpaceScheduleReminder, MindSpaceSaveCategory, MindSpacePublication, MindSpacePublicationStats, MindSpacePublishCheck, PlazaCategory, PlazaPostBrief, MindSpaceRedactedCopyResult, MindSpaceUpload, FeedbackContextInput, FeedbackImageInput, FeedbackSubmission, FeedbackSubmissionType, FeedbackBoardPage, PortalUser, Session, SessionConversationPage, SessionListPage, SessionSummary, SessionEvent, SessionListResponse, UsageRecord, BalanceUpdate, UserNotification, PlanDefinition, ActiveSubscription, UserMemorySyncResponse, } from '../types'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload'; import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message'; import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode'; const API = '/api'; const DEFAULT_API_TIMEOUT_MS = 20_000; const AGENT_CONNECT_TIMEOUT_MS = 60_000; const AGENT_RUNS_PATH = '/agent/runs'; export type AgentRun = { id: string; userId: string; sessionId: string | null; requestId: string; status: 'queued' | 'running' | 'retryable' | 'succeeded' | 'failed'; attempts: number; error: string | null; createdAt: number; updatedAt: number; startedAt: number | null; completedAt: number | null; }; function appendAgentRunValidationInstruction(message: Message, instruction?: string | null): Message { const normalizedInstruction = String(instruction ?? '').trim(); if (!normalizedInstruction) return message; return { ...message, content: [ ...message.content, { type: 'text', text: normalizedInstruction }, ], }; } function withAgentRunValidationMetadata( message: Message, validation?: AgentRunValidation | null, ): Message { if (!validation) return message; const metadata = message.metadata as Message['metadata'] & { memindRun?: Record; }; return { ...message, metadata: { ...message.metadata, memindRun: { ...(metadata.memindRun && typeof metadata.memindRun === 'object' ? metadata.memindRun : {}), validation, }, } as Message['metadata'], }; } function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message { const normalized = normalizeUserMessageForApi(message); const withValidation = withAgentRunValidationMetadata(normalized, options.validation); const withRunMetadata = options.forceDeepReasoning ? { ...withValidation, metadata: { ...withValidation.metadata, memindRun: { ...( withValidation.metadata?.memindRun && typeof withValidation.metadata.memindRun === 'object' ? withValidation.metadata.memindRun : {} ), forceDeepReasoning: true, }, }, } : withValidation; return appendAgentRunValidationInstruction( withRunMetadata, options.toolMode === 'code' ? options.validationInstruction : null, ); } export class ApiError extends Error { readonly status: number; readonly code?: string; readonly details?: InsufficientBalanceDetails | Record; constructor( status: number, message: string, code?: string, details?: InsufficientBalanceDetails | Record, ) { super(sanitizeUserFacingErrorMessage(message)); this.name = 'ApiError'; this.status = status; this.code = code; this.details = details; } } function formatBytes(bytes: number) { if (!Number.isFinite(bytes) || bytes < 0) return '0B'; if (bytes >= 1024 * 1024) { const mb = bytes / 1024 / 1024; return `${Number.isInteger(mb) ? mb : mb.toFixed(1)}MB`; } if (bytes >= 1024) { const kb = bytes / 1024; return `${Number.isInteger(kb) ? kb : kb.toFixed(1)}KB`; } return `${bytes}B`; } function readNumberDetail(details: ApiError['details'], key: string) { if (!details || typeof details !== 'object') return null; const value = (details as Record)[key]; return typeof value === 'number' && Number.isFinite(value) ? value : null; } function isImageUploadFile(file: File) { if (file.type.startsWith('image/')) return true; return /\.(png|jpe?g|webp|gif)$/i.test(file.name); } function normalizeMindSpaceUploadError(error: unknown, file: File): Error { if (!(error instanceof ApiError)) { return error instanceof Error ? error : new Error('上传失败,请重试'); } const imageMax = formatBytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES); if (error.code === 'quota_exceeded' || error.status === 429) { const requiredBytes = readNumberDetail(error.details, 'requiredBytes'); const availableBytes = readNumberDetail(error.details, 'availableBytes'); const detail = requiredBytes !== null && availableBytes !== null ? `当前剩余 ${formatBytes(availableBytes)},本次需要 ${formatBytes(requiredBytes)}。` : ''; return new ApiError( error.status, `剩余空间不足,${detail}请减少图片数量或压缩后重试。`, error.code, error.details, ); } if (error.code === 'file_too_large' || error.status === 413) { const message = isImageUploadFile(file) ? `图片文件过大,单张图片不能超过 ${imageMax},请压缩后重试。` : `文件过大,请压缩到单文件上限以内后重试。`; return new ApiError(error.status, message, error.code, error.details); } if (/MindSpace\s*服务异常/.test(error.message) || error.code === 'internal_error') { return new ApiError( error.status, `上传失败,请减少图片数量或压缩图片后重试;单张图片上限 ${imageMax}。`, error.code, error.details, ); } return error; } function sanitizeUserFacingErrorMessage(message: string) { const normalized = String(message ?? '').trim(); const serviceName = [103, 111, 111, 115, 101] .map((code) => String.fromCharCode(code)) .join(''); const servicePattern = new RegExp(`${serviceName}d?`, 'i'); if (!normalized) return normalized; if (!servicePattern.test(normalized)) return normalized; if (/超时|timeout/i.test(normalized)) { return '后端连接超时,请确认后端服务正常后重试'; } if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) { return '后端连接失败,请稍后重试'; } const serviceProcessPattern = new RegExp(`\\b${serviceName}d\\b`, 'gi'); const servicePatternGlobal = new RegExp(`\\b${serviceName}\\b`, 'gi'); return normalized .replace(serviceProcessPattern, '后端服务') .replace(servicePatternGlobal, '后端'); } async function parseErrorResponse(res: Response): Promise<{ message: string; code?: string; details?: InsufficientBalanceDetails | Record; }> { const text = await res.text().catch(() => ''); try { const body = JSON.parse(text) as Record; const nested = body.error && typeof body.error === 'object' ? (body.error as Record) : body; const message = typeof nested.message === 'string' ? nested.message : typeof body.message === 'string' ? body.message : text; const code = typeof nested.code === 'string' ? nested.code : typeof body.code === 'string' ? body.code : undefined; const details = nested.details ?? body.details; if (code === 'INSUFFICIENT_BALANCE') { return { message: sanitizeUserFacingErrorMessage(message), code, details: { code: 'INSUFFICIENT_BALANCE' as const, balanceCents: Number((details as Record)?.balanceCents ?? body.balanceCents ?? 0), minRechargeCents: Number( (details as Record)?.minRechargeCents ?? body.minRechargeCents ?? 500, ), suggestedTiers: Array.isArray((details as Record)?.suggestedTiers) ? ((details as Record).suggestedTiers as unknown[]).map((value) => Number(value)) : Array.isArray(body.suggestedTiers) ? body.suggestedTiers.map((value) => Number(value)) : [], }, }; } return { message: sanitizeUserFacingErrorMessage(message), code, details: details && typeof details === 'object' ? (details as InsufficientBalanceDetails | Record) : undefined, }; } catch { return { message: sanitizeUserFacingErrorMessage(text || res.statusText) }; } } let unauthorizedHandler: (() => void) | null = null; let unauthorizedHandling = false; export function setUnauthorizedHandler(handler: (() => void) | null) { unauthorizedHandler = handler; if (handler) unauthorizedHandling = false; } export function resetUnauthorizedGuard() { unauthorizedHandling = false; } function notifyUnauthorized() { if (!unauthorizedHandler || unauthorizedHandling) return; unauthorizedHandling = true; unauthorizedHandler(); } async function fetchWithTimeout( input: RequestInfo | URL, init?: RequestInit, timeoutMs = DEFAULT_API_TIMEOUT_MS, ): Promise { const controller = new AbortController(); const upstreamSignal = init?.signal; const timeout = window.setTimeout(() => controller.abort(), timeoutMs); const abortFromUpstream = () => controller.abort(); if (upstreamSignal) { if (upstreamSignal.aborted) controller.abort(); else upstreamSignal.addEventListener('abort', abortFromUpstream, { once: true }); } try { return await fetch(input, { ...init, signal: controller.signal, }); } finally { window.clearTimeout(timeout); upstreamSignal?.removeEventListener('abort', abortFromUpstream); } } async function portalFetch(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetchWithTimeout(path, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers, }, }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { notifyUnauthorized(); const text = await res.text().catch(() => ''); throw new ApiError(401, text || '未授权,请重新登录'); } if (!res.ok) { const parsed = await parseErrorResponse(res); throw new ApiError( res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code, parsed.details, ); } if (res.status === 204) return undefined as T; const text = await res.text().catch(() => ''); if (text.trimStart().startsWith('<')) { throw new ApiError( res.status, `接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`, ); } try { return JSON.parse(text) as T; } catch { throw new ApiError(res.status, '服务器响应格式错误'); } } function formatNetworkError(err: unknown) { const message = err instanceof Error ? err.message : '网络请求失败'; if (err instanceof DOMException && err.name === 'AbortError') { return '后端连接超时,请确认后端服务正常后重试'; } if (message.includes('Failed to fetch') || message.includes('NetworkError')) { return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs'; } return sanitizeUserFacingErrorMessage(message); } function sanitizeSessionEvent(event: SessionEvent): SessionEvent { if (event.type !== 'Error') return event; return { ...event, error: sanitizeUserFacingErrorMessage(event.error) }; } async function apiFetch( path: string, init?: RequestInit, options?: { timeoutMs?: number }, ): Promise { let res: Response; try { res = await fetchWithTimeout( `${API}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers, }, }, options?.timeoutMs, ); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { notifyUnauthorized(); const text = await res.text().catch(() => ''); throw new ApiError(401, text || '未授权,请重新登录'); } if (!res.ok) { const parsed = await parseErrorResponse(res); throw new ApiError( res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code, parsed.details, ); } if (res.status === 204) return undefined as T; const rawText = await res.text().catch(() => ''); if (rawText.trimStart().startsWith('<')) { throw new ApiError( res.status, `接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`, ); } try { return JSON.parse(rawText) as T; } catch { throw new ApiError(res.status, '服务器响应格式错误'); } } export async function startSession(): Promise { return apiFetch( '/agent/start', { method: 'POST', body: JSON.stringify({}), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, ); } export async function bootstrapProjectMemory( sessionId: string, query: string, force = false, ): Promise { return apiFetch('/agent/harness_bootstrap', { method: 'POST', body: JSON.stringify({ sessionId, query, force }), }); } export async function rememberProjectContext( sessionId: string, content: string, title: string, ): Promise { await apiFetch('/agent/harness_remember', { method: 'POST', body: JSON.stringify({ sessionId, content, title }), }); } export async function rememberUserMemory(sessionId: string): Promise { return apiFetch('/user-memory/v1/remember-recent', { method: 'POST', body: JSON.stringify({ sessionId }), }); } export async function syncUserMemory(sessionId: string): Promise { return apiFetch('/user-memory/v1/sync', { method: 'POST', body: JSON.stringify({ sessionId }), }); } export type WechatAuthConfig = { enabled: boolean; inWechat?: boolean; scanEnabled?: boolean; openScanEnabled?: boolean; openAppId?: string; oauthCallbackUrl?: string; }; export type WechatPendingProfile = { nickname: string | null; avatarUrl: string | null; returnTo: string; }; export type WechatBindingStatus = { enabled: boolean; bound: boolean; nickname?: string | null; avatarUrl?: string | null; }; export type WechatAgentRouteStatus = { enabled: boolean; bound: boolean; appId: string | null; openid: string | null; agentSessionId: string | null; routeStatus: string | null; updatedAt: number | null; }; export type WechatJsSdkSignature = { appId: string; timestamp: number; nonceStr: string; signature: string; url: string; jsApiList: string[]; }; export async function getWechatAuthConfig(): Promise { try { const response = await fetch('/auth/wechat/config'); if (!response.ok) return { enabled: false }; return (await response.json()) as WechatAuthConfig; } catch { return { enabled: false }; } } export async function getWechatJsSdkSignature(url: string): Promise { let response: Response; try { response = await fetch(`/auth/wechat/js-sdk-signature?url=${encodeURIComponent(url)}`); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as | (WechatJsSdkSignature & { message?: string }) | null; if (!response.ok || !body?.signature) { throw new ApiError(response.status, body?.message ?? '微信语音初始化失败'); } return body; } export async function checkAuth(): Promise { try { const response = await fetch('/auth/status'); const status = (await response.json()) as AuthStatus; if (!response.ok) { if (status.mode === 'unavailable') return status; return { authenticated: false }; } if (status.authenticated) resetUnauthorizedGuard(); if (status.authenticated && status.mode === 'user' && !status.capabilities) { try { const me = await getMe(); return { ...status, capabilities: me.capabilities, grantedSkills: me.grantedSkills, user: me.user, }; } catch { return status; } } return status; } catch { return { authenticated: false }; } } export async function getMe(): Promise<{ user: PortalUser; capabilities?: CapabilityMap; grantedSkills?: string[]; unrestricted?: boolean; }> { return portalFetch('/auth/me'); } export async function getMyUsage(): Promise { const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage'); return result.records ?? []; } export async function submitFeedback(input: { type: FeedbackSubmissionType; title: string; description: string; contact?: string; images?: FeedbackImageInput[]; context?: FeedbackContextInput; }): Promise { const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: input.type, title: input.title, description: input.description, contact: input.contact, images: input.images, context: input.context, }), }); return result.feedback; } export async function getMyFeedback(limit = 20): Promise { const result = await portalFetch<{ items: FeedbackSubmission[] }>( `/auth/feedback?limit=${encodeURIComponent(String(limit))}`, ); return result.items ?? []; } export async function listFeedbackBoard(page = 1, limit = 10): Promise { const params = new URLSearchParams({ page: String(page), limit: String(limit), }); return portalFetch(`/auth/feedback/board?${params.toString()}`); } export async function getFeedbackDetail(feedbackId: string): Promise<{ item: FeedbackSubmission; isMine: boolean; }> { return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>( `/auth/feedback/${encodeURIComponent(feedbackId)}`, ); } export async function getMyBillingLedger(limit = 30): Promise { const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : ''; const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`); return result.entries ?? []; } export async function getBillingConfig(): Promise { return portalFetch('/auth/billing/config'); } export async function getAvailablePlans(): Promise<{ plans: PlanDefinition[]; subscription: ActiveSubscription | null; balanceCents: number; }> { return portalFetch('/auth/billing/plans'); } export async function purchaseSubscription(planType: string, autoRenew = false): Promise<{ subscription: ActiveSubscription; balanceCents: number; }> { return portalFetch('/auth/billing/subscribe', { method: 'POST', body: JSON.stringify({ planType, autoRenew }), }); } export async function setAutoRenew(enabled: boolean): Promise<{ ok: boolean; updated: boolean }> { return portalFetch('/auth/billing/auto-renew', { method: 'POST', body: JSON.stringify({ enabled }), }); } export async function createRechargeOrder(input: { amountCents: number; payScene: 'native' | 'h5' | 'jsapi'; }): Promise { const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', { method: 'POST', body: JSON.stringify(input), }); return result.order; } export async function getRechargeOrder(orderId: string): Promise<{ order: RechargeOrder; balanceCents: number | null; }> { return portalFetch(`/auth/billing/recharge-orders/${orderId}`); } export async function purchaseSpaceQuota(sizeMb: number): Promise<{ quota: MindSpaceQuota; balanceCents: number; purchasedMb: number; costCents: number; }> { return portalFetch('/auth/billing/space-purchase', { method: 'POST', body: JSON.stringify({ sizeMb }), }); } export async function getMindSpace(): Promise { const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space'); return result.data; } export async function getMindSpaceConversationPackage( sessionId: string, ): Promise { const result = await apiFetch<{ data: MindSpaceConversationPackage }>( `/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}`, ); return result.data; } export function buildMindSpaceConversationPackageManifestDownloadUrl(sessionId: string): string { return `${API}/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/manifest.json`; } export async function ignoreMindSpaceScheduleReminder(reminderId: string) { const result = await apiFetch<{ data: MindSpaceScheduleReminder }>( `/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`, { method: 'POST' }, ); return result.data; } export async function deleteMindSpaceScheduleReminders(ids: string[]) { const result = await apiFetch<{ data: { deleted: number } }>( '/mindspace/v1/schedule/reminders/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }), }, ); return result.data; } export async function listMindSpaceCleanupItems(): Promise<{ items: MindSpaceCleanupItem[]; totalBytes: number; }> { const result = await apiFetch<{ data: { items: MindSpaceCleanupItem[]; totalBytes: number }; }>('/mindspace/v1/space/cleanup'); return result.data; } export async function runMindSpaceCleanup(itemIds: string[]): Promise<{ removedCount: number; freedBytes: number; quota?: MindSpaceQuota; }> { const result = await apiFetch<{ data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota }; }>('/mindspace/v1/space/cleanup', { method: 'POST', body: JSON.stringify({ item_ids: itemIds }), }); return result.data; } export async function listMindSpaceAssets( categoryCode?: string, ): Promise { const query = categoryCode ? `?category_code=${encodeURIComponent(categoryCode)}` : ''; const result = await apiFetch<{ data: MindSpaceAsset[] }>(`/mindspace/v1/assets${query}`); return result.data; } export async function uploadMindSpaceAsset( categoryId: string, file: File, options: { maxImageBytes?: number; onProgress?: (progress: number) => void; sessionId?: string | null; messageId?: string | null; } = {}, ): Promise { const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; if (file.type.startsWith('image/') && file.size > maxImageBytes) { throw new ApiError( 413, `图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`, ); } let created: { data: MindSpaceUpload }; try { created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', { method: 'POST', body: JSON.stringify({ category_id: categoryId, filename: file.name, size_bytes: file.size, declared_mime_type: file.type || null, ...(options.sessionId ? { session_id: options.sessionId } : {}), ...(options.messageId ? { message_id: options.messageId } : {}), }), }); } catch (error) { throw normalizeMindSpaceUploadError(error, file); } try { await uploadFileContent(created.data.uploadUrl, file, options.onProgress); const completed = await apiFetch<{ data: MindSpaceAsset }>( `/mindspace/v1/uploads/${created.data.id}/complete`, { method: 'POST', body: JSON.stringify({}) }, ); return completed.data; } catch (error) { await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, { method: 'DELETE', }).catch(() => {}); throw normalizeMindSpaceUploadError(error, file); } } export async function claimMindSpaceConversationUploads( sessionId: string, messageId: string, ): Promise<{ claimedCount: number }> { const result = await apiFetch<{ data: { claimedCount: number } }>( `/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}/claim-uploads`, { method: 'POST', body: JSON.stringify({ message_id: messageId }), }, ); return result.data; } function uploadFileContent( url: string, file: File, onProgress?: (progress: number) => void, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.upload.onprogress = (event) => { if (!event.lengthComputable || !onProgress) return; onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total))); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { onProgress?.(1); resolve(); return; } let message = '文件内容上传失败'; try { const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } }; message = body?.error?.message ?? message; } catch { // Keep the generic upload error when the response is not JSON. } reject(new ApiError(xhr.status, message)); }; xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败')); xhr.onabort = () => reject(new ApiError(0, '文件上传已取消')); xhr.send(file); }); } export async function deleteMindSpaceAsset(assetId: string): Promise { await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' }); } export async function getMindSpacePageDeletePreview( pageId: string, ): Promise { const result = await apiFetch<{ data: MindSpacePageDeletePreview }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/delete-preview`, ); return result.data; } export async function deleteMindSpacePage( pageId: string, options?: { removeFromPlaza?: boolean }, ): Promise { const params = new URLSearchParams(); if (options?.removeFromPlaza) params.set('remove_from_plaza', 'true'); const query = params.toString() ? `?${params.toString()}` : ''; const result = await apiFetch<{ data: MindSpacePageDeleteResult }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}${query}`, { method: 'DELETE' }, ); return result.data; } export async function listMindSpacePages(options?: { status?: string; limit?: number; offset?: number; categoryCode?: string; }): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> { const params = new URLSearchParams(); if (options?.status) params.set('status', options.status); if (options?.limit != null) params.set('limit', String(options.limit)); if (options?.offset != null) params.set('offset', String(options.offset)); if (options?.categoryCode) params.set('category_code', options.categoryCode); const query = params.toString() ? `?${params.toString()}` : ''; const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>( `/mindspace/v1/pages${query}`, ); return { items: result.data, page: result.page ?? {} }; } export async function getMindSpacePage(pageId: string): Promise { const result = await apiFetch<{ data: MindSpacePage }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}`, ); return result.data; } export async function bindMindSpacePageLiveEdit( pageId: string, sessionId: string, options?: { parentSessionId?: string }, ): Promise<{ sessionId: string; pageId: string; parentSessionId?: string }> { const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId?: string } }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/bind`, { method: 'POST', body: JSON.stringify({ session_id: sessionId, ...(options?.parentSessionId ? { parent_session_id: options.parentSessionId } : {}), }), }, ); return result.data; } export async function forkMindSpacePageEditSession( pageId: string, parentSessionId: string, h5ApiBase?: string | null, ): Promise<{ sessionId: string; pageId: string; parentSessionId: string }> { const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId: string }; }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/fork-session`, { method: 'POST', body: JSON.stringify({ parent_session_id: parentSessionId, ...(h5ApiBase ? { h5_api_base: h5ApiBase } : {}), }), }); return result.data; } export async function closeMindSpacePageEditSession( pageId: string, input: { sessionId: string; parentSessionId?: string | null; summary?: string; }, ): Promise<{ sessionId: string; pageId: string; merged: boolean }> { const result = await apiFetch<{ data: { sessionId: string; pageId: string; merged: boolean }; }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/close-session`, { method: 'POST', body: JSON.stringify({ session_id: input.sessionId, parent_session_id: input.parentSessionId ?? undefined, summary: input.summary ?? '', }), }); return result.data; } export async function getMindSpacePageLiveRevision(pageId: string): Promise<{ pageId: string; versionNo: number; updatedAt: number; liveRevision: number; }> { const result = await apiFetch<{ data: { pageId: string; versionNo: number; updatedAt: number; liveRevision: number }; }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/revision`); return result.data; } export async function analyzeChatSave(input: { sessionId: string; messageId: string; selectedLinkIndex?: number; previewTitle?: string; previewSummary?: string; }): Promise { const result = await apiFetch<{ data: ChatSaveAnalysis }>( '/mindspace/v1/pages/analyze-chat-save', { method: 'POST', body: JSON.stringify({ session_id: input.sessionId, message_id: input.messageId, selected_link_index: input.selectedLinkIndex ?? 0, preview_title: input.previewTitle, preview_summary: input.previewSummary, }), }, ); return result.data; } export function buildChatSavePreviewFrameUrl(input: { sessionId: string; messageId: string; selectedLinkIndex?: number; }) { const params = new URLSearchParams({ session_id: input.sessionId, message_id: input.messageId, selected_link_index: String(input.selectedLinkIndex ?? 0), }); return `/api/mindspace/v1/pages/chat-save-preview?${params.toString()}`; } export function buildChatSaveThumbnailUrl(input: { sessionId: string; messageId: string; selectedLinkIndex?: number; previewTitle?: string; previewSummary?: string; cacheKey?: number | string; }) { const params = new URLSearchParams({ session_id: input.sessionId, message_id: input.messageId, selected_link_index: String(input.selectedLinkIndex ?? 0), }); const title = input.previewTitle?.trim(); const summary = input.previewSummary?.trim(); if (title) params.set('preview_title', title); if (summary) params.set('preview_summary', summary); if (input.cacheKey != null) params.set('v', String(input.cacheKey)); return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`; } export async function quickShareFromChat(input: { sessionId: string; messageId: string; selectedLinkIndex?: number; }): Promise<{ publicUrl: string; filename: string }> { const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>( '/mindspace/v1/pages/quick-share-from-chat', { method: 'POST', body: JSON.stringify({ session_id: input.sessionId, message_id: input.messageId, selected_link_index: input.selectedLinkIndex ?? 0, }), }, ); return result.data; } export async function downloadChatMessageDocx(input: { sessionId: string; messageId: string; selectedLinkIndex?: number; }): Promise<{ blob: Blob; filename: string }> { let res: Response; try { res = await fetch(`${API}/mindspace/v1/pages/chat-save-docx`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: input.sessionId, message_id: input.messageId, selected_link_index: input.selectedLinkIndex ?? 0, }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { notifyUnauthorized(); throw new ApiError(401, '未授权,请重新登录'); } if (!res.ok) { const parsed = await parseErrorResponse(res); throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code); } const disposition = res.headers.get('Content-Disposition') ?? ''; const filenameMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i); const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'mindspace-document.docx'; return { blob: await res.blob(), filename }; } export async function fetchPreviewAsset(path: string): Promise { let res: Response; try { res = await fetch(path, { credentials: 'same-origin' }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { notifyUnauthorized(); throw new ApiError(401, '未授权,请重新登录'); } if (!res.ok) { const parsed = await parseErrorResponse(res); throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code); } const blob = await res.blob(); return URL.createObjectURL(blob); } export async function saveChatMessageAsPage(input: { sessionId: string; messageId: string; title: string; summary?: string; templateId?: string; categoryCode?: MindSpaceSaveCategory; selectedLinkIndex?: number; acknowledgedFindingIds?: string[]; replacePageId?: string; saveAsNew?: boolean; }): Promise { const result = await apiFetch<{ data: ChatSaveResult }>( '/mindspace/v1/pages/save-from-chat', { method: 'POST', body: JSON.stringify({ session_id: input.sessionId, message_id: input.messageId, title: input.title, summary: input.summary, template_id: input.templateId ?? 'editorial', category_code: input.categoryCode ?? 'draft', selected_link_index: input.selectedLinkIndex ?? 0, acknowledged_finding_ids: input.acknowledgedFindingIds, page_type: input.templateId === 'report' ? 'report' : 'article', replace_page_id: input.replacePageId, save_as_new: input.saveAsNew ?? false, }), }, ); return result.data; } export async function createMindSpacePageFromAsset(input: { assetId: string; title?: string; summary?: string; }): Promise { const result = await apiFetch<{ data: { page: MindSpacePage } }>( '/mindspace/v1/pages/from-asset', { method: 'POST', body: JSON.stringify({ asset_id: input.assetId, title: input.title, summary: input.summary, }), }, ); return result.data.page; } export async function createMindSpacePage(input: { title: string; summary?: string; content: string; templateId: string; }): Promise { const result = await apiFetch<{ data: MindSpacePage }>('/mindspace/v1/pages', { method: 'POST', body: JSON.stringify({ title: input.title, summary: input.summary, content: input.content, template_id: input.templateId, page_type: input.templateId === 'report' ? 'report' : 'article', }), }); return result.data; } export async function updateMindSpacePage( pageId: string, input: { expectedVersion: number; title: string; summary: string; content: string; templateId: string; changeNote?: string; }, ): Promise { const result = await apiFetch<{ data: MindSpacePage }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}`, { method: 'PUT', body: JSON.stringify({ expected_version: input.expectedVersion, title: input.title, summary: input.summary, content: input.content, template_id: input.templateId, page_type: input.templateId === 'report' ? 'report' : 'article', change_note: input.changeNote, }), }, ); return result.data; } export async function rewriteMindSpacePageDownloadLinks( pageId: string, content: string, ): Promise { const result = await apiFetch<{ data: { html: string } }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`, { method: 'POST', body: JSON.stringify({ content }), }, ); return result.data.html; } export async function fetchMindSpacePageDraftPreview( pageId: string, input: { title: string; summary: string; content: string; templateId: string; }, ): Promise { let res: Response; try { res = await fetch(`/api/mindspace/v1/pages/${encodeURIComponent(pageId)}/preview-draft`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: input.title, summary: input.summary, content: input.content, template_id: input.templateId, }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { notifyUnauthorized(); throw new ApiError(401, '未授权,请重新登录'); } if (!res.ok) { const parsed = await parseErrorResponse(res); throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code); } return res.text(); } export function openMindSpaceDraftPreviewWindow(html: string) { const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const opened = window.open(url, '_blank', 'noopener,noreferrer'); if (!opened) { URL.revokeObjectURL(url); throw new ApiError(0, '无法打开新窗口,请检查浏览器是否拦截弹窗'); } window.setTimeout(() => URL.revokeObjectURL(url), 120_000); } export async function uploadMindSpacePageThumbnail( pageId: string, input: { imageBase64: string; mimeType?: string; title?: string; summary?: string; content?: string; }, ): Promise<{ updatedAt: number }> { const result = await apiFetch<{ data: { updatedAt: number } }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/upload`, { method: 'POST', body: JSON.stringify({ image_base64: input.imageBase64, mime_type: input.mimeType, title: input.title, summary: input.summary, html: input.content, }), }, ); return result.data; } export async function regenerateMindSpacePageThumbnail( pageId: string, input: { title?: string; summary?: string; content?: string; useAi?: boolean; instruction?: string; } = {}, ): Promise<{ updatedAt: number; content?: string | null }> { const result = await apiFetch<{ data: { updatedAt: number; content?: string | null } }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/regenerate`, { method: 'POST', body: JSON.stringify({ title: input.title, summary: input.summary, html: input.content, use_ai: input.useAi ?? false, instruction: input.instruction, }), }, ); return result.data; } export async function checkMindSpacePagePublication( pageId: string, input: { pageVersionId: string; accessMode: MindSpacePublishCheck['accessMode']; urlSlug: string; password?: string; expiresAt?: number | null; }, ): Promise { const result = await apiFetch<{ data: MindSpacePublishCheck }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-check`, { method: 'POST', body: JSON.stringify({ page_version_id: input.pageVersionId, access_mode: input.accessMode, url_slug: input.urlSlug, password: input.password, expires_at: input.expiresAt, }), }, ); return result.data; } export async function publishMindSpacePage( pageId: string, input: { pageVersionId: string; accessMode: MindSpacePublishCheck['accessMode']; urlSlug: string; acknowledgedFindingIds: string[]; password?: string; expiresAt?: number | null; }, ): Promise { const result = await apiFetch<{ data: MindSpacePublication }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish`, { method: 'POST', body: JSON.stringify({ page_version_id: input.pageVersionId, access_mode: input.accessMode, url_slug: input.urlSlug, password: input.password, expires_at: input.expiresAt, acknowledged_finding_ids: input.acknowledgedFindingIds, }), }, ); return result.data; } export async function updatePublicationStatus( publicationId: string, input: { accessMode: MindSpacePublishCheck['accessMode']; expiresAt?: number | null; }, ): Promise { const result = await apiFetch<{ data: MindSpacePublication }>( `/mindspace/v1/publications/${encodeURIComponent(publicationId)}/update-status`, { method: 'POST', body: JSON.stringify({ access_mode: input.accessMode, expires_at: input.expiresAt, }), }, ); return result.data; } export async function redactMindSpacePage( pageId: string, input: { pageVersionId: string; expectedVersion: number; title: string; summary: string; content: string; }, ): Promise { const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/redact`, { method: 'POST', body: JSON.stringify({ page_version_id: input.pageVersionId, expected_version: input.expectedVersion, title: input.title, summary: input.summary, content: input.content, }), }, ); return result.data; } export async function fixMindSpacePagePublication( pageId: string, input: { pageVersionId: string; expectedVersion: number; title: string; summary: string; content: string; }, ): Promise { const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-fix`, { method: 'POST', body: JSON.stringify({ page_version_id: input.pageVersionId, expected_version: input.expectedVersion, title: input.title, summary: input.summary, content: input.content, }), }, ); return result.data; } /** @deprecated use redactMindSpacePage */ export async function createMindSpaceRedactedCopy( pageId: string, pageVersionId: string, ): Promise { const page = await getMindSpacePage(pageId); return redactMindSpacePage(pageId, { pageVersionId, expectedVersion: page.versionNo, title: page.title, summary: page.summary, content: page.content ?? '', }); } export async function offlineMindSpacePublication(publicationId: string): Promise { await apiFetch( `/mindspace/v1/publications/${encodeURIComponent(publicationId)}/offline`, { method: 'POST', body: JSON.stringify({}) }, ); } export async function getMindSpacePublicationStats( publicationId: string, ): Promise { const result = await apiFetch<{ data: MindSpacePublicationStats }>( `/mindspace/v1/publications/${encodeURIComponent(publicationId)}/stats`, ); return result.data; } export async function listPlazaCategories(): Promise { const result = await apiFetch<{ data: { categories: PlazaCategory[] } }>('/plaza/v1/categories'); return result.data.categories; } export async function publishPageToPlaza(input: { publicationId: string; categoryId: string; tags?: string[]; coverUrl?: string; allowComment?: boolean; }): Promise { const result = await apiFetch<{ data: { post: PlazaPostBrief } }>('/plaza/v1/posts', { method: 'POST', body: JSON.stringify({ publication_id: input.publicationId, category_id: input.categoryId, tags: input.tags ?? [], cover_url: input.coverUrl ?? '', allow_comment: input.allowComment ?? true, }), }); return result.data.post; } export async function createMindSpaceAgentJob(input: { jobType: string; instruction: string; allowedAssetIds: string[]; outputType?: 'page_draft' | 'html_page' | 'markdown'; outputCategoryId?: string; idempotencyKey?: string; locale?: string; timezone?: string; capabilities?: { network?: boolean; shell?: boolean; createPage?: boolean; }; }): Promise { const result = await apiFetch<{ data: MindSpaceAgentJob }>('/mindspace/v1/agent/jobs', { method: 'POST', body: JSON.stringify({ job_type: input.jobType, instruction: input.instruction, allowed_asset_ids: input.allowedAssetIds, output_type: input.outputType ?? 'page_draft', output_category_id: input.outputCategoryId, idempotency_key: input.idempotencyKey, locale: input.locale, timezone: input.timezone, capabilities: input.capabilities, }), }); return result.data; } export async function getMindSpaceAgentJob(jobId: string): Promise { const result = await apiFetch<{ data: MindSpaceAgentJob }>( `/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}`, ); return result.data; } export type MindSpaceListPage = { total?: number; offset?: number; limit?: number; has_more?: boolean; }; export async function listMindSpaceAgentJobs(options?: { limit?: number; offset?: number; }): Promise<{ items: MindSpaceAgentJob[]; page: MindSpaceListPage }> { const limit = options?.limit ?? 10; const offset = options?.offset ?? 0; const result = await apiFetch<{ data: MindSpaceAgentJob[]; page?: MindSpaceListPage }>( `/mindspace/v1/agent/jobs?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`, ); return { items: result.data, page: result.page ?? {} }; } export async function runMindSpaceAgentJob( jobId: string, ): Promise<{ started: boolean; jobId: string }> { const result = await apiFetch<{ data: { started: boolean; jobId: string } }>( `/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/run`, { method: 'POST', body: JSON.stringify({}) }, ); return result.data; } export async function cancelMindSpaceAgentJob(jobId: string): Promise { const result = await apiFetch<{ data: MindSpaceAgentJob }>( `/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST', body: JSON.stringify({}) }, ); return result.data; } export async function retryMindSpaceAgentJob(jobId: string): Promise { const result = await apiFetch<{ data: MindSpaceAgentJob }>( `/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/retry`, { method: 'POST', body: JSON.stringify({}) }, ); return result.data; } export async function getAdminDashboardSummary(): Promise { const result = await portalFetch<{ summary: AdminDashboardSummary }>('/admin-api/summary'); return result.summary; } export async function listAdminUsage(userId?: string): Promise { const query = userId ? `?userId=${encodeURIComponent(userId)}` : ''; const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`); return result.records ?? []; } export async function listAdminLedger(userId?: string): Promise { const query = userId ? `?userId=${encodeURIComponent(userId)}` : ''; const result = await portalFetch<{ entries: LedgerEntry[] }>(`/admin-api/ledger${query}`); return result.entries ?? []; } export async function listCapabilityCatalog(): Promise { const result = await portalFetch<{ catalog: CapabilityDefinition[] }>( '/admin-api/capabilities/catalog', ); return result.catalog ?? []; } export async function getRoleCapabilities(role: 'user' = 'user'): Promise<{ role: string; capabilities: CapabilityMap; }> { return portalFetch(`/admin-api/capabilities/role/${role}`); } export async function updateRoleCapabilities( role: 'user', capabilities: CapabilityMap, ): Promise<{ role: string; capabilities: CapabilityMap }> { return portalFetch(`/admin-api/capabilities/role/${role}`, { method: 'PUT', body: JSON.stringify({ capabilities }), }); } export async function getUserCapabilities(userId: string): Promise<{ userId: string; role: string; unrestricted: boolean; capabilities: CapabilityMap; overrides: CapabilityMap; }> { return portalFetch(`/admin-api/users/${userId}/capabilities`); } export async function updateUserCapabilities( userId: string, capabilities: CapabilityMap, ): Promise<{ userId: string; capabilities: CapabilityMap; }> { return portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'PUT', body: JSON.stringify({ capabilities }), }); } export async function clearUserCapabilityOverrides(userId: string): Promise { await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' }); } export async function listPolicyCatalog(): Promise { const result = await portalFetch<{ catalog: PolicyDefinition[] }>('/admin-api/policies/catalog'); return result.catalog ?? []; } export async function getRolePolicies(role: 'user' = 'user'): Promise<{ role: string; policies: PolicyMap; }> { return portalFetch(`/admin-api/policies/role/${role}`); } export async function updateRolePolicies( role: 'user', policies: PolicyMap, ): Promise<{ role: string; policies: PolicyMap }> { return portalFetch(`/admin-api/policies/role/${role}`, { method: 'PUT', body: JSON.stringify({ policies }), }); } export async function getUserPolicies(userId: string): Promise<{ userId: string; role: string; unrestricted: boolean; policies: PolicyMap; overrides: PolicyMap; }> { return portalFetch(`/admin-api/users/${userId}/policies`); } export async function updateUserPolicies( userId: string, policies: PolicyMap, ): Promise<{ userId: string; policies: PolicyMap; }> { return portalFetch(`/admin-api/users/${userId}/policies`, { method: 'PUT', body: JSON.stringify({ policies }), }); } export async function clearUserPolicyOverrides(userId: string): Promise { await portalFetch(`/admin-api/users/${userId}/policies`, { method: 'DELETE' }); } export async function listSkillCatalog(): Promise { const result = await portalFetch<{ catalog: SkillDefinition[] }>('/admin-api/skills/catalog'); return result.catalog ?? []; } export async function getRoleSkills(role: 'user' = 'user'): Promise<{ role: string; skills: SkillMap; }> { return portalFetch(`/admin-api/skills/role/${role}`); } export async function updateRoleSkills( role: 'user', skills: SkillMap, ): Promise<{ role: string; skills: SkillMap }> { return portalFetch(`/admin-api/skills/role/${role}`, { method: 'PUT', body: JSON.stringify({ skills }), }); } export async function getUserSkills(userId: string): Promise<{ userId: string; role: string; skills: SkillMap; grantedSkills: string[]; overrides: SkillMap; }> { return portalFetch(`/admin-api/users/${userId}/skills`); } export async function updateUserSkills( userId: string, skills: SkillMap, ): Promise<{ userId: string; skills: SkillMap; grantedSkills: string[]; }> { return portalFetch(`/admin-api/users/${userId}/skills`, { method: 'PUT', body: JSON.stringify({ skills }), }); } export async function clearUserSkillOverrides(userId: string): Promise { await portalFetch(`/admin-api/users/${userId}/skills`, { method: 'DELETE' }); } export async function login(username: string, password: string): Promise { let response: Response; try { response = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; user?: PortalUser; mode?: string; } | null; if (!response.ok) { throw new ApiError(response.status, body?.message ?? '登录失败'); } resetUnauthorizedGuard(); if (body?.user) { try { const me = await getMe(); return me.user; } catch { return body.user; } } return null; } export async function loginLegacy(password: string): Promise { let response: Response; try { response = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } if (!response.ok) { const body = (await response.json().catch(() => null)) as { message?: string } | null; throw new ApiError(response.status, body?.message ?? '登录失败'); } } export async function resetPassword( username: string, email: string, password: string, ): Promise { let response: Response; try { response = await fetch('/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, email, password }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string } | null; if (!response.ok) { throw new ApiError(response.status, body?.message ?? '重置失败'); } } export async function getWechatPendingProfile(token: string): Promise { let response: Response; try { response = await fetch(`/auth/wechat/pending/${encodeURIComponent(token)}`); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as WechatPendingProfile & { message?: string; }; if (!response.ok) { throw new ApiError(response.status, body?.message ?? '绑定会话无效'); } return body; } export async function completeWechatRegister(pendingToken: string): Promise<{ user: PortalUser; returnTo: string; }> { let response: Response; try { response = await fetch('/auth/wechat/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; user?: PortalUser; returnTo?: string; }; if (!response.ok || !body?.user) { throw new ApiError(response.status, body?.message ?? '微信注册失败'); } return { user: body.user, returnTo: body.returnTo ?? '/' }; } export async function completeWechatBind( pendingToken: string, username: string, password: string, ): Promise<{ user: PortalUser; returnTo: string }> { let response: Response; try { response = await fetch('/auth/wechat/bind', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, username, password }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; user?: PortalUser; returnTo?: string; }; if (!response.ok || !body?.user) { throw new ApiError(response.status, body?.message ?? '绑定失败'); } return { user: body.user, returnTo: body.returnTo ?? '/' }; } export async function getWechatBindingStatus(): Promise { try { const response = await fetch('/auth/wechat/status'); if (!response.ok) return { enabled: false, bound: false }; return (await response.json()) as WechatBindingStatus; } catch { return { enabled: false, bound: false }; } } export async function listNotifications(status = 'unread', limit = 20): Promise { const params = new URLSearchParams(); if (status && status !== 'all') params.set('status', status); params.set('limit', String(limit)); const response = await fetch(`/auth/notifications?${params}`); if (!response.ok) { throw new ApiError(response.status, '读取通知失败'); } const body = (await response.json()) as { notifications?: UserNotification[] }; return body.notifications ?? []; } export function subscribeNotificationEvents({ onNotification, onSync, }: { onNotification: (notification: UserNotification) => void; onSync?: () => void; }): () => void { const source = new EventSource('/auth/notifications/events', { withCredentials: true }); source.addEventListener('notification', (event) => { try { const payload = JSON.parse((event as MessageEvent).data) as { notification?: UserNotification; }; if (payload.notification) onNotification(payload.notification); } catch { onSync?.(); } }); source.addEventListener('sync', () => onSync?.()); source.onerror = () => { // EventSource reconnects automatically; consumers can keep their last state. }; return () => source.close(); } export async function markNotificationRead(notificationId: string): Promise { const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}/read`, { method: 'POST', }); if (!response.ok) { throw new ApiError(response.status, '通知状态更新失败'); } } export async function markAllNotificationsRead(): Promise { const response = await fetch('/auth/notifications/read-all', { method: 'POST', }); if (!response.ok) { throw new ApiError(response.status, '全部已读失败'); } } export async function deleteNotification(notificationId: string): Promise { const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}`, { method: 'DELETE', }); if (!response.ok && response.status !== 204) { throw new ApiError(response.status, '删除通知失败'); } } export async function clearNotifications(status = 'all'): Promise { const response = await fetch(`/auth/notifications?status=${encodeURIComponent(status)}`, { method: 'DELETE', }); if (!response.ok) { throw new ApiError(response.status, '清空通知失败'); } } export async function getWechatAgentRouteStatus(): Promise { try { const response = await fetch('/auth/wechat/agent-route'); if (!response.ok) { return { enabled: false, bound: false, appId: null, openid: null, agentSessionId: null, routeStatus: null, updatedAt: null, }; } return (await response.json()) as WechatAgentRouteStatus; } catch { return { enabled: false, bound: false, appId: null, openid: null, agentSessionId: null, routeStatus: null, updatedAt: null, }; } } export async function resetWechatAgentRoute(): Promise { let response: Response; try { response = await fetch('/auth/wechat/agent-route/reset', { method: 'POST', }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; route?: WechatAgentRouteStatus; } | null; if (!response.ok || !body?.route) { throw new ApiError(response.status, body?.message ?? '重建公众号 Agent 路由失败'); } return body.route; } export type WechatScanSession = | { mode: 'open'; state: string; openAppId: string; redirectUri: string; expiresInMs: number; } | { mode: 'mp'; state: string; qrUrl: string; expiresInMs: number; }; export async function startWechatScanLogin(returnTo?: string): Promise { const params = new URLSearchParams(); if (returnTo) params.set('return_to', returnTo); const query = params.toString(); let response: Response; try { response = await fetch(`/auth/wechat/scan/start${query ? `?${query}` : ''}`, { method: 'POST', }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; mode?: 'open' | 'mp'; state?: string; openAppId?: string; redirectUri?: string; qrUrl?: string; expiresInMs?: number; }; if (!response.ok || !body?.state || !body?.mode) { throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); } if (body.mode === 'open') { if (!body.openAppId || !body.redirectUri) { throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); } return { mode: 'open', state: body.state, openAppId: body.openAppId, redirectUri: body.redirectUri, expiresInMs: body.expiresInMs ?? 600000, }; } if (!body.qrUrl) { throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); } return { mode: 'mp', state: body.state, qrUrl: body.qrUrl, expiresInMs: body.expiresInMs ?? 600000, }; } export async function pollWechatScanLogin(state: string): Promise<{ status: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired'; pendingToken?: string; message?: string; }> { let response: Response; try { response = await fetch(`/auth/wechat/scan/poll?state=${encodeURIComponent(state)}`); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { status?: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired'; pendingToken?: string; message?: string; }; if (!response.ok) { throw new ApiError(response.status, body?.message ?? '扫码状态查询失败'); } return { status: body?.status ?? 'error', pendingToken: body?.pendingToken, message: body?.message, }; } export async function register( username: string, password: string, displayName?: string, email?: string, attribution?: { utm_source?: string; utm_medium?: string; utm_campaign?: string; ref?: string; }, ): Promise { let response: Response; try { response = await fetch('/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, displayName, email, ...(attribution ?? {}), }), }); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } const body = (await response.json().catch(() => null)) as { message?: string; user?: PortalUser; } | null; if (!response.ok) { throw new ApiError(response.status, body?.message ?? '注册失败'); } if (!body?.user) throw new ApiError(response.status, '注册失败'); return body.user; } export async function listAdminUsers(): Promise { const result = await portalFetch<{ users: AdminUserRow[] }>('/admin-api/users'); return result.users ?? []; } export async function createAdminUser(payload: { username: string; password: string; displayName?: string; workspaceRoot?: string; balanceCents?: number; role?: 'user' | 'admin'; email?: string; }): Promise { const result = await portalFetch<{ user: PortalUser }>('/admin-api/users', { method: 'POST', body: JSON.stringify(payload), }); return result.user; } export async function updateAdminUser( userId: string, payload: Partial<{ displayName: string; status: 'active' | 'suspended' | 'disabled'; workspaceRoot: string; balanceCents: number; role: 'user' | 'admin'; }>, ): Promise { const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload), }); return result.user; } export async function rechargeUser( userId: string, amountCents: number, note?: string, ): Promise { const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}/recharge`, { method: 'POST', body: JSON.stringify({ amountCents, note }), }); return result.user; } export async function listLlmProviderCatalog(): Promise { const result = await portalFetch<{ catalog: LlmProviderDefinition[] }>( '/admin-api/llm-providers/catalog', ); return result.catalog ?? []; } export async function listLlmProviderKeys(): Promise { const result = await portalFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys'); return result.keys ?? []; } export async function createLlmProviderKey(payload: { providerId: string; name: string; apiKey: string; defaultModel?: string; apiUrl?: string; basePath?: string; models?: string | string[]; engine?: string; relayProvider?: string; }): Promise { const result = await portalFetch<{ key: LlmProviderKeyRow }>('/admin-api/llm-providers/keys', { method: 'POST', body: JSON.stringify(payload), }); return result.key; } export async function updateLlmProviderKey( keyId: string, payload: { name?: string; apiKey?: string; defaultModel?: string; status?: 'active' | 'disabled'; apiUrl?: string; basePath?: string; models?: string | string[]; engine?: string; relayProvider?: string; }, ): Promise { const result = await portalFetch<{ key: LlmProviderKeyRow }>( `/admin-api/llm-providers/keys/${keyId}`, { method: 'PATCH', body: JSON.stringify(payload), }, ); return result.key; } export async function selectLlmProviderKey(keyId: string): Promise { const result = await portalFetch<{ key: LlmProviderKeyRow }>( `/admin-api/llm-providers/keys/${keyId}/select`, { method: 'POST' }, ); return result.key; } export async function deleteLlmProviderKey(keyId: string): Promise { await portalFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' }); } export async function syncLlmProviderToRuntime(): Promise<{ synced: boolean }> { return portalFetch('/admin-api/llm-providers/sync', { method: 'POST' }); } export async function getLlmGlobalSettings(): Promise { const result = await portalFetch<{ global: LlmGlobalSettings }>('/admin-api/llm-providers/global'); return result.global; } export async function setLlmGlobalModel(model: string): Promise<{ global: LlmGlobalSettings }> { return portalFetch('/admin-api/llm-providers/global', { method: 'PUT', body: JSON.stringify({ model }), }); } export async function testLlmProviderDraft(payload: { providerId: string; name: string; apiKey: string; apiUrl?: string; basePath?: string; models?: string | string[]; defaultModel?: string; testModel?: string; engine?: string; relayProvider?: string; }): Promise { return portalFetch('/admin-api/llm-providers/test', { method: 'POST', body: JSON.stringify(payload), }); } export async function testLlmProviderKey( keyId: string, model?: string, ): Promise { return portalFetch(`/admin-api/llm-providers/keys/${keyId}/test`, { method: 'POST', body: JSON.stringify(model ? { model } : {}), }); } export async function logout(): Promise { if (import.meta.env.DEV) return; await fetch('/auth/logout', { method: 'POST' }); } export async function resumeSession( sessionId: string, options?: { skipReconcile?: boolean }, ): Promise { const result = await apiFetch<{ session: Session }>( '/agent/resume', { method: 'POST', body: JSON.stringify({ session_id: sessionId, load_model_and_extensions: true, ...(options?.skipReconcile ? { skip_reconcile: true } : {}), }), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, ); return result.session; } export async function readConfig(key: string): Promise { return apiFetch('/config/read', { method: 'POST', body: JSON.stringify({ key, is_secret: false }), }); } export async function updateProvider( sessionId: string, provider: string, model: string, ): Promise { await apiFetch('/agent/update_provider', { method: 'POST', body: JSON.stringify({ session_id: sessionId, provider, model, }), }); } export async function applyLocalLlmFallback(sessionId: string): Promise<{ ok: boolean; providerId?: string; model?: string; source?: string; message?: string; }> { return apiFetch('/llm/apply-local-fallback', { method: 'POST', body: JSON.stringify({ session_id: sessionId }), }); } export async function listSessions(options?: { limit?: number; offset?: number; query?: string; }): Promise<{ items: SessionSummary[]; page: SessionListPage }> { const params = new URLSearchParams(); if (options?.limit != null) params.set('limit', String(options.limit)); if (options?.offset != null) params.set('offset', String(options.offset)); if (options?.query?.trim()) params.set('query', options.query.trim()); const qs = params.size ? `?${params.toString()}` : ''; const result = await apiFetch(`/sessions${qs}`); return { items: result.sessions ?? [], page: result.page ?? {} }; } function sessionPath(sessionId: string, suffix = '') { return `/sessions/${encodeURIComponent(sessionId)}${suffix}`; } export async function getSession(sessionId: string): Promise { return apiFetch(sessionPath(sessionId)); } export async function deleteChatSession(sessionId: string): Promise { await apiFetch(sessionPath(sessionId), { method: 'DELETE' }); } export async function loadSessionDetail( sessionId: string, hints?: { messageCount?: number; updatedAt?: string }, history?: { before?: number; limit?: number }, ): Promise<{ session: Session; messages: Message[]; page: SessionConversationPage; }> { const params = new URLSearchParams(); if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount)); if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt); if (history?.before != null) params.set('history_before', String(history.before)); if (history?.limit != null) params.set('history_limit', String(history.limit)); const qs = params.size ? `?${params.toString()}` : ''; const detail = await apiFetch(`${sessionPath(sessionId)}${qs}`); const messages = normalizeConversationMessages( (detail.conversation ?? []).filter((m) => m.metadata?.userVisible), ); return { session: detail, messages, page: detail.conversation_page ?? {} }; } export async function createAgentRun( sessionId: string | null, requestId: string, userMessage: Message, options: AgentRunCreateOptions = {}, ): Promise { const userMessagePayload = prepareAgentRunUserMessage(userMessage, options); const result = await apiFetch<{ run: AgentRun }>( AGENT_RUNS_PATH, { method: 'POST', body: JSON.stringify({ session_id: sessionId, request_id: requestId, user_message: userMessagePayload, ...(options.toolMode ? { tool_mode: options.toolMode } : {}), ...(options.taskType ? { task_type: options.taskType } : {}), ...(options.forceDeepReasoning ? { force_deep_reasoning: true } : {}), }), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, ); return result.run; } export async function getAgentRun(runId: string): Promise { const result = await apiFetch<{ run: AgentRun }>(`${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}`); return result.run; } export async function cancelRequest(sessionId: string, requestId: string): Promise { await apiFetch(`${sessionPath(sessionId)}/cancel`, { method: 'POST', body: JSON.stringify({ request_id: requestId }), }); } export async function confirmTool( sessionId: string, id: string, action: 'allow_once' | 'deny_once' | 'always_allow', ): Promise { await apiFetch('/action-required/tool-confirmation', { method: 'POST', body: JSON.stringify({ sessionId, id, action, principalType: 'Tool', }), }); } type SubscribeOptions = { pauseWhenHidden?: boolean; onBalance?: (update: BalanceUpdate) => void; }; export function subscribeAgentRunEvents( runId: string, onRun: (run: AgentRun) => void, onError: (error: Error) => void, ): () => void { let closed = false; let activeController: AbortController | null = null; const run = async (controller: AbortController) => { while (!closed && !controller.signal.aborted) { try { const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, { headers: { Accept: 'text/event-stream' }, credentials: 'same-origin', signal: controller.signal, }); if (res.status === 401) { notifyUnauthorized(); throw new ApiError(401, 'SSE 未授权'); } if (!res.ok || !res.body) { throw new ApiError(res.status, `SSE failed: ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (!closed && !controller.signal.aborted) { const { done, value } = await reader.read(); if (done) return; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; for (const chunk of chunks) { let eventName = 'message'; let data = ''; for (const line of chunk.split('\n')) { if (line.startsWith('event:')) eventName = line.slice(6).trim(); if (line.startsWith('data:')) data = line.slice(5).trim(); } if (!data) continue; if (eventName === 'run') { const payload = JSON.parse(data) as { run: AgentRun }; onRun(payload.run); continue; } if (eventName === 'error') { const payload = JSON.parse(data) as { message?: string }; throw new Error(payload.message || '后台任务失败'); } } } } catch (err) { if (controller.signal.aborted || closed) return; onError(err instanceof Error ? err : new Error(String(err))); return; } } }; activeController = new AbortController(); void run(activeController); return () => { closed = true; activeController?.abort(); }; } export function subscribeSessionEvents( sessionId: string, onEvent: (event: SessionEvent) => void, onError: (error: Error) => void, options: SubscribeOptions = {}, ): () => void { const { pauseWhenHidden = true, onBalance } = options; let lastEventId: string | undefined; let closed = false; let paused = false; let activeController: AbortController | null = null; const run = async (controller: AbortController) => { while (!closed && !controller.signal.aborted && !paused) { try { const headers: Record = { Accept: 'text/event-stream' }; if (lastEventId) headers['Last-Event-ID'] = lastEventId; const res = await fetch(`${API}${sessionPath(sessionId)}/events`, { headers, signal: controller.signal, }); if (res.status === 401) { notifyUnauthorized(); throw new ApiError(401, 'SSE 未授权'); } if (!res.ok || !res.body) { throw new ApiError(res.status, `SSE failed: ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (!closed && !controller.signal.aborted && !paused) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; for (const chunk of chunks) { let eventId: string | undefined; let eventName: string | undefined; let data: string | undefined; for (const line of chunk.split('\n')) { if (line.startsWith('id:')) eventId = line.slice(3).trim(); if (line.startsWith('event:')) eventName = line.slice(6).trim(); if (line.startsWith('data:')) data = line.slice(5).trim(); } if (eventId) lastEventId = eventId; if (!data) continue; try { if (eventName === 'balance') { const payload = JSON.parse(data) as BalanceUpdate & { balanceCents?: number }; if (typeof payload.balanceCents === 'number') { onBalance?.({ balanceCents: payload.balanceCents, tokensUsed: typeof payload.tokensUsed === 'number' ? payload.tokensUsed : undefined, lastUsage: payload.lastUsage, }); } continue; } onEvent(sanitizeSessionEvent(JSON.parse(data) as SessionEvent)); } catch { // ignore malformed frames } } } } catch (err) { if (controller.signal.aborted || closed || paused) return; onError(err instanceof Error ? err : new Error(String(err))); await new Promise((r) => setTimeout(r, 1500)); } } }; const startConnection = () => { activeController?.abort(); activeController = new AbortController(); void run(activeController); }; const handleVisibility = () => { if (!pauseWhenHidden) return; if (document.hidden) { paused = true; activeController?.abort(); } else if (paused) { paused = false; startConnection(); } }; if (pauseWhenHidden) { document.addEventListener('visibilitychange', handleVisibility); } startConnection(); return () => { closed = true; activeController?.abort(); if (pauseWhenHidden) { document.removeEventListener('visibilitychange', handleVisibility); } }; }