forked from EduCraft/curriculum-project-hub
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee6da7ceb | |||
| 79f72ecca8 | |||
| ae5f78f036 | |||
| 0782e155f6 | |||
|
0726dc13c8
|
|||
|
fb66614e38
|
|||
| 11de9e81db | |||
| 46ce942aec | |||
| 8990277916 | |||
| b217c16c1b |
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
|
|||||||
workspace or service-user config from widening tools, hooks, MCP servers, or
|
workspace or service-user config from widening tools, hooks, MCP servers, or
|
||||||
sandbox paths.
|
sandbox paths.
|
||||||
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
||||||
assets. A controlled host-console installer imports each version into a
|
assets. Skill content is imported into a content-addressed persistent store
|
||||||
content-addressed persistent store and records its digest in PostgreSQL. A
|
through a shared ingestion pipeline (`importSkillDirectory` for host-console
|
||||||
role selects enabled Organization skills alongside its model, system prompt
|
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
|
||||||
and tool allowlist. Each run copies only those selected immutable versions
|
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
|
||||||
into a run-scoped plugin outside the project workspace; the sandbox exposes
|
symlink rejection, SHA-256 content addressing. The web surface
|
||||||
that snapshot read-only and deletes it after the run. SDK-bundled skills and
|
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
|
||||||
filesystem setting sources remain disabled, so project `.claude` content
|
store as the host-console CLI (`installSkill`); both flow through
|
||||||
cannot register skills or widen tools. Requested skill versions are recorded
|
`commitSkillContent` for deduplication and atomic rename into
|
||||||
on `run.created`; SDK initialization remains authoritative loading evidence.
|
`versions/<digest>/`. Org-admin authentication gates the web surface; the
|
||||||
|
content-addressed store remains platform-controlled. A role selects enabled
|
||||||
|
Organization skills alongside its model, system prompt and tool allowlist.
|
||||||
|
Each run copies only those selected immutable versions into a run-scoped
|
||||||
|
plugin outside the project workspace; the sandbox exposes that snapshot
|
||||||
|
read-only and deletes it after the run. SDK-bundled skills and filesystem
|
||||||
|
setting sources remain disabled, so project `.claude` content cannot register
|
||||||
|
skills or widen tools. Requested skill versions are recorded on
|
||||||
|
`run.created`; SDK initialization remains authoritative loading evidence.
|
||||||
- Network: open (see Open Questions).
|
- Network: open (see Open Questions).
|
||||||
|
|
||||||
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||||
|
|||||||
@@ -226,6 +226,50 @@ export interface CapacityPolicyView {
|
|||||||
dimensions: CapacityDimensionRow[];
|
dimensions: CapacityDimensionRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentRoleRow {
|
||||||
|
id: string;
|
||||||
|
roleId: string;
|
||||||
|
label: string;
|
||||||
|
defaultModel: string | null;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
tools: readonly string[] | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
disabledAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
skillNames: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSkillRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string | null;
|
||||||
|
contentDigest: string;
|
||||||
|
disabledAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
boundRoleIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileEntry {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledSkillResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contentDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentModelRow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
toolCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -261,8 +305,7 @@ export const api = {
|
|||||||
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
||||||
|
|
||||||
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
||||||
myProjects: (slug: string) =>
|
myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
||||||
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
|
||||||
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
||||||
post(`${orgBase(slug)}/folders`, body) as Promise<{
|
post(`${orgBase(slug)}/folders`, body) as Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -338,8 +381,33 @@ export const api = {
|
|||||||
disableFeishuApplication: (slug: string) =>
|
disableFeishuApplication: (slug: string) =>
|
||||||
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
||||||
|
|
||||||
capacityPolicy: (slug: string) =>
|
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
||||||
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
|
||||||
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
||||||
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
||||||
|
|
||||||
|
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
|
||||||
|
upsertAgentRole: (
|
||||||
|
slug: string,
|
||||||
|
roleId: string,
|
||||||
|
body: {
|
||||||
|
label: string;
|
||||||
|
defaultModel?: string | null;
|
||||||
|
systemPrompt?: string | null;
|
||||||
|
tools?: readonly string[] | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
},
|
||||||
|
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
|
||||||
|
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
|
||||||
|
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
|
||||||
|
skillNames: string[];
|
||||||
|
}>,
|
||||||
|
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
|
||||||
|
agentSkillFiles: (slug: string, name: string) =>
|
||||||
|
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
|
||||||
|
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
|
||||||
|
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
|
||||||
|
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
|
||||||
|
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
|
||||||
|
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'org'
|
| 'org'
|
||||||
| 'chevron'
|
| 'chevron'
|
||||||
|
| 'arrow-left'
|
||||||
| 'folder'
|
| 'folder'
|
||||||
| 'file'
|
| 'file'
|
||||||
| 'check';
|
| 'check'
|
||||||
|
| 'roles';
|
||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -100,6 +102,10 @@
|
|||||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if name === 'arrow-left'}
|
||||||
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
{:else if name === 'folder'}
|
{:else if name === 'folder'}
|
||||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path
|
<path
|
||||||
@@ -120,4 +126,12 @@
|
|||||||
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if name === 'roles'}
|
||||||
|
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.847-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.847.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 6l1.035-.259a3.375 3.375 0 002.456-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox, Label } from 'bits-ui';
|
||||||
|
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { fmtDate } from '$lib/format';
|
||||||
|
import { TOOL_OPTIONS } from '$lib/constants';
|
||||||
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
|
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
let {
|
||||||
|
r,
|
||||||
|
models,
|
||||||
|
skills,
|
||||||
|
slug,
|
||||||
|
onupdated,
|
||||||
|
onskillschanged,
|
||||||
|
}: {
|
||||||
|
r: AgentRoleRow;
|
||||||
|
models: AgentModelRow[];
|
||||||
|
skills: AgentSkillRow[];
|
||||||
|
slug: string;
|
||||||
|
onupdated: (updated: AgentRoleRow) => void;
|
||||||
|
onskillschanged: (roleId: string, skillNames: string[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
label: r.label,
|
||||||
|
defaultModel: r.defaultModel ?? '',
|
||||||
|
systemPrompt: r.systemPrompt ?? '',
|
||||||
|
unrestricted: r.tools === null,
|
||||||
|
tools: r.tools ?? [],
|
||||||
|
sortOrder: String(r.sortOrder),
|
||||||
|
isDefault: r.isDefault,
|
||||||
|
skillNames: r.skillNames,
|
||||||
|
};
|
||||||
|
let label = $state(initial.label);
|
||||||
|
let defaultModel = $state(initial.defaultModel);
|
||||||
|
let systemPrompt = $state(initial.systemPrompt);
|
||||||
|
let unrestricted = $state(initial.unrestricted);
|
||||||
|
let selectedTools = $state<string[]>([...initial.tools]);
|
||||||
|
let sortOrder = $state(initial.sortOrder);
|
||||||
|
let isDefault = $state(initial.isDefault);
|
||||||
|
let selectedSkills = $state<string[]>([...initial.skillNames]);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const groupedTools = TOOL_OPTIONS.reduce(
|
||||||
|
(acc, t) => {
|
||||||
|
(acc[t.group] ??= []).push(t);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof TOOL_OPTIONS>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modelItems = $derived([
|
||||||
|
{ value: '', label: '(使用平台默认模型)' },
|
||||||
|
...models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
||||||
|
|
||||||
|
function skillsDirty(): boolean {
|
||||||
|
const a = [...selectedSkills].sort();
|
||||||
|
const b = [...r.skillNames].sort();
|
||||||
|
return a.length !== b.length || a.some((v, i) => v !== b[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const trimmedLabel = label.trim();
|
||||||
|
if (trimmedLabel === '') {
|
||||||
|
toastError('显示名不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = Number(sortOrder);
|
||||||
|
if (sortKeyDirty() && (!Number.isSafeInteger(order) || order < 0)) {
|
||||||
|
toastError('排序必须为非负整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
const tools = unrestricted ? null : selectedTools;
|
||||||
|
try {
|
||||||
|
const updated = await api.upsertAgentRole(slug, r.roleId, {
|
||||||
|
label: trimmedLabel,
|
||||||
|
defaultModel: defaultModel === '' ? null : defaultModel,
|
||||||
|
systemPrompt: systemPrompt === '' ? null : systemPrompt,
|
||||||
|
tools,
|
||||||
|
...(sortKeyDirty() ? { sortOrder: order } : {}),
|
||||||
|
isDefault,
|
||||||
|
});
|
||||||
|
onupdated(updated);
|
||||||
|
if (skillsDirty()) {
|
||||||
|
const res = await api.setAgentRoleSkills(slug, r.roleId, selectedSkills);
|
||||||
|
selectedSkills = [...res.skillNames];
|
||||||
|
onskillschanged(r.roleId, res.skillNames);
|
||||||
|
}
|
||||||
|
toastSuccess('角色已保存');
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeyDirty(): boolean {
|
||||||
|
return Number(sortOrder) !== r.sortOrder;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="saas-card-pad">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="saas-badge-primary font-mono">/{r.roleId}</span>
|
||||||
|
<span class="text-sm text-surface-700">{label || r.label}</span>
|
||||||
|
{#if r.isDefault}
|
||||||
|
<span class="saas-badge-success">默认</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
|
||||||
|
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label.Root for="role-sort-{r.id}" class="saas-label">排序</Label.Root>
|
||||||
|
<input id="role-sort-{r.id}" class="saas-input" type="number" min="0" bind:value={sortOrder} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="saas-label">默认模型</p>
|
||||||
|
<SelectField items={modelItems} bind:value={defaultModel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<span class="saas-label">工具白名单</span>
|
||||||
|
<label class="mb-3 flex cursor-pointer items-center gap-2 border border-surface-300 bg-surface-100 px-3 py-2">
|
||||||
|
<CheckboxControl bind:checked={unrestricted} />
|
||||||
|
<span class="text-sm">不限(使用全部注册工具)</span>
|
||||||
|
</label>
|
||||||
|
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
|
||||||
|
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
|
||||||
|
{#each Object.entries(groupedTools) as [group, tools]}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-600">{group}</p>
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
|
{#each tools as t}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||||
|
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
|
||||||
|
{#snippet children({ checked })}
|
||||||
|
{#if checked}
|
||||||
|
<Icon name="check" class="h-3.5 w-3.5" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Checkbox.Root>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<span class="saas-label">技能绑定</span>
|
||||||
|
{#if skills.length === 0}
|
||||||
|
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
|
{#each skillItems as s}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||||
|
<CheckboxControl
|
||||||
|
checked={selectedSkills.includes(s.value)}
|
||||||
|
onchange={(checked) => {
|
||||||
|
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-xs">{s.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
|
||||||
|
<textarea
|
||||||
|
id="role-prompt-{r.id}"
|
||||||
|
class="saas-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
|
||||||
|
bind:value={systemPrompt}></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<CheckboxControl bind:checked={isDefault} />
|
||||||
|
<span class="text-sm">设为组织默认角色</span>
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-surface-600">更新于 {fmtDate(r.updatedAt)}</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { fmtDate } from '$lib/format';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
let {
|
||||||
|
slug,
|
||||||
|
skill,
|
||||||
|
oninstalled,
|
||||||
|
ondisabled,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
skill: AgentSkillRow;
|
||||||
|
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||||||
|
ondisabled: (name: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
type FileNode = { path: string; content: string };
|
||||||
|
|
||||||
|
let files = $state<FileNode[]>([]);
|
||||||
|
let selectedPath = $state<string | null>(null);
|
||||||
|
let version = $state(skill.version);
|
||||||
|
let description = $state(skill.description ?? '');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let dirty = $state(false);
|
||||||
|
let newFilePath = $state('');
|
||||||
|
let showNewFile = $state(false);
|
||||||
|
|
||||||
|
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||||
|
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||||
|
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.agentSkillFiles(slug, skill.name);
|
||||||
|
files = res.files.map((f) => ({ path: f.path, content: f.content }));
|
||||||
|
if (files.length > 0 && selectedPath === null) {
|
||||||
|
selectedPath = files[0]!.path;
|
||||||
|
}
|
||||||
|
dirty = false;
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(path: string) {
|
||||||
|
selectedPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContent(path: string, content: string) {
|
||||||
|
const file = files.find((f) => f.path === path);
|
||||||
|
if (file) {
|
||||||
|
file.content = content;
|
||||||
|
dirty = true;
|
||||||
|
if (path === 'SKILL.md') {
|
||||||
|
const desc = parseDescription(content);
|
||||||
|
if (desc !== null) description = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFile() {
|
||||||
|
const path = newFilePath.trim();
|
||||||
|
if (path === '') {
|
||||||
|
toastError('文件路径不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.some((f) => f.path === path)) {
|
||||||
|
toastError(`文件已存在:${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/') || path.includes('..')) {
|
||||||
|
toastError('文件路径必须为相对路径');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files.push({ path, content: '' });
|
||||||
|
selectedPath = path;
|
||||||
|
newFilePath = '';
|
||||||
|
showNewFile = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFile(path: string) {
|
||||||
|
if (path === 'SKILL.md') {
|
||||||
|
toastError('SKILL.md 是必需的 manifest,不能删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files = files.filter((f) => f.path !== path);
|
||||||
|
if (selectedPath === path) {
|
||||||
|
selectedPath = files.length > 0 ? files[0]!.path : null;
|
||||||
|
}
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDescription(manifest: string): string | null {
|
||||||
|
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
|
||||||
|
return match ? match[1]!.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!hasManifest) {
|
||||||
|
toastError('缺少 SKILL.md manifest 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedVersion = version.trim();
|
||||||
|
if (trimmedVersion === '') {
|
||||||
|
toastError('版本号不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
|
||||||
|
const result = await api.installAgentSkill(slug, skill.name, {
|
||||||
|
version: trimmedVersion,
|
||||||
|
files: fileEntries,
|
||||||
|
});
|
||||||
|
dirty = false;
|
||||||
|
toastSuccess('技能已保存');
|
||||||
|
oninstalled(result);
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await api.patchAgentSkill(slug, skill.name, { disabled: true });
|
||||||
|
toastSuccess('技能已禁用');
|
||||||
|
ondisabled(skill.name);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDescription() {
|
||||||
|
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||||||
|
if (!manifest) return;
|
||||||
|
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
|
||||||
|
manifest.content = updated;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||||
|
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
return content.replace(regex, `${key}: ${value}`);
|
||||||
|
}
|
||||||
|
const lines = content.split('\n');
|
||||||
|
if (lines[0] === '---') {
|
||||||
|
lines.splice(1, 0, `${key}: ${value}`);
|
||||||
|
} else {
|
||||||
|
lines.unshift('---', `${key}: ${value}`, '---');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (skill.name) loadFiles();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="saas-card-pad">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="saas-badge-primary font-mono">{skill.name}</span>
|
||||||
|
<span class="text-sm text-surface-700">v{skill.version}</span>
|
||||||
|
{#if skill.disabledAt}
|
||||||
|
<span class="saas-badge-error">已禁用</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
|
||||||
|
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
|
||||||
|
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>更新于 {fmtDate(skill.updatedAt)}</span>
|
||||||
|
{#if skill.boundRoleIds.length > 0}
|
||||||
|
<span>·</span>
|
||||||
|
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
|
{#if dirty}
|
||||||
|
<span>·</span>
|
||||||
|
<span class="font-medium text-warning-700">未保存</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
|
||||||
|
<div class="border border-surface-300 bg-surface-100">
|
||||||
|
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
|
||||||
|
<span class="text-xs font-medium text-surface-700">文件</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary-700 hover:text-primary-900"
|
||||||
|
onclick={() => (showNewFile = !showNewFile)}
|
||||||
|
>
|
||||||
|
{showNewFile ? '取消' : '+ 新增'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showNewFile}
|
||||||
|
<div class="border-b border-surface-300 px-3 py-2">
|
||||||
|
<input
|
||||||
|
class="saas-input text-xs"
|
||||||
|
placeholder="如 reference.md"
|
||||||
|
bind:value={newFilePath}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') addFile();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="max-h-80 overflow-y-auto">
|
||||||
|
{#each sortedFiles as f (f.path)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
|
||||||
|
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
|
||||||
|
onclick={() => selectFile(f.path)}
|
||||||
|
>
|
||||||
|
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||||||
|
<span class="truncate font-mono text-xs">{f.path}</span>
|
||||||
|
{#if f.path === 'SKILL.md'}
|
||||||
|
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="ml-auto text-xs text-surface-500 hover:text-error-700"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteFile(f.path);
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteFile(f.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if selectedFile}
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
|
||||||
|
bind:value={selectedFile.content}
|
||||||
|
oninput={() => (dirty = true)}
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
|
||||||
|
选择一个文件或新建文件
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||||
|
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
{#if !skill.disabledAt}
|
||||||
|
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||||
|
禁用
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -32,9 +32,37 @@ export async function loadSession(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
|
||||||
|
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
|
||||||
|
*/
|
||||||
|
export function resolveLoginOrgSlug(): string | null {
|
||||||
|
const path = window.location.pathname.split('/').filter(Boolean);
|
||||||
|
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
|
||||||
|
return decodeURIComponent(path[2]);
|
||||||
|
}
|
||||||
|
const q = new URLSearchParams(window.location.search).get('org');
|
||||||
|
if (q && q.trim() !== '') return q.trim();
|
||||||
|
|
||||||
|
// Alpha Silo public host: <slug>.educraft.paradigm-edu.net (or educraft-dev)
|
||||||
|
const host = window.location.hostname.toLowerCase();
|
||||||
|
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
|
||||||
|
if (m?.[1]) return m[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function redirectToLogin(): void {
|
export function redirectToLogin(): void {
|
||||||
const ret = encodeURIComponent(window.location.pathname + window.location.hash);
|
if (window.location.pathname === '/admin/login') return;
|
||||||
window.location.href = `/auth/feishu?returnTo=${ret}`;
|
const ret = encodeURIComponent(
|
||||||
|
window.location.pathname + window.location.search + window.location.hash,
|
||||||
|
);
|
||||||
|
const slug = resolveLoginOrgSlug();
|
||||||
|
if (slug === null) {
|
||||||
|
// Need an org slug for scoped OAuth — backend login helper can prompt.
|
||||||
|
window.location.href = `/admin/login?returnTo=${ret}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `/auth/feishu/${encodeURIComponent(slug)}?returnTo=${ret}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||||
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
{ key: 'capacity', label: '容量', icon: 'overview' as const },
|
||||||
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
{ key: 'provider', label: '供应方', icon: 'provider' as const },
|
||||||
|
{ key: 'skills', label: '技能', icon: 'roles' as const },
|
||||||
|
{ key: 'roles', label: '角色', icon: 'roles' as const },
|
||||||
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -310,11 +312,7 @@
|
|||||||
<div class="saas-shell">
|
<div class="saas-shell">
|
||||||
<div class="saas-main">
|
<div class="saas-main">
|
||||||
<header class="saas-topbar">
|
<header class="saas-topbar">
|
||||||
<a
|
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
|
||||||
href={`/admin/org/${org.slug}/projects`}
|
|
||||||
class="saas-btn-ghost px-2!"
|
|
||||||
aria-label="返回项目列表"
|
|
||||||
>
|
|
||||||
<Icon name="menu" class="h-5 w-5" />
|
<Icon name="menu" class="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SelectField from '$lib/components/SelectField.svelte';
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
@@ -158,7 +159,8 @@
|
|||||||
href={`/admin/org/${slug}/projects`}
|
href={`/admin/org/${slug}/projects`}
|
||||||
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
|
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
|
||||||
>
|
>
|
||||||
← 返回项目列表
|
<Icon name="arrow-left" class="h-4 w-4" />
|
||||||
|
返回项目列表
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
|
||||||
|
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 { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
|
const slug = $derived(page.params.slug ?? '');
|
||||||
|
|
||||||
|
let roles = $state<AgentRoleRow[]>([]);
|
||||||
|
let models = $state<AgentModelRow[]>([]);
|
||||||
|
let skills = $state<AgentSkillRow[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let newRoleId = $state('');
|
||||||
|
let newLabel = $state('');
|
||||||
|
let adding = $state(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const [r, m, s] = await Promise.all([api.agentRoles(slug), api.agentModels(slug), api.agentSkills(slug)]);
|
||||||
|
roles = r.roles;
|
||||||
|
models = m.models;
|
||||||
|
skills = s.skills;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
roles = [...roles, created];
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (slug) load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="角色"
|
||||||
|
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<LoadingState />
|
||||||
|
{:else if error}
|
||||||
|
<ErrorBanner message={error} onretry={load} />
|
||||||
|
{:else}
|
||||||
|
<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}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each roles as r (r.roleId)}
|
||||||
|
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||||
|
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 slug = $derived(page.params.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}
|
||||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8788',
|
'/api': 'http://127.0.0.1:8788',
|
||||||
'/auth': 'http://127.0.0.1:8788',
|
'/auth': 'http://127.0.0.1:8788',
|
||||||
|
// Backend owns /admin/login (registered before the SPA fallback in
|
||||||
|
// src/admin/static.ts). Proxy it in dev so the SPA doesn't re-render
|
||||||
|
// its layout on that path and 401-redirect into a returnTo loop.
|
||||||
|
'/admin/login': 'http://127.0.0.1:8788',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.25",
|
"version": "0.0.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.25",
|
"version": "0.0.29",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.25",
|
"version": "0.0.29",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { config } from "dotenv";
|
||||||
|
config();
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js";
|
||||||
|
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const keyringFile = process.env["HUB_SECRET_KEYRING_FILE"];
|
||||||
|
if (!keyringFile) throw new Error("HUB_SECRET_KEYRING_FILE is required");
|
||||||
|
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
try {
|
||||||
|
const result = await bootstrapAlphaSilo(
|
||||||
|
prisma,
|
||||||
|
secrets,
|
||||||
|
{
|
||||||
|
organization: {
|
||||||
|
id: "org_default",
|
||||||
|
slug: "default",
|
||||||
|
name: "Default Organization",
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
openId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-owner",
|
||||||
|
displayName: "Dev Owner",
|
||||||
|
},
|
||||||
|
feishu: {
|
||||||
|
appId: process.env["FEISHU_APP_ID"] ?? "dev-app",
|
||||||
|
appSecret: process.env["FEISHU_APP_SECRET"] ?? "dev-secret",
|
||||||
|
botOpenId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-bot",
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
providerId: "openrouter",
|
||||||
|
baseUrl: process.env["ANTHROPIC_BASE_URL"] ?? "https://openrouter.ai/api",
|
||||||
|
authToken: process.env["ANTHROPIC_AUTH_TOKEN"] ?? "dev-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feishu: async () => undefined,
|
||||||
|
provider: async () => undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("bootstrap ok:", JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error("[dev-bootstrap] failed:", error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
||||||
|
*
|
||||||
|
* Surface for browsing and editing Organization-scoped Agent roles and skills.
|
||||||
|
* Roles are org-owned data; the default-model picker is constrained to the
|
||||||
|
* env-default model registry (ADR-0017: there is no org-scoped model list —
|
||||||
|
* `OrganizationAgentRole.defaultModel` selects from the platform-enabled set).
|
||||||
|
*
|
||||||
|
* Skill management (create, read, edit, disable) is performed in-band through
|
||||||
|
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
||||||
|
* content-addressed persistent store used by the host-console CLI. All skill
|
||||||
|
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
|
||||||
|
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
||||||
|
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
||||||
|
* / 16-byte limits, symlink rejection).
|
||||||
|
*/
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
||||||
|
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
||||||
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||||
|
import { handleRouteError } from "../errors.js";
|
||||||
|
|
||||||
|
export interface AgentConfigRouteConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
readonly skillStoreRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAgentConfigRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AgentConfigRouteConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||||
|
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
|
||||||
|
return { roles };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/org/:orgSlug/agent-roles/:roleId", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as {
|
||||||
|
label?: unknown;
|
||||||
|
defaultModel?: unknown;
|
||||||
|
systemPrompt?: unknown;
|
||||||
|
tools?: unknown;
|
||||||
|
sortOrder?: unknown;
|
||||||
|
isDefault?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof body.label !== "string" || body.label.trim() === "") {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "label is required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const role = await agentConfig.upsertRole({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
roleId,
|
||||||
|
label: body.label,
|
||||||
|
...(body.defaultModel === null || typeof body.defaultModel === "string"
|
||||||
|
? { defaultModel: body.defaultModel as string | null }
|
||||||
|
: {}),
|
||||||
|
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
|
||||||
|
? { systemPrompt: body.systemPrompt as string | null }
|
||||||
|
: {}),
|
||||||
|
...(body.tools === null || Array.isArray(body.tools)
|
||||||
|
? { tools: body.tools as readonly string[] | null }
|
||||||
|
: {}),
|
||||||
|
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
|
||||||
|
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
|
||||||
|
});
|
||||||
|
return role;
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as { skillNames?: unknown };
|
||||||
|
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "skillNames must be a string array" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await agentConfig.setRoleSkills({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
roleId,
|
||||||
|
skillNames: body.skillNames as readonly string[],
|
||||||
|
});
|
||||||
|
return { skillNames: body.skillNames as string[] };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
||||||
|
return { skills };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
||||||
|
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
|
||||||
|
if (skill === undefined) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: { code: "not_found", message: `skill not found: ${name}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const files = await agentConfig.readSkillFiles({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
contentDigest: skill.contentDigest,
|
||||||
|
});
|
||||||
|
return { files };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as { version?: unknown; files?: unknown };
|
||||||
|
if (typeof body.version !== "string" || body.version.trim() === "") {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "version is required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Array.isArray(body.files) || body.files.length === 0) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "files must be a non-empty array" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||||
|
for (const entry of body.files) {
|
||||||
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "each file must be an object" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const e = entry as { path?: unknown; content?: unknown };
|
||||||
|
if (typeof e.path !== "string" || typeof e.content !== "string") {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "each file needs string path and content" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
|
||||||
|
}
|
||||||
|
const result = await agentConfig.installSkillFromFiles({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
files: skillFiles,
|
||||||
|
version: body.version,
|
||||||
|
});
|
||||||
|
// The manifest name is authoritative — reject if it doesn't match the
|
||||||
|
// URL param, so clients can't silently rename a skill under a different
|
||||||
|
// route key.
|
||||||
|
if (result.name !== name) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
code: "bad_request",
|
||||||
|
message: `skill manifest name "${result.name}" does not match route "${name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const body = request.body as { description?: unknown; disabled?: unknown };
|
||||||
|
if (body.disabled === true) {
|
||||||
|
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
|
||||||
|
return { disabled: true };
|
||||||
|
}
|
||||||
|
if (typeof body.description === "string") {
|
||||||
|
await agentConfig.updateSkillDescription({
|
||||||
|
organizationId: auth.organization.id,
|
||||||
|
name,
|
||||||
|
description: body.description,
|
||||||
|
});
|
||||||
|
return { updated: true };
|
||||||
|
}
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: "bad_request", message: "provide description or disabled: true" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { orgSlug } = request.params as { orgSlug: string };
|
||||||
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||||
|
if (auth === null) return;
|
||||||
|
const registry = createDefaultModelRegistry(process.env);
|
||||||
|
return { models: registry.listModels() };
|
||||||
|
} catch (err) {
|
||||||
|
return handleRouteError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -433,8 +433,10 @@ async function resolvePostLoginRedirect(
|
|||||||
});
|
});
|
||||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||||
|
// Default / missing returnTo sanitizes to "/admin". Always land in the org
|
||||||
|
// admin SPA (not the legacy static "close this tab" complete page).
|
||||||
if (returnTo === "/admin") {
|
if (returnTo === "/admin") {
|
||||||
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
|
return orgRoot;
|
||||||
}
|
}
|
||||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export async function registerExplorerRoutes(
|
|||||||
workspaceRoot: config.projectWorkspaceRoot,
|
workspaceRoot: config.projectWorkspaceRoot,
|
||||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||||
});
|
});
|
||||||
return reply.status(201).send(result);
|
return reply.status(201).send({ id: result.projectId, name: body.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleRouteError(reply, err);
|
return handleRouteError(reply, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
|||||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
||||||
|
import { readSkillStoreRoot } from "../../agent/skillStore.js";
|
||||||
|
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
|
||||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||||
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||||
@@ -110,6 +112,11 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
});
|
});
|
||||||
|
await registerAgentConfigRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
skillStoreRoot: readSkillStoreRoot(),
|
||||||
|
});
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
|||||||
+273
-17
@@ -1,10 +1,37 @@
|
|||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||||
import { importSkillDirectory } from "./skillStore.js";
|
import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
|
||||||
|
|
||||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
export interface AgentRoleRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly roleId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly defaultModel: string | null;
|
||||||
|
readonly systemPrompt: string | null;
|
||||||
|
readonly tools: readonly string[] | null;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
readonly disabledAt: string | null;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
readonly skillNames: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSkillRow {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly contentDigest: string;
|
||||||
|
readonly disabledAt: string | null;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
readonly boundRoleIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep module for controlled host-console Agent configuration. It owns the
|
* Deep module for controlled host-console Agent configuration. It owns the
|
||||||
* filesystem/DB ordering, Organization checks and role-skill composition so
|
* filesystem/DB ordering, Organization checks and role-skill composition so
|
||||||
@@ -13,43 +40,227 @@ const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|||||||
export class OrganizationAgentConfiguration {
|
export class OrganizationAgentConfiguration {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly skillStoreRoot: string,
|
private readonly skillStoreRoot: string | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const roles = await this.prisma.organizationAgentRole.findMany({
|
||||||
|
where: { organizationId: input.organizationId, disabledAt: null },
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
|
||||||
|
include: {
|
||||||
|
skillBindings: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
|
||||||
|
select: { skill: { select: { name: true, disabledAt: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return roles.map((role) => toRoleRow(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const skills = await this.prisma.organizationAgentSkill.findMany({
|
||||||
|
where: { organizationId: input.organizationId, disabledAt: null },
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
include: {
|
||||||
|
roleBindings: {
|
||||||
|
where: { role: { disabledAt: null } },
|
||||||
|
select: { role: { select: { roleId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return skills.map((skill) => ({
|
||||||
|
id: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
version: skill.version,
|
||||||
|
description: skill.description,
|
||||||
|
contentDigest: skill.contentDigest,
|
||||||
|
disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
|
||||||
|
createdAt: skill.createdAt.toISOString(),
|
||||||
|
updatedAt: skill.updatedAt.toISOString(),
|
||||||
|
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async installSkill(input: {
|
async installSkill(input: {
|
||||||
readonly organizationId: string;
|
readonly organizationId: string;
|
||||||
readonly sourceDir: string;
|
readonly sourceDir: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
await this.requireActiveOrganization(input.organizationId);
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
const version = nonEmpty(input.version, "skill version");
|
const version = nonEmpty(input.version, "skill version");
|
||||||
const imported = await importSkillDirectory({
|
const imported = await importSkillDirectory({
|
||||||
sourceDir: input.sourceDir,
|
sourceDir: input.sourceDir,
|
||||||
storeRoot: this.skillStoreRoot,
|
storeRoot,
|
||||||
});
|
});
|
||||||
|
return this.commitInstalledSkill({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
imported,
|
||||||
|
version,
|
||||||
|
action: "agent_skill.installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install or replace a skill from an in-memory file list (web upload path).
|
||||||
|
* Same content-addressed storage, same session archival semantics as
|
||||||
|
* `installSkill`; only the ingestion source differs.
|
||||||
|
*/
|
||||||
|
async installSkillFromFiles(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly files: readonly SkillFileInput[];
|
||||||
|
readonly version: string;
|
||||||
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const version = nonEmpty(input.version, "skill version");
|
||||||
|
const imported = await importSkillFromFiles({
|
||||||
|
files: input.files,
|
||||||
|
storeRoot,
|
||||||
|
});
|
||||||
|
return this.commitInstalledSkill({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
imported,
|
||||||
|
version,
|
||||||
|
action: "agent_skill.installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all files from the stored skill version identified by content digest.
|
||||||
|
* Returns UTF-8 content for each file; the web editor uses this to populate
|
||||||
|
* its file tree.
|
||||||
|
*/
|
||||||
|
async readSkillFiles(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly contentDigest: string;
|
||||||
|
}): Promise<readonly SkillFileOutput[]> {
|
||||||
|
const storeRoot = this.requireSkillStoreRoot();
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const skill = await this.prisma.organizationAgentSkill.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
contentDigest: input.contentDigest,
|
||||||
|
},
|
||||||
|
select: { id: true, disabledAt: true },
|
||||||
|
});
|
||||||
|
if (skill === null) {
|
||||||
|
throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
|
||||||
|
}
|
||||||
|
return readSkillFiles({
|
||||||
|
storeRoot,
|
||||||
|
contentDigest: input.contentDigest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a skill (soft-delete). Sets `disabledAt` and archives active
|
||||||
|
* sessions of every role bound to this skill, since the execution surface
|
||||||
|
* changes when a bound skill disappears.
|
||||||
|
*/
|
||||||
|
async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise<void> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const skill = await tx.organizationAgentSkill.findUnique({
|
||||||
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||||
|
select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } },
|
||||||
|
});
|
||||||
|
if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
|
||||||
|
if (skill.disabledAt !== null) return;
|
||||||
|
await tx.organizationAgentSkill.update({
|
||||||
|
where: { id: skill.id },
|
||||||
|
data: { disabledAt: new Date() },
|
||||||
|
});
|
||||||
|
await archiveRoleSessions(
|
||||||
|
tx,
|
||||||
|
input.organizationId,
|
||||||
|
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||||
|
);
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
action: "agent_skill.disabled",
|
||||||
|
metadata: { name: input.name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only the `description` label of a skill. This is a label change,
|
||||||
|
* not an execution-surface change — no session archival (ADR-0017).
|
||||||
|
*/
|
||||||
|
async updateSkillDescription(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
|
const description = input.description.trim();
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const skill = await tx.organizationAgentSkill.findUnique({
|
||||||
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
|
||||||
|
select: { id: true, disabledAt: true },
|
||||||
|
});
|
||||||
|
if (skill === null || skill.disabledAt !== null) {
|
||||||
|
throw new Error(`active skill not found in organization: ${input.name}`);
|
||||||
|
}
|
||||||
|
await tx.organizationAgentSkill.update({
|
||||||
|
where: { id: skill.id },
|
||||||
|
data: { description: description === "" ? null : description },
|
||||||
|
});
|
||||||
|
await tx.auditEntry.create({
|
||||||
|
data: {
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
action: "agent_skill.description_updated",
|
||||||
|
metadata: { name: input.name, description: description === "" ? null : description },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireSkillStoreRoot(): string {
|
||||||
|
if (this.skillStoreRoot === null) {
|
||||||
|
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
|
||||||
|
}
|
||||||
|
return this.skillStoreRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared DB commit for an imported skill: upsert the skill record, archive
|
||||||
|
* affected sessions if the content digest changed, and write an audit entry.
|
||||||
|
*/
|
||||||
|
private async commitInstalledSkill(input: {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
|
||||||
|
readonly version: string;
|
||||||
|
readonly action: string;
|
||||||
|
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const previous = await tx.organizationAgentSkill.findUnique({
|
const previous = await tx.organizationAgentSkill.findUnique({
|
||||||
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
|
where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
|
||||||
select: { contentDigest: true },
|
select: { contentDigest: true },
|
||||||
});
|
});
|
||||||
const skill = await tx.organizationAgentSkill.upsert({
|
const skill = await tx.organizationAgentSkill.upsert({
|
||||||
where: {
|
where: {
|
||||||
organizationId_name: {
|
organizationId_name: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
name: imported.name,
|
name: input.imported.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
name: imported.name,
|
name: input.imported.name,
|
||||||
version,
|
version: input.version,
|
||||||
description: imported.description ?? null,
|
description: input.imported.description ?? null,
|
||||||
contentDigest: imported.contentDigest,
|
contentDigest: input.imported.contentDigest,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
version,
|
version: input.version,
|
||||||
description: imported.description ?? null,
|
description: input.imported.description ?? null,
|
||||||
contentDigest: imported.contentDigest,
|
contentDigest: input.imported.contentDigest,
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -69,10 +280,10 @@ export class OrganizationAgentConfiguration {
|
|||||||
await tx.auditEntry.create({
|
await tx.auditEntry.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
action: "agent_skill.installed",
|
action: input.action,
|
||||||
metadata: {
|
metadata: {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
version,
|
version: input.version,
|
||||||
contentDigest: skill.contentDigest,
|
contentDigest: skill.contentDigest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -90,7 +301,7 @@ export class OrganizationAgentConfiguration {
|
|||||||
readonly tools?: readonly string[] | null | undefined;
|
readonly tools?: readonly string[] | null | undefined;
|
||||||
readonly sortOrder?: number | undefined;
|
readonly sortOrder?: number | undefined;
|
||||||
readonly isDefault?: boolean | undefined;
|
readonly isDefault?: boolean | undefined;
|
||||||
}): Promise<{ readonly id: string; readonly roleId: string }> {
|
}): Promise<AgentRoleRow> {
|
||||||
await this.requireActiveOrganization(input.organizationId);
|
await this.requireActiveOrganization(input.organizationId);
|
||||||
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
|
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
|
||||||
const label = nonEmpty(input.label, "role label");
|
const label = nonEmpty(input.label, "role label");
|
||||||
@@ -150,7 +361,12 @@ export class OrganizationAgentConfiguration {
|
|||||||
isDefault: effectiveIsDefault,
|
isDefault: effectiveIsDefault,
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
},
|
},
|
||||||
select: { id: true, roleId: true },
|
include: {
|
||||||
|
skillBindings: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
|
||||||
|
select: { skill: { select: { name: true, disabledAt: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const executionSurfaceChanged = previous !== null && (
|
const executionSurfaceChanged = previous !== null && (
|
||||||
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
|
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
|
||||||
@@ -182,7 +398,7 @@ export class OrganizationAgentConfiguration {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return role;
|
return toRoleRow(role);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,3 +511,43 @@ function normalizeOptionalText(value: string | null | undefined): string | null
|
|||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
return normalized === "" ? null : normalized;
|
return normalized === "" ? null : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTools(value: Prisma.JsonValue): readonly string[] | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
return value.filter((t): t is string => typeof t === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRoleRow(role: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly roleId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly defaultModel: string | null;
|
||||||
|
readonly systemPrompt: string | null;
|
||||||
|
readonly tools: Prisma.JsonValue;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
readonly disabledAt: Date | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly updatedAt: Date;
|
||||||
|
readonly skillBindings: ReadonlyArray<{
|
||||||
|
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
|
||||||
|
}>;
|
||||||
|
}): AgentRoleRow {
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
roleId: role.roleId,
|
||||||
|
label: role.label,
|
||||||
|
defaultModel: role.defaultModel,
|
||||||
|
systemPrompt: role.systemPrompt,
|
||||||
|
tools: parseTools(role.tools),
|
||||||
|
sortOrder: role.sortOrder,
|
||||||
|
isDefault: role.isDefault,
|
||||||
|
disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
|
||||||
|
createdAt: role.createdAt.toISOString(),
|
||||||
|
updatedAt: role.updatedAt.toISOString(),
|
||||||
|
skillNames: role.skillBindings
|
||||||
|
.filter((binding) => binding.skill.disabledAt === null)
|
||||||
|
.map((binding) => binding.skill.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+102
-8
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
|
|||||||
readonly sourceDir: string;
|
readonly sourceDir: string;
|
||||||
readonly storeRoot: string;
|
readonly storeRoot: string;
|
||||||
}): Promise<ImportedSkillContent> {
|
}): Promise<ImportedSkillContent> {
|
||||||
const source = await inspectSkillDirectory(input.sourceDir);
|
return commitSkillContent({
|
||||||
|
storeRoot: input.storeRoot,
|
||||||
|
prepare: async (dir) => {
|
||||||
|
await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
|
||||||
|
return inspectSkillDirectory(dir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a skill from an in-memory file list (web upload path). Each file
|
||||||
|
* path is relative to the skill root and must be a simple relative path
|
||||||
|
* (no absolute, no `..`). A `SKILL.md` manifest is required.
|
||||||
|
*/
|
||||||
|
export async function importSkillFromFiles(input: {
|
||||||
|
readonly files: readonly SkillFileInput[];
|
||||||
|
readonly storeRoot: string;
|
||||||
|
}): Promise<ImportedSkillContent> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const file of input.files) {
|
||||||
|
validateSkillFilePath(file.path);
|
||||||
|
if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
|
||||||
|
seen.add(file.path);
|
||||||
|
}
|
||||||
|
return commitSkillContent({
|
||||||
|
storeRoot: input.storeRoot,
|
||||||
|
prepare: async (dir) => {
|
||||||
|
for (const file of input.files) {
|
||||||
|
const dest = join(dir, file.path);
|
||||||
|
const parent = join(dest, "..");
|
||||||
|
await mkdir(parent, { recursive: true, mode: 0o750 });
|
||||||
|
await writeFile(dest, file.bytes, { mode: 0o640 });
|
||||||
|
}
|
||||||
|
return inspectSkillDirectory(dir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all files from a stored skill version (by content digest). Returns
|
||||||
|
* relative paths and UTF-8 decoded content for the web editor.
|
||||||
|
*/
|
||||||
|
export async function readSkillFiles(input: {
|
||||||
|
readonly storeRoot: string;
|
||||||
|
readonly contentDigest: string;
|
||||||
|
}): Promise<readonly SkillFileOutput[]> {
|
||||||
|
if (!DIGEST_PATTERN.test(input.contentDigest)) {
|
||||||
|
throw new Error(`invalid content digest: ${input.contentDigest}`);
|
||||||
|
}
|
||||||
|
const dir = join(input.storeRoot, "versions", input.contentDigest);
|
||||||
|
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||||
|
await walk(resolve(dir), resolve(dir), files);
|
||||||
|
return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
content: f.bytes.toString("utf8"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileInput {
|
||||||
|
readonly path: string;
|
||||||
|
readonly bytes: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillFileOutput {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared commit logic: populate a temp dir, inspect it, deduplicate against
|
||||||
|
* an existing `versions/<digest>/` destination, and atomically rename.
|
||||||
|
*
|
||||||
|
* `prepare(dir)` writes the skill content into `dir` and returns the inspected
|
||||||
|
* result (name, description, contentDigest). The caller owns the write;
|
||||||
|
* commitSkillContent owns the move.
|
||||||
|
*/
|
||||||
|
async function commitSkillContent(input: {
|
||||||
|
readonly storeRoot: string;
|
||||||
|
readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
|
||||||
|
}): Promise<ImportedSkillContent> {
|
||||||
const versionsRoot = join(input.storeRoot, "versions");
|
const versionsRoot = join(input.storeRoot, "versions");
|
||||||
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
||||||
|
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||||
|
|
||||||
|
let source: ImportedSkillContent;
|
||||||
|
try {
|
||||||
|
source = await input.prepare(temporary);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const destination = join(versionsRoot, source.contentDigest);
|
const destination = join(versionsRoot, source.contentDigest);
|
||||||
|
|
||||||
|
// If the destination already exists with identical content, reuse it.
|
||||||
try {
|
try {
|
||||||
const existing = await inspectSkillDirectory(destination);
|
const existing = await inspectSkillDirectory(destination);
|
||||||
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
||||||
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
||||||
}
|
}
|
||||||
|
await rm(temporary, { recursive: true, force: true });
|
||||||
return source;
|
return source;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingPath(error)) throw error;
|
if (!isMissingPath(error)) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
|
||||||
try {
|
try {
|
||||||
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
|
|
||||||
const copied = await inspectSkillDirectory(temporary);
|
|
||||||
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
|
|
||||||
throw new Error(`skill changed while importing: ${source.name}`);
|
|
||||||
}
|
|
||||||
await rename(temporary, destination);
|
await rename(temporary, destination);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await rm(temporary, { recursive: true, force: true });
|
await rm(temporary, { recursive: true, force: true });
|
||||||
if (isDestinationExists(error)) {
|
if (isDestinationExists(error)) {
|
||||||
const existing = await inspectSkillDirectory(destination);
|
const existing = await inspectSkillDirectory(destination);
|
||||||
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
|
if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSkillFilePath(path: string): void {
|
||||||
|
if (path === "") throw new Error("skill file path is empty");
|
||||||
|
if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
|
||||||
|
if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
|
||||||
|
if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareRunSkillPlugin(input: {
|
export async function prepareRunSkillPlugin(input: {
|
||||||
readonly storeRoot: string;
|
readonly storeRoot: string;
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
||||||
|
*
|
||||||
|
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
||||||
|
* Static SPA assets and the org-admin HTML shell are exempt so a single page
|
||||||
|
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Paths that must not consume the silo HTTP request-rate budget. */
|
||||||
|
export function isSiloHttpRateLimitExempt(url: string): boolean {
|
||||||
|
const path = (url.split("?", 1)[0] ?? url) || "/";
|
||||||
|
|
||||||
|
if (path === "/api/healthz") return true;
|
||||||
|
|
||||||
|
// SvelteKit build output and top-level static files (see admin/static.ts).
|
||||||
|
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
||||||
|
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
||||||
|
|
||||||
|
// SPA index shell for client-side routes (not an API).
|
||||||
|
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export class SiloFixedWindowRateLimiter {
|
export class SiloFixedWindowRateLimiter {
|
||||||
private windowStartedAt: number;
|
private windowStartedAt: number;
|
||||||
private used = 0;
|
private used = 0;
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
|
|||||||
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
||||||
import { readServerBinding } from "./settings/server.js";
|
import { readServerBinding } from "./settings/server.js";
|
||||||
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
||||||
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
|
|||||||
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
||||||
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
||||||
app.addHook("onRequest", async (request, reply) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
if (request.url === "/api/healthz") return;
|
// Static SPA assets / admin HTML shell / healthz do not count toward the
|
||||||
|
// silo requestRate budget (a full admin load can pull dozens of chunks).
|
||||||
|
if (isSiloHttpRateLimitExempt(request.url)) return;
|
||||||
const decision = requestLimiter.consume();
|
const decision = requestLimiter.consume();
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
await reply
|
await reply
|
||||||
|
|||||||
@@ -346,14 +346,7 @@ describe("admin auth + org API guards", () => {
|
|||||||
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
|
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
|
||||||
});
|
});
|
||||||
expect(defaultCallback.statusCode).toBe(302);
|
expect(defaultCallback.statusCode).toBe(302);
|
||||||
expect(defaultCallback.headers.location).toBe(
|
expect(defaultCallback.headers.location).toBe("/admin/org/test-default");
|
||||||
"/auth/feishu/complete?org=Test%20Default%20Organization",
|
|
||||||
);
|
|
||||||
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
|
|
||||||
expect(complete.statusCode).toBe(200);
|
|
||||||
expect(complete.headers["content-type"]).toContain("text/html");
|
|
||||||
expect(complete.body).toContain("登录并加入组织成功");
|
|
||||||
expect(complete.body).toContain("Test Default Organization");
|
|
||||||
|
|
||||||
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
|
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
|
||||||
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
|
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
|
import {
|
||||||
|
isSiloHttpRateLimitExempt,
|
||||||
|
SiloFixedWindowRateLimiter,
|
||||||
|
} from "../../src/deployment/siloRateLimit.js";
|
||||||
|
|
||||||
describe("Silo fixed-window rate limiter", () => {
|
describe("Silo fixed-window rate limiter", () => {
|
||||||
it("returns an explicit retry interval and resets at the next window", () => {
|
it("returns an explicit retry interval and resets at the next window", () => {
|
||||||
@@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => {
|
|||||||
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isSiloHttpRateLimitExempt", () => {
|
||||||
|
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rate-limits APIs and auth", () => {
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
|
import {
|
||||||
|
importSkillDirectory,
|
||||||
|
importSkillFromFiles,
|
||||||
|
prepareRunSkillPlugin,
|
||||||
|
readSkillFiles,
|
||||||
|
} from "../../src/agent/skillStore.js";
|
||||||
|
|
||||||
describe("content-addressed Agent skill store", () => {
|
describe("content-addressed Agent skill store", () => {
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
|
|||||||
})).rejects.toThrow(/content digest mismatch/);
|
})).rejects.toThrow(/content digest mismatch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports a skill from an in-memory file list and reads it back", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
|
||||||
|
];
|
||||||
|
const installed = await importSkillFromFiles({ files, storeRoot });
|
||||||
|
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
|
||||||
|
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
|
||||||
|
expect(readBack).toHaveLength(2);
|
||||||
|
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
|
||||||
|
expect(byPath["SKILL.md"]).toContain("name: web-skill");
|
||||||
|
expect(byPath["reference.md"]).toBe("ref\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects file paths that escape the skill root", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
|
||||||
|
];
|
||||||
|
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate file paths in importSkillFromFiles", async () => {
|
||||||
|
const root = await makeRoot();
|
||||||
|
const storeRoot = join(root, "store");
|
||||||
|
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
|
||||||
|
const files = [
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
|
||||||
|
];
|
||||||
|
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
|
||||||
|
});
|
||||||
|
|
||||||
async function makeRoot(): Promise<string> {
|
async function makeRoot(): Promise<string> {
|
||||||
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
|
|||||||
Reference in New Issue
Block a user