- 引入 DirectAnnouncementCallback 类型用于处理直连模式下的公告 - 在 DelegationManager 中添加 _direct_announcement_callback 属性和设置方法 - 实现 _notify_direct_announcement 方法用于在非总线模式下将公告回写到本地会话 - 在委托取消、完成和分组完成时添加对直连公告的通知逻辑 feat(web): 增加 WebSocket 广播器支持实时会话更新通知 - 创建 WebSocketBroadcaster 类用于跟踪认证的 WebSocket 连接并广播 JSON 事件 - 在应用启动时初始化 websocket_broadcaster 实例 - 实现连接注册、注销和消息广播功能 - 添加过期连接清理机制 feat(agent): 新增系统公告处理方法支持本地处理 - 在 AgentLoop 中添加 process_system_announcement 方法用于在无常驻 run() 场景下处理系统公告 - 创建 InboundMessage 并通过 _process_message 进行处理 feat(cron): 改进定时任务的会话路由解析和实时更新 - 添加 _resolve_cron_session_key 和 _infer_cron_route_from_session_key 辅助函数 - 在 cron 任务执行完成后通过 WebSocket 广播会话更新事件 - 在添加定时任务时自动推断目标会话的渠道和聊天 ID refactor: 项目名称从 Boardware Genius 统一改为 Boardware Agent Sandbox - 更新前端页面标题和描述文本中的产品名称 - 添加新的品牌 Logo 图片资源 - 在前端布局中使用新的 Logo 显示 - 更新授权门户中的品牌信息和 Logo 显示 feat(frontend): 添加会话更新事件监听实现消息自动刷新 - 定义 SessionUpdatedEvent 类型接口 - 在 ChatPage 中添加会话更新事件的处理逻辑 - 当收到会话更新事件时自动重新加载会话列表和当前会话消息 feat(api): 扩展定时任务 API 支持会话键参数 - 在 addCronJob API 参数中添加 session_key 字段 - 更新前端 Cron 页面的表单处理以传递当前会话键
1127 lines
32 KiB
TypeScript
1127 lines
32 KiB
TypeScript
// Nanobot API client — single-user direct mode.
|
||
|
||
import type {
|
||
AuthzBackendRecord,
|
||
AuthzChannelSettings,
|
||
AuthzRegisterBackendResponse,
|
||
AuthzStatus,
|
||
AuthUser,
|
||
ChatMessage,
|
||
CronJob,
|
||
FileAttachment,
|
||
Marketplace,
|
||
MarketplacePlugin,
|
||
PluginInfo,
|
||
Session,
|
||
SessionDetail,
|
||
Skill,
|
||
SlashCommand,
|
||
SystemStatus,
|
||
TokenResponse,
|
||
OutlookConnectionPayload,
|
||
OutlookConnectionTestResult,
|
||
OutlookConnectResult,
|
||
OutlookEventListResponse,
|
||
OutlookMessageDetail,
|
||
OutlookMessageListResponse,
|
||
OutlookOverview,
|
||
OutlookStatus,
|
||
UiAgentDescriptor,
|
||
UiMcpServerDescriptor,
|
||
WsEvent,
|
||
} from '@/types';
|
||
|
||
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
|
||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
|
||
const DEFAULT_API_URL = 'http://127.0.0.1:18080';
|
||
const ACCESS_TOKEN_KEY = 'nanobot_access_token';
|
||
const REFRESH_TOKEN_KEY = 'nanobot_refresh_token';
|
||
const REQUEST_TIMEOUT_MS = 8000;
|
||
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
||
|
||
function isBrowser(): boolean {
|
||
return typeof window !== 'undefined';
|
||
}
|
||
|
||
function normalizeBaseUrl(value?: string | null): string | null {
|
||
const trimmed = value?.trim();
|
||
if (!trimmed) return null;
|
||
return trimmed.replace(/\/+$/, '');
|
||
}
|
||
|
||
export function buildAuthHandoffUrl(response: TokenResponse, nextPath: string): string | null {
|
||
const targetBaseUrl = normalizeBaseUrl(
|
||
response.backend_connection?.frontend_base_url ||
|
||
response.backend_connection?.public_base_url ||
|
||
response.backend_connection?.api_base_url ||
|
||
response.local_backend?.public_base_url
|
||
);
|
||
if (!targetBaseUrl) return null;
|
||
const handoffCode = response.handoff_code?.trim();
|
||
if (!handoffCode) return null;
|
||
|
||
const target = new URL('/handoff', targetBaseUrl);
|
||
target.searchParams.set('code', handoffCode);
|
||
if (nextPath) {
|
||
target.searchParams.set('next', nextPath);
|
||
}
|
||
return target.toString();
|
||
}
|
||
|
||
function getApiBaseUrl(): string {
|
||
if (API_URL) return API_URL;
|
||
if (isBrowser()) return window.location.origin;
|
||
return DEFAULT_API_URL;
|
||
}
|
||
|
||
function buildApiUrl(path: string): string {
|
||
if (!API_URL && isBrowser()) {
|
||
return path;
|
||
}
|
||
return `${getApiBaseUrl()}${path}`;
|
||
}
|
||
|
||
type FetchJsonOptions = RequestInit & {
|
||
timeoutMs?: number;
|
||
};
|
||
|
||
function withTimeout(
|
||
signal?: AbortSignal,
|
||
timeoutMs: number = REQUEST_TIMEOUT_MS
|
||
): { signal: AbortSignal; cleanup: () => void } {
|
||
const controller = new AbortController();
|
||
const timeoutId = globalThis.setTimeout(() => {
|
||
controller.abort(new DOMException('请求超时', 'AbortError'));
|
||
}, timeoutMs);
|
||
|
||
const forwardAbort = () => controller.abort(signal?.reason);
|
||
signal?.addEventListener('abort', forwardAbort, { once: true });
|
||
|
||
return {
|
||
signal: controller.signal,
|
||
cleanup: () => {
|
||
globalThis.clearTimeout(timeoutId);
|
||
signal?.removeEventListener('abort', forwardAbort);
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Token management
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function getAccessToken(): string | null {
|
||
if (!isBrowser()) return null;
|
||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||
}
|
||
|
||
export function getRefreshToken(): string | null {
|
||
if (!isBrowser()) return null;
|
||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||
}
|
||
|
||
export function setTokens(access: string, refresh: string): void {
|
||
if (!isBrowser()) return;
|
||
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||
}
|
||
|
||
export function clearTokens(): void {
|
||
if (!isBrowser()) return;
|
||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||
}
|
||
|
||
export function isLoggedIn(): boolean {
|
||
return !!getAccessToken();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// HTTP helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function authHeaders(includeJsonContentType: boolean = true): Record<string, string> {
|
||
const headers: Record<string, string> = {};
|
||
if (includeJsonContentType) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
const token = getAccessToken();
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T> {
|
||
const mergedHeaders = {
|
||
...authHeaders(),
|
||
...(options?.headers as Record<string, string> | undefined),
|
||
};
|
||
const { signal, cleanup } = withTimeout(options?.signal ?? undefined, options?.timeoutMs);
|
||
let res: Response;
|
||
|
||
try {
|
||
res = await fetch(buildApiUrl(path), {
|
||
headers: mergedHeaders,
|
||
...options,
|
||
signal,
|
||
});
|
||
} catch (error) {
|
||
cleanup();
|
||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||
throw new Error('请求超时');
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
cleanup();
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
if (res.status === 401) {
|
||
clearTokens();
|
||
}
|
||
let detail = text;
|
||
try {
|
||
const parsed = JSON.parse(text);
|
||
if (parsed && typeof parsed.detail === 'string') {
|
||
detail = parsed.detail;
|
||
}
|
||
} catch {
|
||
// keep raw text
|
||
}
|
||
throw new Error(`接口错误 ${res.status}: ${detail}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function register(username: string, email: string, password: string): Promise<TokenResponse> {
|
||
return fetchJSON('/api/auth/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, email, password }),
|
||
});
|
||
}
|
||
|
||
export async function login(username: string, password: string): Promise<TokenResponse> {
|
||
return fetchJSON('/api/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
}
|
||
|
||
export async function consumeHandoffCode(code: string): Promise<TokenResponse> {
|
||
return fetchJSON('/api/auth/handoff/consume', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ code }),
|
||
});
|
||
}
|
||
|
||
export async function logout(): Promise<void> {
|
||
try {
|
||
await fetchJSON('/api/auth/logout', { method: 'POST' });
|
||
} catch {
|
||
// ignore logout network failures
|
||
} finally {
|
||
clearTokens();
|
||
}
|
||
}
|
||
|
||
export async function getMe(): Promise<AuthUser> {
|
||
return fetchJSON('/api/auth/me');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chat (proxied via /api/)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function sendMessage(
|
||
message: string,
|
||
sessionId: string = 'web:default',
|
||
attachments?: FileAttachment[]
|
||
): Promise<{ response?: string; status?: string; session_id: string }> {
|
||
const body: Record<string, unknown> = { message, session_id: sessionId };
|
||
if (attachments && attachments.length > 0) {
|
||
body.attachments = attachments;
|
||
}
|
||
return fetchJSON('/api/chat', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
export function streamMessage(
|
||
message: string,
|
||
sessionId: string,
|
||
onChunk: (content: string) => void,
|
||
onDone: () => void,
|
||
onError: (error: string) => void
|
||
): () => void {
|
||
const controller = new AbortController();
|
||
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(buildApiUrl('/api/chat/stream'), {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify({ message, session_id: sessionId }),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!res.ok || !res.body) {
|
||
onError(`HTTP 错误 ${res.status}`);
|
||
return;
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
const data = line.slice(6);
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
if (parsed.type === 'content') {
|
||
onChunk(parsed.content);
|
||
} else if (parsed.type === 'done') {
|
||
onDone();
|
||
} else if (parsed.type === 'error') {
|
||
onError(parsed.error);
|
||
}
|
||
} catch {
|
||
// skip parse errors
|
||
}
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
if (err.name !== 'AbortError') {
|
||
onError(err.message || '流式请求失败');
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => controller.abort();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WebSocket Manager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type WsStatus = 'disconnected' | 'connecting' | 'connected';
|
||
|
||
export type WsMessageHandler = (data: WsEvent | Record<string, unknown>) => void;
|
||
|
||
export type WsStatusListener = (status: WsStatus) => void;
|
||
|
||
function getWsUrl(): string {
|
||
const baseUrl =
|
||
WS_URL ||
|
||
(API_URL ? API_URL : isBrowser() ? window.location.origin : DEFAULT_API_URL);
|
||
const url = new URL(baseUrl);
|
||
let protocol: 'ws:' | 'wss:';
|
||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||
protocol = url.protocol;
|
||
} else if (url.protocol === 'https:') {
|
||
protocol = 'wss:';
|
||
} else {
|
||
protocol = 'ws:';
|
||
}
|
||
return `${protocol}//${url.host}`;
|
||
}
|
||
|
||
class WebSocketManager {
|
||
private ws: WebSocket | null = null;
|
||
private sessionId: string | null = null;
|
||
private messageHandlers: WsMessageHandler[] = [];
|
||
private statusListeners: WsStatusListener[] = [];
|
||
private status: WsStatus = 'disconnected';
|
||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||
private reconnectDelay = 1000;
|
||
private maxReconnectDelay = 30000;
|
||
private intentionalClose = false;
|
||
|
||
connect(sessionId: string): void {
|
||
if (this.sessionId === sessionId && this.ws?.readyState === globalThis.WebSocket?.OPEN) {
|
||
return;
|
||
}
|
||
|
||
this.intentionalClose = false;
|
||
this.sessionId = sessionId;
|
||
this.reconnectDelay = 1000;
|
||
this._connect();
|
||
}
|
||
|
||
disconnect(): void {
|
||
this.intentionalClose = true;
|
||
this._cleanup();
|
||
this._setStatus('disconnected');
|
||
}
|
||
|
||
sendMessage(content: string): void {
|
||
if (this.ws?.readyState === globalThis.WebSocket?.OPEN) {
|
||
this.ws.send(JSON.stringify({ type: 'message', content }));
|
||
}
|
||
}
|
||
|
||
sendRaw(payload: Record<string, unknown>): void {
|
||
if (this.ws?.readyState === globalThis.WebSocket?.OPEN) {
|
||
this.ws.send(JSON.stringify(payload));
|
||
}
|
||
}
|
||
|
||
onMessage(handler: WsMessageHandler): () => void {
|
||
this.messageHandlers.push(handler);
|
||
return () => {
|
||
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
|
||
};
|
||
}
|
||
|
||
onStatusChange(listener: WsStatusListener): () => void {
|
||
this.statusListeners.push(listener);
|
||
listener(this.status);
|
||
return () => {
|
||
this.statusListeners = this.statusListeners.filter((l) => l !== listener);
|
||
};
|
||
}
|
||
|
||
getStatus(): WsStatus {
|
||
return this.status;
|
||
}
|
||
|
||
private _connect(): void {
|
||
this._cleanup();
|
||
|
||
if (!this.sessionId) return;
|
||
|
||
this._setStatus('connecting');
|
||
|
||
const wsUrl = getWsUrl();
|
||
const token = getAccessToken();
|
||
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||
const ws = new globalThis.WebSocket(`${wsUrl}/ws/${this.sessionId}${query}`);
|
||
|
||
ws.onopen = () => {
|
||
this.reconnectDelay = 1000;
|
||
this._setStatus('connected');
|
||
this._startPing();
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'pong') return;
|
||
for (const handler of this.messageHandlers) {
|
||
handler(data);
|
||
}
|
||
} catch {
|
||
// ignore parse errors
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
this._stopPing();
|
||
if (!this.intentionalClose) {
|
||
this._setStatus('disconnected');
|
||
this._scheduleReconnect();
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
// onclose will fire after onerror
|
||
};
|
||
|
||
this.ws = ws;
|
||
}
|
||
|
||
private _cleanup(): void {
|
||
this._stopPing();
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer);
|
||
this.reconnectTimer = null;
|
||
}
|
||
if (this.ws) {
|
||
this.ws.onopen = null;
|
||
this.ws.onmessage = null;
|
||
this.ws.onclose = null;
|
||
this.ws.onerror = null;
|
||
if (this.ws.readyState === globalThis.WebSocket?.OPEN ||
|
||
this.ws.readyState === globalThis.WebSocket?.CONNECTING) {
|
||
this.ws.close();
|
||
}
|
||
this.ws = null;
|
||
}
|
||
}
|
||
|
||
private _scheduleReconnect(): void {
|
||
if (this.reconnectTimer) return;
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.reconnectTimer = null;
|
||
this._connect();
|
||
}, this.reconnectDelay);
|
||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
||
}
|
||
|
||
private _startPing(): void {
|
||
this._stopPing();
|
||
this.pingTimer = setInterval(() => {
|
||
if (this.ws?.readyState === globalThis.WebSocket?.OPEN) {
|
||
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
private _stopPing(): void {
|
||
if (this.pingTimer) {
|
||
clearInterval(this.pingTimer);
|
||
this.pingTimer = null;
|
||
}
|
||
}
|
||
|
||
private _setStatus(status: WsStatus): void {
|
||
if (this.status === status) return;
|
||
this.status = status;
|
||
for (const listener of this.statusListeners) {
|
||
listener(status);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const wsManager = new WebSocketManager();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sessions (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function listSessions(): Promise<Session[]> {
|
||
return fetchJSON('/api/sessions');
|
||
}
|
||
|
||
export async function createSession(key: string): Promise<SessionDetail> {
|
||
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'POST' });
|
||
}
|
||
|
||
export async function getSession(key: string): Promise<SessionDetail> {
|
||
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}`);
|
||
}
|
||
|
||
export async function deleteSession(key: string): Promise<void> {
|
||
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Status (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function getStatus(): Promise<SystemStatus> {
|
||
return fetchJSON('/api/status');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cron (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function listCronJobs(includeDisabled: boolean = true): Promise<CronJob[]> {
|
||
return fetchJSON(`/api/cron/jobs?include_disabled=${includeDisabled}`);
|
||
}
|
||
|
||
export async function addCronJob(params: {
|
||
name: string;
|
||
message: string;
|
||
every_seconds?: number;
|
||
cron_expr?: string;
|
||
at_iso?: string;
|
||
session_key?: string;
|
||
}): Promise<CronJob> {
|
||
return fetchJSON('/api/cron/jobs', {
|
||
method: 'POST',
|
||
body: JSON.stringify(params),
|
||
});
|
||
}
|
||
|
||
export async function removeCronJob(jobId: string): Promise<void> {
|
||
await fetchJSON(`/api/cron/jobs/${jobId}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function toggleCronJob(jobId: string, enabled: boolean): Promise<CronJob> {
|
||
return fetchJSON(`/api/cron/jobs/${jobId}/toggle`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ enabled }),
|
||
});
|
||
}
|
||
|
||
export async function runCronJob(jobId: string): Promise<void> {
|
||
await fetchJSON(`/api/cron/jobs/${jobId}/run`, { method: 'POST' });
|
||
}
|
||
|
||
export async function ping(): Promise<{ message: string }> {
|
||
return fetchJSON('/api/ping');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skills (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function listSkills(): Promise<Skill[]> {
|
||
return fetchJSON('/api/skills');
|
||
}
|
||
|
||
export async function listCommands(): Promise<SlashCommand[]> {
|
||
return fetchJSON('/api/commands');
|
||
}
|
||
|
||
export async function listPlugins(): Promise<PluginInfo[]> {
|
||
return fetchJSON('/api/plugins');
|
||
}
|
||
|
||
export async function listAgents(): Promise<UiAgentDescriptor[]> {
|
||
return fetchJSON('/api/agents');
|
||
}
|
||
|
||
export async function addAgent(payload: {
|
||
id?: string;
|
||
name?: string;
|
||
description?: string;
|
||
protocol?: string;
|
||
base_url?: string;
|
||
endpoint?: string;
|
||
card_url?: string;
|
||
auth_env?: string;
|
||
auth_mode?: string;
|
||
auth_audience?: string;
|
||
auth_scopes?: string[];
|
||
enabled?: boolean;
|
||
tags?: string[];
|
||
aliases?: string[];
|
||
metadata?: Record<string, unknown>;
|
||
}): Promise<UiAgentDescriptor> {
|
||
return fetchJSON('/api/agents', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function deleteAgent(agentId: string): Promise<void> {
|
||
await fetchJSON(`/api/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function refreshAgents(): Promise<{ agents: UiAgentDescriptor[] }> {
|
||
return fetchJSON('/api/agents/refresh', { method: 'POST' });
|
||
}
|
||
|
||
export async function cancelDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
|
||
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/cancel`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function listMcpServers(): Promise<UiMcpServerDescriptor[]> {
|
||
return fetchJSON('/api/mcp/servers');
|
||
}
|
||
|
||
export async function addMcpServer(payload: {
|
||
id: string;
|
||
command?: string;
|
||
args?: string[];
|
||
env?: Record<string, string>;
|
||
url?: string;
|
||
headers?: Record<string, string>;
|
||
auth_mode?: string;
|
||
auth_audience?: string;
|
||
auth_scopes?: string[];
|
||
tool_timeout?: number;
|
||
}): Promise<UiMcpServerDescriptor> {
|
||
return fetchJSON('/api/mcp/servers', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function updateMcpServer(
|
||
serverId: string,
|
||
payload: {
|
||
id: string;
|
||
command?: string;
|
||
args?: string[];
|
||
env?: Record<string, string>;
|
||
url?: string;
|
||
headers?: Record<string, string>;
|
||
auth_mode?: string;
|
||
auth_audience?: string;
|
||
auth_scopes?: string[];
|
||
tool_timeout?: number;
|
||
}
|
||
): Promise<UiMcpServerDescriptor> {
|
||
return fetchJSON(`/api/mcp/servers/${encodeURIComponent(serverId)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function deleteMcpServer(serverId: string): Promise<void> {
|
||
await fetchJSON(`/api/mcp/servers/${encodeURIComponent(serverId)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export async function getAuthzStatus(): Promise<AuthzStatus> {
|
||
return fetchJSON('/api/authz/status');
|
||
}
|
||
|
||
export async function bindLocalBackendIdentity(payload: {
|
||
backend_id: string;
|
||
client_id: string;
|
||
client_secret: string;
|
||
name?: string;
|
||
public_base_url?: string;
|
||
authz_base_url?: string;
|
||
authz_enabled?: boolean;
|
||
}): Promise<Record<string, unknown>> {
|
||
return fetchJSON('/api/authz/local-backend/bind', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function listAuthzBackends(): Promise<AuthzBackendRecord[]> {
|
||
return fetchJSON('/api/authz/backends');
|
||
}
|
||
|
||
export async function registerAuthzBackend(payload: {
|
||
name?: string;
|
||
backend_id?: string;
|
||
base_url?: string;
|
||
save_to_backend?: boolean;
|
||
authz_base_url?: string;
|
||
}): Promise<AuthzRegisterBackendResponse> {
|
||
return fetchJSON('/api/authz/backends/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function getAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}`);
|
||
}
|
||
|
||
export async function enableAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/enable`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function disableAuthzBackend(backendId: string): Promise<AuthzBackendRecord> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/disable`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function rotateAuthzBackendSecret(backendId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/rotate-secret`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function getAuthzBackendPermissions(backendId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/permissions`);
|
||
}
|
||
|
||
export async function setAuthzBackendPermissions(
|
||
backendId: string,
|
||
payload: Record<string, unknown>
|
||
): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/permissions`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function getAuthzBackendOutlookSettings(backendId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`);
|
||
}
|
||
|
||
export async function setAuthzBackendOutlookSettings(
|
||
backendId: string,
|
||
payload: Record<string, unknown>
|
||
): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function deleteAuthzBackendOutlookSettings(backendId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/backends/${encodeURIComponent(backendId)}/settings/outlook`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export async function listAuthzChannelSettings(): Promise<Record<string, AuthzChannelSettings>> {
|
||
return fetchJSON('/api/authz/channel-settings');
|
||
}
|
||
|
||
export async function getAuthzChannelSettings(channelId: string): Promise<AuthzChannelSettings> {
|
||
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`);
|
||
}
|
||
|
||
export async function setAuthzChannelSettings(
|
||
channelId: string,
|
||
payload: {
|
||
configured?: boolean;
|
||
config?: Record<string, unknown>;
|
||
secrets?: Record<string, unknown>;
|
||
}
|
||
): Promise<AuthzChannelSettings> {
|
||
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
export async function deleteAuthzChannelSettings(channelId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/authz/channel-settings/${encodeURIComponent(channelId)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export async function testMcpServer(serverId: string): Promise<Record<string, unknown>> {
|
||
return fetchJSON(`/api/mcp/servers/${encodeURIComponent(serverId)}/test`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function listMcpTools(): Promise<Array<{ server_id: string; tools: Array<Record<string, unknown>> }>> {
|
||
return fetchJSON('/api/mcp/tools');
|
||
}
|
||
|
||
export async function getOutlookStatus(): Promise<OutlookStatus> {
|
||
return fetchJSON('/api/integrations/outlook/status', {
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function testOutlookConnection(
|
||
payload: OutlookConnectionPayload
|
||
): Promise<OutlookConnectionTestResult> {
|
||
return fetchJSON('/api/integrations/outlook/test-connection', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function connectOutlook(
|
||
payload: OutlookConnectionPayload
|
||
): Promise<OutlookConnectResult> {
|
||
return fetchJSON('/api/integrations/outlook/connect', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function disconnectOutlook(): Promise<{ ok: boolean }> {
|
||
return fetchJSON('/api/integrations/outlook/disconnect', {
|
||
method: 'POST',
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function getOutlookOverview(): Promise<OutlookOverview> {
|
||
return fetchJSON('/api/integrations/outlook/overview', {
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function getOutlookMessages(
|
||
folder: string,
|
||
options?: {
|
||
top?: number;
|
||
skip?: number;
|
||
unreadOnly?: boolean;
|
||
}
|
||
): Promise<OutlookMessageListResponse> {
|
||
const params = new URLSearchParams({
|
||
folder,
|
||
top: String(options?.top ?? 20),
|
||
skip: String(options?.skip ?? 0),
|
||
});
|
||
if (options?.unreadOnly) {
|
||
params.set('unread_only', 'true');
|
||
}
|
||
return fetchJSON(`/api/integrations/outlook/messages?${params.toString()}`, {
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function getOutlookEvents(options: {
|
||
startTime: string;
|
||
endTime: string;
|
||
top?: number;
|
||
skip?: number;
|
||
}): Promise<OutlookEventListResponse> {
|
||
const params = new URLSearchParams({
|
||
start_time: options.startTime,
|
||
end_time: options.endTime,
|
||
top: String(options.top ?? 20),
|
||
skip: String(options.skip ?? 0),
|
||
});
|
||
return fetchJSON(`/api/integrations/outlook/events?${params.toString()}`, {
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function getOutlookMessageDetail(
|
||
messageId: string,
|
||
changekey?: string | null
|
||
): Promise<OutlookMessageDetail> {
|
||
const params = new URLSearchParams({ message_id: messageId });
|
||
if (changekey) {
|
||
params.set('changekey', changekey);
|
||
}
|
||
return fetchJSON(`/api/integrations/outlook/message-detail?${params.toString()}`, {
|
||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
export async function downloadSkill(name: string): Promise<void> {
|
||
const url = buildApiUrl(`/api/skills/${encodeURIComponent(name)}/download`);
|
||
const res = await fetch(url, { headers: authHeaders(false) });
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(`下载失败:${text}`);
|
||
}
|
||
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${name}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
export async function deleteSkill(name: string): Promise<void> {
|
||
await fetchJSON(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function uploadSkill(file: File): Promise<Skill> {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const token = getAccessToken();
|
||
const headers: Record<string, string> = {};
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
|
||
const url = buildApiUrl('/api/skills/upload');
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(`接口错误 ${res.status}: ${text}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Marketplace (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function listMarketplaces(): Promise<Marketplace[]> {
|
||
return fetchJSON('/api/marketplaces');
|
||
}
|
||
|
||
export async function addMarketplace(source: string): Promise<Marketplace> {
|
||
return fetchJSON('/api/marketplaces', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ source }),
|
||
});
|
||
}
|
||
|
||
export async function removeMarketplace(name: string): Promise<void> {
|
||
await fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export async function updateMarketplace(name: string): Promise<Marketplace> {
|
||
return fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}/update`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
export async function listMarketplacePlugins(name: string): Promise<MarketplacePlugin[]> {
|
||
return fetchJSON(`/api/marketplaces/${encodeURIComponent(name)}/plugins`);
|
||
}
|
||
|
||
export async function installMarketplacePlugin(marketplaceName: string, pluginName: string): Promise<void> {
|
||
await fetchJSON(
|
||
`/api/marketplaces/${encodeURIComponent(marketplaceName)}/plugins/${encodeURIComponent(pluginName)}/install`,
|
||
{ method: 'POST' }
|
||
);
|
||
}
|
||
|
||
export async function uninstallPlugin(pluginName: string): Promise<void> {
|
||
await fetchJSON(`/api/plugins/${encodeURIComponent(pluginName)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Files (proxied)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||
|
||
export async function uploadFile(
|
||
file: File,
|
||
sessionId: string = 'web:default',
|
||
onProgress?: (percent: number) => void
|
||
): Promise<FileAttachment> {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
throw new Error('文件过大(最大 50MB)');
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('session_id', sessionId);
|
||
|
||
const result = await new Promise<FileAttachment>((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', buildApiUrl('/api/files/upload'));
|
||
const token = getAccessToken();
|
||
if (token) {
|
||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||
}
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable && onProgress) {
|
||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
const data = JSON.parse(xhr.responseText);
|
||
resolve(data);
|
||
} else {
|
||
reject(new Error(`上传失败:${xhr.status}`));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => reject(new Error('上传失败'));
|
||
xhr.send(formData);
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
export async function listFiles(sessionId?: string): Promise<FileAttachment[]> {
|
||
const params = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : '';
|
||
return fetchJSON(`/api/files${params}`);
|
||
}
|
||
|
||
export async function deleteFile(fileId: string): Promise<void> {
|
||
await fetchJSON(`/api/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export function getFileUrl(fileId: string): string {
|
||
return buildApiUrl(`/api/files/${encodeURIComponent(fileId)}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Workspace Browser
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface WorkspaceItem {
|
||
name: string;
|
||
path: string;
|
||
type: 'file' | 'directory';
|
||
size: number | null;
|
||
content_type?: string;
|
||
modified: string;
|
||
}
|
||
|
||
export interface BrowseResult {
|
||
path: string;
|
||
items: WorkspaceItem[];
|
||
}
|
||
|
||
export async function browseWorkspace(path: string = ''): Promise<BrowseResult> {
|
||
const params = path ? `?path=${encodeURIComponent(path)}` : '';
|
||
return fetchJSON(`/api/workspace/browse${params}`);
|
||
}
|
||
|
||
export function getWorkspaceDownloadUrl(path: string): string {
|
||
return buildApiUrl(`/api/workspace/download?path=${encodeURIComponent(path)}`);
|
||
}
|
||
|
||
export async function uploadToWorkspace(
|
||
file: File,
|
||
dirPath: string = '',
|
||
onProgress?: (percent: number) => void
|
||
): Promise<WorkspaceItem> {
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
throw new Error('文件过大(最大 50MB)');
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('path', dirPath);
|
||
|
||
return new Promise<WorkspaceItem>((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', buildApiUrl('/api/workspace/upload'));
|
||
const token = getAccessToken();
|
||
if (token) {
|
||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||
}
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable && onProgress) {
|
||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve(JSON.parse(xhr.responseText));
|
||
} else {
|
||
reject(new Error(`上传失败:${xhr.status}`));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => reject(new Error('上传失败'));
|
||
xhr.send(formData);
|
||
});
|
||
}
|
||
|
||
export async function deleteWorkspacePath(path: string): Promise<void> {
|
||
await fetchJSON(`/api/workspace/delete?path=${encodeURIComponent(path)}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
export async function createWorkspaceDir(path: string): Promise<WorkspaceItem> {
|
||
return fetchJSON(`/api/workspace/mkdir?path=${encodeURIComponent(path)}`, {
|
||
method: 'POST',
|
||
});
|
||
}
|