/** * 业务接口:模型清单、角色权限、用户、调用统计。 * * 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端—— * 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。 */ import { requestClient } from '#/api/request'; export interface ModelProvider { api_key?: string; api_key_masked?: string; base_url: string; capabilities: string; enabled: boolean; /** 后端算好的实际请求地址(只读) */ endpoint?: string; /** auto = 按接口类型补全路径;exact = 地址原样使用 */ endpoint_mode: string; health: string; id: string; kind: string; max_inflight: number; max_tokens: number; model: string; name: string; rpm_limit: number; temperature: number; timeout_ms: number; } export interface ModelPlan { answer_ids: string; fallback_ids: string; judge_id: string; judge_mode: string; version: number; vision_id: string; } export interface RoleItem { builtin: boolean; code: string; name: string; permissions: string[]; user_count: number; } export interface PermissionItem { code: string; group_name: string; name: string; } export interface UserItem { active: boolean; created_at: string; id: number; must_change_password: boolean; role: string; username: string; } export interface DesktopAccountItem { active_session_count: number; app_version: string; avatar: string; backup_enabled: boolean | null; backup_interval_minutes: number | null; created_at: string; current_device_name: string; device_count: number; id: number; is_online: boolean; last_login_at: string; last_online_at: string; message_count: number; mobile_masked: string; nickname: string; source_account_count: number; status: 'active' | 'disabled'; tenant_id: string; updated_at: string; zyt_sn: string; zyt_user_id: string; } export interface CallStats { avg_ms: number; avg_score: number; chosen: { count: number; provider: string }[]; judged: number; max_ms: number; risk: Record; score_buckets: { count: number; range: string }[]; since: string; total: number; } /** 单次调用的候选出口——`model_calls.candidates_json` 解出来的一项。 */ export interface ModelCallCandidate { error?: string; latency_ms?: number; provider: string; text: string; } /** 单条调用留痕:客户说了什么、模型答了什么、有没有被拦下来。 */ export interface ModelCallLogItem { candidates: ModelCallCandidate[]; chosen: string; created_at: string; customer_text: string; device_id: string; id: number; judge_mode: string; judge_risk: string; judge_score: number; judge_winner: string; /** chat = 回客户的话;guard = 界面识别之类的内部判断 */ purpose: string; reply_text: string; review_reason: string; roles_version: number; task_id: string; total_ms: number; } // ── 模型 ───────────────────────────────────────────────────────────────────── export function fetchModels() { return requestClient.get<{ judge_modes: string[]; kinds: string[]; models: ModelProvider[]; roles: ModelPlan; }>('/models'); } export function saveModel(data: Partial) { return requestClient.post<{ models: ModelProvider[] }>('/models', data); } export function deleteModel(id: string) { return requestClient.delete<{ models: ModelProvider[] }>( `/models/${encodeURIComponent(id)}`, ); } export function saveModelPlan(data: Partial) { return requestClient.post<{ roles: ModelPlan; version: number }>( '/models/plan', data, ); } // ── 角色与权限 ─────────────────────────────────────────────────────────────── export function fetchRoles() { return requestClient.get<{ roles: RoleItem[] }>('/roles'); } export function fetchPermissions() { return requestClient.get<{ permissions: PermissionItem[] }>('/permissions'); } export function saveRole(data: { code: string; name: string; permissions: string[]; }) { return requestClient.post<{ roles: RoleItem[] }>('/roles', data); } export function deleteRole(code: string) { return requestClient.delete<{ roles: RoleItem[] }>( `/roles/${encodeURIComponent(code)}`, ); } // ── 用户 ───────────────────────────────────────────────────────────────────── export function fetchUsers() { return requestClient.get<{ users: UserItem[] }>('/users'); } export function createUser(data: { password: string; role: string; username: string; }) { return requestClient.post<{ users: UserItem[] }>('/users', data); } export function updateUser( id: number, data: { active: boolean; role: string }, ) { return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data); } // ── 客户端账号 ─────────────────────────────────────────────────────────────── export function fetchDesktopAccounts() { return requestClient.get<{ accounts: DesktopAccountItem[]; can_view_all: boolean; can_view_legacy: boolean; }>( '/desktop-accounts', ); } export function updateDesktopAccount( id: number, data: { backup_enabled: boolean | null; backup_interval_minutes: number | null; status: 'active' | 'disabled'; }, ) { return requestClient.put<{ account: DesktopAccountItem }>( `/desktop-accounts/${id}`, data, ); } export function revokeDesktopDevices(id: number) { return requestClient.post(`/desktop-accounts/${id}/revoke-devices`); } export function fetchDesktopAccountAdmins(id: number) { return requestClient.get<{ admin_user_ids: number[] }>( `/desktop-accounts/${id}/admins`, ); } export function updateDesktopAccountAdmins(id: number, adminUserIds: number[]) { return requestClient.put<{ admin_user_ids: number[] }>( `/desktop-accounts/${id}/admins`, { admin_user_ids: adminUserIds }, ); } // ── 运营 ───────────────────────────────────────────────────────────────────── export function fetchCallStats(days = 7, accountId = 0) { return requestClient.get( `/stats/model-calls?days=${days}&account_id=${accountId}`, ); } export function fetchCallLog(params: { accountId?: number; days?: number; limit?: number; offset?: number; /** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */ purpose?: string; q?: string; }) { const query = new URLSearchParams(); query.set('days', String(params.days ?? 7)); query.set('account_id', String(params.accountId ?? 0)); query.set('limit', String(params.limit ?? 50)); query.set('offset', String(params.offset ?? 0)); query.set('purpose', params.purpose ?? 'chat'); if (params.q) query.set('q', params.q); return requestClient.get<{ items: ModelCallLogItem[]; total: number }>( `/stats/model-calls/log?${query.toString()}`, ); } export function fetchAudit(limit = 200) { return requestClient.get<{ entries: { action: string; created_at: string; detail: string; id: number; ip_address: string; username: null | string; }[]; }>(`/audit?limit=${limit}`); } export function changeMyPassword(data: { current_password: string; new_password: string; }) { return requestClient.post('/me/password', data); } // ── 桌面端配置 ─────────────────────────────────────────────────────────────── /** * 下发给桌面客户端的配置。 * * 字段名是大写下划线,和老网页后台的表单完全一致——两边共用后端同一个校验函数, * 不会出现"这边填得进、那边填不进"。 * * 这里**没有模型连接参数**:服务类型、API 地址、密钥、模型名、温度、tokens、 * 超时全部搬到了「AI 模型 → 模型清单 / 角色编排」。桌面端的模型调用走网关, * 密钥不出后端。 */ export interface DesktopConfig { ARCHIVE_AUTO_BACKUP_ENABLED: boolean; ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES: number; AI_AGENT_NAME: string; AI_CONTEXT_ENABLED: boolean; AI_CONTEXT_MAX_ROUNDS: number; AI_COUNTER_INSULT_ENABLED: boolean; AI_DEVELOPMENT_MODE: boolean; AI_ENABLED: boolean; AI_HOSPITAL_NAME: string; AI_MCP_ENABLED: boolean; AI_MCP_MAX_ROUNDS: number; AI_MCP_SERVERS: unknown[]; AI_UI_GUARD_ENABLED: boolean; AI_USE_VISION: boolean; /** 留空 = 按后台自己的地址自动推算 */ AI_GATEWAY_URL: string; /** 桌面客户端启动后从管理端读取的 ZYT 登录 API 基础地址 */ ZYT_LOGIN_API_URL: string; AI_REVIEW_RULES: ReviewRule[]; } /** * 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常 * 自动发送。`id` 可以留空——留空由后端根据 label 自动生成并去重。 */ export interface ReviewRule { id?: string; label: string; keywords: string[]; enabled: boolean; } export interface ConfigEnvelope { bool_keys: string[]; config: DesktopConfig; /** 留空时实际会下发给客户端的网关地址 */ gateway_url_effective: string; /** 登录窗口启动时实际会从公开配置拿到的地址 */ login_api_url_effective: string; /** 模型设置搬去哪儿了,用来在页面上给人指路 */ model_settings_moved_to: string; retired_keys: string[]; updated_at: string; updated_by: string; version: number; } export function fetchConfig() { return requestClient.get('/config'); } export function saveConfig(data: DesktopConfig) { return requestClient.post<{ version: number }>('/config', data); } // ── 桌面端版本升级 ─────────────────────────────────────────────────────────── export interface ReleasePolicy { auto_install: boolean; download_url: string; force_upgrade: boolean; latest_version: string; package_sha256: string; package_size: number; release_channel: 'beta' | 'stable'; release_notes: string; rollout_percent: number; signature_required: boolean; status: 'paused' | 'published'; updated_at: string; updated_by: string; } export interface ReleasePackage { download_url: string; file_name: string; package_sha256: string; package_size: number; } interface ReleaseUploadPart { etag?: string; part_number: number; size_bytes: number; upload_url: string; } interface ReleaseUploadPrepared extends ReleasePackage { media: { id: string }; multipart?: { completed_parts: ReleaseUploadPart[]; part_size: number; parts: ReleaseUploadPart[]; upload_id: string; }; required_headers: Record; reused: boolean; upload_mode: 'multipart' | 'reused' | 'single'; upload_url: string; } export interface UpdateReportItem { current_version: string; device_id: string; error: string; nickname: string; progress: number; status: string; target_version: string; updated_at: string; zyt_sn: string; } export function fetchRelease() { return requestClient.get('/release'); } export function saveRelease(data: { auto_install: boolean; download_url: string; force_upgrade: boolean; latest_version: string; package_sha256: string; package_size: number; release_channel: 'beta' | 'stable'; release_notes: string; rollout_percent: number; signature_required: boolean; status: 'paused' | 'published'; }) { return requestClient.post('/release', data); } async function sha256File(file: File) { if (!globalThis.crypto?.subtle) { throw new Error('当前浏览器不支持安全哈希,请使用 HTTPS 或本机地址打开管理端'); } const digest = await globalThis.crypto.subtle.digest( 'SHA-256', await file.arrayBuffer(), ); return [...new Uint8Array(digest)] .map((value) => value.toString(16).padStart(2, '0')) .join(''); } function putCosBlob( url: string, body: Blob, headers: Record, onProgress?: (loaded: number) => void, ) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url, true); xhr.timeout = 10 * 60 * 1000; Object.entries(headers).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); xhr.upload.onprogress = (event) => onProgress?.(event.loaded); xhr.onerror = () => reject(new Error('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置')); xhr.ontimeout = () => reject(new Error('上传腾讯 COS 超时,请重试')); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve((xhr.getResponseHeader('ETag') || '').replaceAll('"', '')); return; } reject(new Error(`腾讯 COS 上传失败(HTTP ${xhr.status})`)); }; xhr.send(body); }); } async function uploadMultipartPackage( file: File, prepared: ReleaseUploadPrepared, onProgress?: (percent: number) => void, ) { const multipart = prepared.multipart; if (!multipart?.upload_id || !multipart.parts.length) { throw new Error('COS 分块上传参数不完整'); } const partSize = multipart.part_size; const uploaded = new Map(); const completed = new Map(); multipart.completed_parts.forEach((part) => { if (part.etag) { completed.set(part.part_number, part.etag); uploaded.set(part.part_number, part.size_bytes); } }); const reportProgress = () => { const bytes = [...uploaded.values()].reduce((sum, value) => sum + value, 0); onProgress?.(5 + Math.round((bytes / file.size) * 90)); }; reportProgress(); const pending = multipart.parts.filter( (part) => !completed.has(part.part_number), ); async function worker() { for (;;) { const part = pending.shift(); if (!part) return; const start = (part.part_number - 1) * partSize; const blob = file.slice(start, start + part.size_bytes); let etag = ''; let lastError: unknown; for (let attempt = 1; attempt <= 3; attempt += 1) { uploaded.set(part.part_number, 0); try { etag = await putCosBlob(part.upload_url, blob, {}, (loaded) => { uploaded.set(part.part_number, loaded); reportProgress(); }); lastError = undefined; break; } catch (error) { lastError = error; } } if (lastError) throw lastError; if (!etag) { throw new Error('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头'); } uploaded.set(part.part_number, part.size_bytes); completed.set(part.part_number, etag); reportProgress(); } } await Promise.all( Array.from({ length: Math.min(4, Math.max(1, pending.length)) }, worker), ); return requestClient.post( `/release/package/${encodeURIComponent(prepared.media.id)}/multipart-complete`, { parts: [...completed.entries()] .map(([part_number, etag]) => ({ etag, part_number })) .sort((left, right) => left.part_number - right.part_number), upload_id: multipart.upload_id, }, { timeout: 5 * 60 * 1000 }, ); } export async function uploadReleasePackage( file: File, onProgress?: (percent: number) => void, ) { if (!file.name.toLowerCase().endsWith('.exe')) { throw new Error('升级包必须是 Windows EXE 安装包'); } if (file.size <= 0 || file.size > 1024 ** 3) { throw new Error('安装包大小必须大于 0 且不能超过 1GB'); } onProgress?.(1); const packageSha256 = await sha256File(file); onProgress?.(5); const prepared = await requestClient.post( '/release/package/prepare', { file_name: file.name, package_sha256: packageSha256, package_size: file.size, }, ); if (prepared.reused) { onProgress?.(100); return prepared; } if (prepared.upload_mode === 'multipart') { const result = await uploadMultipartPackage(file, prepared, onProgress); onProgress?.(100); return result; } if (!prepared.upload_url) throw new Error('COS 预上传接口没有返回上传地址'); await putCosBlob( prepared.upload_url, file, prepared.required_headers, (loaded) => onProgress?.(5 + Math.round((loaded / file.size) * 90)), ); onProgress?.(96); const result = await requestClient.post( `/release/package/${encodeURIComponent(prepared.media.id)}/complete`, undefined, { timeout: 5 * 60 * 1000 }, ); onProgress?.(100); return result; } export function fetchReleaseHistory() { return requestClient.get<{ history: ReleasePolicy[]; reports: { counts: Record; items: UpdateReportItem[]; }; }>('/releases'); } // ── 模型连通性测试 ─────────────────────────────────────────────────────────── export interface ModelTestResult { endpoint: string; http_status: null | number; latency_ms: number; message: string; model: string; ok: boolean; provider: string; } /** * 给了 provider_id 就测清单里那一条,密钥由后端从库里解密取用—— * 前端从头到尾拿不到明文,也就不可能在网络上多走一趟。 */ export function testModel(data: { api_key?: string; base_url?: string; endpoint_mode?: string; kind?: string; model?: string; provider_id?: string; timeout_seconds?: number; }) { return requestClient.post<{ label: string; result: ModelTestResult }>( '/models/test', data, ); }