import { ApiClientError, createApiClient } from '@geo/http-client' import type { AIPointUsageListResponse, ApiEnvelope, ArticleDetail, ArticleImageUploadResponse, ArticleListParams, ArticleListResponse, ArticleVersion, AuthTokens, Brand, BrandLibrarySummary, BrandRequest, ChangePasswordRequest, ClassifiedQuestion, ClassifyQuestionsRequest, Competitor, CompetitorRequest, ComplianceAckRecord, ComplianceCheckArticleRequest, ComplianceCheckPlainRequest, ComplianceCheckResult, ComplianceManualReview, ComplianceRuntimeStatus, CreateAckRequest, CreateArticleRequest, CreateEnterpriseSiteConnectionRequest, CreateKolPackageRequest, CreateKolPromptRequest, CreateMediaSupplyOrderRequest, CreateMediaSupplyOrderResponse, CreatePluginSessionRequest, CreatePluginSessionResponse, CreatePublishBatchRequest, CreatePublishBatchResponse, CreatePublishJobRequest, CreatePublishJobResponse, DesktopAccountInfo, DesktopClientDownloadableRelease, DesktopClientDownloadableReleaseListResponse, DesktopClientReleaseCheckResponse, DesktopClientReleaseDownloadURLResponse, EnterpriseSiteCapability, EnterpriseSiteCategory, EnterpriseSiteConnection, EnterpriseSitePublishResponse, FolderDeletePreview, GenerateFromRuleRequest, GenerateFromRuleResponse, GenerateImitationRequest, GenerateImitationResponse, ImageAssetListResponse, ImageFolder, ImageReferenceDetail, ImageStorageUsage, ImageUpdateRequest, ImageUploadResponse, InstantTaskListParams, InstantTaskListResponse, JsonValue, Keyword, KeywordRequest, KnowledgeGroup, KnowledgeGroupRequest, KnowledgeItem, KnowledgeItemDetail, KnowledgeTextItemRequest, KnowledgeURLItemRequest, KolAssistRequest, KolAssistStreamEvent, KolAssistSubmitResponse, KolAssistTask, KolDashboardOverview, KolDashboardPackageStat, KolDashboardTrendPoint, KolGenerateRequest, KolGenerateResponse, KolPackageDetail, KolPackageSummary, KolProfile, KolPromptDetail, KolSubscription, KolSubscriptionPromptCard, KolSubscriptionPromptSchema, KolWorkspaceCard, ListCustomerSupplierMediaResourcesResponse, ListDesktopPublishTasksParams, ListMediaSupplyOrdersParams, ListMediaSupplyOrdersResponse, ListMediaSupplyWalletLedgersParams, ListMediaSupplyWalletLedgersResponse, ListPublishRecordsParams, ListSupplierMediaResourcesParams, LoginRequest, LoginResponse, MaterializeQuestionsRequest, MaterializeQuestionsResult, MediaPlatform, MediaSupplyOrderDetail, MediaSupplySearchOptionsResponse, MediaSupplySessionStatus, MediaSupplyWalletStatus, MonitoringCitationSummaryResponse, MonitoringCollectNowResponse, MonitoringDashboardCompositeResponse, MonitoringQuestionDetailResponse, PasswordPublicKeyResponse, PlatformAccount, PromptRule, PromptRuleGroup, PromptRuleGroupRequest, PromptRuleListParams, PromptRuleListResponse, PromptRuleRequest, PromptRuleSimple, PromptRuleStatusRequest, PublishEnterpriseSiteArticleRequest, PublishKolPromptRequest, PublishRecord, PublishRecordListResponse, Question, QuestionCandidateResult, QuestionCombinationFillRequest, QuestionCombinationFillResult, QuestionCombinationRequest, QuestionDistillRequest, QuestionListResponse, QuestionRequest, QuotaSummary, RecentArticle, RefreshResponse, RegenerateArticleResponse, RegisterPluginInstallationRequest, RegisterPluginInstallationResponse, ScheduleTask, ScheduleTaskListParams, ScheduleTaskListResponse, ScheduleTaskRequest, ScheduleTaskStatusRequest, TemplateAnalyzeTaskRequest, TemplateAnalyzeTaskResultResponse, TemplateAssistTaskCreateResponse, TemplateCard, TemplateDetail, TemplateGenerateRequest, TemplateGenerateResponse, TemplateListItem, TemplateOutlineTaskRequest, TemplateOutlineTaskResultResponse, TemplateSaveDraftRequest, TemplateSaveDraftResponse, TemplateTitleTaskRequest, TemplateTitleTaskResultResponse, TenantPublishTaskListResponse, UpdateArticleRequest, UpdateEnterpriseSiteConnectionRequest, UpdateKolPackageRequest, UpdateKolPromptRequest, UpdateQuestionRequest, UserInfo, WorkspaceOverview, } from '@geo/shared-types' import { readStoredCurrentBrandId } from './current-brand' import { convertImageFileToWebp, markImageUploadRequestFailed, markImageUploadRequestFinished, markImageUploadRequestStarted, } from './image-webp' import { clearStoredSession, getStoredAccessToken, getStoredRefreshToken, markStoredMembershipBlocked, readStoredSession, setStoredTokens, } from './session' const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? '' const baseURL = normalizeApiBaseURL(rawBaseURL) const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === 'true' const accessRefreshSkewMs = 2 * 60 * 1000 const aiDistillRequestTimeoutMs = 45_000 const publicClient = createApiClient({ baseURL }) let storedRefreshPromise: Promise | null = null function normalizeApiBaseURL(value: string): string { const trimmed = value.trim().replace(/\/+$/, '') if (!trimmed) { return '' } return trimmed.replace(/\/api$/i, '') } export function getApiBaseURL(): string { if (baseURL) { return baseURL } if (typeof window !== 'undefined') { return window.location.origin } return '' } export function resolveApiURL(value?: string | null): string { const trimmed = String(value ?? '').trim() if (!trimmed) { return '' } if (/^https?:\/\//i.test(trimmed)) { return trimmed } if (/^\/\//.test(trimmed)) { return `https:${trimmed}` } const apiBaseURL = getApiBaseURL() if (!apiBaseURL) { return trimmed } return new URL(trimmed, apiBaseURL.endsWith('/') ? apiBaseURL : `${apiBaseURL}/`).toString() } function toAuthTokens(data: RefreshResponse): AuthTokens { return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: data.expires_at, } } function authRequiredError(): ApiClientError { return new ApiClientError({ message: 'not_authenticated', code: 40100, status: 401, handled: true, }) } function expireStoredSession(): void { clearStoredSession({ notifyAuthExpired: true }) } function markAuthErrorHandled(error: unknown): unknown { if (error instanceof ApiClientError && error.status === 401) { error.handled = true } return error } export function isAuthSessionExpiredError(error: unknown): boolean { return error instanceof ApiClientError && error.status === 401 } export function shouldRefreshAccessToken( expiresAt: number | null, minRemainingMs = accessRefreshSkewMs, ): boolean { return !expiresAt || expiresAt * 1000 - Date.now() <= minRemainingMs } export async function refreshStoredAuthSession( refreshToken = getStoredRefreshToken(), ): Promise { if (!refreshToken) { expireStoredSession() throw authRequiredError() } const requestedRefreshToken = refreshToken if (!storedRefreshPromise) { storedRefreshPromise = publicClient .post('/api/auth/refresh', { refresh_token: requestedRefreshToken, }) .then((data) => { const tokens = toAuthTokens(data) setStoredTokens(tokens) return tokens }) .catch((error) => { if (isAuthSessionExpiredError(error)) { const current = readStoredSession() if ( current.accessToken && current.refreshToken && current.refreshToken !== requestedRefreshToken ) { const tokens = { accessToken: current.accessToken, refreshToken: current.refreshToken, expiresAt: current.accessTokenExpiresAt ?? undefined, } setStoredTokens(tokens) return tokens } expireStoredSession() } throw markAuthErrorHandled(error) }) .finally(() => { storedRefreshPromise = null }) } return storedRefreshPromise } async function getFreshStoredAccessToken(): Promise { const snapshot = readStoredSession() if (snapshot.refreshToken && shouldRefreshAccessToken(snapshot.accessTokenExpiresAt)) { return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken } if (snapshot.accessToken) { return snapshot.accessToken } if (snapshot.refreshToken) { return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken } expireStoredSession() throw authRequiredError() } export const apiClient = createApiClient({ baseURL, auth: { getAccessToken: getStoredAccessToken, getRefreshToken: getStoredRefreshToken, setTokens: setStoredTokens, clearTokens: expireStoredSession, refresh: refreshStoredAuthSession, }, }) const currentBrandScopedPathPatterns = [ /^\/api\/tenant\/workspace\/(?:overview|recent-articles)(?:\/|$)/, /^\/api\/tenant\/articles(?:\/|$)/, /^\/api\/tenant\/templates\/[^/]+\/(?:drafts|generate)(?:\/|$)/, /^\/api\/tenant\/kol\/subscription-prompts\/[^/]+\/generate(?:\/|$)/, /^\/api\/tenant\/schedules(?:\/|$)/, /^\/api\/tenant\/instant-tasks(?:\/|$)/, /^\/api\/tenant\/publish-jobs(?:\/|$)/, /^\/api\/tenant\/publish-records(?:\/|$)/, /^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/, /^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/, /^\/api\/tenant\/media-supply\/orders(?:\/|$)/, /^\/api\/tenant\/prompt-rules(?:\/|$)/, ] function shouldAttachCurrentBrandHeader(url: unknown): boolean { if (typeof url !== 'string') { return false } const pathname = normalizeRequestPath(url) return currentBrandScopedPathPatterns.some((pattern) => pattern.test(pathname)) } function normalizeRequestPath(url: string): string { if (/^https?:\/\//i.test(url)) { try { return new URL(url).pathname } catch { return url } } return url.split('?')[0] || url } function buildCurrentBrandHeaders(path: string): Headers | undefined { if (!shouldAttachCurrentBrandHeader(path)) { return undefined } const brandId = readStoredCurrentBrandId() if (!brandId) { return undefined } const headers = new Headers() headers.set('X-Brand-ID', String(brandId)) return headers } apiClient.raw.interceptors.request.use((config) => { if (!shouldAttachCurrentBrandHeader(config.url)) { return config } const brandId = readStoredCurrentBrandId() if (!brandId) { return config } config.headers = config.headers ?? {} ;(config.headers as Record)['X-Brand-ID'] = String(brandId) return config }) const membershipBlockedErrors = new Set([ 'user_disabled', 'trial_plan_expired', 'subscription_required', 'subscription_inactive', ]) apiClient.raw.interceptors.response.use( (response) => response, (error) => { if (error instanceof ApiClientError && membershipBlockedErrors.has(error.message)) { markStoredMembershipBlocked(error.message) } if (error instanceof ApiClientError && error.message === 'ai_points_insufficient') { void import('./errors').then(({ showAiPointsInsufficientModal }) => { showAiPointsInsufficientModal() }) } return Promise.reject(error) }, ) export const authApi = { passwordPublicKey() { return publicClient.get('/api/auth/password-key') }, login(payload: LoginRequest) { return publicClient.post('/api/auth/login', payload) }, refresh: refreshStoredAuthSession, me() { return apiClient.get('/api/auth/me') }, changePassword(payload: ChangePasswordRequest) { return apiClient.post<{ ok: boolean }, ChangePasswordRequest>('/api/auth/password', payload) }, logout() { return apiClient.post('/api/auth/logout') }, } export const workspaceApi = { overview() { return apiClient.get('/api/tenant/workspace/overview') }, recentArticles() { return apiClient.get('/api/tenant/workspace/recent-articles') }, quotaSummary() { return apiClient.get('/api/tenant/workspace/quota-summary') }, aiPointUsage(params?: { page?: number; page_size?: number; limit?: number; offset?: number }) { return apiClient.get('/api/tenant/workspace/ai-point-usage', { params, }) }, templateCards() { return apiClient.get('/api/tenant/workspace/template-cards') }, kolCards() { return apiClient.get('/api/tenant/workspace/kol-cards') }, } export const templatesApi = { list() { return apiClient.get('/api/tenant/templates') }, detail(id: number) { return apiClient.get(`/api/tenant/templates/${id}`) }, createAnalyzeTask(id: number, payload: TemplateAnalyzeTaskRequest) { return apiClient.post( `/api/tenant/templates/${id}/analyze-tasks`, payload, ) }, getAnalyzeTaskResult(id: number, taskId: string) { return apiClient.get( `/api/tenant/templates/${id}/analyze_task_result`, { params: { task_id: taskId } }, ) }, createTitleTask(id: number, payload: TemplateTitleTaskRequest) { return apiClient.post( `/api/tenant/templates/${id}/title-tasks`, payload, ) }, getTitleTaskResult(id: number, taskId: string) { return apiClient.get( `/api/tenant/templates/${id}/gen_title_task_result`, { params: { task_id: taskId } }, ) }, createOutlineTask(id: number, payload: TemplateOutlineTaskRequest) { return apiClient.post( `/api/tenant/templates/${id}/outline-tasks`, payload, ) }, getOutlineTaskResult(id: number, taskId: string) { return apiClient.get( `/api/tenant/templates/${id}/gen_outline_task_result`, { params: { task_id: taskId } }, ) }, createAssistTask(id: number, payload: TemplateAnalyzeTaskRequest) { return this.createAnalyzeTask(id, payload) }, getAssistTask(id: number, taskId: string) { return this.getAnalyzeTaskResult(id, taskId) }, generate(id: number, payload: TemplateGenerateRequest) { return apiClient.post( `/api/tenant/templates/${id}/generate`, payload, ) }, saveDraft(id: number, payload: TemplateSaveDraftRequest) { return apiClient.post( `/api/tenant/templates/${id}/drafts`, payload, ) }, } export const kolManageApi = { profile() { return apiClient.get('/api/tenant/kol/manage/profile') }, updateProfile(body: { display_name: string; bio?: string | null; avatar_url?: string | null }) { return apiClient.put('/api/tenant/kol/manage/profile', body) }, async uploadAvatar(file: File) { const normalizedFile = await convertImageFileToWebp(file) const progressToken = markImageUploadRequestStarted(normalizedFile.name) try { const formData = new FormData() formData.append('file', normalizedFile, normalizedFile.name) const response = await apiClient.raw.post< ApiEnvelope<{ url: string object_key: string content_type: string size_bytes: number }> >('/api/tenant/kol/manage/profile/avatar', formData) markImageUploadRequestFinished(progressToken) return response.data.data } catch (error) { markImageUploadRequestFailed(progressToken) throw error } }, listPackages() { return apiClient.get('/api/tenant/kol/manage/packages') }, createPackage(body: CreateKolPackageRequest) { return apiClient.post( '/api/tenant/kol/manage/packages', body, ) }, updatePackage(id: number, body: UpdateKolPackageRequest) { return apiClient.put( `/api/tenant/kol/manage/packages/${id}`, body, ) }, publishPackage(id: number) { return apiClient.put>( `/api/tenant/kol/manage/packages/${id}/publish`, {}, ) }, archivePackage(id: number) { return apiClient.put>( `/api/tenant/kol/manage/packages/${id}/archive`, {}, ) }, selfSubscribePackage(id: number) { return apiClient.post>( `/api/tenant/kol/manage/packages/${id}/self-subscription`, {}, ) }, cancelSelfSubscription(id: number) { return apiClient.remove( `/api/tenant/kol/manage/packages/${id}/self-subscription`, ) }, deletePackage(id: number) { return apiClient.remove(`/api/tenant/kol/manage/packages/${id}`) }, listPrompts(packageId: number) { return apiClient.get(`/api/tenant/kol/manage/packages/${packageId}/prompts`) }, createPrompt(packageId: number, body: CreateKolPromptRequest) { return apiClient.post( `/api/tenant/kol/manage/packages/${packageId}/prompts`, body, ) }, getPrompt(id: number) { return apiClient.get(`/api/tenant/kol/manage/prompts/${id}`) }, updatePromptMetadata(id: number, body: UpdateKolPromptRequest) { return apiClient.put( `/api/tenant/kol/manage/prompts/${id}`, body, ) }, deletePrompt(id: number) { return apiClient.remove(`/api/tenant/kol/manage/prompts/${id}`) }, savePrompt(promptId: number, body: PublishKolPromptRequest) { return apiClient.post( `/api/tenant/kol/manage/prompts/${promptId}/save`, body, ) }, publishPrompt(promptId: number, body: PublishKolPromptRequest) { return apiClient.post( `/api/tenant/kol/manage/prompts/${promptId}/publish`, body, ) }, activatePrompt(promptId: number) { return apiClient.put>( `/api/tenant/kol/manage/prompts/${promptId}/activate`, {}, ) }, archivePrompt(promptId: number) { return apiClient.put>( `/api/tenant/kol/manage/prompts/${promptId}/archive`, {}, ) }, submitAssist(body: KolAssistRequest) { return apiClient.post( '/api/tenant/kol/manage/assist', body, ) }, streamAssist( body: KolAssistRequest, handlers: { onEvent: (event: KolAssistStreamEvent) => void onClose?: () => void }, signal: AbortSignal, ) { return subscribeSSE( '/api/tenant/kol/manage/assist/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body), }, handlers, signal, ) }, getAssist(id: string) { return apiClient.get(`/api/tenant/kol/manage/assist/${id}`) }, } export const kolMarketplaceApi = { list(params: { industry?: string; keyword?: string; limit?: number; offset?: number }) { return apiClient.get('/api/tenant/kol/marketplace/packages', { params }) }, detail(id: number) { return apiClient.get(`/api/tenant/kol/marketplace/packages/${id}`) }, subscribe(id: number) { return apiClient.post>( `/api/tenant/kol/marketplace/packages/${id}/subscribe`, {}, ) }, mySubscriptions() { return apiClient.get('/api/tenant/kol/subscriptions') }, mySubscriptionPrompts() { return apiClient.get('/api/tenant/kol/subscription-prompts') }, } export const kolGenerateApi = { schema(id: number | string) { return apiClient.get( `/api/tenant/kol/subscription-prompts/${id}/schema`, ) }, submit(id: number | string, body: KolGenerateRequest) { return apiClient.post( `/api/tenant/kol/subscription-prompts/${id}/generate`, body, ) }, } export const kolDashboardApi = { overview() { return apiClient.get('/api/tenant/kol/dashboard/overview') }, packages() { return apiClient.get('/api/tenant/kol/dashboard/packages') }, trend(periodDays: number) { return apiClient.get('/api/tenant/kol/dashboard/trend', { params: { period_days: periodDays }, }) }, } export const articlesApi = { list(params: ArticleListParams) { return apiClient.get('/api/tenant/articles', { params }) }, create(payload: CreateArticleRequest = {}) { return apiClient.post('/api/tenant/articles', payload) }, generateImitation(payload: GenerateImitationRequest) { return apiClient.post( '/api/tenant/articles/imitations/generate', payload, ) }, regenerate(id: number) { return apiClient.post( `/api/tenant/articles/${id}/regenerate`, null, ) }, detail(id: number) { return apiClient.get(`/api/tenant/articles/${id}`) }, update(id: number, payload: UpdateArticleRequest) { return apiClient.put(`/api/tenant/articles/${id}`, payload) }, async uploadImage(id: number, file: File) { const normalizedFile = await convertImageFileToWebp(file) const progressToken = markImageUploadRequestStarted(normalizedFile.name) try { const formData = new FormData() formData.append('file', normalizedFile, normalizedFile.name) const response = await apiClient.raw.post>( `/api/tenant/articles/${id}/images`, formData, ) markImageUploadRequestFinished(progressToken) return { ...response.data.data, url: resolveApiURL(response.data.data.url), } } catch (error) { markImageUploadRequestFailed(progressToken) throw error } }, versions(id: number) { return apiClient.get(`/api/tenant/articles/${id}/versions`) }, publishBatch(id: number, payload: CreatePublishBatchRequest) { return apiClient.post( `/api/tenant/articles/${id}/publish-batches`, payload, ) }, publishRecords(id: number) { return apiClient.get(`/api/tenant/articles/${id}/publish-records`) }, remove(id: number) { return apiClient.remove(`/api/tenant/articles/${id}`) }, } export const knowledgeApi = { listGroups() { return apiClient.get('/api/tenant/knowledge/groups') }, createGroup(payload: KnowledgeGroupRequest) { return apiClient.post( '/api/tenant/knowledge/groups', payload, ) }, updateGroup(id: number, payload: KnowledgeGroupRequest) { return apiClient.put(`/api/tenant/knowledge/groups/${id}`, payload) }, removeGroup(id: number) { return apiClient.remove(`/api/tenant/knowledge/groups/${id}`) }, listItems(groupId?: number) { return apiClient.get('/api/tenant/knowledge/items', { params: groupId ? { group_id: groupId } : undefined, }) }, detail(id: number) { return apiClient.get(`/api/tenant/knowledge/items/${id}`) }, createTextItem(payload: KnowledgeTextItemRequest) { return apiClient.post( '/api/tenant/knowledge/items/text', payload, ) }, createURLItem(payload: KnowledgeURLItemRequest) { return apiClient.post( '/api/tenant/knowledge/items/url', payload, ) }, async createFileItem(payload: { group_id: number; name?: string; file: File }) { const form = new FormData() form.set('group_id', String(payload.group_id)) if (payload.name?.trim()) { form.set('name', payload.name.trim()) } form.set('file', payload.file) const { data } = await apiClient.raw.post('/api/tenant/knowledge/items/file', form) return data.data as KnowledgeItem }, removeItem(id: number) { return apiClient.remove(`/api/tenant/knowledge/items/${id}`) }, retryItem(id: number) { return apiClient.post(`/api/tenant/knowledge/items/retry/${id}`, null) }, } export const imagesApi = { listFolders() { return apiClient.get('/api/tenant/images/folders') }, createFolder(name: string) { return apiClient.post('/api/tenant/images/folders', { name }) }, updateFolder(id: number, name: string) { return apiClient.put(`/api/tenant/images/folders/${id}`, { name }) }, folderDeletePreview(id: number) { return apiClient.get(`/api/tenant/images/folders/${id}/delete-preview`) }, deleteFolder(id: number, force?: boolean) { return apiClient.remove(`/api/tenant/images/folders/${id}`, { params: force ? { force: 1 } : undefined, }) }, list(params?: { folder_id?: number | null; q?: string; page?: number; page_size?: number }) { return apiClient.get('/api/tenant/images', { params }) }, async upload(file: File, folderId?: number | null) { const normalizedFile = await convertImageFileToWebp(file) const progressToken = markImageUploadRequestStarted(normalizedFile.name) try { const formData = new FormData() formData.append('file', normalizedFile, normalizedFile.name) if (folderId != null) { formData.append('folder_id', String(folderId)) } const response = await apiClient.raw.post>( '/api/tenant/images', formData, ) markImageUploadRequestFinished(progressToken) return { ...response.data.data, url: resolveApiURL(response.data.data.url), } } catch (error) { markImageUploadRequestFailed(progressToken) throw error } }, update(id: number, payload: ImageUpdateRequest) { return apiClient.put(`/api/tenant/images/${id}`, payload) }, getReferences(id: number) { return apiClient.get(`/api/tenant/images/${id}/references`) }, delete(id: number, force?: boolean) { return apiClient.remove(`/api/tenant/images/${id}`, { params: force ? { force: 1 } : undefined, }) }, storageUsage() { return apiClient.get('/api/tenant/images/storage-usage') }, } export interface ArticleGenerationStreamEvent { type: string article_id: number task_id?: number title?: string status?: string delta?: string content?: string error?: string done?: boolean updated_at?: string } export interface ArticleSelectionOptimizeRequest { title: string markdown_content: string selected_text: string instruction: string } export interface ArticleSelectionOptimizeStreamEvent { type: string article_id: number status?: string delta?: string content?: string error?: string model?: string done?: boolean } export function isGenerationStreamEnabled(): boolean { return generationStreamEnabled } export async function subscribeArticleGeneration( articleId: number, handlers: { onEvent: (event: ArticleGenerationStreamEvent) => void onClose?: () => void }, signal: AbortSignal, ): Promise { return subscribeSSE( `/api/tenant/articles/${articleId}/stream`, { method: 'GET', }, handlers, signal, ) } export async function streamArticleSelectionOptimize( articleId: number, payload: ArticleSelectionOptimizeRequest, handlers: { onEvent: (event: ArticleSelectionOptimizeStreamEvent) => void onClose?: () => void }, signal: AbortSignal, ): Promise { return subscribeSSE( `/api/tenant/articles/${articleId}/selection-optimize/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }, handlers, signal, ) } type SSEEventShape = { type: string } async function subscribeSSE( path: string, init: RequestInit, handlers: { onEvent: (event: TEvent) => void onClose?: () => void }, signal: AbortSignal, ): Promise { const requestHeaders = new Headers(init.headers) requestHeaders.set('Accept', 'text/event-stream') requestHeaders.set('Authorization', `Bearer ${await getFreshStoredAccessToken()}`) const currentBrandHeaders = buildCurrentBrandHeaders(path) currentBrandHeaders?.forEach((value, key) => requestHeaders.set(key, value)) let response = await fetch(`${baseURL}${path}`, { ...init, headers: requestHeaders, signal, }) if (response.status === 401) { try { const tokens = await refreshStoredAuthSession() requestHeaders.set('Authorization', `Bearer ${tokens.accessToken}`) response = await fetch(`${baseURL}${path}`, { ...init, headers: requestHeaders, signal, }) } catch (error) { if (isAuthSessionExpiredError(error)) { expireStoredSession() } throw markAuthErrorHandled(error) } } if (!response.ok) { const error = await buildSSERequestError(response) if (response.status === 401) { expireStoredSession() } throw error } if (!response.body) { throw new Error('stream response body is empty') } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) { break } buffer += decoder.decode(value, { stream: true }) buffer = consumeSSEBuffer(buffer, handlers.onEvent) } buffer += decoder.decode() consumeSSEBuffer(buffer, handlers.onEvent) } finally { reader.releaseLock() handlers.onClose?.() } } type SSEErrorEnvelope = { code?: number message?: string detail?: string request_id?: string } async function buildSSERequestError(response: Response): Promise { try { const payload = (await response.clone().json()) as SSEErrorEnvelope | null if (payload && typeof payload === 'object' && !Array.isArray(payload)) { return new ApiClientError({ message: payload.message ?? 'unknown_error', code: payload.code, status: response.status, detail: payload.detail, requestId: payload.request_id, handled: response.status === 401, }) } } catch { // Ignore JSON parse errors and fall back to a generic error below. } let detail: string | undefined try { const text = (await response.text()).trim() detail = text || undefined } catch { detail = undefined } return new ApiClientError({ message: 'unknown_error', status: response.status, detail: detail ?? `stream request failed with status ${response.status}`, handled: response.status === 401, }) } export const brandsApi = { list() { return apiClient.get('/api/tenant/brands') }, getLibrarySummary() { return apiClient.get('/api/tenant/brands/library-summary') }, create(payload: BrandRequest) { return apiClient.post('/api/tenant/brands', payload) }, detail(id: number) { return apiClient.get(`/api/tenant/brands/${id}`) }, update(id: number, payload: BrandRequest) { return apiClient.put(`/api/tenant/brands/${id}`, payload) }, remove(id: number) { return apiClient.remove(`/api/tenant/brands/${id}`) }, listKeywords(brandId: number) { return apiClient.get(`/api/tenant/brands/${brandId}/keywords`) }, createKeyword(brandId: number, payload: KeywordRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/keywords`, payload, ) }, updateKeyword(brandId: number, keywordId: number, payload: KeywordRequest) { return apiClient.put( `/api/tenant/brands/${brandId}/keywords/${keywordId}`, payload, ) }, removeKeyword(brandId: number, keywordId: number) { return apiClient.remove(`/api/tenant/brands/${brandId}/keywords/${keywordId}`) }, listQuestions( brandId: number, params?: { keyword_id?: number | null; page?: number; page_size?: number; q?: string }, ) { return apiClient.get(`/api/tenant/brands/${brandId}/questions`, { params, }) }, previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/combination-preview`, payload, ) }, fillQuestionCombination(brandId: number, payload: QuestionCombinationFillRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/combination-fill`, payload, { timeout: aiDistillRequestTimeoutMs }, ) }, distillQuestions(brandId: number, payload: QuestionDistillRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/ai-distill`, payload, { timeout: aiDistillRequestTimeoutMs }, ) }, classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/classify-metadata`, payload, ) }, materializeQuestions(brandId: number, payload: MaterializeQuestionsRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions/materialize`, payload, ) }, createQuestion(brandId: number, payload: QuestionRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions`, payload, ) }, updateQuestion(brandId: number, questionId: number, payload: UpdateQuestionRequest) { return apiClient.put( `/api/tenant/brands/${brandId}/questions/${questionId}`, payload, ) }, removeQuestion(brandId: number, questionId: number) { return apiClient.remove(`/api/tenant/brands/${brandId}/questions/${questionId}`) }, listCompetitors(brandId: number) { return apiClient.get(`/api/tenant/brands/${brandId}/competitors`) }, createCompetitor(brandId: number, payload: CompetitorRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/competitors`, payload, ) }, updateCompetitor(brandId: number, competitorId: number, payload: CompetitorRequest) { return apiClient.put( `/api/tenant/brands/${brandId}/competitors/${competitorId}`, payload, ) }, removeCompetitor(brandId: number, competitorId: number) { return apiClient.remove(`/api/tenant/brands/${brandId}/competitors/${competitorId}`) }, } export const mediaApi = { platforms() { return apiClient.get('/api/tenant/media/platforms') }, accounts() { return removedMediaApi('accounts') }, createPluginSession(payload: CreatePluginSessionRequest) { void payload return removedMediaApi('create_plugin_session') }, registerPluginInstallation(payload: RegisterPluginInstallationRequest) { void payload return removedMediaApi('register_plugin_installation') }, unbindAccount(id: number) { void id return removedMediaApi('unbind_account') }, } export const mediaSupplyApi = { listResources(params: ListSupplierMediaResourcesParams = {}) { return apiClient.get( '/api/tenant/media-supply/resources', { params, }, ) }, listResourcesByIds(ids: number[], modelId?: number) { return apiClient.get( '/api/tenant/media-supply/resources/by-ids', { params: { ids: ids.join(','), model_id: modelId } }, ) }, searchOptions(modelId?: number) { return apiClient.get( '/api/tenant/media-supply/resources/search-options', { params: { model_id: modelId } }, ) }, createOrder(payload: CreateMediaSupplyOrderRequest) { return apiClient.post( '/api/tenant/media-supply/orders', payload, ) }, listOrders(params: ListMediaSupplyOrdersParams = {}) { return apiClient.get('/api/tenant/media-supply/orders', { params, }) }, getOrder(id: number) { return apiClient.get(`/api/tenant/media-supply/orders/${id}`) }, wallet() { return apiClient.get('/api/tenant/media-supply/wallet') }, listWalletLedgers(params: ListMediaSupplyWalletLedgersParams = {}) { return apiClient.get( '/api/tenant/media-supply/wallet/ledgers', { params }, ) }, sessionStatus() { return apiClient.get('/api/tenant/media-supply/supplier-session') }, } export const tenantAccountsApi = { list() { return apiClient.get('/api/tenant/accounts') }, requestDelete(id: string, options?: { undo?: boolean }) { const query = options?.undo ? '?undo=true' : '' return apiClient.post( `/api/tenant/accounts/${encodeURIComponent(id)}/request-delete${query}`, ) }, } export const desktopClientApi = { downloadableReleases(channel = 'stable') { return apiClient.get( '/api/tenant/desktop-client/releases/downloadable', { params: { channel } }, ) }, publicLatest(params: { platform: string; arch: string; channel?: string; version?: string }) { return publicClient.get('/api/desktop/releases/latest', { params: { platform: params.platform, arch: params.arch, channel: params.channel ?? 'stable', version: params.version ?? '0.0.0', }, }) }, publicDownloadURL(params: { platform: string arch: string channel?: string version?: string }) { return publicClient.get( '/api/desktop/releases/download-url', { params: { platform: params.platform, arch: params.arch, channel: params.channel ?? 'stable', version: params.version ?? '0.0.0', }, }, ) }, async publicFallbackDownloadableReleases(): Promise { const targets = [ { platform: 'win32', arch: 'x64' }, { platform: 'darwin', arch: 'arm64' }, ] const results = await Promise.allSettled( targets.map((target) => this.publicLatest({ ...target, channel: 'stable' })), ) return { items: results.flatMap((result, index) => { if (result.status !== 'fulfilled') { return [] } const release = result.value const target = targets[index] const item: DesktopClientDownloadableRelease = { id: index + 1, platform: release.platform ?? target.platform, arch: release.arch ?? target.arch, channel: release.channel ?? 'stable', version: release.latest_version, download_url: release.download_url ?? null, oss_object_key: release.oss_object_key ?? null, file_name: release.file_name ?? null, file_size_bytes: release.file_size_bytes ?? null, sha256: release.sha256 ?? null, release_notes: release.release_notes ?? null, published_at: release.published_at ?? null, updated_at: release.published_at ?? new Date(0).toISOString(), force_update: release.force_update, min_supported_version: release.min_supported_version, download_source: release.download_source, } return [item] }), } }, } export const publishJobsApi = { create(payload: CreatePublishJobRequest) { return apiClient.post( '/api/tenant/publish-jobs', payload, ) }, } export const publishTasksApi = { list(params: ListDesktopPublishTasksParams = {}) { return apiClient.get('/api/tenant/publish-tasks', { params, }) }, retry(taskId: string) { return apiClient.post( `/api/tenant/publish-tasks/${encodeURIComponent(taskId)}/retry`, ) }, } export const publishRecordsApi = { list(params: ListPublishRecordsParams = {}) { return apiClient.get('/api/tenant/publish-records', { params, }) }, } export const enterpriseSitesApi = { list() { return apiClient.get('/api/tenant/enterprise-sites') }, create(payload: CreateEnterpriseSiteConnectionRequest) { return apiClient.post( '/api/tenant/enterprise-sites', payload, ) }, update(id: number, payload: UpdateEnterpriseSiteConnectionRequest) { return apiClient.patch( `/api/tenant/enterprise-sites/${id}`, payload, ) }, remove(id: number) { return apiClient.remove(`/api/tenant/enterprise-sites/${id}`) }, ping(id: number) { return apiClient.post(`/api/tenant/enterprise-sites/${id}/ping`) }, categories(id: number) { return apiClient.get(`/api/tenant/enterprise-sites/${id}/categories`) }, syncCategories(id: number) { return apiClient.post( `/api/tenant/enterprise-sites/${id}/categories/sync`, ) }, publish(id: number, payload: PublishEnterpriseSiteArticleRequest) { return apiClient.post( `/api/tenant/enterprise-sites/${id}/publish`, payload, ) }, } export const complianceApi = { runtimeStatus() { return apiClient.get('/api/tenant/compliance/runtime-status') }, checkPlain(payload: ComplianceCheckPlainRequest) { return apiClient.post( '/api/tenant/compliance/check', payload, ) }, checkArticle(articleId: number, payload: ComplianceCheckArticleRequest) { return apiClient.post( `/api/tenant/articles/${articleId}/compliance/check`, payload, ) }, latestArticleCheck(articleId: number) { return apiClient.get( `/api/tenant/articles/${articleId}/compliance/latest`, ) }, createAck(recordId: number, payload: CreateAckRequest) { return apiClient.post( `/api/tenant/compliance/check-records/${recordId}/ack`, payload, ) }, submitManualReview(articleId: number, payload: { check_record_id: number; note: string }) { return apiClient.post( `/api/tenant/articles/${articleId}/compliance/manual-reviews`, payload, ) }, latestManualReview(articleId: number, articleVersionId?: number | null) { return apiClient.get( `/api/tenant/articles/${articleId}/compliance/manual-reviews/latest`, { params: articleVersionId ? { article_version_id: articleVersionId } : undefined, }, ) }, cancelManualReview(articleId: number, reviewId: number, payload: { reason?: string }) { return apiClient.post( `/api/tenant/articles/${articleId}/compliance/manual-reviews/${reviewId}/cancel`, payload, ) }, } function removedMediaApi(action: string): Promise { return Promise.reject( new ApiClientError({ message: 'desktop_media_api_removed', detail: `legacy media API action "${action}" has been removed; use desktop client accounts and publish jobs instead`, }), ) } export const monitoringApi = { dashboardComposite(params: { brand_id?: number keyword_id?: number | null question_id?: number | null days?: number business_date?: string ai_platform_id?: string hot_questions_page?: number hot_questions_page_size?: number hot_questions_q?: string }) { return apiClient.get( '/api/tenant/monitoring/dashboard/composite', { params, }, ) }, citationSummary(params: { days?: number brand_id?: number keyword_id?: number | null question_id?: number | null business_date?: string ai_platform_id?: string cited_articles_page?: number cited_articles_page_size?: number }) { return apiClient.get( '/api/tenant/monitoring/citation-summary', { params, }, ) }, questionDetail( brandId: number, questionId: number, params: { date_from?: string date_to?: string question_hash?: string ai_platform_id?: string }, ) { return apiClient.get( `/api/tenant/monitoring/brands/${brandId}/questions/${questionId}/detail`, { params }, ) }, collectNow( brandId: number, payload?: { keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] }, ) { return apiClient.post< MonitoringCollectNowResponse, { keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] } >(`/api/tenant/monitoring/brands/${brandId}/collect-now`, payload ?? {}) }, } export const promptRulesApi = { list(params?: PromptRuleListParams) { return apiClient.get('/api/tenant/prompt-rules', { params }) }, listSimple() { return apiClient.get('/api/tenant/prompt-rules/simple') }, detail(id: number) { return apiClient.get(`/api/tenant/prompt-rules/${id}`) }, create(payload: PromptRuleRequest) { return apiClient.post('/api/tenant/prompt-rules', payload) }, update(id: number, payload: PromptRuleRequest) { return apiClient.put(`/api/tenant/prompt-rules/${id}`, payload) }, remove(id: number) { return apiClient.remove(`/api/tenant/prompt-rules/${id}`) }, toggleStatus(id: number, payload: PromptRuleStatusRequest) { return apiClient.put( `/api/tenant/prompt-rules/${id}/status`, payload, ) }, listGroups() { return apiClient.get('/api/tenant/prompt-rules/groups') }, createGroup(payload: PromptRuleGroupRequest) { return apiClient.post( '/api/tenant/prompt-rules/groups', payload, ) }, updateGroup(id: number, payload: PromptRuleGroupRequest) { return apiClient.put( `/api/tenant/prompt-rules/groups/${id}`, payload, ) }, removeGroup(id: number) { return apiClient.remove(`/api/tenant/prompt-rules/groups/${id}`) }, } export const schedulesApi = { list(params?: ScheduleTaskListParams) { return apiClient.get('/api/tenant/schedules', { params }) }, detail(id: number) { return apiClient.get(`/api/tenant/schedules/${id}`) }, create(payload: ScheduleTaskRequest) { return apiClient.post('/api/tenant/schedules', payload) }, update(id: number, payload: ScheduleTaskRequest) { return apiClient.put(`/api/tenant/schedules/${id}`, payload) }, remove(id: number) { return apiClient.remove(`/api/tenant/schedules/${id}`) }, toggleStatus(id: number, payload: ScheduleTaskStatusRequest) { return apiClient.put( `/api/tenant/schedules/${id}/status`, payload, ) }, } export const generateApi = { fromRule(payload: GenerateFromRuleRequest) { return apiClient.post( '/api/tenant/articles/generate-from-rule', payload, ) }, } export const instantTasksApi = { list(params?: InstantTaskListParams) { return apiClient.get('/api/tenant/instant-tasks', { params }) }, } export function normalizeInputParams( payload: Record, articleId?: number, wizardState?: Record, ): TemplateGenerateRequest { return { article_id: articleId, input_params: payload, wizard_state: wizardState, } } function consumeSSEBuffer( buffer: string, onEvent: (event: TEvent) => void, ): string { let next = buffer for (;;) { const delimiterIndex = next.indexOf('\n\n') if (delimiterIndex === -1) { break } const rawEvent = next.slice(0, delimiterIndex) next = next.slice(delimiterIndex + 2) const parsed = parseSSEEvent(rawEvent) if (parsed) { onEvent(parsed) } } return next } function parseSSEEvent(raw: string): TEvent | null { const lines = raw.split(/\r?\n/) let event = 'message' const dataLines: string[] = [] for (const line of lines) { if (line.startsWith('event:')) { event = line.slice(6).trim() continue } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()) } } if (dataLines.length === 0) { return null } const data = JSON.parse(dataLines.join('\n')) as Record | null if (!data || Array.isArray(data)) { return null } return { ...data, type: event, } as TEvent }