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' }); }