1734 lines
50 KiB
TypeScript
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);
|
|
}
|
|
};
|
|
}
|