/** * 业务接口:模型清单、角色权限、用户、调用统计。 * * 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端—— * 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。 */ import { requestClient } from '#/api/request'; import { t } from '#/locales/t'; import { sha256File } from '#/utils/file-sha256'; /** * 账号下拉框里「全部」的取值,和后端 `admin_api.ALL_ACCOUNTS` 是同一个约定。 * * 用 -1 而不是 0:0 早就有含义了(旧版未归属数据),不传则是"按默认租户查"。 * 这两个都被占着,再拿它们表示「全部」会让三种情况撞在一起。 */ export const ALL_ACCOUNTS = -1; 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; } /** * 权限菜单里的一条:一级菜单(group_name)→ 页面(page_name)→ 权限码。 * * - source = builtin:随代码发布,只读 * - source = custom:管理员在「权限菜单」页自己加的,可改可删 * - source = discovered:代码里用到却没登记,后端启动时自动登记到「未归类」 */ export interface PermissionItem { code: string; enabled: boolean; group_name: string; icon: string; /** page = 能打开页面;action = 页面里的操作;link = 左侧菜单链接 */ kind: 'action' | 'link' | 'page'; link_mode: '' | 'iframe' | 'tab'; link_url: string; name: string; page_name: string; /** 持有这个权限码的角色 */ roles: string[]; sort_order: number; source: 'builtin' | 'custom' | 'discovered'; updated_at: string; } /** 新增/修改自定义条目时提交的字段 */ export interface PermissionForm { code: string; enabled: boolean; group_name: string; icon: string; kind: 'action' | 'link'; link_mode: 'iframe' | 'tab'; link_url: string; name: string; page_name: string; sort_order: number; } /** 左侧菜单里管理员自己加的链接(后端已按当前用户的权限过滤) */ export interface CustomMenuItem { code: string; group_name: string; icon: string; link_mode: '' | 'iframe' | 'tab'; link_url: string; name: string; sort_order: number; } export interface UserItem { identity_source: 'local' | 'zyt_group'; source_account: string; source_name: string; 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, ); } // ── 智能体 ─────────────────────────────────────────────────────────────────── /** * 一条角色规则。三种类型的必填项不一样: * * guide 要有 instruction。关键词留空 = 每轮都注入给模型。 * forbid 要有关键词(回复里不许出现的词)。reply 留空则走客户端内置兜底。 * reply 关键词和话术都要有,命中就直接用这句回,不问模型。 */ export interface AgentRule { enabled: boolean; id?: string; instruction: string; keywords: string[]; label: string; reply: string; type: 'forbid' | 'guide' | 'reply'; } export interface AgentItem { description: string; enabled: boolean; id: string; /** 协作模式下认领本轮的关键词 */ keywords: string[]; name: string; persona: string; /** 数字越小越优先 */ priority: number; role: string; rules: AgentRule[]; updated_at?: string; } export interface AgentPlan { active_ids: string[]; /** single = 只上一个角色;collaborate = 多个同时在场 */ mode: 'collaborate' | 'single'; primary_id: string; updated_at?: string; version: number; } export function fetchAgents() { return requestClient.get<{ agents: AgentItem[]; plan: AgentPlan; plan_modes: string[]; rule_types: string[]; }>('/agents'); } export function saveAgent(data: Partial) { return requestClient.post<{ agents: AgentItem[]; plan: AgentPlan }>( '/agents', data, ); } export function deleteAgent(id: string) { return requestClient.delete<{ agents: AgentItem[]; plan: AgentPlan }>( `/agents/${encodeURIComponent(id)}`, ); } export function saveAgentPlan(data: Partial) { return requestClient.post<{ plan: AgentPlan; version: number }>( '/agents/plan', data, ); } // ── 角色与权限 ─────────────────────────────────────────────────────────────── export function fetchRoles() { return requestClient.get<{ roles: RoleItem[] }>('/roles'); } export function fetchPermissions() { return requestClient.get<{ permissions: PermissionItem[] }>('/permissions'); } export function createPermission(data: PermissionForm) { return requestClient.post<{ permissions: PermissionItem[] }>( '/permissions', data, ); } export function updatePermission(code: string, data: PermissionForm) { return requestClient.put<{ permissions: PermissionItem[] }>( `/permissions/${encodeURIComponent(code)}`, data, ); } export function deletePermission(code: string) { return requestClient.delete<{ permissions: PermissionItem[] }>( `/permissions/${encodeURIComponent(code)}`, ); } export function fetchCustomMenus() { return requestClient.get<{ menus: CustomMenuItem[] }>('/custom-menus'); } 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; username?: string; password?: string }, ) { return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data); } // ── 客户端账号 ─────────────────────────────────────────────────────────────── export function fetchDesktopAccounts(includeCounts = true) { return requestClient.get<{ accounts: DesktopAccountItem[]; can_view_all: boolean; can_view_legacy: boolean; }>( `/desktop-accounts?include_counts=${includeCounts}`, ); } 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, signal?: AbortSignal) { return requestClient.get( `/stats/model-calls?days=${days}&account_id=${accountId}`, { signal }, ); } export function fetchCallLog(params: { accountId?: number; days?: number; limit?: number; offset?: number; /** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */ purpose?: string; q?: string; }, signal?: AbortSignal) { 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()}`, { signal }, ); } 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[]; /** 审核后发送的审核范围:true = 每条都审核;false = 按原规则(医疗助理人工回复后该会话恢复自动) */ AI_REVIEW_EVERY_REPLY: boolean; } /** * 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常 * 自动发送。`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 { package_media_id?: string; 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 { package_media_id: string; download_url_expires_in: number; 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: { package_media_id?: string; 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); } 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(t('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置'))); xhr.ontimeout = () => reject(new Error(t('上传腾讯 COS 超时,请重试'))); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve((xhr.getResponseHeader('ETag') || '').replaceAll('"', '')); return; } reject(new Error(t('腾讯 COS 上传失败(HTTP {status})', { status: 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(t('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(t('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(t('升级包必须是 Windows EXE 安装包')); } if (file.size <= 0 || file.size > 1024 ** 3) { throw new Error(t('安装包大小必须大于 0 且不能超过 1GB')); } onProgress?.(1); const packageSha256 = await sha256File(file, (fraction) => { onProgress?.(1 + Math.floor(fraction * 4)); }); 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(t('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, ); } export interface ModelUsageSummary { request_count: number; known_count: number; missing_count: number; input_tokens: number | null; output_tokens: number | null; total_tokens: number | null; cached_input_tokens: number | null; reasoning_tokens: number | null; input_known_count?: number; output_known_count?: number; cached_input_known_count?: number; reasoning_known_count?: number; } export interface ModelUsageStats { days: number; since: string; timezone: string; summary: ModelUsageSummary; by_model: (ModelUsageSummary & { provider_id: string; provider_name: string; model: string })[]; by_purpose: (ModelUsageSummary & { purpose: string })[]; daily: (ModelUsageSummary & { date: string })[]; legacy_unmetered_calls: number; } export interface ModelUsageLogItem { event_id: string; request_id: string; task_id: string; tenant_id: string; desktop_account_id: number | null; provider_id: string; provider_name: string; model: string; kind: string; purpose: string; role: string; attempt: number; status: string; input_tokens: number | null; output_tokens: number | null; total_tokens: number | null; cached_input_tokens: number | null; reasoning_tokens: number | null; latency_ms: number | null; created_at: string; } export interface ModelUsageLog { total: number; items: ModelUsageLogItem[]; } export interface ModelUsageQuery { days?: number; accountId?: number; purpose?: string; providerId?: string; limit?: number; offset?: number; } function modelUsageQuery(params: ModelUsageQuery) { const query = new URLSearchParams(); query.set('days', String(params.days ?? 7)); query.set('account_id', String(params.accountId ?? ALL_ACCOUNTS)); query.set('purpose', params.purpose ?? ''); if (params.providerId) query.set('provider_id', params.providerId); if (params.limit !== undefined) query.set('limit', String(params.limit)); if (params.offset !== undefined) query.set('offset', String(params.offset)); return query.toString(); } export function fetchModelUsage(params: ModelUsageQuery = {}, signal?: AbortSignal) { return requestClient.get(`/stats/model-usage?${modelUsageQuery(params)}`, { signal }); } export function fetchModelUsageLog(params: ModelUsageQuery = {}, signal?: AbortSignal) { return requestClient.get(`/stats/model-usage/log?${modelUsageQuery(params)}`, { signal }); }