Files
memind/src/api/client.ts
T

2529 lines
73 KiB
TypeScript
Raw Normal View History

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,
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 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,
2026-06-29 22:20:04 +08:00
options: { maxImageBytes?: number; onProgress?: (progress: number) => void } = {},
): 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,
}),
});
try {
2026-06-29 22:20:04 +08:00
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;
}
}
2026-06-29 22:20:04 +08:00
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()}`;
}
2026-06-27 21:57:21 +08:00
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 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[];
2026-06-27 21:57:21 +08:00
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',
2026-06-27 21:57:21 +08:00
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,
2026-07-02 07:19:26 +08:00
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,
2026-07-02 07:19:26 +08:00
...(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);
}
};
}