2581 lines
75 KiB
TypeScript
2581 lines
75 KiB
TypeScript
import type {
|
||
AdminDashboardSummary,
|
||
AdminUserRow,
|
||
AuthStatus,
|
||
CapabilityDefinition,
|
||
CapabilityMap,
|
||
ChatSaveAnalysis,
|
||
ChatSaveResult,
|
||
PolicyDefinition,
|
||
PolicyMap,
|
||
SkillDefinition,
|
||
SkillMap,
|
||
BillingConfig,
|
||
HarnessBootstrapResponse,
|
||
InsufficientBalanceDetails,
|
||
LedgerEntry,
|
||
LlmProviderDefinition,
|
||
LlmProviderKeyRow,
|
||
LlmGlobalSettings,
|
||
LlmConnectionTestResult,
|
||
RechargeOrder,
|
||
Message,
|
||
MindSpace,
|
||
MindSpaceAsset,
|
||
MindSpaceAgentJob,
|
||
MindSpaceCleanupItem,
|
||
MindSpaceConversationPackage,
|
||
MindSpacePage,
|
||
MindSpacePageDeletePreview,
|
||
MindSpacePageDeleteResult,
|
||
MindSpaceQuota,
|
||
MindSpaceScheduleReminder,
|
||
MindSpaceSaveCategory,
|
||
MindSpacePublication,
|
||
MindSpacePublicationStats,
|
||
MindSpacePublishCheck,
|
||
PlazaCategory,
|
||
PlazaPostBrief,
|
||
MindSpaceRedactedCopyResult,
|
||
MindSpaceUpload,
|
||
FeedbackContextInput,
|
||
FeedbackImageInput,
|
||
FeedbackSubmission,
|
||
FeedbackSubmissionType,
|
||
FeedbackBoardPage,
|
||
PortalUser,
|
||
Session,
|
||
SessionConversationPage,
|
||
SessionListPage,
|
||
SessionSummary,
|
||
SessionEvent,
|
||
SessionListResponse,
|
||
UsageRecord,
|
||
BalanceUpdate,
|
||
UserNotification,
|
||
PlanDefinition,
|
||
ActiveSubscription,
|
||
UserMemorySyncResponse,
|
||
} from '../types';
|
||
import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload';
|
||
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
|
||
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
|
||
|
||
const API = '/api';
|
||
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
|
||
const AGENT_RUNS_PATH = '/agent/runs';
|
||
|
||
export type AgentRun = {
|
||
id: string;
|
||
userId: string;
|
||
sessionId: string | null;
|
||
requestId: string;
|
||
status: 'queued' | 'running' | 'retryable' | 'succeeded' | 'failed';
|
||
attempts: number;
|
||
error: string | null;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
startedAt: number | null;
|
||
completedAt: number | null;
|
||
};
|
||
|
||
function appendAgentRunValidationInstruction(message: Message, instruction?: string | null): Message {
|
||
const normalizedInstruction = String(instruction ?? '').trim();
|
||
if (!normalizedInstruction) return message;
|
||
return {
|
||
...message,
|
||
content: [
|
||
...message.content,
|
||
{ type: 'text', text: normalizedInstruction },
|
||
],
|
||
};
|
||
}
|
||
|
||
function withAgentRunValidationMetadata(
|
||
message: Message,
|
||
validation?: AgentRunValidation | null,
|
||
): Message {
|
||
if (!validation) return message;
|
||
const metadata = message.metadata as Message['metadata'] & {
|
||
memindRun?: Record<string, unknown>;
|
||
};
|
||
return {
|
||
...message,
|
||
metadata: {
|
||
...message.metadata,
|
||
memindRun: {
|
||
...(metadata.memindRun && typeof metadata.memindRun === 'object' ? metadata.memindRun : {}),
|
||
validation,
|
||
},
|
||
} as Message['metadata'],
|
||
};
|
||
}
|
||
|
||
function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message {
|
||
const normalized = normalizeUserMessageForApi(message);
|
||
return appendAgentRunValidationInstruction(
|
||
withAgentRunValidationMetadata(normalized, options.validation),
|
||
options.toolMode === 'code' ? options.validationInstruction : null,
|
||
);
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: string;
|
||
readonly details?: InsufficientBalanceDetails | Record<string, unknown>;
|
||
|
||
constructor(
|
||
status: number,
|
||
message: string,
|
||
code?: string,
|
||
details?: InsufficientBalanceDetails | Record<string, unknown>,
|
||
) {
|
||
super(sanitizeUserFacingErrorMessage(message));
|
||
this.name = 'ApiError';
|
||
this.status = status;
|
||
this.code = code;
|
||
this.details = details;
|
||
}
|
||
}
|
||
|
||
function sanitizeUserFacingErrorMessage(message: string) {
|
||
const normalized = String(message ?? '').trim();
|
||
if (!normalized) return normalized;
|
||
if (!/goose|goosed/i.test(normalized)) return normalized;
|
||
if (/超时|timeout/i.test(normalized)) {
|
||
return '后端连接超时,请确认后端服务正常后重试';
|
||
}
|
||
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
|
||
return '后端连接失败,请稍后重试';
|
||
}
|
||
return normalized
|
||
.replace(/\bgoosed\b/gi, '后端服务')
|
||
.replace(/\bgoose\b/gi, '后端');
|
||
}
|
||
|
||
async function parseErrorResponse(res: Response): Promise<{
|
||
message: string;
|
||
code?: string;
|
||
details?: InsufficientBalanceDetails | Record<string, unknown>;
|
||
}> {
|
||
const text = await res.text().catch(() => '');
|
||
try {
|
||
const body = JSON.parse(text) as Record<string, unknown>;
|
||
const nested =
|
||
body.error && typeof body.error === 'object'
|
||
? (body.error as Record<string, unknown>)
|
||
: body;
|
||
const message =
|
||
typeof nested.message === 'string'
|
||
? nested.message
|
||
: typeof body.message === 'string'
|
||
? body.message
|
||
: text;
|
||
const code =
|
||
typeof nested.code === 'string'
|
||
? nested.code
|
||
: typeof body.code === 'string'
|
||
? body.code
|
||
: undefined;
|
||
const details = nested.details ?? body.details;
|
||
if (code === 'INSUFFICIENT_BALANCE') {
|
||
return {
|
||
message: sanitizeUserFacingErrorMessage(message),
|
||
code,
|
||
details: {
|
||
code: 'INSUFFICIENT_BALANCE' as const,
|
||
balanceCents: Number((details as Record<string, unknown>)?.balanceCents ?? body.balanceCents ?? 0),
|
||
minRechargeCents: Number(
|
||
(details as Record<string, unknown>)?.minRechargeCents ?? body.minRechargeCents ?? 500,
|
||
),
|
||
suggestedTiers: Array.isArray((details as Record<string, unknown>)?.suggestedTiers)
|
||
? ((details as Record<string, unknown>).suggestedTiers as unknown[]).map((value) => Number(value))
|
||
: Array.isArray(body.suggestedTiers)
|
||
? body.suggestedTiers.map((value) => Number(value))
|
||
: [],
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
message: sanitizeUserFacingErrorMessage(message),
|
||
code,
|
||
details: details && typeof details === 'object'
|
||
? (details as InsufficientBalanceDetails | Record<string, unknown>)
|
||
: undefined,
|
||
};
|
||
} catch {
|
||
return { message: sanitizeUserFacingErrorMessage(text || res.statusText) };
|
||
}
|
||
}
|
||
|
||
let unauthorizedHandler: (() => void) | null = null;
|
||
let unauthorizedHandling = false;
|
||
|
||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||
unauthorizedHandler = handler;
|
||
if (handler) unauthorizedHandling = false;
|
||
}
|
||
|
||
export function resetUnauthorizedGuard() {
|
||
unauthorizedHandling = false;
|
||
}
|
||
|
||
function notifyUnauthorized() {
|
||
if (!unauthorizedHandler || unauthorizedHandling) return;
|
||
unauthorizedHandling = true;
|
||
unauthorizedHandler();
|
||
}
|
||
|
||
async function fetchWithTimeout(
|
||
input: RequestInfo | URL,
|
||
init?: RequestInit,
|
||
timeoutMs = DEFAULT_API_TIMEOUT_MS,
|
||
): Promise<Response> {
|
||
const controller = new AbortController();
|
||
const upstreamSignal = init?.signal;
|
||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||
|
||
const abortFromUpstream = () => controller.abort();
|
||
if (upstreamSignal) {
|
||
if (upstreamSignal.aborted) controller.abort();
|
||
else upstreamSignal.addEventListener('abort', abortFromUpstream, { once: true });
|
||
}
|
||
|
||
try {
|
||
return await fetch(input, {
|
||
...init,
|
||
signal: controller.signal,
|
||
});
|
||
} finally {
|
||
window.clearTimeout(timeout);
|
||
upstreamSignal?.removeEventListener('abort', abortFromUpstream);
|
||
}
|
||
}
|
||
|
||
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetchWithTimeout(path, {
|
||
...init,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...init?.headers,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
const text = await res.text().catch(() => '');
|
||
throw new ApiError(401, text || '未授权,请重新登录');
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const parsed = await parseErrorResponse(res);
|
||
throw new ApiError(
|
||
res.status,
|
||
parsed.message || `${res.status} ${res.statusText}`,
|
||
parsed.code,
|
||
parsed.details,
|
||
);
|
||
}
|
||
|
||
if (res.status === 204) return undefined as T;
|
||
const text = await res.text().catch(() => '');
|
||
if (text.trimStart().startsWith('<')) {
|
||
throw new ApiError(
|
||
res.status,
|
||
`接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
|
||
);
|
||
}
|
||
try {
|
||
return JSON.parse(text) as T;
|
||
} catch {
|
||
throw new ApiError(res.status, '服务器响应格式错误');
|
||
}
|
||
}
|
||
|
||
function formatNetworkError(err: unknown) {
|
||
const message = err instanceof Error ? err.message : '网络请求失败';
|
||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||
return '后端连接超时,请确认后端服务正常后重试';
|
||
}
|
||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||
return '无法连接后端服务,请先运行: pnpm dev 或 node server.mjs';
|
||
}
|
||
return sanitizeUserFacingErrorMessage(message);
|
||
}
|
||
|
||
function sanitizeSessionEvent(event: SessionEvent): SessionEvent {
|
||
if (event.type !== 'Error') return event;
|
||
return { ...event, error: sanitizeUserFacingErrorMessage(event.error) };
|
||
}
|
||
|
||
async function apiFetch<T>(
|
||
path: string,
|
||
init?: RequestInit,
|
||
options?: { timeoutMs?: number },
|
||
): Promise<T> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetchWithTimeout(
|
||
`${API}${path}`,
|
||
{
|
||
...init,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...init?.headers,
|
||
},
|
||
},
|
||
options?.timeoutMs,
|
||
);
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
const text = await res.text().catch(() => '');
|
||
throw new ApiError(401, text || '未授权,请重新登录');
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const parsed = await parseErrorResponse(res);
|
||
throw new ApiError(
|
||
res.status,
|
||
parsed.message || `${res.status} ${res.statusText}`,
|
||
parsed.code,
|
||
parsed.details,
|
||
);
|
||
}
|
||
|
||
if (res.status === 204) return undefined as T;
|
||
const rawText = await res.text().catch(() => '');
|
||
if (rawText.trimStart().startsWith('<')) {
|
||
throw new ApiError(
|
||
res.status,
|
||
`接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
|
||
);
|
||
}
|
||
try {
|
||
return JSON.parse(rawText) as T;
|
||
} catch {
|
||
throw new ApiError(res.status, '服务器响应格式错误');
|
||
}
|
||
}
|
||
|
||
export async function startSession(): Promise<Session> {
|
||
return apiFetch<Session>(
|
||
'/agent/start',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({}),
|
||
},
|
||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||
);
|
||
}
|
||
|
||
export async function bootstrapProjectMemory(
|
||
sessionId: string,
|
||
query: string,
|
||
force = false,
|
||
): Promise<HarnessBootstrapResponse> {
|
||
return apiFetch<HarnessBootstrapResponse>('/agent/harness_bootstrap', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId, query, force }),
|
||
});
|
||
}
|
||
|
||
export async function rememberProjectContext(
|
||
sessionId: string,
|
||
content: string,
|
||
title: string,
|
||
): Promise<void> {
|
||
await apiFetch('/agent/harness_remember', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId, content, title }),
|
||
});
|
||
}
|
||
|
||
export async function rememberUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
|
||
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/remember-recent', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId }),
|
||
});
|
||
}
|
||
|
||
export async function syncUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
|
||
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/sync', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId }),
|
||
});
|
||
}
|
||
|
||
export type WechatAuthConfig = {
|
||
enabled: boolean;
|
||
inWechat?: boolean;
|
||
scanEnabled?: boolean;
|
||
openScanEnabled?: boolean;
|
||
openAppId?: string;
|
||
oauthCallbackUrl?: string;
|
||
};
|
||
|
||
export type WechatPendingProfile = {
|
||
nickname: string | null;
|
||
avatarUrl: string | null;
|
||
returnTo: string;
|
||
};
|
||
|
||
export type WechatBindingStatus = {
|
||
enabled: boolean;
|
||
bound: boolean;
|
||
nickname?: string | null;
|
||
avatarUrl?: string | null;
|
||
};
|
||
|
||
export type WechatAgentRouteStatus = {
|
||
enabled: boolean;
|
||
bound: boolean;
|
||
appId: string | null;
|
||
openid: string | null;
|
||
agentSessionId: string | null;
|
||
routeStatus: string | null;
|
||
updatedAt: number | null;
|
||
};
|
||
|
||
export type WechatJsSdkSignature = {
|
||
appId: string;
|
||
timestamp: number;
|
||
nonceStr: string;
|
||
signature: string;
|
||
url: string;
|
||
jsApiList: string[];
|
||
};
|
||
|
||
export async function getWechatAuthConfig(): Promise<WechatAuthConfig> {
|
||
try {
|
||
const response = await fetch('/auth/wechat/config');
|
||
if (!response.ok) return { enabled: false };
|
||
return (await response.json()) as WechatAuthConfig;
|
||
} catch {
|
||
return { enabled: false };
|
||
}
|
||
}
|
||
|
||
export async function getWechatJsSdkSignature(url: string): Promise<WechatJsSdkSignature> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`/auth/wechat/js-sdk-signature?url=${encodeURIComponent(url)}`);
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as
|
||
| (WechatJsSdkSignature & { message?: string })
|
||
| null;
|
||
if (!response.ok || !body?.signature) {
|
||
throw new ApiError(response.status, body?.message ?? '微信语音初始化失败');
|
||
}
|
||
return body;
|
||
}
|
||
|
||
export async function checkAuth(): Promise<AuthStatus> {
|
||
try {
|
||
const response = await fetch('/auth/status');
|
||
const status = (await response.json()) as AuthStatus;
|
||
if (!response.ok) {
|
||
if (status.mode === 'unavailable') return status;
|
||
return { authenticated: false };
|
||
}
|
||
if (status.authenticated) resetUnauthorizedGuard();
|
||
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
|
||
try {
|
||
const me = await getMe();
|
||
return {
|
||
...status,
|
||
capabilities: me.capabilities,
|
||
grantedSkills: me.grantedSkills,
|
||
user: me.user,
|
||
};
|
||
} catch {
|
||
return status;
|
||
}
|
||
}
|
||
return status;
|
||
} catch {
|
||
return { authenticated: false };
|
||
}
|
||
}
|
||
|
||
export async function getMe(): Promise<{
|
||
user: PortalUser;
|
||
capabilities?: CapabilityMap;
|
||
grantedSkills?: string[];
|
||
unrestricted?: boolean;
|
||
}> {
|
||
return portalFetch('/auth/me');
|
||
}
|
||
|
||
export async function getMyUsage(): Promise<UsageRecord[]> {
|
||
const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage');
|
||
return result.records ?? [];
|
||
}
|
||
|
||
export async function submitFeedback(input: {
|
||
type: FeedbackSubmissionType;
|
||
title: string;
|
||
description: string;
|
||
contact?: string;
|
||
images?: FeedbackImageInput[];
|
||
context?: FeedbackContextInput;
|
||
}): Promise<FeedbackSubmission> {
|
||
const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: input.type,
|
||
title: input.title,
|
||
description: input.description,
|
||
contact: input.contact,
|
||
images: input.images,
|
||
context: input.context,
|
||
}),
|
||
});
|
||
return result.feedback;
|
||
}
|
||
|
||
export async function getMyFeedback(limit = 20): Promise<FeedbackSubmission[]> {
|
||
const result = await portalFetch<{ items: FeedbackSubmission[] }>(
|
||
`/auth/feedback?limit=${encodeURIComponent(String(limit))}`,
|
||
);
|
||
return result.items ?? [];
|
||
}
|
||
|
||
export async function listFeedbackBoard(page = 1, limit = 10): Promise<FeedbackBoardPage> {
|
||
const params = new URLSearchParams({
|
||
page: String(page),
|
||
limit: String(limit),
|
||
});
|
||
return portalFetch<FeedbackBoardPage>(`/auth/feedback/board?${params.toString()}`);
|
||
}
|
||
|
||
export async function getFeedbackDetail(feedbackId: string): Promise<{
|
||
item: FeedbackSubmission;
|
||
isMine: boolean;
|
||
}> {
|
||
return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>(
|
||
`/auth/feedback/${encodeURIComponent(feedbackId)}`,
|
||
);
|
||
}
|
||
|
||
export async function getMyBillingLedger(limit = 30): Promise<LedgerEntry[]> {
|
||
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
|
||
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`);
|
||
return result.entries ?? [];
|
||
}
|
||
|
||
export async function getBillingConfig(): Promise<BillingConfig> {
|
||
return portalFetch('/auth/billing/config');
|
||
}
|
||
|
||
export async function getAvailablePlans(): Promise<{
|
||
plans: PlanDefinition[];
|
||
subscription: ActiveSubscription | null;
|
||
balanceCents: number;
|
||
}> {
|
||
return portalFetch('/auth/billing/plans');
|
||
}
|
||
|
||
export async function purchaseSubscription(planType: string, autoRenew = false): Promise<{
|
||
subscription: ActiveSubscription;
|
||
balanceCents: number;
|
||
}> {
|
||
return portalFetch('/auth/billing/subscribe', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ planType, autoRenew }),
|
||
});
|
||
}
|
||
|
||
export async function setAutoRenew(enabled: boolean): Promise<{ ok: boolean; updated: boolean }> {
|
||
return portalFetch('/auth/billing/auto-renew', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ enabled }),
|
||
});
|
||
}
|
||
|
||
export async function createRechargeOrder(input: {
|
||
amountCents: number;
|
||
payScene: 'native' | 'h5' | 'jsapi';
|
||
}): Promise<RechargeOrder> {
|
||
const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', {
|
||
method: 'POST',
|
||
body: JSON.stringify(input),
|
||
});
|
||
return result.order;
|
||
}
|
||
|
||
export async function getRechargeOrder(orderId: string): Promise<{
|
||
order: RechargeOrder;
|
||
balanceCents: number | null;
|
||
}> {
|
||
return portalFetch(`/auth/billing/recharge-orders/${orderId}`);
|
||
}
|
||
|
||
export async function purchaseSpaceQuota(sizeMb: number): Promise<{
|
||
quota: MindSpaceQuota;
|
||
balanceCents: number;
|
||
purchasedMb: number;
|
||
costCents: number;
|
||
}> {
|
||
return portalFetch('/auth/billing/space-purchase', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sizeMb }),
|
||
});
|
||
}
|
||
|
||
export async function getMindSpace(): Promise<MindSpace> {
|
||
const result = await apiFetch<{ data: MindSpace }>('/mindspace/v1/space');
|
||
return result.data;
|
||
}
|
||
|
||
export async function getMindSpaceConversationPackage(
|
||
sessionId: string,
|
||
): Promise<MindSpaceConversationPackage> {
|
||
const result = await apiFetch<{ data: MindSpaceConversationPackage }>(
|
||
`/mindspace/v1/conversation-packages/${encodeURIComponent(sessionId)}`,
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function ignoreMindSpaceScheduleReminder(reminderId: string) {
|
||
const result = await apiFetch<{ data: MindSpaceScheduleReminder }>(
|
||
`/mindspace/v1/schedule/reminders/${encodeURIComponent(reminderId)}/ignore`,
|
||
{ method: 'POST' },
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function deleteMindSpaceScheduleReminders(ids: string[]) {
|
||
const result = await apiFetch<{ data: { deleted: number } }>(
|
||
'/mindspace/v1/schedule/reminders/bulk-delete',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ ids }),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function listMindSpaceCleanupItems(): Promise<{
|
||
items: MindSpaceCleanupItem[];
|
||
totalBytes: number;
|
||
}> {
|
||
const result = await apiFetch<{
|
||
data: { items: MindSpaceCleanupItem[]; totalBytes: number };
|
||
}>('/mindspace/v1/space/cleanup');
|
||
return result.data;
|
||
}
|
||
|
||
export async function runMindSpaceCleanup(itemIds: string[]): Promise<{
|
||
removedCount: number;
|
||
freedBytes: number;
|
||
quota?: MindSpaceQuota;
|
||
}> {
|
||
const result = await apiFetch<{
|
||
data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota };
|
||
}>('/mindspace/v1/space/cleanup', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ item_ids: itemIds }),
|
||
});
|
||
return result.data;
|
||
}
|
||
|
||
export async function listMindSpaceAssets(
|
||
categoryCode?: string,
|
||
): Promise<MindSpaceAsset[]> {
|
||
const query = categoryCode ? `?category_code=${encodeURIComponent(categoryCode)}` : '';
|
||
const result = await apiFetch<{ data: MindSpaceAsset[] }>(`/mindspace/v1/assets${query}`);
|
||
return result.data;
|
||
}
|
||
|
||
export async function uploadMindSpaceAsset(
|
||
categoryId: string,
|
||
file: File,
|
||
options: {
|
||
maxImageBytes?: number;
|
||
onProgress?: (progress: number) => void;
|
||
sessionId?: string | null;
|
||
messageId?: string | null;
|
||
} = {},
|
||
): Promise<MindSpaceAsset> {
|
||
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
|
||
if (file.type.startsWith('image/') && file.size > maxImageBytes) {
|
||
throw new ApiError(
|
||
413,
|
||
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`,
|
||
);
|
||
}
|
||
|
||
const created = await apiFetch<{ data: MindSpaceUpload }>('/mindspace/v1/uploads', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
category_id: categoryId,
|
||
filename: file.name,
|
||
size_bytes: file.size,
|
||
declared_mime_type: file.type || null,
|
||
...(options.sessionId ? { session_id: options.sessionId } : {}),
|
||
...(options.messageId ? { message_id: options.messageId } : {}),
|
||
}),
|
||
});
|
||
|
||
try {
|
||
await uploadFileContent(created.data.uploadUrl, file, options.onProgress);
|
||
const completed = await apiFetch<{ data: MindSpaceAsset }>(
|
||
`/mindspace/v1/uploads/${created.data.id}/complete`,
|
||
{ method: 'POST', body: JSON.stringify({}) },
|
||
);
|
||
return completed.data;
|
||
} catch (error) {
|
||
await apiFetch(`/mindspace/v1/uploads/${created.data.id}`, {
|
||
method: 'DELETE',
|
||
}).catch(() => {});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function uploadFileContent(
|
||
url: string,
|
||
file: File,
|
||
onProgress?: (progress: number) => void,
|
||
): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('PUT', url);
|
||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||
xhr.upload.onprogress = (event) => {
|
||
if (!event.lengthComputable || !onProgress) return;
|
||
onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total)));
|
||
};
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
onProgress?.(1);
|
||
resolve();
|
||
return;
|
||
}
|
||
let message = '文件内容上传失败';
|
||
try {
|
||
const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } };
|
||
message = body?.error?.message ?? message;
|
||
} catch {
|
||
// Keep the generic upload error when the response is not JSON.
|
||
}
|
||
reject(new ApiError(xhr.status, message));
|
||
};
|
||
xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败'));
|
||
xhr.onabort = () => reject(new ApiError(0, '文件上传已取消'));
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
|
||
export async function deleteMindSpaceAsset(assetId: string): Promise<void> {
|
||
await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function getMindSpacePageDeletePreview(
|
||
pageId: string,
|
||
): Promise<MindSpacePageDeletePreview> {
|
||
const result = await apiFetch<{ data: MindSpacePageDeletePreview }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/delete-preview`,
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function deleteMindSpacePage(
|
||
pageId: string,
|
||
options?: { removeFromPlaza?: boolean },
|
||
): Promise<MindSpacePageDeleteResult> {
|
||
const params = new URLSearchParams();
|
||
if (options?.removeFromPlaza) params.set('remove_from_plaza', 'true');
|
||
const query = params.toString() ? `?${params.toString()}` : '';
|
||
const result = await apiFetch<{ data: MindSpacePageDeleteResult }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}${query}`,
|
||
{ method: 'DELETE' },
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function listMindSpacePages(options?: {
|
||
status?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
categoryCode?: string;
|
||
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
|
||
const params = new URLSearchParams();
|
||
if (options?.status) params.set('status', options.status);
|
||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||
if (options?.offset != null) params.set('offset', String(options.offset));
|
||
if (options?.categoryCode) params.set('category_code', options.categoryCode);
|
||
const query = params.toString() ? `?${params.toString()}` : '';
|
||
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
|
||
`/mindspace/v1/pages${query}`,
|
||
);
|
||
return { items: result.data, page: result.page ?? {} };
|
||
}
|
||
|
||
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
|
||
const result = await apiFetch<{ data: MindSpacePage }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function bindMindSpacePageLiveEdit(
|
||
pageId: string,
|
||
sessionId: string,
|
||
options?: { parentSessionId?: string },
|
||
): Promise<{ sessionId: string; pageId: string; parentSessionId?: string }> {
|
||
const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId?: string } }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/bind`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
...(options?.parentSessionId ? { parent_session_id: options.parentSessionId } : {}),
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function forkMindSpacePageEditSession(
|
||
pageId: string,
|
||
parentSessionId: string,
|
||
h5ApiBase?: string | null,
|
||
): Promise<{ sessionId: string; pageId: string; parentSessionId: string }> {
|
||
const result = await apiFetch<{
|
||
data: { sessionId: string; pageId: string; parentSessionId: string };
|
||
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/fork-session`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
parent_session_id: parentSessionId,
|
||
...(h5ApiBase ? { h5_api_base: h5ApiBase } : {}),
|
||
}),
|
||
});
|
||
return result.data;
|
||
}
|
||
|
||
export async function closeMindSpacePageEditSession(
|
||
pageId: string,
|
||
input: {
|
||
sessionId: string;
|
||
parentSessionId?: string | null;
|
||
summary?: string;
|
||
},
|
||
): Promise<{ sessionId: string; pageId: string; merged: boolean }> {
|
||
const result = await apiFetch<{
|
||
data: { sessionId: string; pageId: string; merged: boolean };
|
||
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/close-session`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: input.sessionId,
|
||
parent_session_id: input.parentSessionId ?? undefined,
|
||
summary: input.summary ?? '',
|
||
}),
|
||
});
|
||
return result.data;
|
||
}
|
||
|
||
export async function getMindSpacePageLiveRevision(pageId: string): Promise<{
|
||
pageId: string;
|
||
versionNo: number;
|
||
updatedAt: number;
|
||
liveRevision: number;
|
||
}> {
|
||
const result = await apiFetch<{
|
||
data: { pageId: string; versionNo: number; updatedAt: number; liveRevision: number };
|
||
}>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/revision`);
|
||
return result.data;
|
||
}
|
||
|
||
export async function analyzeChatSave(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
previewTitle?: string;
|
||
previewSummary?: string;
|
||
}): Promise<ChatSaveAnalysis> {
|
||
const result = await apiFetch<{ data: ChatSaveAnalysis }>(
|
||
'/mindspace/v1/pages/analyze-chat-save',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
selected_link_index: input.selectedLinkIndex ?? 0,
|
||
preview_title: input.previewTitle,
|
||
preview_summary: input.previewSummary,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export function buildChatSavePreviewFrameUrl(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
}) {
|
||
const params = new URLSearchParams({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
selected_link_index: String(input.selectedLinkIndex ?? 0),
|
||
});
|
||
return `/api/mindspace/v1/pages/chat-save-preview?${params.toString()}`;
|
||
}
|
||
|
||
export function buildChatSaveThumbnailUrl(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
previewTitle?: string;
|
||
previewSummary?: string;
|
||
cacheKey?: number | string;
|
||
}) {
|
||
const params = new URLSearchParams({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
selected_link_index: String(input.selectedLinkIndex ?? 0),
|
||
});
|
||
const title = input.previewTitle?.trim();
|
||
const summary = input.previewSummary?.trim();
|
||
if (title) params.set('preview_title', title);
|
||
if (summary) params.set('preview_summary', summary);
|
||
if (input.cacheKey != null) params.set('v', String(input.cacheKey));
|
||
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
|
||
}
|
||
|
||
export async function quickShareFromChat(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
}): Promise<{ publicUrl: string; filename: string }> {
|
||
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
|
||
'/mindspace/v1/pages/quick-share-from-chat',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
selected_link_index: input.selectedLinkIndex ?? 0,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function downloadChatMessageDocx(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
selectedLinkIndex?: number;
|
||
}): Promise<{ blob: Blob; filename: string }> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${API}/mindspace/v1/pages/chat-save-docx`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
selected_link_index: input.selectedLinkIndex ?? 0,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
throw new ApiError(401, '未授权,请重新登录');
|
||
}
|
||
if (!res.ok) {
|
||
const parsed = await parseErrorResponse(res);
|
||
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
||
}
|
||
const disposition = res.headers.get('Content-Disposition') ?? '';
|
||
const filenameMatch =
|
||
disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i);
|
||
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'mindspace-document.docx';
|
||
return { blob: await res.blob(), filename };
|
||
}
|
||
|
||
export async function fetchPreviewAsset(path: string): Promise<string> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(path, { credentials: 'same-origin' });
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
throw new ApiError(401, '未授权,请重新登录');
|
||
}
|
||
if (!res.ok) {
|
||
const parsed = await parseErrorResponse(res);
|
||
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
||
}
|
||
const blob = await res.blob();
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
export async function saveChatMessageAsPage(input: {
|
||
sessionId: string;
|
||
messageId: string;
|
||
title: string;
|
||
summary?: string;
|
||
templateId?: string;
|
||
categoryCode?: MindSpaceSaveCategory;
|
||
selectedLinkIndex?: number;
|
||
acknowledgedFindingIds?: string[];
|
||
replacePageId?: string;
|
||
saveAsNew?: boolean;
|
||
}): Promise<ChatSaveResult> {
|
||
const result = await apiFetch<{ data: ChatSaveResult }>(
|
||
'/mindspace/v1/pages/save-from-chat',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: input.sessionId,
|
||
message_id: input.messageId,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
template_id: input.templateId ?? 'editorial',
|
||
category_code: input.categoryCode ?? 'draft',
|
||
selected_link_index: input.selectedLinkIndex ?? 0,
|
||
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
||
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||
replace_page_id: input.replacePageId,
|
||
save_as_new: input.saveAsNew ?? false,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function createMindSpacePageFromAsset(input: {
|
||
assetId: string;
|
||
title?: string;
|
||
summary?: string;
|
||
}): Promise<MindSpacePage> {
|
||
const result = await apiFetch<{ data: { page: MindSpacePage } }>(
|
||
'/mindspace/v1/pages/from-asset',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
asset_id: input.assetId,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
}),
|
||
},
|
||
);
|
||
return result.data.page;
|
||
}
|
||
|
||
export async function createMindSpacePage(input: {
|
||
title: string;
|
||
summary?: string;
|
||
content: string;
|
||
templateId: string;
|
||
}): Promise<MindSpacePage> {
|
||
const result = await apiFetch<{ data: MindSpacePage }>('/mindspace/v1/pages', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: input.title,
|
||
summary: input.summary,
|
||
content: input.content,
|
||
template_id: input.templateId,
|
||
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||
}),
|
||
});
|
||
return result.data;
|
||
}
|
||
|
||
export async function updateMindSpacePage(
|
||
pageId: string,
|
||
input: {
|
||
expectedVersion: number;
|
||
title: string;
|
||
summary: string;
|
||
content: string;
|
||
templateId: string;
|
||
changeNote?: string;
|
||
},
|
||
): Promise<MindSpacePage> {
|
||
const result = await apiFetch<{ data: MindSpacePage }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
|
||
{
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
expected_version: input.expectedVersion,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
content: input.content,
|
||
template_id: input.templateId,
|
||
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||
change_note: input.changeNote,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function rewriteMindSpacePageDownloadLinks(
|
||
pageId: string,
|
||
content: string,
|
||
): Promise<string> {
|
||
const result = await apiFetch<{ data: { html: string } }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ content }),
|
||
},
|
||
);
|
||
return result.data.html;
|
||
}
|
||
|
||
export async function fetchMindSpacePageDraftPreview(
|
||
pageId: string,
|
||
input: {
|
||
title: string;
|
||
summary: string;
|
||
content: string;
|
||
templateId: string;
|
||
},
|
||
): Promise<string> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`/api/mindspace/v1/pages/${encodeURIComponent(pageId)}/preview-draft`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: input.title,
|
||
summary: input.summary,
|
||
content: input.content,
|
||
template_id: input.templateId,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
throw new ApiError(401, '未授权,请重新登录');
|
||
}
|
||
if (!res.ok) {
|
||
const parsed = await parseErrorResponse(res);
|
||
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
||
}
|
||
return res.text();
|
||
}
|
||
|
||
export function openMindSpaceDraftPreviewWindow(html: string) {
|
||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
||
if (!opened) {
|
||
URL.revokeObjectURL(url);
|
||
throw new ApiError(0, '无法打开新窗口,请检查浏览器是否拦截弹窗');
|
||
}
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 120_000);
|
||
}
|
||
|
||
export async function uploadMindSpacePageThumbnail(
|
||
pageId: string,
|
||
input: {
|
||
imageBase64: string;
|
||
mimeType?: string;
|
||
title?: string;
|
||
summary?: string;
|
||
content?: string;
|
||
},
|
||
): Promise<{ updatedAt: number }> {
|
||
const result = await apiFetch<{ data: { updatedAt: number } }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/upload`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
image_base64: input.imageBase64,
|
||
mime_type: input.mimeType,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
html: input.content,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function regenerateMindSpacePageThumbnail(
|
||
pageId: string,
|
||
input: {
|
||
title?: string;
|
||
summary?: string;
|
||
content?: string;
|
||
useAi?: boolean;
|
||
instruction?: string;
|
||
} = {},
|
||
): Promise<{ updatedAt: number; content?: string | null }> {
|
||
const result = await apiFetch<{ data: { updatedAt: number; content?: string | null } }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/thumbnail/regenerate`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
title: input.title,
|
||
summary: input.summary,
|
||
html: input.content,
|
||
use_ai: input.useAi ?? false,
|
||
instruction: input.instruction,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function checkMindSpacePagePublication(
|
||
pageId: string,
|
||
input: {
|
||
pageVersionId: string;
|
||
accessMode: MindSpacePublishCheck['accessMode'];
|
||
urlSlug: string;
|
||
password?: string;
|
||
expiresAt?: number | null;
|
||
},
|
||
): Promise<MindSpacePublishCheck> {
|
||
const result = await apiFetch<{ data: MindSpacePublishCheck }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-check`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
page_version_id: input.pageVersionId,
|
||
access_mode: input.accessMode,
|
||
url_slug: input.urlSlug,
|
||
password: input.password,
|
||
expires_at: input.expiresAt,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function publishMindSpacePage(
|
||
pageId: string,
|
||
input: {
|
||
pageVersionId: string;
|
||
accessMode: MindSpacePublishCheck['accessMode'];
|
||
urlSlug: string;
|
||
acknowledgedFindingIds: string[];
|
||
password?: string;
|
||
expiresAt?: number | null;
|
||
},
|
||
): Promise<MindSpacePublication> {
|
||
const result = await apiFetch<{ data: MindSpacePublication }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
page_version_id: input.pageVersionId,
|
||
access_mode: input.accessMode,
|
||
url_slug: input.urlSlug,
|
||
password: input.password,
|
||
expires_at: input.expiresAt,
|
||
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function updatePublicationStatus(
|
||
publicationId: string,
|
||
input: {
|
||
accessMode: MindSpacePublishCheck['accessMode'];
|
||
expiresAt?: number | null;
|
||
},
|
||
): Promise<MindSpacePublication> {
|
||
const result = await apiFetch<{ data: MindSpacePublication }>(
|
||
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/update-status`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
access_mode: input.accessMode,
|
||
expires_at: input.expiresAt,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function redactMindSpacePage(
|
||
pageId: string,
|
||
input: {
|
||
pageVersionId: string;
|
||
expectedVersion: number;
|
||
title: string;
|
||
summary: string;
|
||
content: string;
|
||
},
|
||
): Promise<MindSpaceRedactedCopyResult> {
|
||
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/redact`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
page_version_id: input.pageVersionId,
|
||
expected_version: input.expectedVersion,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
content: input.content,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function fixMindSpacePagePublication(
|
||
pageId: string,
|
||
input: {
|
||
pageVersionId: string;
|
||
expectedVersion: number;
|
||
title: string;
|
||
summary: string;
|
||
content: string;
|
||
},
|
||
): Promise<MindSpaceRedactedCopyResult> {
|
||
const result = await apiFetch<{ data: MindSpaceRedactedCopyResult }>(
|
||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/publish-fix`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
page_version_id: input.pageVersionId,
|
||
expected_version: input.expectedVersion,
|
||
title: input.title,
|
||
summary: input.summary,
|
||
content: input.content,
|
||
}),
|
||
},
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
/** @deprecated use redactMindSpacePage */
|
||
export async function createMindSpaceRedactedCopy(
|
||
pageId: string,
|
||
pageVersionId: string,
|
||
): Promise<MindSpaceRedactedCopyResult> {
|
||
const page = await getMindSpacePage(pageId);
|
||
return redactMindSpacePage(pageId, {
|
||
pageVersionId,
|
||
expectedVersion: page.versionNo,
|
||
title: page.title,
|
||
summary: page.summary,
|
||
content: page.content ?? '',
|
||
});
|
||
}
|
||
|
||
export async function offlineMindSpacePublication(publicationId: string): Promise<void> {
|
||
await apiFetch(
|
||
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/offline`,
|
||
{ method: 'POST', body: JSON.stringify({}) },
|
||
);
|
||
}
|
||
|
||
export async function getMindSpacePublicationStats(
|
||
publicationId: string,
|
||
): Promise<MindSpacePublicationStats> {
|
||
const result = await apiFetch<{ data: MindSpacePublicationStats }>(
|
||
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/stats`,
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function listPlazaCategories(): Promise<PlazaCategory[]> {
|
||
const result = await apiFetch<{ data: { categories: PlazaCategory[] } }>('/plaza/v1/categories');
|
||
return result.data.categories;
|
||
}
|
||
|
||
export async function publishPageToPlaza(input: {
|
||
publicationId: string;
|
||
categoryId: string;
|
||
tags?: string[];
|
||
coverUrl?: string;
|
||
allowComment?: boolean;
|
||
}): Promise<PlazaPostBrief> {
|
||
const result = await apiFetch<{ data: { post: PlazaPostBrief } }>('/plaza/v1/posts', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
publication_id: input.publicationId,
|
||
category_id: input.categoryId,
|
||
tags: input.tags ?? [],
|
||
cover_url: input.coverUrl ?? '',
|
||
allow_comment: input.allowComment ?? true,
|
||
}),
|
||
});
|
||
return result.data.post;
|
||
}
|
||
|
||
export async function createMindSpaceAgentJob(input: {
|
||
jobType: string;
|
||
instruction: string;
|
||
allowedAssetIds: string[];
|
||
outputType?: 'page_draft' | 'html_page' | 'markdown';
|
||
outputCategoryId?: string;
|
||
idempotencyKey?: string;
|
||
locale?: string;
|
||
timezone?: string;
|
||
capabilities?: {
|
||
network?: boolean;
|
||
shell?: boolean;
|
||
createPage?: boolean;
|
||
};
|
||
}): Promise<MindSpaceAgentJob> {
|
||
const result = await apiFetch<{ data: MindSpaceAgentJob }>('/mindspace/v1/agent/jobs', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
job_type: input.jobType,
|
||
instruction: input.instruction,
|
||
allowed_asset_ids: input.allowedAssetIds,
|
||
output_type: input.outputType ?? 'page_draft',
|
||
output_category_id: input.outputCategoryId,
|
||
idempotency_key: input.idempotencyKey,
|
||
locale: input.locale,
|
||
timezone: input.timezone,
|
||
capabilities: input.capabilities,
|
||
}),
|
||
});
|
||
return result.data;
|
||
}
|
||
|
||
export async function getMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}`,
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export type MindSpaceListPage = {
|
||
total?: number;
|
||
offset?: number;
|
||
limit?: number;
|
||
has_more?: boolean;
|
||
};
|
||
|
||
export async function listMindSpaceAgentJobs(options?: {
|
||
limit?: number;
|
||
offset?: number;
|
||
}): Promise<{ items: MindSpaceAgentJob[]; page: MindSpaceListPage }> {
|
||
const limit = options?.limit ?? 10;
|
||
const offset = options?.offset ?? 0;
|
||
const result = await apiFetch<{ data: MindSpaceAgentJob[]; page?: MindSpaceListPage }>(
|
||
`/mindspace/v1/agent/jobs?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`,
|
||
);
|
||
return { items: result.data, page: result.page ?? {} };
|
||
}
|
||
|
||
export async function runMindSpaceAgentJob(
|
||
jobId: string,
|
||
): Promise<{ started: boolean; jobId: string }> {
|
||
const result = await apiFetch<{ data: { started: boolean; jobId: string } }>(
|
||
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/run`,
|
||
{ method: 'POST', body: JSON.stringify({}) },
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function cancelMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||
{ method: 'POST', body: JSON.stringify({}) },
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function retryMindSpaceAgentJob(jobId: string): Promise<MindSpaceAgentJob> {
|
||
const result = await apiFetch<{ data: MindSpaceAgentJob }>(
|
||
`/mindspace/v1/agent/jobs/${encodeURIComponent(jobId)}/retry`,
|
||
{ method: 'POST', body: JSON.stringify({}) },
|
||
);
|
||
return result.data;
|
||
}
|
||
|
||
export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary> {
|
||
const result = await portalFetch<{ summary: AdminDashboardSummary }>('/admin-api/summary');
|
||
return result.summary;
|
||
}
|
||
|
||
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
|
||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
|
||
return result.records ?? [];
|
||
}
|
||
|
||
export async function listAdminLedger(userId?: string): Promise<LedgerEntry[]> {
|
||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/admin-api/ledger${query}`);
|
||
return result.entries ?? [];
|
||
}
|
||
|
||
export async function listCapabilityCatalog(): Promise<CapabilityDefinition[]> {
|
||
const result = await portalFetch<{ catalog: CapabilityDefinition[] }>(
|
||
'/admin-api/capabilities/catalog',
|
||
);
|
||
return result.catalog ?? [];
|
||
}
|
||
|
||
export async function getRoleCapabilities(role: 'user' = 'user'): Promise<{
|
||
role: string;
|
||
capabilities: CapabilityMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/capabilities/role/${role}`);
|
||
}
|
||
|
||
export async function updateRoleCapabilities(
|
||
role: 'user',
|
||
capabilities: CapabilityMap,
|
||
): Promise<{ role: string; capabilities: CapabilityMap }> {
|
||
return portalFetch(`/admin-api/capabilities/role/${role}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ capabilities }),
|
||
});
|
||
}
|
||
|
||
export async function getUserCapabilities(userId: string): Promise<{
|
||
userId: string;
|
||
role: string;
|
||
unrestricted: boolean;
|
||
capabilities: CapabilityMap;
|
||
overrides: CapabilityMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/capabilities`);
|
||
}
|
||
|
||
export async function updateUserCapabilities(
|
||
userId: string,
|
||
capabilities: CapabilityMap,
|
||
): Promise<{
|
||
userId: string;
|
||
capabilities: CapabilityMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/capabilities`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ capabilities }),
|
||
});
|
||
}
|
||
|
||
export async function clearUserCapabilityOverrides(userId: string): Promise<void> {
|
||
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||
const result = await portalFetch<{ catalog: PolicyDefinition[] }>('/admin-api/policies/catalog');
|
||
return result.catalog ?? [];
|
||
}
|
||
|
||
export async function getRolePolicies(role: 'user' = 'user'): Promise<{
|
||
role: string;
|
||
policies: PolicyMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/policies/role/${role}`);
|
||
}
|
||
|
||
export async function updateRolePolicies(
|
||
role: 'user',
|
||
policies: PolicyMap,
|
||
): Promise<{ role: string; policies: PolicyMap }> {
|
||
return portalFetch(`/admin-api/policies/role/${role}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ policies }),
|
||
});
|
||
}
|
||
|
||
export async function getUserPolicies(userId: string): Promise<{
|
||
userId: string;
|
||
role: string;
|
||
unrestricted: boolean;
|
||
policies: PolicyMap;
|
||
overrides: PolicyMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/policies`);
|
||
}
|
||
|
||
export async function updateUserPolicies(
|
||
userId: string,
|
||
policies: PolicyMap,
|
||
): Promise<{
|
||
userId: string;
|
||
policies: PolicyMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/policies`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ policies }),
|
||
});
|
||
}
|
||
|
||
export async function clearUserPolicyOverrides(userId: string): Promise<void> {
|
||
await portalFetch(`/admin-api/users/${userId}/policies`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function listSkillCatalog(): Promise<SkillDefinition[]> {
|
||
const result = await portalFetch<{ catalog: SkillDefinition[] }>('/admin-api/skills/catalog');
|
||
return result.catalog ?? [];
|
||
}
|
||
|
||
export async function getRoleSkills(role: 'user' = 'user'): Promise<{
|
||
role: string;
|
||
skills: SkillMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/skills/role/${role}`);
|
||
}
|
||
|
||
export async function updateRoleSkills(
|
||
role: 'user',
|
||
skills: SkillMap,
|
||
): Promise<{ role: string; skills: SkillMap }> {
|
||
return portalFetch(`/admin-api/skills/role/${role}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ skills }),
|
||
});
|
||
}
|
||
|
||
export async function getUserSkills(userId: string): Promise<{
|
||
userId: string;
|
||
role: string;
|
||
skills: SkillMap;
|
||
grantedSkills: string[];
|
||
overrides: SkillMap;
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/skills`);
|
||
}
|
||
|
||
export async function updateUserSkills(
|
||
userId: string,
|
||
skills: SkillMap,
|
||
): Promise<{
|
||
userId: string;
|
||
skills: SkillMap;
|
||
grantedSkills: string[];
|
||
}> {
|
||
return portalFetch(`/admin-api/users/${userId}/skills`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ skills }),
|
||
});
|
||
}
|
||
|
||
export async function clearUserSkillOverrides(userId: string): Promise<void> {
|
||
await portalFetch(`/admin-api/users/${userId}/skills`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function login(username: string, password: string): Promise<PortalUser | null> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
user?: PortalUser;
|
||
mode?: string;
|
||
} | null;
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, body?.message ?? '登录失败');
|
||
}
|
||
resetUnauthorizedGuard();
|
||
if (body?.user) {
|
||
try {
|
||
const me = await getMe();
|
||
return me.user;
|
||
} catch {
|
||
return body.user;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export async function loginLegacy(password: string): Promise<void> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
if (!response.ok) {
|
||
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
||
throw new ApiError(response.status, body?.message ?? '登录失败');
|
||
}
|
||
}
|
||
|
||
export async function resetPassword(
|
||
username: string,
|
||
email: string,
|
||
password: string,
|
||
): Promise<void> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/reset-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, email, password }),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, body?.message ?? '重置失败');
|
||
}
|
||
}
|
||
|
||
export async function getWechatPendingProfile(token: string): Promise<WechatPendingProfile> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`/auth/wechat/pending/${encodeURIComponent(token)}`);
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as WechatPendingProfile & {
|
||
message?: string;
|
||
};
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, body?.message ?? '绑定会话无效');
|
||
}
|
||
return body;
|
||
}
|
||
|
||
export async function completeWechatRegister(pendingToken: string): Promise<{
|
||
user: PortalUser;
|
||
returnTo: string;
|
||
}> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/wechat/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pendingToken }),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
user?: PortalUser;
|
||
returnTo?: string;
|
||
};
|
||
if (!response.ok || !body?.user) {
|
||
throw new ApiError(response.status, body?.message ?? '微信注册失败');
|
||
}
|
||
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
||
}
|
||
|
||
export async function completeWechatBind(
|
||
pendingToken: string,
|
||
username: string,
|
||
password: string,
|
||
): Promise<{ user: PortalUser; returnTo: string }> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/wechat/bind', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pendingToken, username, password }),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
user?: PortalUser;
|
||
returnTo?: string;
|
||
};
|
||
if (!response.ok || !body?.user) {
|
||
throw new ApiError(response.status, body?.message ?? '绑定失败');
|
||
}
|
||
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
||
}
|
||
|
||
export async function getWechatBindingStatus(): Promise<WechatBindingStatus> {
|
||
try {
|
||
const response = await fetch('/auth/wechat/status');
|
||
if (!response.ok) return { enabled: false, bound: false };
|
||
return (await response.json()) as WechatBindingStatus;
|
||
} catch {
|
||
return { enabled: false, bound: false };
|
||
}
|
||
}
|
||
|
||
export async function listNotifications(status = 'unread', limit = 20): Promise<UserNotification[]> {
|
||
const params = new URLSearchParams();
|
||
if (status && status !== 'all') params.set('status', status);
|
||
params.set('limit', String(limit));
|
||
const response = await fetch(`/auth/notifications?${params}`);
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, '读取通知失败');
|
||
}
|
||
const body = (await response.json()) as { notifications?: UserNotification[] };
|
||
return body.notifications ?? [];
|
||
}
|
||
|
||
export function subscribeNotificationEvents({
|
||
onNotification,
|
||
onSync,
|
||
}: {
|
||
onNotification: (notification: UserNotification) => void;
|
||
onSync?: () => void;
|
||
}): () => void {
|
||
const source = new EventSource('/auth/notifications/events', { withCredentials: true });
|
||
source.addEventListener('notification', (event) => {
|
||
try {
|
||
const payload = JSON.parse((event as MessageEvent).data) as {
|
||
notification?: UserNotification;
|
||
};
|
||
if (payload.notification) onNotification(payload.notification);
|
||
} catch {
|
||
onSync?.();
|
||
}
|
||
});
|
||
source.addEventListener('sync', () => onSync?.());
|
||
source.onerror = () => {
|
||
// EventSource reconnects automatically; consumers can keep their last state.
|
||
};
|
||
return () => source.close();
|
||
}
|
||
|
||
export async function markNotificationRead(notificationId: string): Promise<void> {
|
||
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}/read`, {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, '通知状态更新失败');
|
||
}
|
||
}
|
||
|
||
export async function markAllNotificationsRead(): Promise<void> {
|
||
const response = await fetch('/auth/notifications/read-all', {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, '全部已读失败');
|
||
}
|
||
}
|
||
|
||
export async function deleteNotification(notificationId: string): Promise<void> {
|
||
const response = await fetch(`/auth/notifications/${encodeURIComponent(notificationId)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok && response.status !== 204) {
|
||
throw new ApiError(response.status, '删除通知失败');
|
||
}
|
||
}
|
||
|
||
export async function clearNotifications(status = 'all'): Promise<void> {
|
||
const response = await fetch(`/auth/notifications?status=${encodeURIComponent(status)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, '清空通知失败');
|
||
}
|
||
}
|
||
|
||
export async function getWechatAgentRouteStatus(): Promise<WechatAgentRouteStatus> {
|
||
try {
|
||
const response = await fetch('/auth/wechat/agent-route');
|
||
if (!response.ok) {
|
||
return {
|
||
enabled: false,
|
||
bound: false,
|
||
appId: null,
|
||
openid: null,
|
||
agentSessionId: null,
|
||
routeStatus: null,
|
||
updatedAt: null,
|
||
};
|
||
}
|
||
return (await response.json()) as WechatAgentRouteStatus;
|
||
} catch {
|
||
return {
|
||
enabled: false,
|
||
bound: false,
|
||
appId: null,
|
||
openid: null,
|
||
agentSessionId: null,
|
||
routeStatus: null,
|
||
updatedAt: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function resetWechatAgentRoute(): Promise<WechatAgentRouteStatus> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/wechat/agent-route/reset', {
|
||
method: 'POST',
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
route?: WechatAgentRouteStatus;
|
||
} | null;
|
||
if (!response.ok || !body?.route) {
|
||
throw new ApiError(response.status, body?.message ?? '重建公众号 Agent 路由失败');
|
||
}
|
||
return body.route;
|
||
}
|
||
|
||
export type WechatScanSession =
|
||
| {
|
||
mode: 'open';
|
||
state: string;
|
||
openAppId: string;
|
||
redirectUri: string;
|
||
expiresInMs: number;
|
||
}
|
||
| {
|
||
mode: 'mp';
|
||
state: string;
|
||
qrUrl: string;
|
||
expiresInMs: number;
|
||
};
|
||
|
||
export async function startWechatScanLogin(returnTo?: string): Promise<WechatScanSession> {
|
||
const params = new URLSearchParams();
|
||
if (returnTo) params.set('return_to', returnTo);
|
||
const query = params.toString();
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`/auth/wechat/scan/start${query ? `?${query}` : ''}`, {
|
||
method: 'POST',
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
mode?: 'open' | 'mp';
|
||
state?: string;
|
||
openAppId?: string;
|
||
redirectUri?: string;
|
||
qrUrl?: string;
|
||
expiresInMs?: number;
|
||
};
|
||
if (!response.ok || !body?.state || !body?.mode) {
|
||
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
||
}
|
||
if (body.mode === 'open') {
|
||
if (!body.openAppId || !body.redirectUri) {
|
||
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
||
}
|
||
return {
|
||
mode: 'open',
|
||
state: body.state,
|
||
openAppId: body.openAppId,
|
||
redirectUri: body.redirectUri,
|
||
expiresInMs: body.expiresInMs ?? 600000,
|
||
};
|
||
}
|
||
if (!body.qrUrl) {
|
||
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
||
}
|
||
return {
|
||
mode: 'mp',
|
||
state: body.state,
|
||
qrUrl: body.qrUrl,
|
||
expiresInMs: body.expiresInMs ?? 600000,
|
||
};
|
||
}
|
||
|
||
export async function pollWechatScanLogin(state: string): Promise<{
|
||
status: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
||
pendingToken?: string;
|
||
message?: string;
|
||
}> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`/auth/wechat/scan/poll?state=${encodeURIComponent(state)}`);
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
status?: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
||
pendingToken?: string;
|
||
message?: string;
|
||
};
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, body?.message ?? '扫码状态查询失败');
|
||
}
|
||
return {
|
||
status: body?.status ?? 'error',
|
||
pendingToken: body?.pendingToken,
|
||
message: body?.message,
|
||
};
|
||
}
|
||
|
||
export async function register(
|
||
username: string,
|
||
password: string,
|
||
displayName?: string,
|
||
email?: string,
|
||
attribution?: {
|
||
utm_source?: string;
|
||
utm_medium?: string;
|
||
utm_campaign?: string;
|
||
ref?: string;
|
||
},
|
||
): Promise<PortalUser> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch('/auth/register', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
username,
|
||
password,
|
||
displayName,
|
||
email,
|
||
...(attribution ?? {}),
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
throw new ApiError(0, formatNetworkError(err));
|
||
}
|
||
const body = (await response.json().catch(() => null)) as {
|
||
message?: string;
|
||
user?: PortalUser;
|
||
} | null;
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, body?.message ?? '注册失败');
|
||
}
|
||
if (!body?.user) throw new ApiError(response.status, '注册失败');
|
||
return body.user;
|
||
}
|
||
|
||
export async function listAdminUsers(): Promise<AdminUserRow[]> {
|
||
const result = await portalFetch<{ users: AdminUserRow[] }>('/admin-api/users');
|
||
return result.users ?? [];
|
||
}
|
||
|
||
export async function createAdminUser(payload: {
|
||
username: string;
|
||
password: string;
|
||
displayName?: string;
|
||
workspaceRoot?: string;
|
||
balanceCents?: number;
|
||
role?: 'user' | 'admin';
|
||
email?: string;
|
||
}): Promise<PortalUser> {
|
||
const result = await portalFetch<{ user: PortalUser }>('/admin-api/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
return result.user;
|
||
}
|
||
|
||
export async function updateAdminUser(
|
||
userId: string,
|
||
payload: Partial<{
|
||
displayName: string;
|
||
status: 'active' | 'suspended' | 'disabled';
|
||
workspaceRoot: string;
|
||
balanceCents: number;
|
||
role: 'user' | 'admin';
|
||
}>,
|
||
): Promise<PortalUser> {
|
||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
return result.user;
|
||
}
|
||
|
||
export async function rechargeUser(
|
||
userId: string,
|
||
amountCents: number,
|
||
note?: string,
|
||
): Promise<PortalUser> {
|
||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}/recharge`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ amountCents, note }),
|
||
});
|
||
return result.user;
|
||
}
|
||
|
||
export async function listLlmProviderCatalog(): Promise<LlmProviderDefinition[]> {
|
||
const result = await portalFetch<{ catalog: LlmProviderDefinition[] }>(
|
||
'/admin-api/llm-providers/catalog',
|
||
);
|
||
return result.catalog ?? [];
|
||
}
|
||
|
||
export async function listLlmProviderKeys(): Promise<LlmProviderKeyRow[]> {
|
||
const result = await portalFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
|
||
return result.keys ?? [];
|
||
}
|
||
|
||
export async function createLlmProviderKey(payload: {
|
||
providerId: string;
|
||
name: string;
|
||
apiKey: string;
|
||
defaultModel?: string;
|
||
apiUrl?: string;
|
||
basePath?: string;
|
||
models?: string | string[];
|
||
engine?: string;
|
||
relayProvider?: string;
|
||
}): Promise<LlmProviderKeyRow> {
|
||
const result = await portalFetch<{ key: LlmProviderKeyRow }>('/admin-api/llm-providers/keys', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
return result.key;
|
||
}
|
||
|
||
export async function updateLlmProviderKey(
|
||
keyId: string,
|
||
payload: {
|
||
name?: string;
|
||
apiKey?: string;
|
||
defaultModel?: string;
|
||
status?: 'active' | 'disabled';
|
||
apiUrl?: string;
|
||
basePath?: string;
|
||
models?: string | string[];
|
||
engine?: string;
|
||
relayProvider?: string;
|
||
},
|
||
): Promise<LlmProviderKeyRow> {
|
||
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
||
`/admin-api/llm-providers/keys/${keyId}`,
|
||
{
|
||
method: 'PATCH',
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
return result.key;
|
||
}
|
||
|
||
export async function selectLlmProviderKey(keyId: string): Promise<LlmProviderKeyRow> {
|
||
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
||
`/admin-api/llm-providers/keys/${keyId}/select`,
|
||
{ method: 'POST' },
|
||
);
|
||
return result.key;
|
||
}
|
||
|
||
export async function deleteLlmProviderKey(keyId: string): Promise<void> {
|
||
await portalFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function syncLlmProviderToGoosed(): Promise<{ synced: boolean }> {
|
||
return portalFetch('/admin-api/llm-providers/sync', { method: 'POST' });
|
||
}
|
||
|
||
export async function getLlmGlobalSettings(): Promise<LlmGlobalSettings> {
|
||
const result = await portalFetch<{ global: LlmGlobalSettings }>('/admin-api/llm-providers/global');
|
||
return result.global;
|
||
}
|
||
|
||
export async function setLlmGlobalModel(model: string): Promise<{ global: LlmGlobalSettings }> {
|
||
return portalFetch('/admin-api/llm-providers/global', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ model }),
|
||
});
|
||
}
|
||
|
||
export async function testLlmProviderDraft(payload: {
|
||
providerId: string;
|
||
name: string;
|
||
apiKey: string;
|
||
apiUrl?: string;
|
||
basePath?: string;
|
||
models?: string | string[];
|
||
defaultModel?: string;
|
||
testModel?: string;
|
||
engine?: string;
|
||
relayProvider?: string;
|
||
}): Promise<LlmConnectionTestResult> {
|
||
return portalFetch('/admin-api/llm-providers/test', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function testLlmProviderKey(
|
||
keyId: string,
|
||
model?: string,
|
||
): Promise<LlmConnectionTestResult> {
|
||
return portalFetch(`/admin-api/llm-providers/keys/${keyId}/test`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(model ? { model } : {}),
|
||
});
|
||
}
|
||
|
||
export async function logout(): Promise<void> {
|
||
if (import.meta.env.DEV) return;
|
||
await fetch('/auth/logout', { method: 'POST' });
|
||
}
|
||
|
||
export async function resumeSession(
|
||
sessionId: string,
|
||
options?: { skipReconcile?: boolean },
|
||
): Promise<Session> {
|
||
const result = await apiFetch<{ session: Session }>(
|
||
'/agent/resume',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
load_model_and_extensions: true,
|
||
...(options?.skipReconcile ? { skip_reconcile: true } : {}),
|
||
}),
|
||
},
|
||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||
);
|
||
return result.session;
|
||
}
|
||
|
||
export async function readConfig(key: string): Promise<string | null> {
|
||
return apiFetch<string | null>('/config/read', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ key, is_secret: false }),
|
||
});
|
||
}
|
||
|
||
export async function updateProvider(
|
||
sessionId: string,
|
||
provider: string,
|
||
model: string,
|
||
): Promise<void> {
|
||
await apiFetch('/agent/update_provider', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
provider,
|
||
model,
|
||
}),
|
||
});
|
||
}
|
||
|
||
export async function applyLocalLlmFallback(sessionId: string): Promise<{
|
||
ok: boolean;
|
||
providerId?: string;
|
||
model?: string;
|
||
source?: string;
|
||
message?: string;
|
||
}> {
|
||
return apiFetch('/llm/apply-local-fallback', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session_id: sessionId }),
|
||
});
|
||
}
|
||
|
||
export async function listSessions(options?: {
|
||
limit?: number;
|
||
offset?: number;
|
||
query?: string;
|
||
}): Promise<{ items: SessionSummary[]; page: SessionListPage }> {
|
||
const params = new URLSearchParams();
|
||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||
if (options?.offset != null) params.set('offset', String(options.offset));
|
||
if (options?.query?.trim()) params.set('query', options.query.trim());
|
||
const qs = params.size ? `?${params.toString()}` : '';
|
||
const result = await apiFetch<SessionListResponse>(`/sessions${qs}`);
|
||
return { items: result.sessions ?? [], page: result.page ?? {} };
|
||
}
|
||
|
||
function sessionPath(sessionId: string, suffix = '') {
|
||
return `/sessions/${encodeURIComponent(sessionId)}${suffix}`;
|
||
}
|
||
|
||
export async function getSession(sessionId: string): Promise<Session> {
|
||
return apiFetch<Session>(sessionPath(sessionId));
|
||
}
|
||
|
||
export async function deleteChatSession(sessionId: string): Promise<void> {
|
||
await apiFetch(sessionPath(sessionId), { method: 'DELETE' });
|
||
}
|
||
|
||
export async function loadSessionDetail(
|
||
sessionId: string,
|
||
hints?: { messageCount?: number; updatedAt?: string },
|
||
history?: { before?: number; limit?: number },
|
||
): Promise<{
|
||
session: Session;
|
||
messages: Message[];
|
||
page: SessionConversationPage;
|
||
}> {
|
||
const params = new URLSearchParams();
|
||
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
|
||
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
|
||
if (history?.before != null) params.set('history_before', String(history.before));
|
||
if (history?.limit != null) params.set('history_limit', String(history.limit));
|
||
const qs = params.size ? `?${params.toString()}` : '';
|
||
const detail = await apiFetch<Session>(`${sessionPath(sessionId)}${qs}`);
|
||
const messages = normalizeConversationMessages(
|
||
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
|
||
);
|
||
return { session: detail, messages, page: detail.conversation_page ?? {} };
|
||
}
|
||
|
||
export async function createAgentRun(
|
||
sessionId: string | null,
|
||
requestId: string,
|
||
userMessage: Message,
|
||
options: AgentRunCreateOptions = {},
|
||
): Promise<AgentRun> {
|
||
const userMessagePayload = prepareAgentRunUserMessage(userMessage, options);
|
||
const result = await apiFetch<{ run: AgentRun }>(
|
||
AGENT_RUNS_PATH,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
request_id: requestId,
|
||
user_message: userMessagePayload,
|
||
...(options.toolMode ? { tool_mode: options.toolMode } : {}),
|
||
...(options.taskType ? { task_type: options.taskType } : {}),
|
||
}),
|
||
},
|
||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||
);
|
||
return result.run;
|
||
}
|
||
|
||
export async function getAgentRun(runId: string): Promise<AgentRun> {
|
||
const result = await apiFetch<{ run: AgentRun }>(`${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}`);
|
||
return result.run;
|
||
}
|
||
|
||
export async function cancelRequest(sessionId: string, requestId: string): Promise<void> {
|
||
await apiFetch(`${sessionPath(sessionId)}/cancel`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ request_id: requestId }),
|
||
});
|
||
}
|
||
|
||
export async function confirmTool(
|
||
sessionId: string,
|
||
id: string,
|
||
action: 'allow_once' | 'deny_once' | 'always_allow',
|
||
): Promise<void> {
|
||
await apiFetch('/action-required/tool-confirmation', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId,
|
||
id,
|
||
action,
|
||
principalType: 'Tool',
|
||
}),
|
||
});
|
||
}
|
||
|
||
type SubscribeOptions = {
|
||
pauseWhenHidden?: boolean;
|
||
onBalance?: (update: BalanceUpdate) => void;
|
||
};
|
||
|
||
export function subscribeAgentRunEvents(
|
||
runId: string,
|
||
onRun: (run: AgentRun) => void,
|
||
onError: (error: Error) => void,
|
||
): () => void {
|
||
let closed = false;
|
||
let activeController: AbortController | null = null;
|
||
|
||
const run = async (controller: AbortController) => {
|
||
while (!closed && !controller.signal.aborted) {
|
||
try {
|
||
const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, {
|
||
headers: { Accept: 'text/event-stream' },
|
||
credentials: 'same-origin',
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
throw new ApiError(401, 'SSE 未授权');
|
||
}
|
||
|
||
if (!res.ok || !res.body) {
|
||
throw new ApiError(res.status, `SSE failed: ${res.status}`);
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (!closed && !controller.signal.aborted) {
|
||
const { done, value } = await reader.read();
|
||
if (done) return;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const chunks = buffer.split('\n\n');
|
||
buffer = chunks.pop() ?? '';
|
||
|
||
for (const chunk of chunks) {
|
||
let eventName = 'message';
|
||
let data = '';
|
||
for (const line of chunk.split('\n')) {
|
||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||
if (line.startsWith('data:')) data = line.slice(5).trim();
|
||
}
|
||
if (!data) continue;
|
||
if (eventName === 'run') {
|
||
const payload = JSON.parse(data) as { run: AgentRun };
|
||
onRun(payload.run);
|
||
continue;
|
||
}
|
||
if (eventName === 'error') {
|
||
const payload = JSON.parse(data) as { message?: string };
|
||
throw new Error(payload.message || '后台任务失败');
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (controller.signal.aborted || closed) return;
|
||
onError(err instanceof Error ? err : new Error(String(err)));
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
activeController = new AbortController();
|
||
void run(activeController);
|
||
|
||
return () => {
|
||
closed = true;
|
||
activeController?.abort();
|
||
};
|
||
}
|
||
|
||
export function subscribeSessionEvents(
|
||
sessionId: string,
|
||
onEvent: (event: SessionEvent) => void,
|
||
onError: (error: Error) => void,
|
||
options: SubscribeOptions = {},
|
||
): () => void {
|
||
const { pauseWhenHidden = true, onBalance } = options;
|
||
let lastEventId: string | undefined;
|
||
let closed = false;
|
||
let paused = false;
|
||
let activeController: AbortController | null = null;
|
||
|
||
const run = async (controller: AbortController) => {
|
||
while (!closed && !controller.signal.aborted && !paused) {
|
||
try {
|
||
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
||
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
|
||
|
||
const res = await fetch(`${API}${sessionPath(sessionId)}/events`, {
|
||
headers,
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (res.status === 401) {
|
||
notifyUnauthorized();
|
||
throw new ApiError(401, 'SSE 未授权');
|
||
}
|
||
|
||
if (!res.ok || !res.body) {
|
||
throw new ApiError(res.status, `SSE failed: ${res.status}`);
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (!closed && !controller.signal.aborted && !paused) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const chunks = buffer.split('\n\n');
|
||
buffer = chunks.pop() ?? '';
|
||
|
||
for (const chunk of chunks) {
|
||
let eventId: string | undefined;
|
||
let eventName: string | undefined;
|
||
let data: string | undefined;
|
||
for (const line of chunk.split('\n')) {
|
||
if (line.startsWith('id:')) eventId = line.slice(3).trim();
|
||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||
if (line.startsWith('data:')) data = line.slice(5).trim();
|
||
}
|
||
if (eventId) lastEventId = eventId;
|
||
if (!data) continue;
|
||
try {
|
||
if (eventName === 'balance') {
|
||
const payload = JSON.parse(data) as BalanceUpdate & { balanceCents?: number };
|
||
if (typeof payload.balanceCents === 'number') {
|
||
onBalance?.({
|
||
balanceCents: payload.balanceCents,
|
||
tokensUsed:
|
||
typeof payload.tokensUsed === 'number' ? payload.tokensUsed : undefined,
|
||
lastUsage: payload.lastUsage,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
onEvent(sanitizeSessionEvent(JSON.parse(data) as SessionEvent));
|
||
} catch {
|
||
// ignore malformed frames
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (controller.signal.aborted || closed || paused) return;
|
||
onError(err instanceof Error ? err : new Error(String(err)));
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
}
|
||
}
|
||
};
|
||
|
||
const startConnection = () => {
|
||
activeController?.abort();
|
||
activeController = new AbortController();
|
||
void run(activeController);
|
||
};
|
||
|
||
const handleVisibility = () => {
|
||
if (!pauseWhenHidden) return;
|
||
if (document.hidden) {
|
||
paused = true;
|
||
activeController?.abort();
|
||
} else if (paused) {
|
||
paused = false;
|
||
startConnection();
|
||
}
|
||
};
|
||
|
||
if (pauseWhenHidden) {
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
}
|
||
|
||
startConnection();
|
||
return () => {
|
||
closed = true;
|
||
activeController?.abort();
|
||
if (pauseWhenHidden) {
|
||
document.removeEventListener('visibilitychange', handleVisibility);
|
||
}
|
||
};
|
||
}
|