Files
curriculum-project-hub/hub/admin-web/src/routes/admin/capabilities/+page.svelte
T
hongjr03 54837717fd feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)
Register pbank as an ADR-0027 external capability with org-scoped
username/password envelopes, readiness via /login, and in-process
cph_hub MCP tools (search/get/get_many) that materialize sources under
the run workspace. Extend the capability secret payload for docmind vs
pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the
pbank-problem-report skill. Credentials never reach the Agent process.
2026-07-23 20:13:00 +08:00

312 lines
11 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
type CapKind = 'docmind' | 'pbank';
const KNOWN_CAPABILITIES = [
{
id: 'pdf_to_md_bundle',
kind: 'docmind' as const,
label: 'PDF → Markdown',
description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)'
},
{
id: 'audio_video_to_text',
kind: 'docmind' as const,
label: '音视频 → 文本',
description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)'
},
{
id: 'pbank',
kind: 'pbank' as const,
label: '题库 (PBank)',
description: '搜索/拉取 Paradigm 题库题目与源工程;Agent 通过 pbank_* 工具访问,凭据不下发到 Agent 进程'
}
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let editingKind = $state<CapKind>('docmind');
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let baseUrl = $state('https://pbank.paradigm-edu.net/api');
let username = $state('');
let password = $state('');
let rightsStatus = $state('owned');
let rightsHolder = $state('Paradigm Education');
let rightsScope = $state('internal teaching-material production');
let rightsNote = $state('');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string, kind: CapKind) {
editingCap = capId;
editingKind = kind;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
baseUrl = 'https://pbank.paradigm-edu.net/api';
username = '';
password = '';
rightsStatus = 'owned';
rightsHolder = 'Paradigm Education';
rightsScope = 'internal teaching-material production';
rightsNote = '';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
saving = true;
try {
let result: CapabilityConnection;
if (editingKind === 'docmind') {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'docmind',
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim()
});
} else {
if (baseUrl.trim() === '' || username.trim() === '' || password.trim() === '') {
toastError('Base URL、用户名、密码均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'pbank',
baseUrl: baseUrl.trim(),
username: username.trim(),
password: password.trim(),
...(rightsStatus.trim() !== '' ? { rightsStatus: rightsStatus.trim() } : {}),
...(rightsHolder.trim() !== '' ? { rightsHolder: rightsHolder.trim() } : {}),
...(rightsScope.trim() !== '' ? { rightsScope: rightsScope.trim() } : {}),
...(rightsNote.trim() !== '' ? { rightsNote: rightsNote.trim() } : {})
});
}
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id, cap.kind)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
{#if cap.kind === 'docmind'}
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input
id="ak-secret-{cap.id}"
class="saas-input"
type="password"
bind:value={accessKeySecret}
/>
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
{:else}
<p class="saas-muted mb-3 text-sm">
Paradigm 题库登录凭据。激活前会探测 /login;凭据仅写入信封新版本。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="pbank-base-{cap.id}">API Base URL</Label.Root>
<input id="pbank-base-{cap.id}" class="saas-input font-mono text-sm" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-user-{cap.id}">用户名</Label.Root>
<input id="pbank-user-{cap.id}" class="saas-input font-mono text-sm" bind:value={username} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-pass-{cap.id}">密码</Label.Root>
<input id="pbank-pass-{cap.id}" class="saas-input" type="password" bind:value={password} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-status-{cap.id}">权利状态</Label.Root>
<input
id="pbank-rights-status-{cap.id}"
class="saas-input font-mono text-sm"
bind:value={rightsStatus}
placeholder="owned | exclusive_license | licensed_adapt | unknown"
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-holder-{cap.id}">权利主体</Label.Root>
<input
id="pbank-rights-holder-{cap.id}"
class="saas-input text-sm"
bind:value={rightsHolder}
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-scope-{cap.id}">使用范围</Label.Root>
<input id="pbank-rights-scope-{cap.id}" class="saas-input text-sm" bind:value={rightsScope} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-note-{cap.id}">权利说明(可选)</Label.Root>
<textarea
id="pbank-rights-note-{cap.id}"
class="saas-input min-h-20 text-sm"
bind:value={rightsNote}
></textarea>
</div>
</div>
{/if}
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}