Files
memind/src/api/client.ts
T
2026-07-02 21:33:31 +08:00

2581 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
MindSpaceConversationPackage,
MindSpacePage,
MindSpacePageDeletePreview,
MindSpacePageDeleteResult,
MindSpaceQuota,
MindSpaceScheduleReminder,
MindSpaceSaveCategory,
MindSpacePublication,
MindSpacePublicationStats,
MindSpacePublishCheck,
PlazaCategory,
PlazaPostBrief,
MindSpaceRedactedCopyResult,
MindSpaceUpload,
FeedbackContextInput,
FeedbackImageInput,
FeedbackSubmission,
FeedbackSubmissionType,
FeedbackBoardPage,
PortalUser,
Session,
SessionConversationPage,
SessionListPage,
SessionSummary,
SessionEvent,
SessionListResponse,
UsageRecord,
BalanceUpdate,
UserNotification,
PlanDefinition,
ActiveSubscription,
UserMemorySyncResponse,
} from '../types';
import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
const API = '/api';
const DEFAULT_API_TIMEOUT_MS = 20_000;
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
const AGENT_RUNS_PATH = '/agent/runs';
export type AgentRun = {
id: string;
userId: string;
sessionId: string | null;
requestId: string;
status: 'queued' | 'running' | 'retryable' | 'succeeded' | 'failed';
attempts: number;
error: string | null;
createdAt: number;
updatedAt: number;
startedAt: number | null;
completedAt: number | null;
};
function appendAgentRunValidationInstruction(message: Message, instruction?: string | null): Message {
const normalizedInstruction = String(instruction ?? '').trim();
if (!normalizedInstruction) return message;
return {
...message,
content: [
...message.content,
{ type: 'text', text: normalizedInstruction },
],
};
}
function withAgentRunValidationMetadata(
message: Message,
validation?: AgentRunValidation | null,
): Message {
if (!validation) return message;
const metadata = message.metadata as Message['metadata'] & {
memindRun?: Record<string, unknown>;
};
return {
...message,
metadata: {
...message.metadata,
memindRun: {
...(metadata.memindRun && typeof metadata.memindRun === 'object' ? metadata.memindRun : {}),
validation,
},
} as Message['metadata'],
};
}
function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message {
const normalized = normalizeUserMessageForApi(message);
return appendAgentRunValidationInstruction(
withAgentRunValidationMetadata(normalized, options.validation),
options.toolMode === 'code' ? options.validationInstruction : null,
);
}
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(sanitizeUserFacingErrorMessage(message));
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
}
function sanitizeUserFacingErrorMessage(message: string) {
const normalized = String(message ?? '').trim();
if (!normalized) return normalized;
if (!/goose|goosed/i.test(normalized)) return normalized;
if (/超时|timeout/i.test(normalized)) {
return '后端连接超时,请确认后端服务正常后重试';
}
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
return '后端连接失败,请稍后重试';
}
return normalized
.replace(/\bgoosed\b/gi, '后端服务')
.replace(/\bgoose\b/gi, '后端');
}
async function parseErrorResponse(res: Response): Promise<{
message: string;
code?: string;
details?: InsufficientBalanceDetails | Record<string, unknown>;
}> {
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: sanitizeUserFacingErrorMessage(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: sanitizeUserFacingErrorMessage(message),
code,
details: details && typeof details === 'object'
? (details as InsufficientBalanceDetails | Record<string, unknown>)
: undefined,
};
} catch {
return { message: sanitizeUserFacingErrorMessage(text || res.statusText) };
}
}
let unauthorizedHandler: (() => void) | null = null;
let unauthorizedHandling = false;
export function setUnauthorizedHandler(handler: (() => void) | null) {
unauthorizedHandler = handler;
if (handler) unauthorizedHandling = false;
}
export function resetUnauthorizedGuard() {
unauthorizedHandling = false;
}
function notifyUnauthorized() {
if (!unauthorizedHandler || unauthorizedHandling) return;
unauthorizedHandling = true;
unauthorizedHandler();
}
async function fetchWithTimeout(
input: RequestInfo | URL,
init?: RequestInit,
timeoutMs = DEFAULT_API_TIMEOUT_MS,
): Promise<Response> {
const controller = new AbortController();
const upstreamSignal = init?.signal;
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
const abortFromUpstream = () => controller.abort();
if (upstreamSignal) {
if (upstreamSignal.aborted) controller.abort();
else upstreamSignal.addEventListener('abort', abortFromUpstream, { once: true });
}
try {
return await fetch(input, {
...init,
signal: controller.signal,
});
} finally {
window.clearTimeout(timeout);
upstreamSignal?.removeEventListener('abort', abortFromUpstream);
}
}
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetchWithTimeout(path, {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
} catch (err) {
throw new ApiError(0, formatNetworkError(err));
}
if (res.status === 401) {
notifyUnauthorized();
const text = await res.text().catch(() => '');
throw new ApiError(401, text || '未授权,请重新登录');
}
if (!res.ok) {
const parsed = await parseErrorResponse(res);
throw new ApiError(
res.status,
parsed.message || `${res.status} ${res.statusText}`,
parsed.code,
parsed.details,
);
}
if (res.status === 204) return undefined as T;
const text = await res.text().catch(() => '');
if (text.trimStart().startsWith('<')) {
throw new ApiError(
res.status,
`接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs`,
);
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(res.status, '服务器响应格式错误');
}
}
function formatNetworkError(err: unknown) {
const message = err instanceof Error ? err.message : '网络请求失败';
if (err instanceof DOMException && err.name === 'AbortError') {
return '后端连接超时,请确认后端服务正常后重试';
}
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs';
}
return sanitizeUserFacingErrorMessage(message);
}
function sanitizeSessionEvent(event: SessionEvent): SessionEvent {
if (event.type !== 'Error') return event;
return { ...event, error: sanitizeUserFacingErrorMessage(event.error) };
}
async function apiFetch<T>(
path: string,
init?: RequestInit,
options?: { timeoutMs?: number },
): Promise<T> {
let res: Response;
try {
res = await fetchWithTimeout(
`${API}${path}`,
{
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
},
options?.timeoutMs,
);
} catch (err) {
throw new ApiError(0, formatNetworkError(err));
}
if (res.status === 401) {
notifyUnauthorized();
const text = await res.text().catch(() => '');
throw new ApiError(401, text || '未授权,请重新登录');
}
if (!res.ok) {
const parsed = await parseErrorResponse(res);
throw new ApiError(
res.status,
parsed.message || `${res.status} ${res.statusText}`,
parsed.code,
parsed.details,
);
}
if (res.status === 204) return undefined as T;
const rawText = await res.text().catch(() => '');
if (rawText.trimStart().startsWith('<')) {
throw new ApiError(
res.status,
`接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs`,
);
}
try {
return JSON.parse(rawText) as T;
} catch {
throw new ApiError(res.status, '服务器响应格式错误');
}
}
export async function startSession(): Promise<Session> {
return apiFetch<Session>(
'/agent/start',
{
method: 'POST',
body: JSON.stringify({}),
},
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
);
}
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 async function rememberUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/remember-recent', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
export async function syncUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/sync', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
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 type WechatAgentRouteStatus = {
enabled: boolean;
bound: boolean;
appId: string | null;
openid: string | null;
agentSessionId: string | null;
routeStatus: string | null;
updatedAt: number | null;
};
export type WechatJsSdkSignature = {
appId: string;
timestamp: number;
nonceStr: string;
signature: string;
url: string;
jsApiList: string[];
};
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 getWechatJsSdkSignature(url: string): Promise<WechatJsSdkSignature> {
let response: Response;
try {
response = await fetch(`/auth/wechat/js-sdk-signature?url=${encodeURIComponent(url)}`);
} catch (err) {
throw new ApiError(0, formatNetworkError(err));
}
const body = (await response.json().catch(() => null)) as
| (WechatJsSdkSignature & { message?: string })
| null;
if (!response.ok || !body?.signature) {
throw new ApiError(response.status, body?.message ?? '微信语音初始化失败');
}
return body;
}
export async function checkAuth(): Promise<AuthStatus> {
try {
const response = await fetch('/auth/status');
const status = (await response.json()) as AuthStatus;
if (!response.ok) {
if (status.mode === 'unavailable') return status;
return { authenticated: false };
}
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 submitFeedback(input: {
type: FeedbackSubmissionType;
title: string;
description: string;
contact?: string;
images?: FeedbackImageInput[];
context?: FeedbackContextInput;
}): Promise<FeedbackSubmission> {
const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: input.type,
title: input.title,
description: input.description,
contact: input.contact,
images: input.images,
context: input.context,
}),
});
return result.feedback;
}
export async function getMyFeedback(limit = 20): Promise<FeedbackSubmission[]> {
const result = await portalFetch<{ items: FeedbackSubmission[] }>(
`/auth/feedback?limit=${encodeURIComponent(String(limit))}`,
);
return result.items ?? [];
}
export async function listFeedbackBoard(page = 1, limit = 10): Promise<FeedbackBoardPage> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
return portalFetch<FeedbackBoardPage>(`/auth/feedback/board?${params.toString()}`);
}
export async function getFeedbackDetail(feedbackId: string): Promise<{
item: FeedbackSubmission;
isMine: boolean;
}> {
return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>(
`/auth/feedback/${encodeURIComponent(feedbackId)}`,
);
}
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 getAvailablePlans(): Promise<{
plans: PlanDefinition[];
subscription: ActiveSubscription | null;
balanceCents: number;
}> {
return portalFetch('/auth/billing/plans');
}
export async function purchaseSubscription(planType: string, autoRenew = false): Promise<{
subscription: ActiveSubscription;
balanceCents: number;
}> {
return portalFetch('/auth/billing/subscribe', {
method: 'POST',
body: JSON.stringify({ planType, autoRenew }),
});
}
export async function setAutoRenew(enabled: boolean): Promise<{ ok: boolean; updated: boolean }> {
return portalFetch('/auth/billing/auto-renew', {
method: 'POST',
body: JSON.stringify({ enabled }),
});
}
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 purchaseSpaceQuota(sizeMb: number): Promise<{
quota: MindSpaceQuota;
balanceCents: number;
purchasedMb: number;
costCents: number;
}> {
return portalFetch('/auth/billing/space-purchase', {
method: 'POST',
body: JSON.stringify({ sizeMb }),
});
}
export async function getMindSpace(): Promise<MindSpace> {
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
return result.data;
}
export async function getMindSpaceConversationPackage(
sessionId: string,
): Promise<MindSpaceConversationPackage> {
const result = await apiFetch<{ data: MindSpaceConversationPackage }>(
`/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}`,
);
return result.data;
}
export async function ignoreMindSpaceScheduleReminder(reminderId: string) {
const result = await apiFetch<{ data: MindSpaceScheduleReminder }>(
`/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`,
{ method: 'POST' },
);
return result.data;
}
export async function deleteMindSpaceScheduleReminders(ids: string[]) {
const result = await apiFetch<{ data: { deleted: number } }>(
'/mindspace/v1/schedule/reminders/bulk-delete',
{
method: 'POST',
body: JSON.stringify({ ids }),
},
);
return result.data;
}
export async function listMindSpaceCleanupItems(): Promise<{
items: MindSpaceCleanupItem[];
totalBytes: number;
}> {
const result = await apiFetch<{
data: { items: MindSpaceCleanupItem[]; totalBytes: number };
}>('/mindspace/v1/space/cleanup');
return result.data;
}
export async function runMindSpaceCleanup(itemIds: string[]): Promise<{
removedCount: number;
freedBytes: number;
quota?: MindSpaceQuota;
}> {
const result = await apiFetch<{
data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota };
}>('/mindspace/v1/space/cleanup', {
method: 'POST',
body: JSON.stringify({ item_ids: itemIds }),
});
return result.data;
}
export async function listMindSpaceAssets(
categoryCode?: string,
): Promise<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,
options: {
maxImageBytes?: number;
onProgress?: (progress: number) => void;
sessionId?: string | null;
messageId?: string | null;
} = {},
): Promise<MindSpaceAsset> {
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
if (file.type.startsWith('image/') && file.size > maxImageBytes) {
throw new ApiError(
413,
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`,
);
}
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,
...(options.sessionId ? { session_id: options.sessionId } : {}),
...(options.messageId ? { message_id: options.messageId } : {}),
}),
});
try {
await uploadFileContent(created.data.uploadUrl, file, options.onProgress);
const completed = await apiFetch<{ data: MindSpaceAsset }>(
`/mindspace/v1/uploads/${created.data.id}/complete`,
{ method: 'POST', body: JSON.stringify({}) },
);
return completed.data;
} catch (error) {
await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, {
method: 'DELETE',
}).catch(() => {});
throw error;
}
}
function uploadFileContent(
url: string,
file: File,
onProgress?: (progress: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !onProgress) return;
onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total)));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.(1);
resolve();
return;
}
let message = '文件内容上传失败';
try {
const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } };
message = body?.error?.message ?? message;
} catch {
// Keep the generic upload error when the response is not JSON.
}
reject(new ApiError(xhr.status, message));
};
xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败'));
xhr.onabort = () => reject(new ApiError(0, '文件上传已取消'));
xhr.send(file);
});
}
export async function deleteMindSpaceAsset(assetId: string): Promise<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,
options?: { removeFromPlaza?: boolean },
): Promise<MindSpacePageDeleteResult> {
const params = new URLSearchParams();
if (options?.removeFromPlaza) params.set('remove_from_plaza', 'true');
const query = params.toString() ? `?${params.toString()}` : '';
const result = await apiFetch<{ data: MindSpacePageDeleteResult }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}${query}`,
{ method: 'DELETE' },
);
return result.data;
}
export async function listMindSpacePages(options?: {
status?: string;
limit?: number;
offset?: number;
categoryCode?: string;
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.categoryCode) params.set('category_code', options.categoryCode);
const query = params.toString() ? `?${params.toString()}` : '';
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
`/mindspace/v1/pages${query}`,
);
return { items: result.data, page: result.page ?? {} };
}
export async function getMindSpacePage(pageId: string): Promise<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 quickShareFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ publicUrl: string; filename: string }> {
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
'/mindspace/v1/pages/quick-share-from-chat',
{
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
},
);
return result.data;
}
export async function downloadChatMessageDocx(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ blob: Blob; filename: string }> {
let res: Response;
try {
res = await fetch(`${API}/mindspace/v1/pages/chat-save-docx`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
});
} 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 disposition = res.headers.get('Content-Disposition') ?? '';
const filenameMatch =
disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'mindspace-document.docx';
return { blob: await res.blob(), filename };
}
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[];
replacePageId?: string;
saveAsNew?: boolean;
}): 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',
replace_page_id: input.replacePageId,
save_as_new: input.saveAsNew ?? false,
}),
},
);
return result.data;
}
export async function createMindSpacePageFromAsset(input: {
assetId: string;
title?: string;
summary?: string;
}): Promise<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 rewriteMindSpacePageDownloadLinks(
pageId: string,
content: string,
): Promise<string> {
const result = await apiFetch<{ data: { html: string } }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`,
{
method: 'POST',
body: JSON.stringify({ content }),
},
);
return result.data.html;
}
export async function fetchMindSpacePageDraftPreview(
pageId: string,
input: {
title: string;
summary: string;
content: string;
templateId: string;
},
): Promise<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 updatePublicationStatus(
publicationId: string,
input: {
accessMode: MindSpacePublishCheck['accessMode'];
expiresAt?: number | null;
},
): Promise<MindSpacePublication> {
const result = await apiFetch<{ data: MindSpacePublication }>(
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/update-status`,
{
method: 'POST',
body: JSON.stringify({
access_mode: input.accessMode,
expires_at: input.expiresAt,
}),
},
);
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;
}
export async function fixMindSpacePagePublication(
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)}/publish-fix`,
{
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 async function listNotifications(status = 'unread', limit = 20): Promise<UserNotification[]> {
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
params.set('limit', String(limit));
const response = await fetch(`/auth/notifications?${params}`);
if (!response.ok) {
throw new ApiError(response.status, '读取通知失败');
}
const body = (await response.json()) as { notifications?: UserNotification[] };
return body.notifications ?? [];
}
export function subscribeNotificationEvents({
onNotification,
onSync,
}: {
onNotification: (notification: UserNotification) => void;
onSync?: () => void;
}): () => void {
const source = new EventSource('/auth/notifications/events', { withCredentials: true });
source.addEventListener('notification', (event) => {
try {
const payload = JSON.parse((event as MessageEvent).data) as {
notification?: UserNotification;
};
if (payload.notification) onNotification(payload.notification);
} catch {
onSync?.();
}
});
source.addEventListener('sync', () => onSync?.());
source.onerror = () => {
// EventSource reconnects automatically; consumers can keep their last state.
};
return () => source.close();
}
export async function markNotificationRead(notificationId: string): Promise<void> {
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}/read`, {
method: 'POST',
});
if (!response.ok) {
throw new ApiError(response.status, '通知状态更新失败');
}
}
export async function markAllNotificationsRead(): Promise<void> {
const response = await fetch('/auth/notifications/read-all', {
method: 'POST',
});
if (!response.ok) {
throw new ApiError(response.status, '全部已读失败');
}
}
export async function deleteNotification(notificationId: string): Promise<void> {
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
throw new ApiError(response.status, '删除通知失败');
}
}
export async function clearNotifications(status = 'all'): Promise<void> {
const response = await fetch(`/auth/notifications?status=${encodeURIComponent(status)}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new ApiError(response.status, '清空通知失败');
}
}
export async function getWechatAgentRouteStatus(): Promise<WechatAgentRouteStatus> {
try {
const response = await fetch('/auth/wechat/agent-route');
if (!response.ok) {
return {
enabled: false,
bound: false,
appId: null,
openid: null,
agentSessionId: null,
routeStatus: null,
updatedAt: null,
};
}
return (await response.json()) as WechatAgentRouteStatus;
} catch {
return {
enabled: false,
bound: false,
appId: null,
openid: null,
agentSessionId: null,
routeStatus: null,
updatedAt: null,
};
}
}
export async function resetWechatAgentRoute(): Promise<WechatAgentRouteStatus> {
let response: Response;
try {
response = await fetch('/auth/wechat/agent-route/reset', {
method: 'POST',
});
} catch (err) {
throw new ApiError(0, formatNetworkError(err));
}
const body = (await response.json().catch(() => null)) as {
message?: string;
route?: WechatAgentRouteStatus;
} | null;
if (!response.ok || !body?.route) {
throw new ApiError(response.status, body?.message ?? '重建公众号 Agent 路由失败');
}
return body.route;
}
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 } : {}),
}),
},
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
);
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(options?: {
limit?: number;
offset?: number;
query?: string;
}): Promise<{ items: SessionSummary[]; page: SessionListPage }> {
const params = new URLSearchParams();
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.query?.trim()) params.set('query', options.query.trim());
const qs = params.size ? `?${params.toString()}` : '';
const result = await apiFetch<SessionListResponse>(`/sessions${qs}`);
return { items: result.sessions ?? [], page: result.page ?? {} };
}
function sessionPath(sessionId: string, suffix = '') {
return `/sessions/${encodeURIComponent(sessionId)}${suffix}`;
}
export async function getSession(sessionId: string): Promise<Session> {
return apiFetch<Session>(sessionPath(sessionId));
}
export async function deleteChatSession(sessionId: string): Promise<void> {
await apiFetch(sessionPath(sessionId), { method: 'DELETE' });
}
export async function loadSessionDetail(
sessionId: string,
hints?: { messageCount?: number; updatedAt?: string },
history?: { before?: number; limit?: number },
): Promise<{
session: Session;
messages: Message[];
page: SessionConversationPage;
}> {
const params = new URLSearchParams();
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
if (history?.before != null) params.set('history_before', String(history.before));
if (history?.limit != null) params.set('history_limit', String(history.limit));
const qs = params.size ? `?${params.toString()}` : '';
const detail = await apiFetch<Session>(`${sessionPath(sessionId)}${qs}`);
const messages = normalizeConversationMessages(
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
);
return { session: detail, messages, page: detail.conversation_page ?? {} };
}
export async function createAgentRun(
sessionId: string | null,
requestId: string,
userMessage: Message,
options: AgentRunCreateOptions = {},
): Promise<AgentRun> {
const userMessagePayload = prepareAgentRunUserMessage(userMessage, options);
const result = await apiFetch<{ run: AgentRun }>(
AGENT_RUNS_PATH,
{
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
user_message: userMessagePayload,
...(options.toolMode ? { tool_mode: options.toolMode } : {}),
...(options.taskType ? { task_type: options.taskType } : {}),
}),
},
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
);
return result.run;
}
export async function getAgentRun(runId: string): Promise<AgentRun> {
const result = await apiFetch<{ run: AgentRun }>(`${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}`);
return result.run;
}
export async function cancelRequest(sessionId: string, requestId: string): Promise<void> {
await apiFetch(`${sessionPath(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 subscribeAgentRunEvents(
runId: string,
onRun: (run: AgentRun) => void,
onError: (error: Error) => void,
): () => void {
let closed = false;
let activeController: AbortController | null = null;
const run = async (controller: AbortController) => {
while (!closed && !controller.signal.aborted) {
try {
const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, {
headers: { Accept: 'text/event-stream' },
credentials: 'same-origin',
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) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
let eventName = 'message';
let data = '';
for (const line of chunk.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) data = line.slice(5).trim();
}
if (!data) continue;
if (eventName === 'run') {
const payload = JSON.parse(data) as { run: AgentRun };
onRun(payload.run);
continue;
}
if (eventName === 'error') {
const payload = JSON.parse(data) as { message?: string };
throw new Error(payload.message || '后台任务失败');
}
}
}
} catch (err) {
if (controller.signal.aborted || closed) return;
onError(err instanceof Error ? err : new Error(String(err)));
return;
}
}
};
activeController = new AbortController();
void run(activeController);
return () => {
closed = true;
activeController?.abort();
};
}
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}${sessionPath(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(sanitizeSessionEvent(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);
}
};
}