862 lines
25 KiB
TypeScript
862 lines
25 KiB
TypeScript
/**
|
||
* 业务接口:模型清单、角色权限、用户、调用统计。
|
||
*
|
||
* 全部对 `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<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,
|
||
);
|
||
}
|
||
|
||
// ── 智能体 ───────────────────────────────────────────────────────────────────
|
||
/**
|
||
* 一条角色规则。三种类型的必填项不一样:
|
||
*
|
||
* 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<AgentItem>) {
|
||
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<AgentPlan>) {
|
||
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<CallStats>(
|
||
`/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<ConfigEnvelope>('/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<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: {
|
||
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<ReleasePolicy>('/release', data);
|
||
}
|
||
|
||
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(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<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(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<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(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<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(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<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,
|
||
);
|
||
}
|
||
|
||
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<ModelUsageStats>(`/stats/model-usage?${modelUsageQuery(params)}`, { signal });
|
||
}
|
||
|
||
export function fetchModelUsageLog(params: ModelUsageQuery = {}, signal?: AbortSignal) {
|
||
return requestClient.get<ModelUsageLog>(`/stats/model-usage/log?${modelUsageQuery(params)}`, { signal });
|
||
}
|