631 lines
18 KiB
TypeScript
631 lines
18 KiB
TypeScript
/**
|
||
* 业务接口:模型清单、角色权限、用户、调用统计。
|
||
*
|
||
* 全部对 `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<string, number>;
|
||
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<ModelProvider>) {
|
||
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<ModelPlan>) {
|
||
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<CallStats>(
|
||
`/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<ConfigEnvelope>('/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<string, string>;
|
||
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<ReleasePolicy>('/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<ReleasePolicy>('/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<string, string>,
|
||
onProgress?: (loaded: number) => void,
|
||
) {
|
||
return new Promise<string>((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<number, number>();
|
||
const completed = new Map<number, string>();
|
||
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<ReleasePackage>(
|
||
`/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<ReleaseUploadPrepared>(
|
||
'/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<ReleasePackage>(
|
||
`/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<string, number>;
|
||
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,
|
||
);
|
||
}
|