9ed857e159
Make GET /api/tenant/brands/:id/questions return a paginated QuestionListResponse (items/total/page/page_size) with optional `q` full-text filter, validated page/page_size query params, and per-params cache keys. Add a usePaginatedBrandQuestions composable backing the imitation, template-wizard, and tracking question selects with search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a deep-linked or pre-selected question is fetched even when off the first page. Brands view now drives its questions table via server pagination. Also redesign the Tracking hot-questions and cited-articles lists (mention-rate badges/bars, metric groups, refreshed styling) and fall back to citation-fact article_id/title when no high-confidence URL alias matches, so cited articles surface even without an alias row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1711 lines
50 KiB
TypeScript
1711 lines
50 KiB
TypeScript
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<AuthTokens> | 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<AuthTokens> {
|
|
if (!refreshToken) {
|
|
expireStoredSession()
|
|
throw authRequiredError()
|
|
}
|
|
|
|
const requestedRefreshToken = refreshToken
|
|
|
|
if (!storedRefreshPromise) {
|
|
storedRefreshPromise = publicClient
|
|
.post<RefreshResponse, { refresh_token: string }>('/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<string> {
|
|
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<string, string>)['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<PasswordPublicKeyResponse>('/api/auth/password-key')
|
|
},
|
|
login(payload: LoginRequest) {
|
|
return publicClient.post<LoginResponse, LoginRequest>('/api/auth/login', payload)
|
|
},
|
|
refresh: refreshStoredAuthSession,
|
|
me() {
|
|
return apiClient.get<UserInfo>('/api/auth/me')
|
|
},
|
|
changePassword(payload: ChangePasswordRequest) {
|
|
return apiClient.post<{ ok: boolean }, ChangePasswordRequest>('/api/auth/password', payload)
|
|
},
|
|
logout() {
|
|
return apiClient.post<null>('/api/auth/logout')
|
|
},
|
|
}
|
|
|
|
export const workspaceApi = {
|
|
overview() {
|
|
return apiClient.get<WorkspaceOverview>('/api/tenant/workspace/overview')
|
|
},
|
|
recentArticles() {
|
|
return apiClient.get<RecentArticle[]>('/api/tenant/workspace/recent-articles')
|
|
},
|
|
quotaSummary() {
|
|
return apiClient.get<QuotaSummary>('/api/tenant/workspace/quota-summary')
|
|
},
|
|
aiPointUsage(params?: { page?: number; page_size?: number; limit?: number; offset?: number }) {
|
|
return apiClient.get<AIPointUsageListResponse>('/api/tenant/workspace/ai-point-usage', {
|
|
params,
|
|
})
|
|
},
|
|
templateCards() {
|
|
return apiClient.get<TemplateCard[]>('/api/tenant/workspace/template-cards')
|
|
},
|
|
kolCards() {
|
|
return apiClient.get<KolWorkspaceCard[]>('/api/tenant/workspace/kol-cards')
|
|
},
|
|
}
|
|
|
|
export const templatesApi = {
|
|
list() {
|
|
return apiClient.get<TemplateListItem[]>('/api/tenant/templates')
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<TemplateDetail>(`/api/tenant/templates/${id}`)
|
|
},
|
|
createAnalyzeTask(id: number, payload: TemplateAnalyzeTaskRequest) {
|
|
return apiClient.post<TemplateAssistTaskCreateResponse, TemplateAnalyzeTaskRequest>(
|
|
`/api/tenant/templates/${id}/analyze-tasks`,
|
|
payload,
|
|
)
|
|
},
|
|
getAnalyzeTaskResult(id: number, taskId: string) {
|
|
return apiClient.get<TemplateAnalyzeTaskResultResponse>(
|
|
`/api/tenant/templates/${id}/analyze_task_result`,
|
|
{ params: { task_id: taskId } },
|
|
)
|
|
},
|
|
createTitleTask(id: number, payload: TemplateTitleTaskRequest) {
|
|
return apiClient.post<TemplateAssistTaskCreateResponse, TemplateTitleTaskRequest>(
|
|
`/api/tenant/templates/${id}/title-tasks`,
|
|
payload,
|
|
)
|
|
},
|
|
getTitleTaskResult(id: number, taskId: string) {
|
|
return apiClient.get<TemplateTitleTaskResultResponse>(
|
|
`/api/tenant/templates/${id}/gen_title_task_result`,
|
|
{ params: { task_id: taskId } },
|
|
)
|
|
},
|
|
createOutlineTask(id: number, payload: TemplateOutlineTaskRequest) {
|
|
return apiClient.post<TemplateAssistTaskCreateResponse, TemplateOutlineTaskRequest>(
|
|
`/api/tenant/templates/${id}/outline-tasks`,
|
|
payload,
|
|
)
|
|
},
|
|
getOutlineTaskResult(id: number, taskId: string) {
|
|
return apiClient.get<TemplateOutlineTaskResultResponse>(
|
|
`/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<TemplateGenerateResponse, TemplateGenerateRequest>(
|
|
`/api/tenant/templates/${id}/generate`,
|
|
payload,
|
|
)
|
|
},
|
|
saveDraft(id: number, payload: TemplateSaveDraftRequest) {
|
|
return apiClient.post<TemplateSaveDraftResponse, TemplateSaveDraftRequest>(
|
|
`/api/tenant/templates/${id}/drafts`,
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const kolManageApi = {
|
|
profile() {
|
|
return apiClient.get<KolProfile>('/api/tenant/kol/manage/profile')
|
|
},
|
|
updateProfile(body: { display_name: string; bio?: string | null; avatar_url?: string | null }) {
|
|
return apiClient.put<KolProfile, typeof body>('/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<KolPackageSummary[]>('/api/tenant/kol/manage/packages')
|
|
},
|
|
createPackage(body: CreateKolPackageRequest) {
|
|
return apiClient.post<KolPackageSummary, CreateKolPackageRequest>(
|
|
'/api/tenant/kol/manage/packages',
|
|
body,
|
|
)
|
|
},
|
|
updatePackage(id: number, body: UpdateKolPackageRequest) {
|
|
return apiClient.put<KolPackageSummary, UpdateKolPackageRequest>(
|
|
`/api/tenant/kol/manage/packages/${id}`,
|
|
body,
|
|
)
|
|
},
|
|
publishPackage(id: number) {
|
|
return apiClient.put<KolPackageSummary, Record<string, never>>(
|
|
`/api/tenant/kol/manage/packages/${id}/publish`,
|
|
{},
|
|
)
|
|
},
|
|
archivePackage(id: number) {
|
|
return apiClient.put<KolPackageSummary, Record<string, never>>(
|
|
`/api/tenant/kol/manage/packages/${id}/archive`,
|
|
{},
|
|
)
|
|
},
|
|
selfSubscribePackage(id: number) {
|
|
return apiClient.post<KolPackageSummary, Record<string, never>>(
|
|
`/api/tenant/kol/manage/packages/${id}/self-subscription`,
|
|
{},
|
|
)
|
|
},
|
|
cancelSelfSubscription(id: number) {
|
|
return apiClient.remove<KolPackageSummary>(
|
|
`/api/tenant/kol/manage/packages/${id}/self-subscription`,
|
|
)
|
|
},
|
|
deletePackage(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/kol/manage/packages/${id}`)
|
|
},
|
|
listPrompts(packageId: number) {
|
|
return apiClient.get<KolPromptDetail[]>(`/api/tenant/kol/manage/packages/${packageId}/prompts`)
|
|
},
|
|
createPrompt(packageId: number, body: CreateKolPromptRequest) {
|
|
return apiClient.post<KolPromptDetail, CreateKolPromptRequest>(
|
|
`/api/tenant/kol/manage/packages/${packageId}/prompts`,
|
|
body,
|
|
)
|
|
},
|
|
getPrompt(id: number) {
|
|
return apiClient.get<KolPromptDetail>(`/api/tenant/kol/manage/prompts/${id}`)
|
|
},
|
|
updatePromptMetadata(id: number, body: UpdateKolPromptRequest) {
|
|
return apiClient.put<KolPromptDetail, UpdateKolPromptRequest>(
|
|
`/api/tenant/kol/manage/prompts/${id}`,
|
|
body,
|
|
)
|
|
},
|
|
deletePrompt(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/kol/manage/prompts/${id}`)
|
|
},
|
|
savePrompt(promptId: number, body: PublishKolPromptRequest) {
|
|
return apiClient.post<KolPromptDetail, PublishKolPromptRequest>(
|
|
`/api/tenant/kol/manage/prompts/${promptId}/save`,
|
|
body,
|
|
)
|
|
},
|
|
publishPrompt(promptId: number, body: PublishKolPromptRequest) {
|
|
return apiClient.post<KolPromptDetail, PublishKolPromptRequest>(
|
|
`/api/tenant/kol/manage/prompts/${promptId}/publish`,
|
|
body,
|
|
)
|
|
},
|
|
activatePrompt(promptId: number) {
|
|
return apiClient.put<KolPromptDetail, Record<string, never>>(
|
|
`/api/tenant/kol/manage/prompts/${promptId}/activate`,
|
|
{},
|
|
)
|
|
},
|
|
archivePrompt(promptId: number) {
|
|
return apiClient.put<KolPromptDetail, Record<string, never>>(
|
|
`/api/tenant/kol/manage/prompts/${promptId}/archive`,
|
|
{},
|
|
)
|
|
},
|
|
submitAssist(body: KolAssistRequest) {
|
|
return apiClient.post<KolAssistSubmitResponse, KolAssistRequest>(
|
|
'/api/tenant/kol/manage/assist',
|
|
body,
|
|
)
|
|
},
|
|
streamAssist(
|
|
body: KolAssistRequest,
|
|
handlers: {
|
|
onEvent: (event: KolAssistStreamEvent) => void
|
|
onClose?: () => void
|
|
},
|
|
signal: AbortSignal,
|
|
) {
|
|
return subscribeSSE<KolAssistStreamEvent>(
|
|
'/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<KolAssistTask>(`/api/tenant/kol/manage/assist/${id}`)
|
|
},
|
|
}
|
|
|
|
export const kolMarketplaceApi = {
|
|
list(params: { industry?: string; keyword?: string; limit?: number; offset?: number }) {
|
|
return apiClient.get<KolPackageSummary[]>('/api/tenant/kol/marketplace/packages', { params })
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<KolPackageDetail>(`/api/tenant/kol/marketplace/packages/${id}`)
|
|
},
|
|
subscribe(id: number) {
|
|
return apiClient.post<KolSubscription, Record<string, never>>(
|
|
`/api/tenant/kol/marketplace/packages/${id}/subscribe`,
|
|
{},
|
|
)
|
|
},
|
|
mySubscriptions() {
|
|
return apiClient.get<KolSubscription[]>('/api/tenant/kol/subscriptions')
|
|
},
|
|
mySubscriptionPrompts() {
|
|
return apiClient.get<KolSubscriptionPromptCard[]>('/api/tenant/kol/subscription-prompts')
|
|
},
|
|
}
|
|
|
|
export const kolGenerateApi = {
|
|
schema(id: number | string) {
|
|
return apiClient.get<KolSubscriptionPromptSchema>(
|
|
`/api/tenant/kol/subscription-prompts/${id}/schema`,
|
|
)
|
|
},
|
|
submit(id: number | string, body: KolGenerateRequest) {
|
|
return apiClient.post<KolGenerateResponse, KolGenerateRequest>(
|
|
`/api/tenant/kol/subscription-prompts/${id}/generate`,
|
|
body,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const kolDashboardApi = {
|
|
overview() {
|
|
return apiClient.get<KolDashboardOverview>('/api/tenant/kol/dashboard/overview')
|
|
},
|
|
packages() {
|
|
return apiClient.get<KolDashboardPackageStat[]>('/api/tenant/kol/dashboard/packages')
|
|
},
|
|
trend(periodDays: number) {
|
|
return apiClient.get<KolDashboardTrendPoint[]>('/api/tenant/kol/dashboard/trend', {
|
|
params: { period_days: periodDays },
|
|
})
|
|
},
|
|
}
|
|
|
|
export const articlesApi = {
|
|
list(params: ArticleListParams) {
|
|
return apiClient.get<ArticleListResponse>('/api/tenant/articles', { params })
|
|
},
|
|
create(payload: CreateArticleRequest = {}) {
|
|
return apiClient.post<ArticleDetail, CreateArticleRequest>('/api/tenant/articles', payload)
|
|
},
|
|
generateImitation(payload: GenerateImitationRequest) {
|
|
return apiClient.post<GenerateImitationResponse, GenerateImitationRequest>(
|
|
'/api/tenant/articles/imitations/generate',
|
|
payload,
|
|
)
|
|
},
|
|
regenerate(id: number) {
|
|
return apiClient.post<RegenerateArticleResponse, null>(
|
|
`/api/tenant/articles/${id}/regenerate`,
|
|
null,
|
|
)
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`)
|
|
},
|
|
update(id: number, payload: UpdateArticleRequest) {
|
|
return apiClient.put<ArticleDetail, UpdateArticleRequest>(`/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<ApiEnvelope<ArticleImageUploadResponse>>(
|
|
`/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<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`)
|
|
},
|
|
publishBatch(id: number, payload: CreatePublishBatchRequest) {
|
|
return apiClient.post<CreatePublishBatchResponse, CreatePublishBatchRequest>(
|
|
`/api/tenant/articles/${id}/publish-batches`,
|
|
payload,
|
|
)
|
|
},
|
|
publishRecords(id: number) {
|
|
return apiClient.get<PublishRecord[]>(`/api/tenant/articles/${id}/publish-records`)
|
|
},
|
|
remove(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/articles/${id}`)
|
|
},
|
|
}
|
|
|
|
export const knowledgeApi = {
|
|
listGroups() {
|
|
return apiClient.get<KnowledgeGroup[]>('/api/tenant/knowledge/groups')
|
|
},
|
|
createGroup(payload: KnowledgeGroupRequest) {
|
|
return apiClient.post<KnowledgeGroup, KnowledgeGroupRequest>(
|
|
'/api/tenant/knowledge/groups',
|
|
payload,
|
|
)
|
|
},
|
|
updateGroup(id: number, payload: KnowledgeGroupRequest) {
|
|
return apiClient.put<null, KnowledgeGroupRequest>(`/api/tenant/knowledge/groups/${id}`, payload)
|
|
},
|
|
removeGroup(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/knowledge/groups/${id}`)
|
|
},
|
|
listItems(groupId?: number) {
|
|
return apiClient.get<KnowledgeItem[]>('/api/tenant/knowledge/items', {
|
|
params: groupId ? { group_id: groupId } : undefined,
|
|
})
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<KnowledgeItemDetail>(`/api/tenant/knowledge/items/${id}`)
|
|
},
|
|
createTextItem(payload: KnowledgeTextItemRequest) {
|
|
return apiClient.post<KnowledgeItem, KnowledgeTextItemRequest>(
|
|
'/api/tenant/knowledge/items/text',
|
|
payload,
|
|
)
|
|
},
|
|
createURLItem(payload: KnowledgeURLItemRequest) {
|
|
return apiClient.post<KnowledgeItem, KnowledgeURLItemRequest>(
|
|
'/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<null>(`/api/tenant/knowledge/items/${id}`)
|
|
},
|
|
retryItem(id: number) {
|
|
return apiClient.post<KnowledgeItem, null>(`/api/tenant/knowledge/items/retry/${id}`, null)
|
|
},
|
|
}
|
|
|
|
export const imagesApi = {
|
|
listFolders() {
|
|
return apiClient.get<ImageFolder[]>('/api/tenant/images/folders')
|
|
},
|
|
createFolder(name: string) {
|
|
return apiClient.post<ImageFolder, { name: string }>('/api/tenant/images/folders', { name })
|
|
},
|
|
updateFolder(id: number, name: string) {
|
|
return apiClient.put<null, { name: string }>(`/api/tenant/images/folders/${id}`, { name })
|
|
},
|
|
folderDeletePreview(id: number) {
|
|
return apiClient.get<FolderDeletePreview>(`/api/tenant/images/folders/${id}/delete-preview`)
|
|
},
|
|
deleteFolder(id: number, force?: boolean) {
|
|
return apiClient.remove<null>(`/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<ImageAssetListResponse>('/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<ApiEnvelope<ImageUploadResponse>>(
|
|
'/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<null, ImageUpdateRequest>(`/api/tenant/images/${id}`, payload)
|
|
},
|
|
getReferences(id: number) {
|
|
return apiClient.get<ImageReferenceDetail>(`/api/tenant/images/${id}/references`)
|
|
},
|
|
delete(id: number, force?: boolean) {
|
|
return apiClient.remove<null>(`/api/tenant/images/${id}`, {
|
|
params: force ? { force: 1 } : undefined,
|
|
})
|
|
},
|
|
storageUsage() {
|
|
return apiClient.get<ImageStorageUsage>('/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<void> {
|
|
return subscribeSSE<ArticleGenerationStreamEvent>(
|
|
`/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<void> {
|
|
return subscribeSSE<ArticleSelectionOptimizeStreamEvent>(
|
|
`/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<TEvent extends SSEEventShape>(
|
|
path: string,
|
|
init: RequestInit,
|
|
handlers: {
|
|
onEvent: (event: TEvent) => void
|
|
onClose?: () => void
|
|
},
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
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<TEvent>(buffer, handlers.onEvent)
|
|
}
|
|
|
|
buffer += decoder.decode()
|
|
consumeSSEBuffer<TEvent>(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<ApiClientError> {
|
|
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<Brand[]>('/api/tenant/brands')
|
|
},
|
|
getLibrarySummary() {
|
|
return apiClient.get<BrandLibrarySummary>('/api/tenant/brands/library-summary')
|
|
},
|
|
create(payload: BrandRequest) {
|
|
return apiClient.post<Brand, BrandRequest>('/api/tenant/brands', payload)
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<Brand>(`/api/tenant/brands/${id}`)
|
|
},
|
|
update(id: number, payload: BrandRequest) {
|
|
return apiClient.put<null, BrandRequest>(`/api/tenant/brands/${id}`, payload)
|
|
},
|
|
remove(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/brands/${id}`)
|
|
},
|
|
listKeywords(brandId: number) {
|
|
return apiClient.get<Keyword[]>(`/api/tenant/brands/${brandId}/keywords`)
|
|
},
|
|
createKeyword(brandId: number, payload: KeywordRequest) {
|
|
return apiClient.post<Keyword, KeywordRequest>(
|
|
`/api/tenant/brands/${brandId}/keywords`,
|
|
payload,
|
|
)
|
|
},
|
|
updateKeyword(brandId: number, keywordId: number, payload: KeywordRequest) {
|
|
return apiClient.put<null, KeywordRequest>(
|
|
`/api/tenant/brands/${brandId}/keywords/${keywordId}`,
|
|
payload,
|
|
)
|
|
},
|
|
removeKeyword(brandId: number, keywordId: number) {
|
|
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/keywords/${keywordId}`)
|
|
},
|
|
listQuestions(
|
|
brandId: number,
|
|
params?: { keyword_id?: number | null; page?: number; page_size?: number; q?: string },
|
|
) {
|
|
return apiClient.get<QuestionListResponse>(`/api/tenant/brands/${brandId}/questions`, {
|
|
params,
|
|
})
|
|
},
|
|
previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) {
|
|
return apiClient.post<QuestionCandidateResult, QuestionCombinationRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/combination-preview`,
|
|
payload,
|
|
)
|
|
},
|
|
fillQuestionCombination(brandId: number, payload: QuestionCombinationFillRequest) {
|
|
return apiClient.post<QuestionCombinationFillResult, QuestionCombinationFillRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/combination-fill`,
|
|
payload,
|
|
{ timeout: aiDistillRequestTimeoutMs },
|
|
)
|
|
},
|
|
distillQuestions(brandId: number, payload: QuestionDistillRequest) {
|
|
return apiClient.post<QuestionCandidateResult, QuestionDistillRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/ai-distill`,
|
|
payload,
|
|
{ timeout: aiDistillRequestTimeoutMs },
|
|
)
|
|
},
|
|
classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) {
|
|
return apiClient.post<ClassifiedQuestion[], ClassifyQuestionsRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/classify-metadata`,
|
|
payload,
|
|
)
|
|
},
|
|
materializeQuestions(brandId: number, payload: MaterializeQuestionsRequest) {
|
|
return apiClient.post<MaterializeQuestionsResult, MaterializeQuestionsRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/materialize`,
|
|
payload,
|
|
)
|
|
},
|
|
createQuestion(brandId: number, payload: QuestionRequest) {
|
|
return apiClient.post<Question, QuestionRequest>(
|
|
`/api/tenant/brands/${brandId}/questions`,
|
|
payload,
|
|
)
|
|
},
|
|
updateQuestion(brandId: number, questionId: number, payload: UpdateQuestionRequest) {
|
|
return apiClient.put<null, UpdateQuestionRequest>(
|
|
`/api/tenant/brands/${brandId}/questions/${questionId}`,
|
|
payload,
|
|
)
|
|
},
|
|
removeQuestion(brandId: number, questionId: number) {
|
|
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/questions/${questionId}`)
|
|
},
|
|
listCompetitors(brandId: number) {
|
|
return apiClient.get<Competitor[]>(`/api/tenant/brands/${brandId}/competitors`)
|
|
},
|
|
createCompetitor(brandId: number, payload: CompetitorRequest) {
|
|
return apiClient.post<Competitor, CompetitorRequest>(
|
|
`/api/tenant/brands/${brandId}/competitors`,
|
|
payload,
|
|
)
|
|
},
|
|
updateCompetitor(brandId: number, competitorId: number, payload: CompetitorRequest) {
|
|
return apiClient.put<null, CompetitorRequest>(
|
|
`/api/tenant/brands/${brandId}/competitors/${competitorId}`,
|
|
payload,
|
|
)
|
|
},
|
|
removeCompetitor(brandId: number, competitorId: number) {
|
|
return apiClient.remove<null>(`/api/tenant/brands/${brandId}/competitors/${competitorId}`)
|
|
},
|
|
}
|
|
|
|
export const mediaApi = {
|
|
platforms() {
|
|
return apiClient.get<MediaPlatform[]>('/api/tenant/media/platforms')
|
|
},
|
|
accounts() {
|
|
return removedMediaApi<PlatformAccount[]>('accounts')
|
|
},
|
|
createPluginSession(payload: CreatePluginSessionRequest) {
|
|
void payload
|
|
return removedMediaApi<CreatePluginSessionResponse>('create_plugin_session')
|
|
},
|
|
registerPluginInstallation(payload: RegisterPluginInstallationRequest) {
|
|
void payload
|
|
return removedMediaApi<RegisterPluginInstallationResponse>('register_plugin_installation')
|
|
},
|
|
unbindAccount(id: number) {
|
|
void id
|
|
return removedMediaApi<null>('unbind_account')
|
|
},
|
|
}
|
|
|
|
export const mediaSupplyApi = {
|
|
listResources(params: ListSupplierMediaResourcesParams = {}) {
|
|
return apiClient.get<ListCustomerSupplierMediaResourcesResponse>(
|
|
'/api/tenant/media-supply/resources',
|
|
{
|
|
params,
|
|
},
|
|
)
|
|
},
|
|
listResourcesByIds(ids: number[], modelId?: number) {
|
|
return apiClient.get<ListCustomerSupplierMediaResourcesResponse>(
|
|
'/api/tenant/media-supply/resources/by-ids',
|
|
{ params: { ids: ids.join(','), model_id: modelId } },
|
|
)
|
|
},
|
|
searchOptions(modelId?: number) {
|
|
return apiClient.get<MediaSupplySearchOptionsResponse>(
|
|
'/api/tenant/media-supply/resources/search-options',
|
|
{ params: { model_id: modelId } },
|
|
)
|
|
},
|
|
createOrder(payload: CreateMediaSupplyOrderRequest) {
|
|
return apiClient.post<CreateMediaSupplyOrderResponse, CreateMediaSupplyOrderRequest>(
|
|
'/api/tenant/media-supply/orders',
|
|
payload,
|
|
)
|
|
},
|
|
listOrders(params: ListMediaSupplyOrdersParams = {}) {
|
|
return apiClient.get<ListMediaSupplyOrdersResponse>('/api/tenant/media-supply/orders', {
|
|
params,
|
|
})
|
|
},
|
|
getOrder(id: number) {
|
|
return apiClient.get<MediaSupplyOrderDetail>(`/api/tenant/media-supply/orders/${id}`)
|
|
},
|
|
wallet() {
|
|
return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet')
|
|
},
|
|
listWalletLedgers(params: ListMediaSupplyWalletLedgersParams = {}) {
|
|
return apiClient.get<ListMediaSupplyWalletLedgersResponse>(
|
|
'/api/tenant/media-supply/wallet/ledgers',
|
|
{ params },
|
|
)
|
|
},
|
|
sessionStatus() {
|
|
return apiClient.get<MediaSupplySessionStatus>('/api/tenant/media-supply/supplier-session')
|
|
},
|
|
}
|
|
|
|
export const tenantAccountsApi = {
|
|
list() {
|
|
return apiClient.get<DesktopAccountInfo[]>('/api/tenant/accounts')
|
|
},
|
|
requestDelete(id: string, options?: { undo?: boolean }) {
|
|
const query = options?.undo ? '?undo=true' : ''
|
|
return apiClient.post<DesktopAccountInfo>(
|
|
`/api/tenant/accounts/${encodeURIComponent(id)}/request-delete${query}`,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const desktopClientApi = {
|
|
downloadableReleases(channel = 'stable') {
|
|
return apiClient.get<DesktopClientDownloadableReleaseListResponse>(
|
|
'/api/tenant/desktop-client/releases/downloadable',
|
|
{ params: { channel } },
|
|
)
|
|
},
|
|
publicLatest(params: { platform: string; arch: string; channel?: string; version?: string }) {
|
|
return publicClient.get<DesktopClientReleaseCheckResponse>('/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<DesktopClientReleaseDownloadURLResponse>(
|
|
'/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<DesktopClientDownloadableReleaseListResponse> {
|
|
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<CreatePublishJobResponse, CreatePublishJobRequest>(
|
|
'/api/tenant/publish-jobs',
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const publishTasksApi = {
|
|
list(params: ListDesktopPublishTasksParams = {}) {
|
|
return apiClient.get<TenantPublishTaskListResponse>('/api/tenant/publish-tasks', {
|
|
params,
|
|
})
|
|
},
|
|
retry(taskId: string) {
|
|
return apiClient.post<CreatePublishJobResponse>(
|
|
`/api/tenant/publish-tasks/${encodeURIComponent(taskId)}/retry`,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const publishRecordsApi = {
|
|
list(params: ListPublishRecordsParams = {}) {
|
|
return apiClient.get<PublishRecordListResponse>('/api/tenant/publish-records', {
|
|
params,
|
|
})
|
|
},
|
|
}
|
|
|
|
export const enterpriseSitesApi = {
|
|
list() {
|
|
return apiClient.get<EnterpriseSiteConnection[]>('/api/tenant/enterprise-sites')
|
|
},
|
|
create(payload: CreateEnterpriseSiteConnectionRequest) {
|
|
return apiClient.post<EnterpriseSiteConnection, CreateEnterpriseSiteConnectionRequest>(
|
|
'/api/tenant/enterprise-sites',
|
|
payload,
|
|
)
|
|
},
|
|
update(id: number, payload: UpdateEnterpriseSiteConnectionRequest) {
|
|
return apiClient.patch<EnterpriseSiteConnection, UpdateEnterpriseSiteConnectionRequest>(
|
|
`/api/tenant/enterprise-sites/${id}`,
|
|
payload,
|
|
)
|
|
},
|
|
remove(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/enterprise-sites/${id}`)
|
|
},
|
|
ping(id: number) {
|
|
return apiClient.post<EnterpriseSiteCapability>(`/api/tenant/enterprise-sites/${id}/ping`)
|
|
},
|
|
categories(id: number) {
|
|
return apiClient.get<EnterpriseSiteCategory[]>(`/api/tenant/enterprise-sites/${id}/categories`)
|
|
},
|
|
syncCategories(id: number) {
|
|
return apiClient.post<EnterpriseSiteCategory[]>(
|
|
`/api/tenant/enterprise-sites/${id}/categories/sync`,
|
|
)
|
|
},
|
|
publish(id: number, payload: PublishEnterpriseSiteArticleRequest) {
|
|
return apiClient.post<EnterpriseSitePublishResponse, PublishEnterpriseSiteArticleRequest>(
|
|
`/api/tenant/enterprise-sites/${id}/publish`,
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const complianceApi = {
|
|
runtimeStatus() {
|
|
return apiClient.get<ComplianceRuntimeStatus>('/api/tenant/compliance/runtime-status')
|
|
},
|
|
checkPlain(payload: ComplianceCheckPlainRequest) {
|
|
return apiClient.post<ComplianceCheckResult, ComplianceCheckPlainRequest>(
|
|
'/api/tenant/compliance/check',
|
|
payload,
|
|
)
|
|
},
|
|
checkArticle(articleId: number, payload: ComplianceCheckArticleRequest) {
|
|
return apiClient.post<ComplianceCheckResult, ComplianceCheckArticleRequest>(
|
|
`/api/tenant/articles/${articleId}/compliance/check`,
|
|
payload,
|
|
)
|
|
},
|
|
latestArticleCheck(articleId: number) {
|
|
return apiClient.get<ComplianceCheckResult>(
|
|
`/api/tenant/articles/${articleId}/compliance/latest`,
|
|
)
|
|
},
|
|
createAck(recordId: number, payload: CreateAckRequest) {
|
|
return apiClient.post<ComplianceAckRecord, CreateAckRequest>(
|
|
`/api/tenant/compliance/check-records/${recordId}/ack`,
|
|
payload,
|
|
)
|
|
},
|
|
submitManualReview(articleId: number, payload: { check_record_id: number; note: string }) {
|
|
return apiClient.post<ComplianceManualReview, typeof payload>(
|
|
`/api/tenant/articles/${articleId}/compliance/manual-reviews`,
|
|
payload,
|
|
)
|
|
},
|
|
latestManualReview(articleId: number, articleVersionId?: number | null) {
|
|
return apiClient.get<ComplianceManualReview>(
|
|
`/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<ComplianceManualReview, typeof payload>(
|
|
`/api/tenant/articles/${articleId}/compliance/manual-reviews/${reviewId}/cancel`,
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
function removedMediaApi<T>(action: string): Promise<T> {
|
|
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<MonitoringDashboardCompositeResponse>(
|
|
'/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<MonitoringCitationSummaryResponse>(
|
|
'/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<MonitoringQuestionDetailResponse>(
|
|
`/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<PromptRuleListResponse>('/api/tenant/prompt-rules', { params })
|
|
},
|
|
listSimple() {
|
|
return apiClient.get<PromptRuleSimple[]>('/api/tenant/prompt-rules/simple')
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<PromptRule>(`/api/tenant/prompt-rules/${id}`)
|
|
},
|
|
create(payload: PromptRuleRequest) {
|
|
return apiClient.post<PromptRule, PromptRuleRequest>('/api/tenant/prompt-rules', payload)
|
|
},
|
|
update(id: number, payload: PromptRuleRequest) {
|
|
return apiClient.put<null, PromptRuleRequest>(`/api/tenant/prompt-rules/${id}`, payload)
|
|
},
|
|
remove(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/prompt-rules/${id}`)
|
|
},
|
|
toggleStatus(id: number, payload: PromptRuleStatusRequest) {
|
|
return apiClient.put<null, PromptRuleStatusRequest>(
|
|
`/api/tenant/prompt-rules/${id}/status`,
|
|
payload,
|
|
)
|
|
},
|
|
listGroups() {
|
|
return apiClient.get<PromptRuleGroup[]>('/api/tenant/prompt-rules/groups')
|
|
},
|
|
createGroup(payload: PromptRuleGroupRequest) {
|
|
return apiClient.post<PromptRuleGroup, PromptRuleGroupRequest>(
|
|
'/api/tenant/prompt-rules/groups',
|
|
payload,
|
|
)
|
|
},
|
|
updateGroup(id: number, payload: PromptRuleGroupRequest) {
|
|
return apiClient.put<null, PromptRuleGroupRequest>(
|
|
`/api/tenant/prompt-rules/groups/${id}`,
|
|
payload,
|
|
)
|
|
},
|
|
removeGroup(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/prompt-rules/groups/${id}`)
|
|
},
|
|
}
|
|
|
|
export const schedulesApi = {
|
|
list(params?: ScheduleTaskListParams) {
|
|
return apiClient.get<ScheduleTaskListResponse>('/api/tenant/schedules', { params })
|
|
},
|
|
detail(id: number) {
|
|
return apiClient.get<ScheduleTask>(`/api/tenant/schedules/${id}`)
|
|
},
|
|
create(payload: ScheduleTaskRequest) {
|
|
return apiClient.post<ScheduleTask, ScheduleTaskRequest>('/api/tenant/schedules', payload)
|
|
},
|
|
update(id: number, payload: ScheduleTaskRequest) {
|
|
return apiClient.put<null, ScheduleTaskRequest>(`/api/tenant/schedules/${id}`, payload)
|
|
},
|
|
remove(id: number) {
|
|
return apiClient.remove<null>(`/api/tenant/schedules/${id}`)
|
|
},
|
|
toggleStatus(id: number, payload: ScheduleTaskStatusRequest) {
|
|
return apiClient.put<null, ScheduleTaskStatusRequest>(
|
|
`/api/tenant/schedules/${id}/status`,
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const generateApi = {
|
|
fromRule(payload: GenerateFromRuleRequest) {
|
|
return apiClient.post<GenerateFromRuleResponse, GenerateFromRuleRequest>(
|
|
'/api/tenant/articles/generate-from-rule',
|
|
payload,
|
|
)
|
|
},
|
|
}
|
|
|
|
export const instantTasksApi = {
|
|
list(params?: InstantTaskListParams) {
|
|
return apiClient.get<InstantTaskListResponse>('/api/tenant/instant-tasks', { params })
|
|
},
|
|
}
|
|
|
|
export function normalizeInputParams(
|
|
payload: Record<string, JsonValue>,
|
|
articleId?: number,
|
|
wizardState?: Record<string, JsonValue>,
|
|
): TemplateGenerateRequest {
|
|
return {
|
|
article_id: articleId,
|
|
input_params: payload,
|
|
wizard_state: wizardState,
|
|
}
|
|
}
|
|
|
|
function consumeSSEBuffer<TEvent extends SSEEventShape>(
|
|
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<TEvent>(rawEvent)
|
|
if (parsed) {
|
|
onEvent(parsed)
|
|
}
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
function parseSSEEvent<TEvent extends SSEEventShape>(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<string, unknown> | null
|
|
if (!data || Array.isArray(data)) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...data,
|
|
type: event,
|
|
} as TEvent
|
|
}
|