diff --git a/src/api.ts b/src/api.ts index fd89c4c..59f0446 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,16 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } +export interface ExecEnvView { + configured: boolean + root?: string + python?: string + base?: string + layers?: string[] + requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[] + bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[] +} + async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() @@ -79,6 +89,17 @@ export const api = { adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), + adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json(r)), + adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)), + adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)), + adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)), + adminExecEnvWheel: (file: File, conversationId: string, reason: string) => { + const body = new FormData() + body.set('wheel', file) + body.set('conversationId', conversationId) + body.set('reason', reason) + return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r)) + }, createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index ae9762a..1f6a8e0 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -8,6 +8,7 @@ import { useState } from 'react' import { store, useStore } from '../store' import { EmptyState, Icon, type IconName } from '@ui' import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' +import { ExecEnvPanel } from './ExecEnvPanel' import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { RdmRolesPanel } from './adminRdmSections' import type { AdminZoneSection } from '../api' @@ -34,6 +35,7 @@ const SECTIONS: AdminSectionDef[] = [ // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, + { key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, ] diff --git a/src/components/ExecEnvPanel.tsx b/src/components/ExecEnvPanel.tsx new file mode 100644 index 0000000..0b37a1d --- /dev/null +++ b/src/components/ExecEnvPanel.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import { api, type ExecEnvView } from '../api' +import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui' + +export function ExecEnvPanel() { + const [view, setView] = useState(null) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const [conv, setConv] = useState('') + const [reason, setReason] = useState('') + const [wheel, setWheel] = useState(null) + + const refresh = async () => { + try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) } + } + useEffect(() => { refresh() }, []) + + const run = async (work: () => Promise) => { + setErr(''); setNotice(''); setBusy(true) + try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + if (!view) return 加载 Python 环境… + if (!view.configured) return 这台服务没有配置分层 Python 环境根 + + return ( +
+ + + + + + run(async () => { + const out = await api.adminExecEnvRebase() + return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。` + })}>重建基础层 + + + + {(view.requests ?? []).length === 0 && 还没有扩展包申请} + {(view.requests ?? []).map(req => ( + + {req.status === 'pending_approval' && ( + + run(async () => { + const out = await api.adminExecEnvDecision(req.id, true, false) + return out.detail || out.status + })}>批准 + run(async () => { + const out = await api.adminExecEnvDecision(req.id, true, true) + return out.detail || '已用隔离层' + })}>隔离层 + run(async () => { + const out = await api.adminExecEnvDecision(req.id, false, false) + return out.detail || '已拒绝' + })}>拒绝 + + )} + + ))} + + + {(view.bindings ?? []).map(b => ( + + + + ))} + + run(async () => { + const out = await api.adminExecEnvGC() + const n = out.removed?.length ?? 0 + return n === 0 ? '没有可回收的层' : `已回收 ${n} 层` + })}>回收未引用层 + + + + + setConv(e.target.value)} placeholder="会话 ID" /> + + + setReason(e.target.value)} /> + + + setWheel(e.target.files?.[0] ?? null)} /> + +
+ run(async () => { + const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim()) + return `${out.file}:${out.outcome.status}` + })}>提交审批 +
+
+ {notice &&
{notice}
} + {err &&
{err}
} +
+ ) +}