b0f5d6a51c
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1839 lines
52 KiB
TypeScript
1839 lines
52 KiB
TypeScript
import type {
|
|
AdminDashboardSummary,
|
|
AdminUserRow,
|
|
AuthStatus,
|
|
CapabilityDefinition,
|
|
CapabilityMap,
|
|
ChatSaveAnalysis,
|
|
ChatSaveResult,
|
|
PolicyDefinition,
|
|
PolicyMap,
|
|
SkillDefinition,
|
|
SkillMap,
|
|
BillingConfig,
|
|
HarnessBootstrapResponse,
|
|
InsufficientBalanceDetails,
|
|
LedgerEntry,
|
|
LlmProviderDefinition,
|
|
LlmProviderKeyRow,
|
|
LlmGlobalSettings,
|
|
LlmConnectionTestResult,
|
|
RechargeOrder,
|
|
Message,
|
|
MindSpace,
|
|
MindSpaceAsset,
|
|
MindSpaceAgentJob,
|
|
MindSpaceCleanupItem,
|
|
MindSpacePage,
|
|
MindSpacePageDeletePreview,
|
|
MindSpacePageDeleteResult,
|
|
MindSpaceQuota,
|
|
MindSpaceSaveCategory,
|
|
MindSpacePublication,
|
|
MindSpacePublicationStats,
|
|
MindSpacePublishCheck,
|
|
PlazaCategory,
|
|
PlazaPostBrief,
|
|
MindSpaceRedactedCopyResult,
|
|
MindSpaceUpload,
|
|
PortalUser,
|
|
Session,
|
|
SessionEvent,
|
|
SessionListResponse,
|
|
UsageRecord,
|
|
BalanceUpdate,
|
|
} from '../types';
|
|
|
|
const API = '/api';
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly code?: string;
|
|
readonly details?: InsufficientBalanceDetails | Record<string, unknown>;
|
|
|
|
constructor(
|
|
status: number,
|
|
message: string,
|
|
code?: string,
|
|
details?: InsufficientBalanceDetails | Record<string, unknown>,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
async function parseErrorResponse(res: Response) {
|
|
const text = await res.text().catch(() => '');
|
|
try {
|
|
const body = JSON.parse(text) as Record<string, unknown>;
|
|
const nested =
|
|
body.error && typeof body.error === 'object'
|
|
? (body.error as Record<string, unknown>)
|
|
: body;
|
|
const message =
|
|
typeof nested.message === 'string'
|
|
? nested.message
|
|
: typeof body.message === 'string'
|
|
? body.message
|
|
: text;
|
|
const code =
|
|
typeof nested.code === 'string'
|
|
? nested.code
|
|
: typeof body.code === 'string'
|
|
? body.code
|
|
: undefined;
|
|
const details = nested.details ?? body.details;
|
|
if (code === 'INSUFFICIENT_BALANCE') {
|
|
return {
|
|
message,
|
|
code,
|
|
details: {
|
|
code: 'INSUFFICIENT_BALANCE' as const,
|
|
balanceCents: Number((details as Record<string, unknown>)?.balanceCents ?? body.balanceCents ?? 0),
|
|
minRechargeCents: Number(
|
|
(details as Record<string, unknown>)?.minRechargeCents ?? body.minRechargeCents ?? 500,
|
|
),
|
|
suggestedTiers: Array.isArray((details as Record<string, unknown>)?.suggestedTiers)
|
|
? ((details as Record<string, unknown>).suggestedTiers as unknown[]).map((value) => Number(value))
|
|
: Array.isArray(body.suggestedTiers)
|
|
? body.suggestedTiers.map((value) => Number(value))
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
return { message, code, details };
|
|
} catch {
|
|
return { message: text || res.statusText };
|
|
}
|
|
}
|
|
|
|
let unauthorizedHandler: (() => void) | null = null;
|
|
let unauthorizedHandling = false;
|
|
|
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
|
unauthorizedHandler = handler;
|
|
if (handler) unauthorizedHandling = false;
|
|
}
|
|
|
|
export function resetUnauthorizedGuard() {
|
|
unauthorizedHandling = false;
|
|
}
|
|
|
|
function notifyUnauthorized() {
|
|
if (!unauthorizedHandler || unauthorizedHandling) return;
|
|
unauthorizedHandling = true;
|
|
unauthorizedHandler();
|
|
}
|
|
|
|
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...init?.headers,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
|
|
if (res.status === 401) {
|
|
notifyUnauthorized();
|
|
const text = await res.text().catch(() => '');
|
|
throw new ApiError(401, text || '未授权,请重新登录');
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const parsed = await parseErrorResponse(res);
|
|
throw new ApiError(
|
|
res.status,
|
|
parsed.message || `${res.status} ${res.statusText}`,
|
|
parsed.code,
|
|
parsed.details,
|
|
);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
function formatNetworkError(err: unknown) {
|
|
const message = err instanceof Error ? err.message : '网络请求失败';
|
|
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
|
return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs';
|
|
}
|
|
return message;
|
|
}
|
|
|
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${API}${path}`, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...init?.headers,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
|
|
if (res.status === 401) {
|
|
notifyUnauthorized();
|
|
const text = await res.text().catch(() => '');
|
|
throw new ApiError(401, text || '未授权,请重新登录');
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const parsed = await parseErrorResponse(res);
|
|
throw new ApiError(
|
|
res.status,
|
|
parsed.message || `${res.status} ${res.statusText}`,
|
|
parsed.code,
|
|
parsed.details,
|
|
);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export async function startSession(): Promise<Session> {
|
|
return apiFetch<Session>('/agent/start', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
export async function bootstrapProjectMemory(
|
|
sessionId: string,
|
|
query: string,
|
|
force = false,
|
|
): Promise<HarnessBootstrapResponse> {
|
|
return apiFetch<HarnessBootstrapResponse>('/agent/harness_bootstrap', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sessionId, query, force }),
|
|
});
|
|
}
|
|
|
|
export async function rememberProjectContext(
|
|
sessionId: string,
|
|
content: string,
|
|
title: string,
|
|
): Promise<void> {
|
|
await apiFetch('/agent/harness_remember', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sessionId, content, title }),
|
|
});
|
|
}
|
|
|
|
export type WechatAuthConfig = {
|
|
enabled: boolean;
|
|
inWechat?: boolean;
|
|
scanEnabled?: boolean;
|
|
openScanEnabled?: boolean;
|
|
openAppId?: string;
|
|
oauthCallbackUrl?: string;
|
|
};
|
|
|
|
export type WechatPendingProfile = {
|
|
nickname: string | null;
|
|
avatarUrl: string | null;
|
|
returnTo: string;
|
|
};
|
|
|
|
export type WechatBindingStatus = {
|
|
enabled: boolean;
|
|
bound: boolean;
|
|
nickname?: string | null;
|
|
avatarUrl?: string | null;
|
|
};
|
|
|
|
export async function getWechatAuthConfig(): Promise<WechatAuthConfig> {
|
|
try {
|
|
const response = await fetch('/auth/wechat/config');
|
|
if (!response.ok) return { enabled: false };
|
|
return (await response.json()) as WechatAuthConfig;
|
|
} catch {
|
|
return { enabled: false };
|
|
}
|
|
}
|
|
|
|
export async function checkAuth(): Promise<AuthStatus> {
|
|
try {
|
|
const response = await fetch('/auth/status');
|
|
if (!response.ok) return { authenticated: false };
|
|
const status = (await response.json()) as AuthStatus;
|
|
if (status.authenticated) resetUnauthorizedGuard();
|
|
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
|
|
try {
|
|
const me = await getMe();
|
|
return {
|
|
...status,
|
|
capabilities: me.capabilities,
|
|
grantedSkills: me.grantedSkills,
|
|
user: me.user,
|
|
};
|
|
} catch {
|
|
return status;
|
|
}
|
|
}
|
|
return status;
|
|
} catch {
|
|
return { authenticated: false };
|
|
}
|
|
}
|
|
|
|
export async function getMe(): Promise<{
|
|
user: PortalUser;
|
|
capabilities?: CapabilityMap;
|
|
grantedSkills?: string[];
|
|
unrestricted?: boolean;
|
|
}> {
|
|
return portalFetch('/auth/me');
|
|
}
|
|
|
|
export async function getMyUsage(): Promise<UsageRecord[]> {
|
|
const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage');
|
|
return result.records ?? [];
|
|
}
|
|
|
|
export async function getMyBillingLedger(limit = 30): Promise<LedgerEntry[]> {
|
|
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
|
|
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`);
|
|
return result.entries ?? [];
|
|
}
|
|
|
|
export async function getBillingConfig(): Promise<BillingConfig> {
|
|
return portalFetch('/auth/billing/config');
|
|
}
|
|
|
|
export async function createRechargeOrder(input: {
|
|
amountCents: number;
|
|
payScene: 'native' | 'h5' | 'jsapi';
|
|
}): Promise<RechargeOrder> {
|
|
const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', {
|
|
method: 'POST',
|
|
body: JSON.stringify(input),
|
|
});
|
|
return result.order;
|
|
}
|
|
|
|
export async function getRechargeOrder(orderId: string): Promise<{
|
|
order: RechargeOrder;
|
|
balanceCents: number | null;
|
|
}> {
|
|
return portalFetch(`/auth/billing/recharge-orders/${orderId}`);
|
|
}
|
|
|
|
export async function getMindSpace(): Promise<MindSpace> {
|
|
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
|
|
return result.data;
|
|
}
|
|
|
|
export async function listMindSpaceCleanupItems(): Promise<{
|
|
items: MindSpaceCleanupItem[];
|
|
totalBytes: number;
|
|
}> {
|
|
const result = await apiFetch<{
|
|
data: { items: MindSpaceCleanupItem[]; totalBytes: number };
|
|
}>('/mindspace/v1/space/cleanup');
|
|
return result.data;
|
|
}
|
|
|
|
export async function runMindSpaceCleanup(itemIds: string[]): Promise<{
|
|
removedCount: number;
|
|
freedBytes: number;
|
|
quota?: MindSpaceQuota;
|
|
}> {
|
|
const result = await apiFetch<{
|
|
data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota };
|
|
}>('/mindspace/v1/space/cleanup', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ item_ids: itemIds }),
|
|
});
|
|
return result.data;
|
|
}
|
|
|
|
export async function listMindSpaceAssets(
|
|
categoryCode?: string,
|
|
): Promise<MindSpaceAsset[]> {
|
|
const query = categoryCode ? `?category_code=${encodeURIComponent(categoryCode)}` : '';
|
|
const result = await apiFetch<{ data: MindSpaceAsset[] }>(`/mindspace/v1/assets${query}`);
|
|
return result.data;
|
|
}
|
|
|
|
export async function uploadMindSpaceAsset(
|
|
categoryId: string,
|
|
file: File,
|
|
): Promise<MindSpaceAsset> {
|
|
const created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
category_id: categoryId,
|
|
filename: file.name,
|
|
size_bytes: file.size,
|
|
declared_mime_type: file.type || null,
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const contentResponse = await fetch(created.data.uploadUrl, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
body: file,
|
|
});
|
|
if (!contentResponse.ok) {
|
|
const body = (await contentResponse.json().catch(() => null)) as {
|
|
error?: { message?: string };
|
|
} | null;
|
|
throw new ApiError(
|
|
contentResponse.status,
|
|
body?.error?.message ?? '文件内容上传失败',
|
|
);
|
|
}
|
|
const completed = await apiFetch<{ data: MindSpaceAsset }>(
|
|
`/mindspace/v1/uploads/${created.data.id}/complete`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
return completed.data;
|
|
} catch (error) {
|
|
await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, {
|
|
method: 'DELETE',
|
|
}).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function deleteMindSpaceAsset(assetId: string): Promise<void> {
|
|
await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function getMindSpacePageDeletePreview(
|
|
pageId: string,
|
|
): Promise<MindSpacePageDeletePreview> {
|
|
const result = await apiFetch<{ data: MindSpacePageDeletePreview }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/delete-preview`,
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function deleteMindSpacePage(pageId: string): Promise<MindSpacePageDeleteResult> {
|
|
const result = await apiFetch<{ data: MindSpacePageDeleteResult }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
|
{ method: 'DELETE' },
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function listMindSpacePages(status?: string): Promise<MindSpacePage[]> {
|
|
const query = status ? `?status=${encodeURIComponent(status)}` : '';
|
|
const result = await apiFetch<{ data: MindSpacePage[] }>(`/mindspace/v1/pages${query}`);
|
|
return result.data;
|
|
}
|
|
|
|
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
|
|
const result = await apiFetch<{ data: MindSpacePage }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function bindMindSpacePageLiveEdit(
|
|
pageId: string,
|
|
sessionId: string,
|
|
options?: { parentSessionId?: string },
|
|
): Promise<{ sessionId: string; pageId: string; parentSessionId?: string }> {
|
|
const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId?: string } }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/bind`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
...(options?.parentSessionId ? { parent_session_id: options.parentSessionId } : {}),
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function forkMindSpacePageEditSession(
|
|
pageId: string,
|
|
parentSessionId: string,
|
|
h5ApiBase?: string | null,
|
|
): Promise<{ sessionId: string; pageId: string; parentSessionId: string }> {
|
|
const result = await apiFetch<{
|
|
data: { sessionId: string; pageId: string; parentSessionId: string };
|
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/fork-session`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
parent_session_id: parentSessionId,
|
|
...(h5ApiBase ? { h5_api_base: h5ApiBase } : {}),
|
|
}),
|
|
});
|
|
return result.data;
|
|
}
|
|
|
|
export async function closeMindSpacePageEditSession(
|
|
pageId: string,
|
|
input: {
|
|
sessionId: string;
|
|
parentSessionId?: string | null;
|
|
summary?: string;
|
|
},
|
|
): Promise<{ sessionId: string; pageId: string; merged: boolean }> {
|
|
const result = await apiFetch<{
|
|
data: { sessionId: string; pageId: string; merged: boolean };
|
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/close-session`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: input.sessionId,
|
|
parent_session_id: input.parentSessionId ?? undefined,
|
|
summary: input.summary ?? '',
|
|
}),
|
|
});
|
|
return result.data;
|
|
}
|
|
|
|
export async function getMindSpacePageLiveRevision(pageId: string): Promise<{
|
|
pageId: string;
|
|
versionNo: number;
|
|
updatedAt: number;
|
|
liveRevision: number;
|
|
}> {
|
|
const result = await apiFetch<{
|
|
data: { pageId: string; versionNo: number; updatedAt: number; liveRevision: number };
|
|
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/revision`);
|
|
return result.data;
|
|
}
|
|
|
|
export async function analyzeChatSave(input: {
|
|
sessionId: string;
|
|
messageId: string;
|
|
selectedLinkIndex?: number;
|
|
previewTitle?: string;
|
|
previewSummary?: string;
|
|
}): Promise<ChatSaveAnalysis> {
|
|
const result = await apiFetch<{ data: ChatSaveAnalysis }>(
|
|
'/mindspace/v1/pages/analyze-chat-save',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: input.sessionId,
|
|
message_id: input.messageId,
|
|
selected_link_index: input.selectedLinkIndex ?? 0,
|
|
preview_title: input.previewTitle,
|
|
preview_summary: input.previewSummary,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export function buildChatSavePreviewFrameUrl(input: {
|
|
sessionId: string;
|
|
messageId: string;
|
|
selectedLinkIndex?: number;
|
|
}) {
|
|
const params = new URLSearchParams({
|
|
session_id: input.sessionId,
|
|
message_id: input.messageId,
|
|
selected_link_index: String(input.selectedLinkIndex ?? 0),
|
|
});
|
|
return `/api/mindspace/v1/pages/chat-save-preview?${params.toString()}`;
|
|
}
|
|
|
|
export function buildChatSaveThumbnailUrl(input: {
|
|
sessionId: string;
|
|
messageId: string;
|
|
selectedLinkIndex?: number;
|
|
previewTitle?: string;
|
|
previewSummary?: string;
|
|
cacheKey?: number | string;
|
|
}) {
|
|
const params = new URLSearchParams({
|
|
session_id: input.sessionId,
|
|
message_id: input.messageId,
|
|
selected_link_index: String(input.selectedLinkIndex ?? 0),
|
|
});
|
|
const title = input.previewTitle?.trim();
|
|
const summary = input.previewSummary?.trim();
|
|
if (title) params.set('preview_title', title);
|
|
if (summary) params.set('preview_summary', summary);
|
|
if (input.cacheKey != null) params.set('v', String(input.cacheKey));
|
|
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
|
|
}
|
|
|
|
export async function fetchPreviewAsset(path: string): Promise<string> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, { credentials: 'same-origin' });
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
if (res.status === 401) {
|
|
notifyUnauthorized();
|
|
throw new ApiError(401, '未授权,请重新登录');
|
|
}
|
|
if (!res.ok) {
|
|
const parsed = await parseErrorResponse(res);
|
|
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
|
}
|
|
const blob = await res.blob();
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
export async function saveChatMessageAsPage(input: {
|
|
sessionId: string;
|
|
messageId: string;
|
|
title: string;
|
|
summary?: string;
|
|
templateId?: string;
|
|
categoryCode?: MindSpaceSaveCategory;
|
|
selectedLinkIndex?: number;
|
|
acknowledgedFindingIds?: string[];
|
|
}): Promise<ChatSaveResult> {
|
|
const result = await apiFetch<{ data: ChatSaveResult }>(
|
|
'/mindspace/v1/pages/save-from-chat',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: input.sessionId,
|
|
message_id: input.messageId,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
template_id: input.templateId ?? 'editorial',
|
|
category_code: input.categoryCode ?? 'draft',
|
|
selected_link_index: input.selectedLinkIndex ?? 0,
|
|
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function createMindSpacePageFromAsset(input: {
|
|
assetId: string;
|
|
title?: string;
|
|
summary?: string;
|
|
}): Promise<MindSpacePage> {
|
|
const result = await apiFetch<{ data: { page: MindSpacePage } }>(
|
|
'/mindspace/v1/pages/from-asset',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
asset_id: input.assetId,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
}),
|
|
},
|
|
);
|
|
return result.data.page;
|
|
}
|
|
|
|
export async function createMindSpacePage(input: {
|
|
title: string;
|
|
summary?: string;
|
|
content: string;
|
|
templateId: string;
|
|
}): Promise<MindSpacePage> {
|
|
const result = await apiFetch<{ data: MindSpacePage }>('/mindspace/v1/pages', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: input.title,
|
|
summary: input.summary,
|
|
content: input.content,
|
|
template_id: input.templateId,
|
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
|
}),
|
|
});
|
|
return result.data;
|
|
}
|
|
|
|
export async function updateMindSpacePage(
|
|
pageId: string,
|
|
input: {
|
|
expectedVersion: number;
|
|
title: string;
|
|
summary: string;
|
|
content: string;
|
|
templateId: string;
|
|
changeNote?: string;
|
|
},
|
|
): Promise<MindSpacePage> {
|
|
const result = await apiFetch<{ data: MindSpacePage }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
expected_version: input.expectedVersion,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
content: input.content,
|
|
template_id: input.templateId,
|
|
page_type: input.templateId === 'report' ? 'report' : 'article',
|
|
change_note: input.changeNote,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function fetchMindSpacePageDraftPreview(
|
|
pageId: string,
|
|
input: {
|
|
title: string;
|
|
summary: string;
|
|
content: string;
|
|
templateId: string;
|
|
},
|
|
): Promise<string> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`/api/mindspace/v1/pages/${encodeURIComponent(pageId)}/preview-draft`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: input.title,
|
|
summary: input.summary,
|
|
content: input.content,
|
|
template_id: input.templateId,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
if (res.status === 401) {
|
|
notifyUnauthorized();
|
|
throw new ApiError(401, '未授权,请重新登录');
|
|
}
|
|
if (!res.ok) {
|
|
const parsed = await parseErrorResponse(res);
|
|
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
|
}
|
|
return res.text();
|
|
}
|
|
|
|
export function openMindSpaceDraftPreviewWindow(html: string) {
|
|
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
|
if (!opened) {
|
|
URL.revokeObjectURL(url);
|
|
throw new ApiError(0, '无法打开新窗口,请检查浏览器是否拦截弹窗');
|
|
}
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 120_000);
|
|
}
|
|
|
|
export async function uploadMindSpacePageThumbnail(
|
|
pageId: string,
|
|
input: {
|
|
imageBase64: string;
|
|
mimeType?: string;
|
|
title?: string;
|
|
summary?: string;
|
|
content?: string;
|
|
},
|
|
): Promise<{ updatedAt: number }> {
|
|
const result = await apiFetch<{ data: { updatedAt: number } }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/upload`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
image_base64: input.imageBase64,
|
|
mime_type: input.mimeType,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
html: input.content,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function regenerateMindSpacePageThumbnail(
|
|
pageId: string,
|
|
input: {
|
|
title?: string;
|
|
summary?: string;
|
|
content?: string;
|
|
useAi?: boolean;
|
|
instruction?: string;
|
|
} = {},
|
|
): Promise<{ updatedAt: number; content?: string | null }> {
|
|
const result = await apiFetch<{ data: { updatedAt: number; content?: string | null } }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/regenerate`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: input.title,
|
|
summary: input.summary,
|
|
html: input.content,
|
|
use_ai: input.useAi ?? false,
|
|
instruction: input.instruction,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function checkMindSpacePagePublication(
|
|
pageId: string,
|
|
input: {
|
|
pageVersionId: string;
|
|
accessMode: MindSpacePublishCheck['accessMode'];
|
|
urlSlug: string;
|
|
password?: string;
|
|
expiresAt?: number | null;
|
|
},
|
|
): Promise<MindSpacePublishCheck> {
|
|
const result = await apiFetch<{ data: MindSpacePublishCheck }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-check`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
page_version_id: input.pageVersionId,
|
|
access_mode: input.accessMode,
|
|
url_slug: input.urlSlug,
|
|
password: input.password,
|
|
expires_at: input.expiresAt,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function publishMindSpacePage(
|
|
pageId: string,
|
|
input: {
|
|
pageVersionId: string;
|
|
accessMode: MindSpacePublishCheck['accessMode'];
|
|
urlSlug: string;
|
|
acknowledgedFindingIds: string[];
|
|
password?: string;
|
|
expiresAt?: number | null;
|
|
},
|
|
): Promise<MindSpacePublication> {
|
|
const result = await apiFetch<{ data: MindSpacePublication }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
page_version_id: input.pageVersionId,
|
|
access_mode: input.accessMode,
|
|
url_slug: input.urlSlug,
|
|
password: input.password,
|
|
expires_at: input.expiresAt,
|
|
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function redactMindSpacePage(
|
|
pageId: string,
|
|
input: {
|
|
pageVersionId: string;
|
|
expectedVersion: number;
|
|
title: string;
|
|
summary: string;
|
|
content: string;
|
|
},
|
|
): Promise<MindSpaceRedactedCopyResult> {
|
|
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
|
|
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/redact`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
page_version_id: input.pageVersionId,
|
|
expected_version: input.expectedVersion,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
content: input.content,
|
|
}),
|
|
},
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
/** @deprecated use redactMindSpacePage */
|
|
export async function createMindSpaceRedactedCopy(
|
|
pageId: string,
|
|
pageVersionId: string,
|
|
): Promise<MindSpaceRedactedCopyResult> {
|
|
const page = await getMindSpacePage(pageId);
|
|
return redactMindSpacePage(pageId, {
|
|
pageVersionId,
|
|
expectedVersion: page.versionNo,
|
|
title: page.title,
|
|
summary: page.summary,
|
|
content: page.content ?? '',
|
|
});
|
|
}
|
|
|
|
export async function offlineMindSpacePublication(publicationId: string): Promise<void> {
|
|
await apiFetch(
|
|
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/offline`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
}
|
|
|
|
export async function getMindSpacePublicationStats(
|
|
publicationId: string,
|
|
): Promise<MindSpacePublicationStats> {
|
|
const result = await apiFetch<{ data: MindSpacePublicationStats }>(
|
|
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/stats`,
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function listPlazaCategories(): Promise<PlazaCategory[]> {
|
|
const result = await apiFetch<{ data: { categories: PlazaCategory[] } }>('/plaza/v1/categories');
|
|
return result.data.categories;
|
|
}
|
|
|
|
export async function publishPageToPlaza(input: {
|
|
publicationId: string;
|
|
categoryId: string;
|
|
tags?: string[];
|
|
coverUrl?: string;
|
|
allowComment?: boolean;
|
|
}): Promise<PlazaPostBrief> {
|
|
const result = await apiFetch<{ data: { post: PlazaPostBrief } }>('/plaza/v1/posts', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
publication_id: input.publicationId,
|
|
category_id: input.categoryId,
|
|
tags: input.tags ?? [],
|
|
cover_url: input.coverUrl ?? '',
|
|
allow_comment: input.allowComment ?? true,
|
|
}),
|
|
});
|
|
return result.data.post;
|
|
}
|
|
|
|
export async function createMindSpaceAgentJob(input: {
|
|
jobType: string;
|
|
instruction: string;
|
|
allowedAssetIds: string[];
|
|
outputType?: 'page_draft' | 'html_page' | 'markdown';
|
|
outputCategoryId?: string;
|
|
idempotencyKey?: string;
|
|
locale?: string;
|
|
timezone?: string;
|
|
capabilities?: {
|
|
network?: boolean;
|
|
shell?: boolean;
|
|
createPage?: boolean;
|
|
};
|
|
}): Promise<MindSpaceAgentJob> {
|
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>('/mindspace/v1/agent/jobs', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
job_type: input.jobType,
|
|
instruction: input.instruction,
|
|
allowed_asset_ids: input.allowedAssetIds,
|
|
output_type: input.outputType ?? 'page_draft',
|
|
output_category_id: input.outputCategoryId,
|
|
idempotency_key: input.idempotencyKey,
|
|
locale: input.locale,
|
|
timezone: input.timezone,
|
|
capabilities: input.capabilities,
|
|
}),
|
|
});
|
|
return result.data;
|
|
}
|
|
|
|
export async function getMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}`,
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export type MindSpaceListPage = {
|
|
total?: number;
|
|
offset?: number;
|
|
limit?: number;
|
|
has_more?: boolean;
|
|
};
|
|
|
|
export async function listMindSpaceAgentJobs(options?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<{ items: MindSpaceAgentJob[]; page: MindSpaceListPage }> {
|
|
const limit = options?.limit ?? 10;
|
|
const offset = options?.offset ?? 0;
|
|
const result = await apiFetch<{ data: MindSpaceAgentJob[]; page?: MindSpaceListPage }>(
|
|
`/mindspace/v1/agent/jobs?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`,
|
|
);
|
|
return { items: result.data, page: result.page ?? {} };
|
|
}
|
|
|
|
export async function runMindSpaceAgentJob(
|
|
jobId: string,
|
|
): Promise<{ started: boolean; jobId: string }> {
|
|
const result = await apiFetch<{ data: { started: boolean; jobId: string } }>(
|
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/run`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function cancelMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/cancel`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function retryMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
|
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
|
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/retry`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
return result.data;
|
|
}
|
|
|
|
export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary> {
|
|
const result = await portalFetch<{ summary: AdminDashboardSummary }>('/admin-api/summary');
|
|
return result.summary;
|
|
}
|
|
|
|
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
|
|
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
|
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
|
|
return result.records ?? [];
|
|
}
|
|
|
|
export async function listAdminLedger(userId?: string): Promise<LedgerEntry[]> {
|
|
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
|
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/admin-api/ledger${query}`);
|
|
return result.entries ?? [];
|
|
}
|
|
|
|
export async function listCapabilityCatalog(): Promise<CapabilityDefinition[]> {
|
|
const result = await portalFetch<{ catalog: CapabilityDefinition[] }>(
|
|
'/admin-api/capabilities/catalog',
|
|
);
|
|
return result.catalog ?? [];
|
|
}
|
|
|
|
export async function getRoleCapabilities(role: 'user' = 'user'): Promise<{
|
|
role: string;
|
|
capabilities: CapabilityMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/capabilities/role/${role}`);
|
|
}
|
|
|
|
export async function updateRoleCapabilities(
|
|
role: 'user',
|
|
capabilities: CapabilityMap,
|
|
): Promise<{ role: string; capabilities: CapabilityMap }> {
|
|
return portalFetch(`/admin-api/capabilities/role/${role}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ capabilities }),
|
|
});
|
|
}
|
|
|
|
export async function getUserCapabilities(userId: string): Promise<{
|
|
userId: string;
|
|
role: string;
|
|
unrestricted: boolean;
|
|
capabilities: CapabilityMap;
|
|
overrides: CapabilityMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/capabilities`);
|
|
}
|
|
|
|
export async function updateUserCapabilities(
|
|
userId: string,
|
|
capabilities: CapabilityMap,
|
|
): Promise<{
|
|
userId: string;
|
|
capabilities: CapabilityMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/capabilities`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ capabilities }),
|
|
});
|
|
}
|
|
|
|
export async function clearUserCapabilityOverrides(userId: string): Promise<void> {
|
|
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
|
const result = await portalFetch<{ catalog: PolicyDefinition[] }>('/admin-api/policies/catalog');
|
|
return result.catalog ?? [];
|
|
}
|
|
|
|
export async function getRolePolicies(role: 'user' = 'user'): Promise<{
|
|
role: string;
|
|
policies: PolicyMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/policies/role/${role}`);
|
|
}
|
|
|
|
export async function updateRolePolicies(
|
|
role: 'user',
|
|
policies: PolicyMap,
|
|
): Promise<{ role: string; policies: PolicyMap }> {
|
|
return portalFetch(`/admin-api/policies/role/${role}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ policies }),
|
|
});
|
|
}
|
|
|
|
export async function getUserPolicies(userId: string): Promise<{
|
|
userId: string;
|
|
role: string;
|
|
unrestricted: boolean;
|
|
policies: PolicyMap;
|
|
overrides: PolicyMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/policies`);
|
|
}
|
|
|
|
export async function updateUserPolicies(
|
|
userId: string,
|
|
policies: PolicyMap,
|
|
): Promise<{
|
|
userId: string;
|
|
policies: PolicyMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/policies`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ policies }),
|
|
});
|
|
}
|
|
|
|
export async function clearUserPolicyOverrides(userId: string): Promise<void> {
|
|
await portalFetch(`/admin-api/users/${userId}/policies`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function listSkillCatalog(): Promise<SkillDefinition[]> {
|
|
const result = await portalFetch<{ catalog: SkillDefinition[] }>('/admin-api/skills/catalog');
|
|
return result.catalog ?? [];
|
|
}
|
|
|
|
export async function getRoleSkills(role: 'user' = 'user'): Promise<{
|
|
role: string;
|
|
skills: SkillMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/skills/role/${role}`);
|
|
}
|
|
|
|
export async function updateRoleSkills(
|
|
role: 'user',
|
|
skills: SkillMap,
|
|
): Promise<{ role: string; skills: SkillMap }> {
|
|
return portalFetch(`/admin-api/skills/role/${role}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ skills }),
|
|
});
|
|
}
|
|
|
|
export async function getUserSkills(userId: string): Promise<{
|
|
userId: string;
|
|
role: string;
|
|
skills: SkillMap;
|
|
grantedSkills: string[];
|
|
overrides: SkillMap;
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/skills`);
|
|
}
|
|
|
|
export async function updateUserSkills(
|
|
userId: string,
|
|
skills: SkillMap,
|
|
): Promise<{
|
|
userId: string;
|
|
skills: SkillMap;
|
|
grantedSkills: string[];
|
|
}> {
|
|
return portalFetch(`/admin-api/users/${userId}/skills`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ skills }),
|
|
});
|
|
}
|
|
|
|
export async function clearUserSkillOverrides(userId: string): Promise<void> {
|
|
await portalFetch(`/admin-api/users/${userId}/skills`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function login(username: string, password: string): Promise<PortalUser | null> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
message?: string;
|
|
user?: PortalUser;
|
|
mode?: string;
|
|
} | null;
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body?.message ?? '登录失败');
|
|
}
|
|
resetUnauthorizedGuard();
|
|
if (body?.user) {
|
|
try {
|
|
const me = await getMe();
|
|
return me.user;
|
|
} catch {
|
|
return body.user;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function loginLegacy(password: string): Promise<void> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
|
throw new ApiError(response.status, body?.message ?? '登录失败');
|
|
}
|
|
}
|
|
|
|
export async function resetPassword(
|
|
username: string,
|
|
email: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/reset-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, email, password }),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body?.message ?? '重置失败');
|
|
}
|
|
}
|
|
|
|
export async function getWechatPendingProfile(token: string): Promise<WechatPendingProfile> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/auth/wechat/pending/${encodeURIComponent(token)}`);
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as WechatPendingProfile & {
|
|
message?: string;
|
|
};
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body?.message ?? '绑定会话无效');
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export async function completeWechatRegister(pendingToken: string): Promise<{
|
|
user: PortalUser;
|
|
returnTo: string;
|
|
}> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/wechat/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pendingToken }),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
message?: string;
|
|
user?: PortalUser;
|
|
returnTo?: string;
|
|
};
|
|
if (!response.ok || !body?.user) {
|
|
throw new ApiError(response.status, body?.message ?? '微信注册失败');
|
|
}
|
|
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
|
}
|
|
|
|
export async function completeWechatBind(
|
|
pendingToken: string,
|
|
username: string,
|
|
password: string,
|
|
): Promise<{ user: PortalUser; returnTo: string }> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/wechat/bind', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pendingToken, username, password }),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
message?: string;
|
|
user?: PortalUser;
|
|
returnTo?: string;
|
|
};
|
|
if (!response.ok || !body?.user) {
|
|
throw new ApiError(response.status, body?.message ?? '绑定失败');
|
|
}
|
|
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
|
}
|
|
|
|
export async function getWechatBindingStatus(): Promise<WechatBindingStatus> {
|
|
try {
|
|
const response = await fetch('/auth/wechat/status');
|
|
if (!response.ok) return { enabled: false, bound: false };
|
|
return (await response.json()) as WechatBindingStatus;
|
|
} catch {
|
|
return { enabled: false, bound: false };
|
|
}
|
|
}
|
|
|
|
export type WechatScanSession =
|
|
| {
|
|
mode: 'open';
|
|
state: string;
|
|
openAppId: string;
|
|
redirectUri: string;
|
|
expiresInMs: number;
|
|
}
|
|
| {
|
|
mode: 'mp';
|
|
state: string;
|
|
qrUrl: string;
|
|
expiresInMs: number;
|
|
};
|
|
|
|
export async function startWechatScanLogin(returnTo?: string): Promise<WechatScanSession> {
|
|
const params = new URLSearchParams();
|
|
if (returnTo) params.set('return_to', returnTo);
|
|
const query = params.toString();
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/auth/wechat/scan/start${query ? `?${query}` : ''}`, {
|
|
method: 'POST',
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
message?: string;
|
|
mode?: 'open' | 'mp';
|
|
state?: string;
|
|
openAppId?: string;
|
|
redirectUri?: string;
|
|
qrUrl?: string;
|
|
expiresInMs?: number;
|
|
};
|
|
if (!response.ok || !body?.state || !body?.mode) {
|
|
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
|
}
|
|
if (body.mode === 'open') {
|
|
if (!body.openAppId || !body.redirectUri) {
|
|
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
|
}
|
|
return {
|
|
mode: 'open',
|
|
state: body.state,
|
|
openAppId: body.openAppId,
|
|
redirectUri: body.redirectUri,
|
|
expiresInMs: body.expiresInMs ?? 600000,
|
|
};
|
|
}
|
|
if (!body.qrUrl) {
|
|
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
|
}
|
|
return {
|
|
mode: 'mp',
|
|
state: body.state,
|
|
qrUrl: body.qrUrl,
|
|
expiresInMs: body.expiresInMs ?? 600000,
|
|
};
|
|
}
|
|
|
|
export async function pollWechatScanLogin(state: string): Promise<{
|
|
status: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
|
pendingToken?: string;
|
|
message?: string;
|
|
}> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/auth/wechat/scan/poll?state=${encodeURIComponent(state)}`);
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
status?: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
|
pendingToken?: string;
|
|
message?: string;
|
|
};
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body?.message ?? '扫码状态查询失败');
|
|
}
|
|
return {
|
|
status: body?.status ?? 'error',
|
|
pendingToken: body?.pendingToken,
|
|
message: body?.message,
|
|
};
|
|
}
|
|
|
|
export async function register(
|
|
username: string,
|
|
password: string,
|
|
displayName?: string,
|
|
email?: string,
|
|
attribution?: {
|
|
utm_source?: string;
|
|
utm_medium?: string;
|
|
utm_campaign?: string;
|
|
ref?: string;
|
|
},
|
|
): Promise<PortalUser> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username,
|
|
password,
|
|
displayName,
|
|
email,
|
|
...(attribution ?? {}),
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
throw new ApiError(0, formatNetworkError(err));
|
|
}
|
|
const body = (await response.json().catch(() => null)) as {
|
|
message?: string;
|
|
user?: PortalUser;
|
|
} | null;
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body?.message ?? '注册失败');
|
|
}
|
|
if (!body?.user) throw new ApiError(response.status, '注册失败');
|
|
return body.user;
|
|
}
|
|
|
|
export async function listAdminUsers(): Promise<AdminUserRow[]> {
|
|
const result = await portalFetch<{ users: AdminUserRow[] }>('/admin-api/users');
|
|
return result.users ?? [];
|
|
}
|
|
|
|
export async function createAdminUser(payload: {
|
|
username: string;
|
|
password: string;
|
|
displayName?: string;
|
|
workspaceRoot?: string;
|
|
balanceCents?: number;
|
|
role?: 'user' | 'admin';
|
|
email?: string;
|
|
}): Promise<PortalUser> {
|
|
const result = await portalFetch<{ user: PortalUser }>('/admin-api/users', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return result.user;
|
|
}
|
|
|
|
export async function updateAdminUser(
|
|
userId: string,
|
|
payload: Partial<{
|
|
displayName: string;
|
|
status: 'active' | 'suspended' | 'disabled';
|
|
workspaceRoot: string;
|
|
balanceCents: number;
|
|
role: 'user' | 'admin';
|
|
}>,
|
|
): Promise<PortalUser> {
|
|
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return result.user;
|
|
}
|
|
|
|
export async function rechargeUser(
|
|
userId: string,
|
|
amountCents: number,
|
|
note?: string,
|
|
): Promise<PortalUser> {
|
|
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}/recharge`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amountCents, note }),
|
|
});
|
|
return result.user;
|
|
}
|
|
|
|
export async function listLlmProviderCatalog(): Promise<LlmProviderDefinition[]> {
|
|
const result = await portalFetch<{ catalog: LlmProviderDefinition[] }>(
|
|
'/admin-api/llm-providers/catalog',
|
|
);
|
|
return result.catalog ?? [];
|
|
}
|
|
|
|
export async function listLlmProviderKeys(): Promise<LlmProviderKeyRow[]> {
|
|
const result = await portalFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
|
|
return result.keys ?? [];
|
|
}
|
|
|
|
export async function createLlmProviderKey(payload: {
|
|
providerId: string;
|
|
name: string;
|
|
apiKey: string;
|
|
defaultModel?: string;
|
|
apiUrl?: string;
|
|
basePath?: string;
|
|
models?: string | string[];
|
|
engine?: string;
|
|
relayProvider?: string;
|
|
}): Promise<LlmProviderKeyRow> {
|
|
const result = await portalFetch<{ key: LlmProviderKeyRow }>('/admin-api/llm-providers/keys', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return result.key;
|
|
}
|
|
|
|
export async function updateLlmProviderKey(
|
|
keyId: string,
|
|
payload: {
|
|
name?: string;
|
|
apiKey?: string;
|
|
defaultModel?: string;
|
|
status?: 'active' | 'disabled';
|
|
apiUrl?: string;
|
|
basePath?: string;
|
|
models?: string | string[];
|
|
engine?: string;
|
|
relayProvider?: string;
|
|
},
|
|
): Promise<LlmProviderKeyRow> {
|
|
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
|
`/admin-api/llm-providers/keys/${keyId}`,
|
|
{
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
},
|
|
);
|
|
return result.key;
|
|
}
|
|
|
|
export async function selectLlmProviderKey(keyId: string): Promise<LlmProviderKeyRow> {
|
|
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
|
`/admin-api/llm-providers/keys/${keyId}/select`,
|
|
{ method: 'POST' },
|
|
);
|
|
return result.key;
|
|
}
|
|
|
|
export async function deleteLlmProviderKey(keyId: string): Promise<void> {
|
|
await portalFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function syncLlmProviderToGoosed(): Promise<{ synced: boolean }> {
|
|
return portalFetch('/admin-api/llm-providers/sync', { method: 'POST' });
|
|
}
|
|
|
|
export async function getLlmGlobalSettings(): Promise<LlmGlobalSettings> {
|
|
const result = await portalFetch<{ global: LlmGlobalSettings }>('/admin-api/llm-providers/global');
|
|
return result.global;
|
|
}
|
|
|
|
export async function setLlmGlobalModel(model: string): Promise<{ global: LlmGlobalSettings }> {
|
|
return portalFetch('/admin-api/llm-providers/global', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ model }),
|
|
});
|
|
}
|
|
|
|
export async function testLlmProviderDraft(payload: {
|
|
providerId: string;
|
|
name: string;
|
|
apiKey: string;
|
|
apiUrl?: string;
|
|
basePath?: string;
|
|
models?: string | string[];
|
|
defaultModel?: string;
|
|
testModel?: string;
|
|
engine?: string;
|
|
relayProvider?: string;
|
|
}): Promise<LlmConnectionTestResult> {
|
|
return portalFetch('/admin-api/llm-providers/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function testLlmProviderKey(
|
|
keyId: string,
|
|
model?: string,
|
|
): Promise<LlmConnectionTestResult> {
|
|
return portalFetch(`/admin-api/llm-providers/keys/${keyId}/test`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(model ? { model } : {}),
|
|
});
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
if (import.meta.env.DEV) return;
|
|
await fetch('/auth/logout', { method: 'POST' });
|
|
}
|
|
|
|
export async function resumeSession(
|
|
sessionId: string,
|
|
options?: { skipReconcile?: boolean },
|
|
): Promise<Session> {
|
|
const result = await apiFetch<{ session: Session }>('/agent/resume', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
load_model_and_extensions: true,
|
|
...(options?.skipReconcile ? { skip_reconcile: true } : {}),
|
|
}),
|
|
});
|
|
return result.session;
|
|
}
|
|
|
|
export async function readConfig(key: string): Promise<string | null> {
|
|
return apiFetch<string | null>('/config/read', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ key, is_secret: false }),
|
|
});
|
|
}
|
|
|
|
export async function updateProvider(
|
|
sessionId: string,
|
|
provider: string,
|
|
model: string,
|
|
): Promise<void> {
|
|
await apiFetch('/agent/update_provider', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
provider,
|
|
model,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function applyLocalLlmFallback(sessionId: string): Promise<{
|
|
ok: boolean;
|
|
providerId?: string;
|
|
model?: string;
|
|
source?: string;
|
|
message?: string;
|
|
}> {
|
|
return apiFetch('/llm/apply-local-fallback', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ session_id: sessionId }),
|
|
});
|
|
}
|
|
|
|
export async function listSessions(): Promise<Session[]> {
|
|
const result = await apiFetch<SessionListResponse>('/sessions');
|
|
return result.sessions ?? [];
|
|
}
|
|
|
|
export async function getSession(sessionId: string): Promise<Session> {
|
|
return apiFetch<Session>(`/sessions/${sessionId}`);
|
|
}
|
|
|
|
export async function deleteChatSession(sessionId: string): Promise<void> {
|
|
await apiFetch(`/sessions/${sessionId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function loadSessionDetail(sessionId: string): Promise<{
|
|
session: Session;
|
|
messages: Message[];
|
|
}> {
|
|
const detail = await getSession(sessionId);
|
|
const messages = (detail.conversation ?? []).filter((m) => m.metadata?.userVisible);
|
|
return { session: detail, messages };
|
|
}
|
|
|
|
export async function sendReply(
|
|
sessionId: string,
|
|
requestId: string,
|
|
userMessage: Message,
|
|
): Promise<void> {
|
|
await apiFetch(`/sessions/${sessionId}/reply`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
request_id: requestId,
|
|
user_message: userMessage,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export async function cancelRequest(sessionId: string, requestId: string): Promise<void> {
|
|
await apiFetch(`/sessions/${sessionId}/cancel`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ request_id: requestId }),
|
|
});
|
|
}
|
|
|
|
export async function confirmTool(
|
|
sessionId: string,
|
|
id: string,
|
|
action: 'allow_once' | 'deny_once' | 'always_allow',
|
|
): Promise<void> {
|
|
await apiFetch('/action-required/tool-confirmation', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
id,
|
|
action,
|
|
principalType: 'Tool',
|
|
}),
|
|
});
|
|
}
|
|
|
|
type SubscribeOptions = {
|
|
pauseWhenHidden?: boolean;
|
|
onBalance?: (update: BalanceUpdate) => void;
|
|
};
|
|
|
|
export function subscribeSessionEvents(
|
|
sessionId: string,
|
|
onEvent: (event: SessionEvent) => void,
|
|
onError: (error: Error) => void,
|
|
options: SubscribeOptions = {},
|
|
): () => void {
|
|
const { pauseWhenHidden = true, onBalance } = options;
|
|
let lastEventId: string | undefined;
|
|
let closed = false;
|
|
let paused = false;
|
|
let activeController: AbortController | null = null;
|
|
|
|
const run = async (controller: AbortController) => {
|
|
while (!closed && !controller.signal.aborted && !paused) {
|
|
try {
|
|
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
|
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
|
|
|
|
const res = await fetch(`${API}/sessions/${sessionId}/events`, {
|
|
headers,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
notifyUnauthorized();
|
|
throw new ApiError(401, 'SSE 未授权');
|
|
}
|
|
|
|
if (!res.ok || !res.body) {
|
|
throw new ApiError(res.status, `SSE failed: ${res.status}`);
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (!closed && !controller.signal.aborted && !paused) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const chunks = buffer.split('\n\n');
|
|
buffer = chunks.pop() ?? '';
|
|
|
|
for (const chunk of chunks) {
|
|
let eventId: string | undefined;
|
|
let eventName: string | undefined;
|
|
let data: string | undefined;
|
|
for (const line of chunk.split('\n')) {
|
|
if (line.startsWith('id:')) eventId = line.slice(3).trim();
|
|
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
|
if (line.startsWith('data:')) data = line.slice(5).trim();
|
|
}
|
|
if (eventId) lastEventId = eventId;
|
|
if (!data) continue;
|
|
try {
|
|
if (eventName === 'balance') {
|
|
const payload = JSON.parse(data) as BalanceUpdate & { balanceCents?: number };
|
|
if (typeof payload.balanceCents === 'number') {
|
|
onBalance?.({
|
|
balanceCents: payload.balanceCents,
|
|
tokensUsed:
|
|
typeof payload.tokensUsed === 'number' ? payload.tokensUsed : undefined,
|
|
lastUsage: payload.lastUsage,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
onEvent(JSON.parse(data) as SessionEvent);
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (controller.signal.aborted || closed || paused) return;
|
|
onError(err instanceof Error ? err : new Error(String(err)));
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
}
|
|
}
|
|
};
|
|
|
|
const startConnection = () => {
|
|
activeController?.abort();
|
|
activeController = new AbortController();
|
|
void run(activeController);
|
|
};
|
|
|
|
const handleVisibility = () => {
|
|
if (!pauseWhenHidden) return;
|
|
if (document.hidden) {
|
|
paused = true;
|
|
activeController?.abort();
|
|
} else if (paused) {
|
|
paused = false;
|
|
startConnection();
|
|
}
|
|
};
|
|
|
|
if (pauseWhenHidden) {
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
}
|
|
|
|
startConnection();
|
|
return () => {
|
|
closed = true;
|
|
activeController?.abort();
|
|
if (pauseWhenHidden) {
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
}
|
|
};
|
|
}
|