Files
memind/src/api/client.ts
T

1734 lines
50 KiB
TypeScript

import type {
AdminDashboardSummary,
AdminUserRow,
AuthStatus,
CapabilityDefinition,
CapabilityMap,
ChatSaveAnalysis,
PolicyDefinition,
PolicyMap,
SkillDefinition,
SkillMap,
BillingConfig,
HarnessBootstrapResponse,
LedgerEntry,
LlmProviderDefinition,
LlmProviderKeyRow,
LlmGlobalSettings,
LlmConnectionTestResult,
RechargeOrder,
Message,
MindSpaceQuota,
PlazaCategory,
PlazaPostBrief,
FeedbackContextInput,
FeedbackImageInput,
FeedbackSubmission,
FeedbackSubmissionType,
FeedbackBoardPage,
PortalUser,
Session,
SessionConversationPage,
SessionListPage,
SessionSummary,
SessionEvent,
SessionListResponse,
UsageRecord,
BalanceUpdate,
UserNotification,
PlanDefinition,
ActiveSubscription,
UserMemorySyncResponse,
} from '../types';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
import {
API,
ApiError,
apiFetch,
formatNetworkError,
notifyUnauthorized,
parseErrorResponse,
portalFetch,
resetUnauthorizedGuard,
sanitizeSessionEvent,
} from './core';
export { ApiError, resetUnauthorizedGuard, setUnauthorizedHandler } from './core';
export {
buildMindSpaceConversationPackageManifestDownloadUrl,
claimMindSpaceConversationUploads,
deleteMindSpaceAsset,
deleteMindSpaceScheduleReminders,
getMindSpace,
getMindSpaceConversationPackage,
ignoreMindSpaceScheduleReminder,
listMindSpaceAssets,
listMindSpaceCleanupItems,
runMindSpaceCleanup,
uploadMindSpaceAsset,
} from './mindspace-assets';
export {
cancelMindSpaceAgentJob,
createMindSpaceAgentJob,
getMindSpaceAgentJob,
listMindSpaceAgentJobs,
retryMindSpaceAgentJob,
runMindSpaceAgentJob,
} from './mindspace-agent-jobs';
export {
bindMindSpacePageLiveEdit,
closeMindSpacePageEditSession,
createMindSpacePage,
createMindSpacePageFromAsset,
deleteMindSpacePage,
fetchMindSpacePageDraftPreview,
forkMindSpacePageEditSession,
getMindSpacePage,
getMindSpacePageDeletePreview,
getMindSpacePageLiveRevision,
listMindSpacePages,
openMindSpaceDraftPreviewWindow,
regenerateMindSpacePageThumbnail,
rewriteMindSpacePageDownloadLinks,
saveChatMessageAsPage,
type MindSpaceListPage,
updateMindSpacePage,
uploadMindSpacePageThumbnail,
} from './mindspace-pages';
export {
checkMindSpacePagePublication,
createMindSpaceRedactedCopy,
fixMindSpacePagePublication,
getMindSpacePublicationStats,
offlineMindSpacePublication,
publishMindSpacePage,
redactMindSpacePage,
updatePublicationStatus,
} from './mindspace-publications';
export {
applyPageDataPublishPolicy,
buildPageDataExportUrl,
closePageDataDataset,
getPageDataOpsOverview,
getPageDataPolicy,
listOwnerPageDataPolicies,
listPageDataDatasets,
listPageDataLogs,
resetPageDataPassword,
restorePageDataRow,
revokePageDataTokens,
savePageDataPolicy,
} from './page-data';
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
const QUICK_PLAZA_TIMEOUT_MS = 120_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);
const withValidation = withAgentRunValidationMetadata(normalized, options.validation);
const withRunMetadata = options.forceDeepReasoning
? {
...withValidation,
metadata: {
...withValidation.metadata,
memindRun: {
...(
withValidation.metadata?.memindRun &&
typeof withValidation.metadata.memindRun === 'object'
? withValidation.metadata.memindRun
: {}
),
forceDeepReasoning: true,
},
},
}
: withValidation;
return appendAgentRunValidationInstruction(
withRunMetadata,
options.toolMode === 'code' ? options.validationInstruction : null,
);
}
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 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 quickPlazaFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}, signal?: AbortSignal): Promise<{
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
}> {
const result = await apiFetch<{
data: {
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
};
}>('/mindspace/v1/pages/quick-plaza-from-chat', {
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
signal,
}, { timeoutMs: QUICK_PLAZA_TIMEOUT_MS });
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 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 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.status === 401) {
notifyUnauthorized();
throw new ApiError(401, '未授权,请重新登录');
}
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 syncLlmProviderToRuntime(): 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> {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
}
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 } : {}),
...(options.forceDeepReasoning ? { force_deep_reasoning: true } : {}),
...(options.selectedAssetIds?.length
? { selected_asset_ids: options.selectedAssetIds }
: {}),
}),
},
{ 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;
let lastEventId: string | undefined;
const run = async (controller: AbortController) => {
while (!closed && !controller.signal.aborted) {
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, {
headers,
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) 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 = 'message';
let data = '';
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;
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)));
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
};
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);
}
};
}