Files
kefu/wechat_rpa/tmp/archive-performance-before/frontend/api/console.ts
T
2026-09-21 10:34:06 +08:00

631 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 业务接口:模型清单、角色权限、用户、调用统计。
*
* 全部对 `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,
);
}