forked from EduCraft/curriculum-project-hub
854c6189bb
Add a shared transparent OrganizationAgentConfigFolder tree for grouping agent roles and skills in the admin UI without affecting identity, bindings, run loading, or slash commands.
328 lines
10 KiB
Svelte
328 lines
10 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
|
||
import { session } from '$lib/session';
|
||
import { resolveOrg } from '$lib/org';
|
||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||
import RoleCard from '$lib/components/RoleCard.svelte';
|
||
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
|
||
import Modal from '$lib/components/Modal.svelte';
|
||
import SelectField from '$lib/components/SelectField.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
|
||
let roles = $state<AgentRoleRow[]>([]);
|
||
let models = $state<AgentModelRow[]>([]);
|
||
let skills = $state<AgentSkillRow[]>([]);
|
||
let folders = $state<AgentConfigFolderRow[]>([]);
|
||
let loading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
|
||
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
|
||
let selectedFolder = $state<string>('all');
|
||
|
||
let newRoleId = $state('');
|
||
let newLabel = $state('');
|
||
let adding = $state(false);
|
||
|
||
let showFolderModal = $state(false);
|
||
let folderModalMode = $state<'create' | 'rename'>('create');
|
||
let folderModalId = $state<string | null>(null);
|
||
let folderName = $state('');
|
||
let folderParent = $state('');
|
||
let savingFolder = $state(false);
|
||
|
||
async function load() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
|
||
roles = r.roles;
|
||
skills = s.skills;
|
||
folders = f.folders;
|
||
// Model fetch hits the provider API and may fail or be slow; load it
|
||
// independently so roles remain editable even without a model list.
|
||
models = [];
|
||
api.agentModels(slug)
|
||
.then((m) => { models = m.models; })
|
||
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
const counts = $derived.by(() => {
|
||
const map: Record<string, number> = {};
|
||
for (const r of roles) {
|
||
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
|
||
}
|
||
return map;
|
||
});
|
||
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
|
||
const visibleRoles = $derived(
|
||
selectedFolder === 'all'
|
||
? roles
|
||
: selectedFolder === 'unfiled'
|
||
? roles.filter((r) => !r.folderId)
|
||
: roles.filter((r) => r.folderId === selectedFolder),
|
||
);
|
||
|
||
function folderPath(f: AgentConfigFolderRow): string {
|
||
const parts: string[] = [f.name];
|
||
let cur: AgentConfigFolderRow | undefined = f;
|
||
while (cur?.parentId) {
|
||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||
if (!parent) break;
|
||
parts.unshift(parent.name);
|
||
cur = parent;
|
||
}
|
||
return parts.join(' / ');
|
||
}
|
||
|
||
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
|
||
function subtreeIds(folderId: string): Set<string> {
|
||
const ids = new Set<string>([folderId]);
|
||
let grew = true;
|
||
while (grew) {
|
||
grew = false;
|
||
for (const f of folders) {
|
||
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
|
||
ids.add(f.id);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
const folderItems = $derived([
|
||
{ value: '', label: '(未分类)' },
|
||
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
|
||
]);
|
||
|
||
const moveTargetItems = $derived.by(() => {
|
||
if (folderModalMode !== 'rename' || folderModalId === null) {
|
||
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
|
||
}
|
||
const excluded = subtreeIds(folderModalId);
|
||
return [
|
||
{ value: '', label: '(根)' },
|
||
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
|
||
];
|
||
});
|
||
|
||
async function add() {
|
||
const roleId = newRoleId.trim();
|
||
const label = newLabel.trim();
|
||
if (roleId === '' || label === '') {
|
||
toastError('角色 ID 与显示名均为必填');
|
||
return;
|
||
}
|
||
if (roles.some((r) => r.roleId === roleId)) {
|
||
toastError(`角色 ID 已存在:${roleId}`);
|
||
return;
|
||
}
|
||
adding = true;
|
||
try {
|
||
const created = await api.upsertAgentRole(slug, roleId, { label });
|
||
let folderId: string | null = null;
|
||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
|
||
folderId = selectedFolder;
|
||
}
|
||
roles = [...roles, { ...created, folderId }];
|
||
newRoleId = '';
|
||
newLabel = '';
|
||
toastSuccess('角色已创建');
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
adding = false;
|
||
}
|
||
}
|
||
|
||
function onRoleUpdated(updated: AgentRoleRow) {
|
||
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
|
||
if (updated.isDefault) {
|
||
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
|
||
}
|
||
}
|
||
|
||
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
|
||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
|
||
}
|
||
|
||
function onRoleFolderChanged(roleId: string, folderId: string | null) {
|
||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
|
||
}
|
||
|
||
function openCreateFolder(parentId: string | null) {
|
||
folderModalMode = 'create';
|
||
folderModalId = null;
|
||
folderName = '';
|
||
folderParent = parentId ?? '';
|
||
showFolderModal = true;
|
||
}
|
||
|
||
function openRenameFolder(folder: AgentConfigFolderRow) {
|
||
folderModalMode = 'rename';
|
||
folderModalId = folder.id;
|
||
folderName = folder.name;
|
||
folderParent = folder.parentId ?? '';
|
||
showFolderModal = true;
|
||
}
|
||
|
||
async function submitFolderModal() {
|
||
const name = folderName.trim();
|
||
if (name === '') {
|
||
toastError('文件夹名称不能为空');
|
||
return;
|
||
}
|
||
savingFolder = true;
|
||
try {
|
||
if (folderModalMode === 'create') {
|
||
await api.createAgentConfigFolder(slug, {
|
||
name,
|
||
...(folderParent !== '' ? { parentId: folderParent } : {}),
|
||
});
|
||
toastSuccess('文件夹已创建');
|
||
} else if (folderModalId !== null) {
|
||
await api.patchAgentConfigFolder(slug, folderModalId, {
|
||
name,
|
||
parentId: folderParent === '' ? null : folderParent,
|
||
});
|
||
toastSuccess('文件夹已更新');
|
||
}
|
||
showFolderModal = false;
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
savingFolder = false;
|
||
}
|
||
}
|
||
|
||
async function deleteFolder(folder: AgentConfigFolderRow) {
|
||
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
|
||
try {
|
||
await api.deleteAgentConfigFolder(slug, folder.id);
|
||
if (selectedFolder === folder.id) selectedFolder = 'all';
|
||
toastSuccess('文件夹已删除');
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (slug) load();
|
||
});
|
||
</script>
|
||
|
||
<PageHeader
|
||
title="角色"
|
||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
|
||
/>
|
||
|
||
{#if loading}
|
||
<LoadingState />
|
||
{:else if error}
|
||
<ErrorBanner message={error} onretry={load} />
|
||
{:else}
|
||
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
|
||
<div class="h-fit lg:sticky lg:top-4">
|
||
<AgentConfigFolderNav
|
||
{folders}
|
||
selected={selectedFolder}
|
||
{counts}
|
||
totalCount={roles.length}
|
||
{unfiledCount}
|
||
onselect={(id) => (selectedFolder = id)}
|
||
oncreate={openCreateFolder}
|
||
onrename={openRenameFolder}
|
||
ondelete={deleteFolder}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="saas-card-pad mb-6">
|
||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||
<input
|
||
class="saas-input font-mono text-sm"
|
||
placeholder="角色 ID(如 draft)"
|
||
bind:value={newRoleId}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') add();
|
||
}}
|
||
/>
|
||
<input
|
||
class="saas-input"
|
||
placeholder="显示名(如 草稿)"
|
||
bind:value={newLabel}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') add();
|
||
}}
|
||
/>
|
||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||
</div>
|
||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
|
||
</div>
|
||
|
||
{#if roles.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||
</div>
|
||
{:else if visibleRoles.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-4">
|
||
{#each visibleRoles as r (r.roleId)}
|
||
<RoleCard
|
||
{r}
|
||
{models}
|
||
{skills}
|
||
{slug}
|
||
{folderItems}
|
||
onupdated={onRoleUpdated}
|
||
onskillschanged={onRoleSkillsChanged}
|
||
onfolderchanged={onRoleFolderChanged}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
|
||
<label class="saas-label" for="agent-folder-name">名称</label>
|
||
<input
|
||
id="agent-folder-name"
|
||
class="saas-input mb-4"
|
||
bind:value={folderName}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') submitFolderModal();
|
||
}}
|
||
/>
|
||
<p class="saas-label">父文件夹</p>
|
||
<div class="mb-4">
|
||
<SelectField items={moveTargetItems} bind:value={folderParent} />
|
||
</div>
|
||
<div class="flex justify-end gap-2">
|
||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
|
||
{savingFolder ? '保存中…' : '保存'}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
{/if}
|