From f6dcf5b14a5afa2bce26569f2be3309fe923f590 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 14 Jul 2026 15:26:10 +0800 Subject: [PATCH] refactor(api): split MindSpace client modules --- src/api/client.ts | 886 ++---------------------------------- src/api/core.ts | 257 +++++++++++ src/api/mindspace-assets.ts | 246 ++++++++++ src/api/mindspace-pages.ts | 351 ++++++++++++++ 4 files changed, 900 insertions(+), 840 deletions(-) create mode 100644 src/api/core.ts create mode 100644 src/api/mindspace-assets.ts create mode 100644 src/api/mindspace-pages.ts diff --git a/src/api/client.ts b/src/api/client.ts index c45f9b0..cd7527a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,14 +5,12 @@ import type { CapabilityDefinition, CapabilityMap, ChatSaveAnalysis, - ChatSaveResult, PolicyDefinition, PolicyMap, SkillDefinition, SkillMap, BillingConfig, HarnessBootstrapResponse, - InsufficientBalanceDetails, LedgerEntry, LlmProviderDefinition, LlmProviderKeyRow, @@ -20,17 +18,8 @@ import type { LlmConnectionTestResult, RechargeOrder, Message, - MindSpace, - MindSpaceAsset, MindSpaceAgentJob, - MindSpaceCleanupItem, - MindSpaceConversationPackage, - MindSpacePage, - MindSpacePageDeletePreview, - MindSpacePageDeleteResult, MindSpaceQuota, - MindSpaceScheduleReminder, - MindSpaceSaveCategory, MindSpacePublication, MindSpacePublicationStats, MindSpacePublishCheck, @@ -41,7 +30,6 @@ import type { PlazaCategory, PlazaPostBrief, MindSpaceRedactedCopyResult, - MindSpaceUpload, FeedbackContextInput, FeedbackImageInput, FeedbackSubmission, @@ -61,12 +49,55 @@ import type { 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'; +import { + API, + ApiError, + apiFetch, + formatNetworkError, + notifyUnauthorized, + parseErrorResponse, + portalFetch, + resetUnauthorizedGuard, + sanitizeSessionEvent, +} from './core'; +import { getMindSpacePage, type MindSpaceListPage } from './mindspace-pages'; +export { ApiError, resetUnauthorizedGuard, setUnauthorizedHandler } from './core'; +export { + buildMindSpaceConversationPackageManifestDownloadUrl, + claimMindSpaceConversationUploads, + deleteMindSpaceAsset, + deleteMindSpaceScheduleReminders, + getMindSpace, + getMindSpaceConversationPackage, + ignoreMindSpaceScheduleReminder, + listMindSpaceAssets, + listMindSpaceCleanupItems, + runMindSpaceCleanup, + uploadMindSpaceAsset, +} from './mindspace-assets'; +export { + bindMindSpacePageLiveEdit, + closeMindSpacePageEditSession, + createMindSpacePage, + createMindSpacePageFromAsset, + deleteMindSpacePage, + fetchMindSpacePageDraftPreview, + forkMindSpacePageEditSession, + getMindSpacePage, + getMindSpacePageDeletePreview, + getMindSpacePageLiveRevision, + listMindSpacePages, + openMindSpaceDraftPreviewWindow, + regenerateMindSpacePageThumbnail, + rewriteMindSpacePageDownloadLinks, + saveChatMessageAsPage, + type MindSpaceListPage, + updateMindSpacePage, + uploadMindSpacePageThumbnail, +} from './mindspace-pages'; -const API = '/api'; -const DEFAULT_API_TIMEOUT_MS = 20_000; const AGENT_CONNECT_TIMEOUT_MS = 60_000; const QUICK_PLAZA_TIMEOUT_MS = 120_000; const AGENT_RUNS_PATH = '/agent/runs'; @@ -143,323 +174,6 @@ function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOpt ); } -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', @@ -728,293 +442,6 @@ export async function purchaseSpaceQuota(sizeMb: number): Promise<{ }); } -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; @@ -1174,220 +601,6 @@ export async function fetchPreviewAsset(path: string): Promise { 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: { @@ -1768,13 +981,6 @@ export async function getMindSpaceAgentJob(jobId: string): Promise; + + 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; + } +} + +export 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, '后端'); +} + +export 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; +} + +export function notifyUnauthorized() { + if (!unauthorizedHandler || unauthorizedHandling) return; + unauthorizedHandling = true; + unauthorizedHandler(); +} + +export 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); + } +} + +export 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, '服务器响应格式错误'); + } +} + +export 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); +} + +export function sanitizeSessionEvent(event: SessionEvent): SessionEvent { + if (event.type !== 'Error') return event; + return { ...event, error: sanitizeUserFacingErrorMessage(event.error) }; +} + +export 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, '服务器响应格式错误'); + } +} diff --git a/src/api/mindspace-assets.ts b/src/api/mindspace-assets.ts new file mode 100644 index 0000000..4fde725 --- /dev/null +++ b/src/api/mindspace-assets.ts @@ -0,0 +1,246 @@ +import type { + MindSpace, + MindSpaceAsset, + MindSpaceCleanupItem, + MindSpaceConversationPackage, + MindSpaceQuota, + MindSpaceScheduleReminder, + MindSpaceUpload, +} from '../types'; +import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload'; +import { API, ApiError, apiFetch } from './core'; + +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; +} + +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' }); +} diff --git a/src/api/mindspace-pages.ts b/src/api/mindspace-pages.ts new file mode 100644 index 0000000..f6e1ebe --- /dev/null +++ b/src/api/mindspace-pages.ts @@ -0,0 +1,351 @@ +import type { + ChatSaveResult, + MindSpacePage, + MindSpacePageDeletePreview, + MindSpacePageDeleteResult, + MindSpaceSaveCategory, +} from '../types'; +import { + ApiError, + apiFetch, + formatNetworkError, + notifyUnauthorized, + parseErrorResponse, +} from './core'; + +export type MindSpaceListPage = { + total?: number; + offset?: number; + limit?: number; + has_more?: boolean; +}; + +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 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 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; +}