forked from EduCraft/curriculum-project-hub
7f09fb1f13
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
145 lines
4.3 KiB
Svelte
145 lines
4.3 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { api, type AgentSkillRow, type SkillFileEntry } 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 SkillEditor from '$lib/components/SkillEditor.svelte';
|
||
import { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
|
||
let skills = $state<AgentSkillRow[]>([]);
|
||
let loading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
|
||
let showNewSkill = $state(false);
|
||
let newSkillName = $state('');
|
||
let newSkillVersion = $state('0.1.0');
|
||
let newSkillDescription = $state('');
|
||
let creating = $state(false);
|
||
|
||
async function load() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
const res = await api.agentSkills(slug);
|
||
skills = res.skills;
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
async function createSkill() {
|
||
const name = newSkillName.trim();
|
||
if (name === '') {
|
||
toastError('技能名称不能为空');
|
||
return;
|
||
}
|
||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||
return;
|
||
}
|
||
const version = newSkillVersion.trim();
|
||
if (version === '') {
|
||
toastError('版本号不能为空');
|
||
return;
|
||
}
|
||
creating = true;
|
||
try {
|
||
const manifest = buildManifest(name, newSkillDescription.trim());
|
||
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||
const result = await api.installAgentSkill(slug, name, { version, files });
|
||
toastSuccess(`技能 ${result.name} 已创建`);
|
||
newSkillName = '';
|
||
newSkillDescription = '';
|
||
showNewSkill = false;
|
||
await load();
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
creating = false;
|
||
}
|
||
}
|
||
|
||
function buildManifest(name: string, description: string): string {
|
||
const desc = description === '' ? name : description;
|
||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||
}
|
||
|
||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||
load();
|
||
}
|
||
|
||
function onDisabled(_name: string) {
|
||
load();
|
||
}
|
||
|
||
$effect(() => {
|
||
if (slug) load();
|
||
});
|
||
</script>
|
||
|
||
<PageHeader
|
||
title="技能"
|
||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||
/>
|
||
|
||
{#if loading}
|
||
<LoadingState />
|
||
{:else if error}
|
||
<ErrorBanner message={error} onretry={load} />
|
||
{:else}
|
||
<div class="saas-card-pad mb-6">
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="saas-section-title">新建技能</h2>
|
||
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
||
{showNewSkill ? '取消' : '+ 新建'}
|
||
</button>
|
||
</div>
|
||
{#if showNewSkill}
|
||
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||
<input
|
||
class="saas-input font-mono text-sm"
|
||
placeholder="技能名(如 typst-help)"
|
||
bind:value={newSkillName}
|
||
/>
|
||
<input
|
||
class="saas-input text-sm"
|
||
placeholder="版本号"
|
||
bind:value={newSkillVersion}
|
||
/>
|
||
<input
|
||
class="saas-input text-sm"
|
||
placeholder="描述"
|
||
bind:value={newSkillDescription}
|
||
/>
|
||
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||
{creating ? '创建中…' : '创建'}
|
||
</button>
|
||
</div>
|
||
<p class="mt-2 text-xs text-surface-600">
|
||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if skills.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-4">
|
||
{#each skills as skill (skill.id)}
|
||
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|