Compare commits

...

7 Commits

Author SHA1 Message Date
ChickenPige0n 3f3628e4a0 feat(hub): add SearchableSelectField component and update RoleCard to use it 2026-07-19 14:32:01 +08:00
ChickenPige0n 854c6189bb feat(hub): org-scoped agent role/skill folder tree (ADR-0028)
Add a shared transparent OrganizationAgentConfigFolder tree for grouping
agent roles and skills in the admin UI without affecting identity, bindings,
run loading, or slash commands.
2026-07-19 14:18:19 +08:00
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00
hongjr03 eb0be43eac feat(hub): usage fact breakdown API + admin usage/session UI + release v0.0.34 (#10)
Expose UsageFact kind/capability rollups on org and project usage reports, and add admin pages that separate model tokens from external-capability meters.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:19:59 +08:00
hongjr03 ce18740870 feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 21:43:00 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
hongjr03 5e10419fc8 fix(hub): docmind client stream upload + correct API response parsing (#7)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:26:11 +08:00
56 changed files with 3988 additions and 710 deletions
+4
View File
@@ -36,6 +36,10 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
+81
View File
@@ -0,0 +1,81 @@
# ADR 0028: Agent Configuration Folder Tree
## Status
Accepted.
## Context
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
configuration, managed without process restarts. The org admin surfaces for
them (`/admin/roles` and `/admin/skills`) render every
role/skill as one large editor card in a single flat list. As an Organization
accumulates roles and skills, the management pages degrade into an endless
scroll with no grouping affordance.
The project explorer already solves the analogous problem for projects with
transparent folders (ADR-0021): org-scoped navigation nodes that are not
permission resources. Roles and skills need the same affordance, but their
semantics differ from projects in one crucial way: skill names and role IDs
are referenced by role→skill bindings, run-time skill snapshot loading, and
Feishu slash commands. Any grouping mechanism must not leak into those
resolution paths.
## Decision
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
skills:
- One folder tree per Organization is shared by both roles and skills (e.g. a
"高三化学组" folder groups that team's roles and its skills together). It is
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
trees are managed independently and never reference each other.
- Folders are **transparent organization nodes**, following the ADR-0021
project-folder precedent: they exist for management-surface navigation and
grouping only, are not permission resources, and hold no grants.
- Folder membership is **not part of role/skill identity or resolution**:
- skill `name` and role `roleId` remain unique per Organization regardless
of folder membership;
- role→skill bindings, run admission's frozen role snapshot, run-scoped
skill loading, and Feishu slash commands never reference folders.
- Each role/skill sits in at most one folder; membership is optional
(unfiled items remain first-class). Folders nest arbitrarily.
- Folder assignment is a label-class change in the ADR-0017 sense: it never
archives Agent sessions, because the execution surface (model, prompt,
tools, skill content) is untouched.
- A folder can be deleted only when empty — no child folders, no roles, no
skills. Relocating items out of a folder is an explicit user action, so no
orphan-placement rule is needed yet.
- Admin web renders both pages as a left folder tree plus the item list of
the selected folder ("all" and "unfiled" included). The host-console CLI is
unchanged: folder management lives in the web surface, and CLI
`upsert-role`/`install-skill` never touch folder assignment.
## Consequences
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
`parentId`, delete restricted while referenced) plus nullable `folderId` on
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
delete, though the service refuses to delete non-empty folders).
- The spec pins the transparency and single-membership semantics in
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
not re-derive them differently (e.g. path-style names or per-folder
uniqueness).
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
that skip session archival by construction.
- Moving a role/skill between folders changes nothing about authorization,
run resolution, or audit-visible configuration lineage beyond the folder
assignment event itself.
## Open Questions / Deferred
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
per-item select for now.
- Folder-level usage aggregation for roles/skills is deferred (project
folders already aggregate usage under ADR-0021; agent configuration has no
usage dimension yet).
- CLI flags for folder assignment are deferred until a console workflow asks
for them.
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
for now: the Organization keeps exactly one active default role
(ADR-0017/0018 invariant) regardless of folder structure.
+131
View File
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
updatedAt: string;
}
export interface CapabilityConnection {
id: string;
capabilityId: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
@@ -183,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals {
folderId: string | null;
}
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
export interface UsageBreakdownRow {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
breakdown: UsageBreakdownRow[];
}
export interface ProjectUsageReport extends ProjectUsageRow {
from: string | null;
to: string | null;
breakdown: UsageBreakdownRow[];
}
export interface UsageFactRow {
id: string;
occurredAt: string;
kind: string;
provider: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
quantity: number | null;
unit: string | null;
costUsd: number | null;
costSource: string;
capabilityId: string | null;
correlationId: string | null;
}
export interface SessionRunRow {
id: string;
status: string;
model: string;
provider: string;
inputTokens: number | null;
outputTokens: number | null;
costUsd: number | null;
costSource: string | null;
startedAt: string;
finishedAt: string | null;
error: string | null;
usageFacts: UsageFactRow[];
}
export interface SessionDetail {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
project: { id: string; name: string };
runs: SessionRunRow[];
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
@@ -239,6 +317,7 @@ export interface AgentRoleRow {
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
folderId: string | null;
}
export interface AgentSkillRow {
@@ -251,6 +330,14 @@ export interface AgentSkillRow {
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
folderId: string | null;
}
/** ADR-0028 transparent folder node shared by agent roles and skills. */
export interface AgentConfigFolderRow {
id: string;
name: string;
parentId: string | null;
}
export interface SkillFileEntry {
@@ -343,6 +430,8 @@ export const api = {
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[];
}>,
session: (slug: string, sessionId: string) =>
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
@@ -351,6 +440,16 @@ export const api = {
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
const qs = q.toString();
return get(
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
) as Promise<ProjectUsageReport>;
},
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
@@ -381,6 +480,21 @@ export const api = {
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capabilityConnections: (slug: string) =>
get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
capabilityConnection: (slug: string, capabilityId: string) =>
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
connection: CapabilityConnection | null;
}>,
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
@@ -410,4 +524,21 @@ export const api = {
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[] }>,
agentConfigFolders: (slug: string) =>
get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>,
createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) =>
post(`${orgBase(slug)}/agent-config-folders`, body) as Promise<AgentConfigFolderRow>,
patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise<AgentConfigFolderRow>,
deleteAgentConfigFolder: (slug: string, folderId: string) =>
del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>,
setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
setAgentSkillFolder: (slug: string, name: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
};
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
@@ -33,7 +33,7 @@
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/org/${slug}/projects/${p.id}`}
href={`/admin/projects/${p.id}`}
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
>
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
@@ -5,6 +5,7 @@
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
@@ -14,15 +15,20 @@
models,
skills,
slug,
folderItems,
onupdated,
onskillschanged,
onfolderchanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props();
const initial = {
@@ -44,6 +50,8 @@
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
@@ -53,10 +61,15 @@
{} as Record<string, typeof TOOL_OPTIONS>,
);
const modelItems = $derived([
{ value: '', label: '(使用平台默认模型)' },
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
]);
const modelItems = $derived.by(() => {
const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` }));
const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog];
// Keep a previously saved model selectable even if it left the live catalog.
if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) {
items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` });
}
return items;
});
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
@@ -105,6 +118,24 @@
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the configuration save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === r.folderId) return;
savingFolder = true;
try {
await api.setAgentRoleFolder(slug, r.roleId, folderId);
onfolderchanged(r.roleId, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = r.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
</script>
<div class="saas-card-pad">
@@ -114,6 +145,12 @@
{#if r.isDefault}
<span class="saas-badge-success">默认</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -129,7 +166,13 @@
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} />
<SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div>
<div class="mt-4">
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
@@ -3,18 +3,24 @@
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
slug,
skill,
folderItems,
oninstalled,
ondisabled,
onfolderchanged,
}: {
slug: string;
skill: AgentSkillRow;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void;
onfolderchanged: (name: string, folderId: string | null) => void;
} = $props();
type FileNode = { path: string; content: string };
@@ -28,6 +34,8 @@
let dirty = $state(false);
let newFilePath = $state('');
let showNewFile = $state(false);
let folderValue = $state(skill.folderId ?? '');
let savingFolder = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
@@ -152,6 +160,24 @@
dirty = true;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the content save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === skill.folderId) return;
savingFolder = true;
try {
await api.setAgentSkillFolder(slug, skill.name, folderId);
onfolderchanged(skill.name, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = skill.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) {
@@ -178,6 +204,12 @@
{#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
+46
View File
@@ -29,6 +29,52 @@ export function fmtNum(n: number): string {
return n.toLocaleString();
}
const USAGE_KIND_LABELS: Record<string, string> = {
model_completion: '模型完成',
external_capability: '外部能力',
tool_proxy: '工具代理',
};
const METER_UNIT_LABELS: Record<string, string> = {
pages: '页',
audio_seconds: '音频秒',
invocations: '次调用',
};
export function usageKindLabel(kind: string): string {
return USAGE_KIND_LABELS[kind] ?? kind;
}
export function meterUnitLabel(unit: string | null | undefined): string {
if (!unit) return '—';
return METER_UNIT_LABELS[unit] ?? unit;
}
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
if (quantity === null || quantity === undefined) return '—';
const u = meterUnitLabel(unit);
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
}
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
const hasIn = input !== null && input !== undefined;
const hasOut = output !== null && output !== undefined;
if (!hasIn && !hasOut) return '—';
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
}
export function runStatusLabel(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return '完成';
if (key === 'FAILED') return '失败';
if (key === 'CANCELED') return '取消';
if (key === 'TIMED_OUT') return '超时';
if (key === 'RUNNING') return '运行中';
if (key === 'QUEUED') return '排队';
return status;
}
export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
+40
View File
@@ -0,0 +1,40 @@
import type { MeResponse, OrgMembership } from './api';
/** Alpha Silo host prefix: <slug>.educraft[.dev].… */
export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null {
const host = hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
return m?.[1] ?? null;
}
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
/**
* Resolve the tenancy for this browser session.
* Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership.
*/
export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null {
if (!me || me.organizations.length === 0) return null;
const host = hostOrgSlug();
if (host) {
const byHost = me.organizations.find((o) => o.slug === host);
if (byHost) return byHost;
}
const q = new URLSearchParams(search).get('org')?.trim();
if (q) {
const byQuery = me.organizations.find((o) => o.slug === q);
if (byQuery) return byQuery;
}
const admin = me.organizations.find((o) => isOrgAdmin(o));
return admin ?? me.organizations[0] ?? null;
}
/** SPA paths no longer embed org slug (subdomain carries tenancy). */
export function adminPath(rest: string = ''): string {
const cleaned = rest.replace(/^\/+/, '');
return cleaned === '' ? '/admin' : `/admin/${cleaned}`;
}
+7 -4
View File
@@ -35,12 +35,9 @@ 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.
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
*/
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();
@@ -48,6 +45,12 @@ export function resolveLoginOrgSlug(): string | null {
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];
// Legacy path while old bookmarks still land briefly before redirect.
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
return null;
}
+47 -70
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
@@ -20,6 +21,7 @@
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
@@ -28,68 +30,52 @@
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
return memberships().filter((o) => isOrgAdmin(o));
}
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
return resolveOrg($session.me, page.url.search);
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
// /admin/projects or /admin/projects/:id
return parts[0] === 'admin' && parts[1] === 'projects';
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
if (parts[0] !== 'admin') return '';
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
if (key === 'overview') return adminPath();
return adminPath(key);
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`);
// Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
const url = new URL(page.url.href);
url.searchParams.set('org', nextSlug);
void goto(`${url.pathname}${url.search}`, { replaceState: true });
}
function handleLogout(e: Event) {
@@ -112,33 +98,23 @@
$effect(() => {
if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
const path = page.url.pathname;
// Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
// Org admins: route to their first admin org if none matched as admin.
const org = currentOrg();
const admins = adminOrgs();
if (matched && isAdmin(matched)) {
if (org && isOrgAdmin(org)) {
redirecting = false;
return;
}
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders;
// the org overview and other admin-only surfaces are not for them.
if (matched && !isAdmin(matched)) {
// Member only: allow project routes; bounce admin-only surfaces to projects.
if (org && !isOrgAdmin(org)) {
redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean);
const onOverview = parts.length === 3; // /admin/org/:slug
if (onOverview) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
if (!isOnProjectRoute()) {
const target = adminPath('projects');
if (path !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -146,12 +122,11 @@
return;
}
// No matched org and no admin orgs: route a member to their first org's
// projects page so they can reach project MANAGE surfaces.
if (!matched && memberships().length > 0) {
const home = memberships()[0];
const target = `/admin/org/${home.slug}/projects`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
if (!org && memberships().length > 0) {
const home = admins[0] ?? memberships()[0]!;
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
if (path !== target && !path.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -172,14 +147,14 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold"
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
>
!
</div>
@@ -192,7 +167,7 @@
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold"
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
>
CPH
</div>
@@ -201,7 +176,7 @@
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isAdmin(currentOrg()!)}
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
@@ -226,10 +201,11 @@
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div>
<div class="truncate text-xs text-surface-600">{org.name}</div>
</div>
</div>
{#if me.organizations.length > 1}
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
@@ -237,6 +213,7 @@
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
{/if}
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
@@ -306,13 +283,13 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
@@ -341,7 +318,7 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!)}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
@@ -350,7 +327,7 @@
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
@@ -361,14 +338,14 @@
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a>
<a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null;
return target ? `/admin` : null;
}
onMount(() => {
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
@@ -10,7 +11,8 @@
import SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? '');
const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
let orgSlug = $derived(orgFromSession?.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
@@ -94,19 +96,56 @@
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p>
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
@@ -129,7 +168,11 @@
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="font-medium">
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
{p.projectName}
</a>
</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
@@ -0,0 +1,205 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string) {
editingCap = capId;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,13 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -10,7 +12,8 @@
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
@@ -0,0 +1,23 @@
<script lang="ts">
/**
* Legacy bookmarks: /admin/org/:slug[/...] → /admin[/...]
* Tenancy lives on the silo host, not the path.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const raw = page.params.path ?? '';
const segments = raw.split('/').filter(Boolean);
// Drop the old org slug (first segment) when present.
const rest = segments.length > 0 ? segments.slice(1).join('/') : '';
const target = rest === '' ? '/admin' : `/admin/${rest}`;
const q = page.url.search;
void goto(`${target}${q}`, { replaceState: true });
});
</script>
<div class="saas-status-panel">
<p class="text-sm text-surface-600">正在重定向到新地址…</p>
</div>
@@ -1,129 +0,0 @@
<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, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
roles = r.roles;
skills = s.skills;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
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}
@@ -1,141 +0,0 @@
<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}
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -13,8 +14,8 @@
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
@@ -87,7 +88,7 @@
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
window.location.href = `/admin/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -207,7 +208,7 @@
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
@@ -7,8 +7,19 @@
type TeamRow,
type SessionSummary,
type ExplorerData,
type ProjectUsageReport,
} from '$lib/api';
import { fmtDate, permissionRoleLabel } from '$lib/format';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
permissionRoleLabel,
usageKindLabel,
} from '$lib/format';
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -18,7 +29,8 @@
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const projectId = $derived(page.params.projectId ?? '');
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
@@ -26,6 +38,7 @@
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
@@ -49,14 +62,18 @@
// Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([
const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
p.actorIsOrgAdmin
? api.projectUsage(slug, projectId)
: Promise.resolve(null as ProjectUsageReport | null),
]);
sessions = s.sessions;
teams = t.teams;
explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? '';
}
@@ -95,7 +112,7 @@
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`;
window.location.href = `/admin/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -156,7 +173,7 @@
{:else if proj}
<div class="mb-2">
<a
href={`/admin/org/${slug}/projects`}
href={`/admin/projects`}
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" />
@@ -269,9 +286,67 @@
</div>
{#if actorIsOrgAdmin}
{#if projectUsage}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">项目用量分账</h3>
<p class="saas-muted mt-0.5 text-xs">
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
</div>
{#if projectUsage.breakdown.length === 0}
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each projectUsage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums text-xs">
{#if row.unit}
{fmtQuantity(row.quantity, row.unit)}
{:else}
{fmtTokens(row.inputTokens, row.outputTokens)}
{/if}
</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
@@ -283,6 +358,7 @@
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -292,6 +368,14 @@
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
<td class="text-right">
<a
class="text-sm text-primary-700 hover:underline"
href={`/admin/sessions/${s.id}`}
>
详情
</a>
</td>
</tr>
{/each}
</tbody>
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
@@ -0,0 +1,327 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
roles = r.roles;
skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const r of roles) {
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
const visibleRoles = $derived(
selectedFolder === 'all'
? roles
: selectedFolder === 'unfiled'
? roles.filter((r) => !r.folderId)
: roles.filter((r) => r.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
let folderId: string | null = null;
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
function onRoleFolderChanged(roleId: string, folderId: string | null) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={roles.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleRoles as r (r.roleId)}
<RoleCard
{r}
{models}
{skills}
{slug}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -0,0 +1,238 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
runStatusLabel,
usageKindLabel,
} from '$lib/format';
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 Icon from '$lib/components/Icon.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const sessionId = $derived(page.params.sessionId ?? '');
let detail = $state<SessionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let expandedRunId = $state<string | null>(null);
async function load() {
if (!slug || !sessionId) return;
loading = true;
error = null;
try {
detail = await api.session(slug, sessionId);
if (detail.runs.length > 0) {
expandedRunId = detail.runs[0]!.id;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function statusClass(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return 'saas-badge-success';
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
return 'saas-badge-primary';
}
function factMeter(f: UsageFactRow): string {
if (f.unit) return fmtQuantity(f.quantity, f.unit);
if (f.inputTokens !== null || f.outputTokens !== null) {
return fmtTokens(f.inputTokens, f.outputTokens);
}
return '—';
}
function factSource(f: UsageFactRow): string {
if (f.capabilityId) return f.capabilityId;
if (f.model) return f.model;
return '—';
}
function runCostHint(run: SessionRunRow): string {
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
if (f.costUsd === null) return acc;
return (acc ?? 0) + f.costUsd;
}, null);
const cache = run.costUsd;
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
}
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
return '成本未知';
}
function toggleRun(id: string) {
expandedRunId = expandedRunId === id ? null : id;
}
$effect(() => {
if (slug && sessionId) void load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if detail}
<div class="mb-2">
<a
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
href={`/admin/projects/${detail.project.id}`}
>
<Icon name="arrow-left" class="h-4 w-4" />
返回项目 {detail.project.name}
</a>
</div>
<PageHeader
title={detail.title?.trim() || '未命名会话'}
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
/>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-surface-600">会话 ID</dt>
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
</div>
<div>
<dt class="text-surface-600">项目</dt>
<dd class="mt-0.5">
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
{detail.project.name}
</a>
</dd>
</div>
<div>
<dt class="text-surface-600">创建 / 更新</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
</div>
{#if detail.archivedAt}
<div>
<dt class="text-surface-600">已归档</dt>
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
</div>
{/if}
<div>
<dt class="text-surface-600">运行数</dt>
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
</div>
</dl>
</div>
<div class="mb-3">
<h2 class="saas-section-title">运行与计费事实</h2>
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
</div>
{#if detail.runs.length === 0}
<div class="saas-card">
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
</div>
{:else}
<div class="space-y-3">
{#each detail.runs as run (run.id)}
{@const open = expandedRunId === run.id}
<div class="saas-card overflow-hidden">
<button
type="button"
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
onclick={() => toggleRun(run.id)}
>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
</div>
<div class="text-xs text-surface-700">
{fmtDate(run.startedAt)}
{#if run.finishedAt}
{fmtDate(run.finishedAt)}
{/if}
</div>
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
{#if run.error}
<div class="text-xs text-error-700">{run.error}</div>
{/if}
</div>
<div class="shrink-0 text-right text-sm">
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
</div>
</button>
{#if open}
<div class="border-t border-surface-200">
{#if run.usageFacts.length === 0}
<div class="px-5 py-4">
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>计量</th>
<th>成本</th>
<th>来源</th>
<th>关联 ID</th>
</tr>
</thead>
<tbody>
{#each run.usageFacts as fact}
<tr>
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
<td>
<span
class={fact.kind === 'external_capability'
? 'saas-badge-primary'
: 'saas-badge-success'}
>
{usageKindLabel(fact.kind)}
</span>
</td>
<td class="font-mono text-xs">{fact.provider}</td>
<td class="font-mono text-xs">{factSource(fact)}</td>
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
{fact.correlationId ?? '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,335 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
skills = s.skills;
folders = f.folders;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const s of skills) {
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
const visibleSkills = $derived(
selectedFolder === 'all'
? skills
: selectedFolder === 'unfiled'
? skills.filter((s) => !s.folderId)
: skills.filter((s) => s.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function 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 });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
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();
}
function onSkillFolderChanged(name: string, folderId: string | null) {
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<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 if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleSkills as skill (skill.id)}
<SkillEditor
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -2,6 +2,8 @@
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,7 +11,8 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let teams = $state<TeamRow[]>([]);
let loading = $state(true);
@@ -0,0 +1,241 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDateOnly,
fmtNum,
fmtQuantity,
fmtTokens,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let usage = $state<UsageReport | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let from = $state('');
let to = $state('');
function toIsoStart(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T00:00:00`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
function toIsoEnd(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T23:59:59.999`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
async function load() {
if (!slug) return;
loading = true;
error = null;
try {
usage = await api.usage(slug, {
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function clearRange() {
from = '';
to = '';
void load();
}
function sourceLabel(row: UsageBreakdownRow): string {
if (row.capabilityId) return row.capabilityId;
if (row.model) return row.model;
return '—';
}
function meterCell(row: UsageBreakdownRow): string {
if (row.unit) return fmtQuantity(row.quantity, row.unit);
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
return '—';
}
function meterHint(row: UsageBreakdownRow): string {
if (row.unit) return '非 token 计量';
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
return '无计量';
}
$effect(() => {
if (slug) void load();
});
</script>
{#if loading && !usage}
<LoadingState />
{:else if error && !usage}
<ErrorBanner message={error} onretry={load} />
{:else if usage}
<PageHeader
title="用量报告"
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
/>
<div class="saas-card-pad mb-6">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="saas-label" for="usage-from"></label>
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
</div>
<div>
<label class="saas-label" for="usage-to"></label>
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
</div>
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
{loading ? '加载中…' : '应用筛选'}
</button>
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
清除
</button>
{#if usage.from || usage.to}
<p class="saas-muted grow text-right text-xs">
窗口:
{usage.from ? fmtDateOnly(usage.from) : '—'}
{usage.to ? fmtDateOnly(usage.to) : '—'}
</p>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-error-700">{error}</p>
{/if}
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard
label="有成本 / 无成本"
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
hint="无成本 = 成本未知不是 $0"
/>
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
<StatCard
label="分账条目"
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
/>
</div>
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
<p class="saas-muted mt-0.5 text-xs">
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
</p>
</div>
{#if usage.breakdown.length === 0}
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>有成本 / 未知</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability'
? 'saas-badge-primary'
: row.kind === 'model_completion'
? 'saas-badge-success'
: 'saas-badge-primary'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{sourceLabel(row)}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">
<div>{meterCell(row)}</div>
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
</td>
<td class="tabular-nums text-surface-700">
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
</td>
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
</div>
{#if usage.projects.length === 0}
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>有成本 / 未知</th>
<th>in / out tokens</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-700">
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
</td>
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
<td class="text-right">
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
查看项目
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
+24 -6
View File
@@ -406,7 +406,8 @@
resize: vertical;
}
.saas-select-trigger {
.saas-select-trigger,
.saas-combobox-input {
display: inline-flex;
width: 100%;
align-items: center;
@@ -425,14 +426,25 @@
text-align: left;
}
.saas-combobox-input {
cursor: text;
padding-right: 2.25rem;
}
.saas-combobox-input::placeholder {
color: var(--color-surface-500);
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
.saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
.saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@@ -443,9 +455,15 @@
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
max-height: min(
18rem,
var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden;
border-radius: 0;
border: 1px solid var(--color-surface-400);
+20 -112
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
@@ -401,7 +401,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -424,31 +423,6 @@
"xml2js": "^0.6.2"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1038,7 +1012,6 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.14.1"
},
@@ -1577,8 +1550,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
@@ -1779,7 +1751,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -1793,7 +1764,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1803,7 +1773,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -1939,7 +1908,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
@@ -1964,7 +1932,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1978,7 +1945,6 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2124,7 +2090,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2138,7 +2103,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2168,7 +2132,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -2178,7 +2141,6 @@
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"object-assign": "^4",
"vary": "^1"
@@ -2196,7 +2158,6 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -2254,7 +2215,6 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2315,8 +2275,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/effect": {
"version": "3.21.0",
@@ -2344,7 +2303,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2447,8 +2405,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "3.0.3",
@@ -2465,7 +2422,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2475,7 +2431,6 @@
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"peer": true,
"dependencies": {
"eventsource-parser": "^3.0.1"
},
@@ -2507,7 +2462,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -2551,7 +2505,6 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"ip-address": "^10.2.0"
},
@@ -2570,7 +2523,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2580,7 +2532,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2590,7 +2541,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -2681,8 +2631,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense",
"peer": true
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.3",
@@ -2781,7 +2730,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -2853,7 +2801,6 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -2863,7 +2810,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -3013,7 +2959,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -3080,7 +3025,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -3096,8 +3040,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
@@ -3127,15 +3070,13 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
@@ -3152,7 +3093,6 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@@ -3187,7 +3127,6 @@
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
@@ -3206,8 +3145,7 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/kitx": {
"version": "2.2.0",
@@ -3570,7 +3508,6 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -3580,7 +3517,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -3660,7 +3596,6 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -3702,7 +3637,6 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3754,7 +3688,6 @@
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ee-first": "1.1.1"
},
@@ -3767,7 +3700,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"peer": true,
"dependencies": {
"wrappy": "1"
}
@@ -3777,7 +3709,6 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -3787,7 +3718,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3797,7 +3727,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@@ -3830,6 +3759,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3879,7 +3809,6 @@
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.20.0"
}
@@ -3932,6 +3861,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.19.3",
"@prisma/engines": "6.19.3"
@@ -3995,7 +3925,6 @@
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -4009,7 +3938,6 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.10"
}
@@ -4067,7 +3995,6 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
},
@@ -4081,7 +4008,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -4199,7 +4125,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
@@ -4246,8 +4171,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
@@ -4291,7 +4215,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
@@ -4318,7 +4241,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -4328,7 +4250,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -4345,7 +4266,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -4370,15 +4290,13 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -4391,7 +4309,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -4559,7 +4476,6 @@
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
@@ -4570,7 +4486,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4658,7 +4573,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.6"
}
@@ -4667,8 +4581,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
@@ -4684,6 +4597,7 @@
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
@@ -4702,7 +4616,6 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
@@ -4721,7 +4634,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -4735,7 +4647,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -4745,7 +4656,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -4763,6 +4673,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4782,7 +4693,6 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4792,7 +4702,6 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4803,6 +4712,7 @@
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4970,7 +4880,6 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -5002,8 +4911,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
@@ -5053,6 +4961,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -5062,7 +4971,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.35",
"private": true,
"type": "module",
"engines": {
@@ -0,0 +1,33 @@
-- ADR-0028 agent configuration folder tree. One org-scoped transparent folder
-- tree shared by Agent roles and skills (management-surface grouping only);
-- skill name / roleId uniqueness, role→skill bindings, run-time skill loading
-- and Feishu slash commands never reference folders. A folder deletes only
-- when empty (service-enforced); item `folderId` references are SetNull as
-- backstop.
-- CreateTable
CREATE TABLE "OrganizationAgentConfigFolder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationAgentConfigFolder_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "OrganizationAgentSkill" ADD COLUMN "folderId" TEXT;
ALTER TABLE "OrganizationAgentRole" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "OrganizationAgentConfigFolder_organizationId_parentId_idx" ON "OrganizationAgentConfigFolder"("organizationId", "parentId");
CREATE INDEX "OrganizationAgentSkill_organizationId_folderId_idx" ON "OrganizationAgentSkill"("organizationId", "folderId");
CREATE INDEX "OrganizationAgentRole_organizationId_folderId_idx" ON "OrganizationAgentRole"("organizationId", "folderId");
-- AddForeignKey
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+30
View File
@@ -47,6 +47,7 @@ model Organization {
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@ -95,16 +96,19 @@ model OrganizationAgentSkill {
version String
description String?
contentDigest String
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest])
}
@@ -121,17 +125,20 @@ model OrganizationAgentRole {
tools Json?
sortOrder Int @default(0)
isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -151,6 +158,29 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId])
}
/// ADR-0028: org-scoped transparent folder tree shared by agent roles and
/// skills. Management-surface navigation/grouping only — not a permission
/// resource, and never referenced by role→skill bindings, run-time skill
/// loading, or Feishu slash commands. Skill name and roleId stay unique per
/// organization regardless of folder membership. A folder is deleted only
/// when empty (service-enforced); item references are SetNull as backstop.
model OrganizationAgentConfigFolder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent OrganizationAgentConfigFolder? @relation("agentConfigFolderTree", fields: [parentId], references: [id], onDelete: Restrict)
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
skills OrganizationAgentSkill[]
roles OrganizationAgentRole[]
@@index([organizationId, parentId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
+89
View File
@@ -0,0 +1,89 @@
/**
* Smoke test: real Aliyun docmind API call using createReadStream.
* Run: node --experimental-strip-types scripts/smoke-docmind.ts <pdf-path>
*/
import DocmindClient from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"];
const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"];
if (accessKeyId === undefined || accessKeySecret === undefined) {
console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.");
process.exit(1);
}
const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf";
console.log(`Parsing: ${pdfPath}`);
const client = new DocmindClient.default({
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
accessKeyId,
accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as never);
const fileStream = createReadStream(pdfPath);
const runtime = new RuntimeOptions({});
console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance(
new DocmindClient.SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName: basename(pdfPath),
outputFormat: ["markdown"],
formulaEnhancement: true,
}),
runtime,
);
const jobId = submitResp.body?.data?.id;
console.log("Job ID:", jobId);
if (jobId === undefined) {
console.error("No job id:", JSON.stringify(submitResp.body));
process.exit(1);
}
console.log("Polling...");
const deadline = Date.now() + 5 * 60_000;
let status = "";
let markdownUrl = "";
let pageCount = 0;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10_000));
const resp = await client.queryDocParserStatus(
new DocmindClient.QueryDocParserStatusRequest({ id: jobId }),
);
const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data;
status = data?.status ?? "";
console.log(` status: ${status}`);
if (status === "success") {
const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = md?.outputFileUrl ?? "";
pageCount = data?.pageCountEstimate ?? 0;
break;
}
if (status === "fail") {
console.error("Job failed!");
process.exit(1);
}
}
if (status !== "success" || markdownUrl === "") {
console.error("Failed or timed out:", status);
process.exit(1);
}
console.log("Downloading markdown from OSS...");
const mdResp = await fetch(markdownUrl);
const markdown = await mdResp.text();
console.log("--- Result ---");
console.log("Pages:", pageCount);
console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4));
console.log("Job ID:", jobId);
console.log("Markdown length:", markdown.length, "chars");
console.log("--- Markdown (first 1000 chars) ---");
console.log(markdown.slice(0, 1000));
+63
View File
@@ -0,0 +1,63 @@
---
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles PDFs from Feishu messages, local workspace files, and produces
high-quality Markdown with LaTeX formulas and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF to Markdown, extract text
from a PDF, or turn a PDF document into an editable format.
## How it works
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
Alibaba Cloud Document Mind to parse the PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files
## Workflow
### PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
### PDF already in the workspace
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send the generated markdown back to the
user if they requested it.
## Output
The tool returns a list of generated files:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
+121
View File
@@ -15,6 +15,10 @@
* `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).
*
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
* roles and skills for management-surface grouping. Folder endpoints never
* touch session state — assignment is a label-class change (ADR-0017).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err);
}
});
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
return { folders };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await agentConfig.createFolder({
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
});
return reply.status(201).send(folder);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
const folder = await agentConfig.updateFolder({
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return folder;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await agentConfig.deleteFolder({
organizationId: auth.organization.id,
folderId,
});
return { deleted: true };
} catch (err) {
return handleRouteError(reply, err);
}
});
// Folder assignment is a label-class change (ADR-0017): these endpoints
// never archive Agent sessions (ADR-0028).
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", 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 { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setRoleFolder({
organizationId: auth.organization.id,
roleId,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", 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 { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setSkillFolder({
organizationId: auth.organization.id,
name,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+17 -6
View File
@@ -432,16 +432,16 @@ async function resolvePostLoginRedirect(
select: { organization: { select: { slug: true, name: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
const orgRoot = "/admin";
// 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") {
return orgRoot;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
return normalizeAdminReturnTo(returnTo) ?? orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo;
return normalizeAdminReturnTo(returnTo) ?? "/admin";
}
const membership = await prisma.organizationMembership.findFirst({
where: {
@@ -454,7 +454,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
if (membership !== null) {
return `/admin/org/${membership.organization.slug}`;
return "/admin";
}
// Member-only or no org: still land on a shell page (SPA will explain).
const any = await prisma.organizationMembership.findFirst({
@@ -463,7 +463,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
if (any !== null) {
return `/admin/org/${any.organization.slug}`;
return "/admin/projects";
}
return "/admin/login?error=no_organization";
}
@@ -489,7 +489,18 @@ export function sanitizeReturnTo(raw: string): string {
if (!raw.startsWith("/admin")) {
return "/admin";
}
return raw;
return normalizeAdminReturnTo(raw) ?? "/admin";
}
/** Map legacy `/admin/org/:slug[...]` bookmarks onto slugless `/admin[...]` paths. */
export function normalizeAdminReturnTo(path: string): string | null {
if (!path.startsWith("/admin")) return null;
const legacy = path.match(/^\/admin\/org\/[^/]+(\/.*)?$/);
if (legacy) {
const rest = legacy[1] ?? "";
return rest === "" ? "/admin" : `/admin${rest}`;
}
return path;
}
function trimTrailingSlash(url: string): string {
@@ -0,0 +1,131 @@
/**
* ADR-0027: Admin routes for organization-scoped capability connections.
* GET /api/org/:orgSlug/capability-connections — list all
* GET /api/org/:orgSlug/capability-connections/:capId — read one
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface CapabilityConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly readinessProbe?: CapabilityReadinessProbe;
}
export async function registerCapabilityConnectionRoutes(
app: FastifyInstance,
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connections: await connections.list(auth.organization.id) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
return handleRouteError(reply, error);
}
});
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connection: await connections.read(auth.organization.id, capabilityId) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
const { created, ...metadata } = result;
return reply.status(created ? 201 : 200).send(metadata);
} catch (error) {
const facts = error instanceof CapabilityReadinessError
? {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
}
: { errorCode: "capability_connection_write_failed" };
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
return handleRouteError(reply, error);
}
});
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const result = await connections.disable({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
}, "Capability Connection disabled");
return reply.send(result);
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
return handleRouteError(reply, error);
}
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
}
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
};
}
+11
View File
@@ -22,6 +22,8 @@ import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
@@ -30,6 +32,7 @@ export interface OrgRouteConfig {
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -138,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
? { readinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
await registerCapabilityConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.capabilityReadinessProbe !== undefined
? { readinessProbe: config.capabilityReadinessProbe }
: {}),
});
}
+231
View File
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
readonly folderId: string | null;
}
export interface AgentSkillRow {
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly boundRoleIds: readonly string[];
readonly folderId: string | null;
}
/**
* ADR-0028 transparent folder node of the org's shared agent-config folder
* tree. Grouping only: never part of skill/role identity or run resolution.
*/
export interface AgentConfigFolderRow {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
}
/**
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
folderId: skill.folderId,
}));
}
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
await this.requireActiveOrganization(input.organizationId);
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
where: { organizationId: input.organizationId },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: { id: true, name: true, parentId: true },
});
return folders;
}
async createFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
const name = nonEmpty(input.name, "folder name");
return this.prisma.$transaction(async (tx) => {
if (input.parentId !== undefined) {
await requireFolder(tx, input.organizationId, input.parentId);
}
const folder = await tx.organizationAgentConfigFolder.create({
data: {
organizationId: input.organizationId,
name,
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.created",
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
},
});
return folder;
});
}
/**
* Rename and/or move a folder inside the same Organization tree. Moving is
* rejected when the target parent is the folder itself or one of its
* descendants (would create a cycle).
*/
async updateFolder(input: {
readonly organizationId: string;
readonly folderId: string;
readonly name?: string | undefined;
readonly parentId?: string | null | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
return this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent");
}
await requireFolder(tx, input.organizationId, input.parentId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
UNION ALL
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
}
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
const updated = await tx.organizationAgentConfigFolder.update({
where: { id: folder.id },
data: {
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.updated",
metadata: {
folderId: folder.id,
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
},
});
return updated;
});
}
/**
* Delete a folder. Refused while the folder still has child folders, roles
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
* rule is needed).
*/
async deleteFolder(input: {
readonly organizationId: string;
readonly folderId: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
const childFolders = await tx.organizationAgentConfigFolder.count({
where: { parentId: folder.id },
});
if (childFolders > 0) {
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
}
const skills = await tx.organizationAgentSkill.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
const roles = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
if (skills > 0 || roles > 0) {
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
}
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.deleted",
metadata: { folderId: folder.id, name: folder.name },
},
});
});
}
/**
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
* a label-class change in the ADR-0017 sense — the execution surface is
* untouched, so no session archival (ADR-0028).
*/
async setSkillFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly folderId: string | null;
}): 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 },
});
if (skill === null || skill.disabledAt !== null) {
throw new Error(`active skill not found in organization: ${input.name}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.folder_set",
metadata: { name: input.name, folderId: input.folderId },
},
});
});
}
/**
* Assign a role to a folder (or unfile it with `folderId: null`). Same
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
*/
async setRoleFolder(input: {
readonly organizationId: string;
readonly roleId: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const role = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { id: true, disabledAt: true },
});
if (role === null || role.disabledAt !== null) {
throw new Error(`active role not found in organization: ${input.roleId}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentRole.update({
where: { id: role.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.folder_set",
metadata: { roleId: input.roleId, folderId: input.folderId },
},
});
});
}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
@@ -472,6 +688,19 @@ export class OrganizationAgentConfiguration {
}
}
async function requireFolder(
tx: Prisma.TransactionClient,
organizationId: string,
folderId: string,
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
const folder = await tx.organizationAgentConfigFolder.findFirst({
where: { id: folderId, organizationId },
select: { id: true, name: true, parentId: true },
});
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
return folder;
}
async function archiveRoleSessions(
tx: Prisma.TransactionClient,
organizationId: string,
@@ -530,6 +759,7 @@ function toRoleRow(role: {
readonly disabledAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly folderId: string | null;
readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>;
@@ -549,5 +779,6 @@ function toRoleRow(role: {
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
folderId: role.folderId,
};
}
+3
View File
@@ -14,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -50,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["convert_pdf_to_md", "convert_pdf_to_md"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"],
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
@@ -0,0 +1,266 @@
/**
* ADR-0027: Organization-scoped capability connection service. Manages the
* lifecycle (rotate / read / disable) of capability credentials stored in
* ADR-0024 encrypted envelopes with purpose="capability".
*
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
*/
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
export interface CapabilityCredentialInput {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export interface RotateCapabilityInput extends CapabilityCredentialInput {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}
export interface CapabilityConnectionMetadata {
readonly id: string;
readonly capabilityId: string;
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly activeVersion: number | null;
readonly keyId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
readonly created: boolean;
}
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
}
const payload = validateCredential(input);
await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.upsert({
where: {
organizationId_capabilityId: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
},
},
update: {},
create: {
id: randomUUID(),
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "DRAFT",
},
});
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: {
activeSecretVersion: true,
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
},
});
if (locked.organizationId !== input.organizationId) {
throw new Error("Capability Connection scope changed during rotation");
}
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
const secretVersionId = randomUUID();
const envelope = this.secrets.encryptJson(
{
purpose: "capability",
organizationId: input.organizationId,
connectionId: locked.id,
secretVersionId,
},
payload,
);
const now = new Date();
const secretVersion = await tx.capabilityCredentialVersion.create({
data: {
id: secretVersionId,
connectionId: locked.id,
version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: input.actorUserId,
},
});
if (locked.activeSecretVersion !== null) {
await tx.capabilityCredentialVersion.update({
where: { id: locked.activeSecretVersion.id },
data: { retiredAt: now },
});
}
const activated = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersion.id,
activatedAt: now,
disabledAt: null,
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: version === 1 ? "capability.created" : "capability.rotated",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
},
},
});
return {
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
created: version === 1,
};
});
}
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
const connections = await this.prisma.organizationCapabilityConnection.findMany({
where: { organizationId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
orderBy: { capabilityId: "asc" },
});
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
}
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
where: { organizationId, capabilityId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
}
async disable(input: {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}): Promise<CapabilityConnectionMetadata> {
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.findFirst({
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
select: { id: true },
});
if (connection === null) throw new Error("Capability Connection not found");
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
const disabled = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: { status: "DISABLED", disabledAt: new Date() },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: "capability.disabled",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
previousStatus: locked.status,
status: "DISABLED",
},
},
});
return toMetadata(disabled, locked.activeSecretVersion);
});
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
}
function toMetadata(
connection: {
readonly id: string;
readonly capabilityId: string;
readonly status: string;
readonly createdAt: Date;
readonly updatedAt: Date;
},
secret: { readonly version: number; readonly keyId: string } | null,
): CapabilityConnectionMetadata {
return {
id: connection.id,
capabilityId: connection.capabilityId,
status: connection.status as CapabilityConnectionMetadata["status"],
activeVersion: secret?.version ?? null,
keyId: secret?.keyId ?? null,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
}
async function requireCapabilityAdmin(
tx: Prisma.TransactionClient,
input: { readonly organizationId: string; readonly actorUserId: string },
): Promise<void> {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.actorUserId,
role: { in: ["OWNER", "ADMIN"] },
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
}
}
function nonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
message: string,
readonly category: NetworkFailureCategory | "configuration" | "http",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "CapabilityReadinessError";
}
}
/**
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
* with a dummy id. The API will return:
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable
*/
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: { authorization: authHeader, accept: "application/json" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"docmind credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
await response.body?.cancel();
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`docmind credential rejected: status ${response.status}`,
"http",
response.status,
);
}
};
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
return `Basic ${credentials}`;
}
+108 -71
View File
@@ -3,68 +3,49 @@
*
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
* parsing (large model version) API. The API is asynchronous:
* 1. SubmitDocParserJobAdvance — upload local file, get job id
* 2. QueryDocParserStatus — poll until completed
* 3. GetDocParserResult — fetch markdown + page images
* 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id
* 2. QueryDocParserStatus — poll until data.status === "success";
* the markdown output URL is returned in outputFormatResult
* 3. Download markdown from OSS, extract and download referenced images
*
* Pricing (2025-07, aliyun docmind):
* - 图文文档基础链路: 0.02元/页
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
* - 视频: 0.002元/秒
* - 音频: 0.00035元/秒
*
* The adapter requests OutputFormat=markdown and FormulaEnhancement=true for
* PDF inputs. Page images are returned as URLs and downloaded into the
* workspace by the adapter.
*/
import $DocmindClient, {
SubmitDocParserJobAdvanceRequest,
QueryDocParserStatusRequest,
GetDocParserResultRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { readFile } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image (page render) from the parsed document. */
/** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage {
/** Suggested relative filename (e.g. "page_1.jpg"). */
readonly filename: string;
/** Raw image bytes (downloaded from the docmind URL). */
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface DocmindParseResult {
/** Markdown text with image references (relative to output dir). */
readonly markdown: string;
/** Page images extracted from the document, downloaded as bytes. */
readonly images: readonly DocmindExtractedImage[];
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
readonly pageCount: number;
/** USD cost if the service reported one; null if unknown (ADR-0022).
* docmind bills in CNY per page; cost is derived from page count × unit price. */
readonly costUsd: number | null;
/** External job id for the UsageFact correlationId. */
readonly requestId: string | null;
}
/** Options passed to the client. */
export interface DocmindParseOptions {
/** Absolute path to the input file on the Hub's filesystem. */
readonly inputFilePath: string;
}
/**
* Client interface for the Alibaba Cloud Document Mind parsing service.
* The real implementation uses the official SDK; tests inject a mock.
*/
export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
}
/** Errors raised by the docmind client. */
export class DocmindClientError extends Error {
constructor(
message: string,
@@ -76,22 +57,13 @@ export class DocmindClientError extends Error {
}
}
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD, rate ~7.15). Updated when pricing confirmed. */
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */
const COST_PER_PAGE_USD = 0.0056;
/** Poll interval for QueryDocParserStatus (ms). Aliyun recommends 10s. */
const POLL_INTERVAL_MS = 10_000;
/** Max poll duration (ms). Aliyun allows 120 minutes; we cap at 5 minutes for a single page bundle. */
const POLL_TIMEOUT_MS = 5 * 60_000;
/** Type alias for the SDK client constructor's config parameter. */
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
/**
* Real Alibaba Cloud Document Mind client using the official SDK.
* Submits a local file, polls for completion, fetches the markdown result,
* and downloads page images.
*/
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
const config: DocmindConfig = {
@@ -103,11 +75,13 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file upload.
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileBuffer = await readFile(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileBuffer,
fileUrlObject: fileStream,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
@@ -127,57 +101,112 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
}
// 2. Poll until completed.
// 2. Poll QueryDocParserStatus until data.status === "success" or "fail".
const deadline = Date.now() + POLL_TIMEOUT_MS;
let completed = false;
let status = "";
let markdownUrl: string | null = null;
let pageCount = 0;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
const statusResponse = await client.queryDocParserStatus(statusReq);
const body = statusResponse.body as { completed?: boolean; status?: string };
completed = body.completed === true;
status = body.status ?? "";
if (completed) break;
const body = statusResponse.body as {
data?: {
status?: string;
pageCountEstimate?: number;
outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>;
};
};
status = body.data?.status ?? "";
if (status === "success") {
const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = mdResult?.outputFileUrl ?? null;
pageCount = body.data?.pageCountEstimate ?? 0;
break;
}
if (!completed) {
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
}
if (status === "Fail") {
if (status === "fail") {
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
}
}
if (status !== "success") {
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
}
if (markdownUrl === null) {
throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output");
}
// 3. Fetch result.
const resultReq = new GetDocParserResultRequest({ id: jobId });
const resultResponse = await client.getDocParserResult(resultReq);
const resultBody = resultResponse.body as {
data?: { markdown?: string; docInfo?: { pages?: Array<{ imageUrl?: string; pageIdCurDoc?: number }> } };
};
const markdown = resultBody.data?.markdown ?? "";
// 3. Download the markdown file from OSS.
let markdown: string;
try {
const mdResp = await fetch(markdownUrl);
if (!mdResp.ok) {
throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable");
}
markdown = await mdResp.text();
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
if (markdown === "") {
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Download page images.
const pages = resultBody.data?.docInfo?.pages ?? [];
// 4. Download images referenced in the markdown (OSS URLs).
// Markdown contains ![filename](http://...oss.../image.png?...) entries.
// We rewrite them to local relative paths and download the images.
const images: DocmindExtractedImage[] = [];
for (const page of pages) {
if (page.imageUrl === undefined || page.imageUrl === null || page.imageUrl === "") continue;
const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => {
const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length));
if (idx >= 0) {
const img = images[idx]!;
return `![${altText}](${img.filename})`;
}
return match;
});
// Collect all image URLs first, then download.
const imageUrls: Array<{ url: string; altText: string }> = [];
let match: RegExpExecArray | null;
const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
while ((match = collectRegex.exec(markdown)) !== null) {
imageUrls.push({ url: match[2]!, altText: match[1]! });
}
for (let i = 0; i < imageUrls.length; i++) {
const { url, altText } = imageUrls[i]!;
const filename = extractFilename(altText, url, i);
try {
const resp = await fetch(page.imageUrl);
if (!resp.ok) continue;
const data = new Uint8Array(await resp.arrayBuffer());
const pageNum = page.pageIdCurDoc ?? images.length + 1;
images.push({ filename: `page_${pageNum}.jpg`, data });
const imgResp = await fetch(url);
if (!imgResp.ok) continue;
const data = new Uint8Array(await imgResp.arrayBuffer());
images.push({ filename, data });
} catch {
// Best-effort: skip images that fail to download.
}
}
const pageCount = pages.length > 0 ? pages.length : countMarkdownPages(markdown);
const costUsd = pageCount * COST_PER_PAGE_USD;
// Rewrite markdown with local image paths.
let finalMarkdown = markdown;
let imageIdx = 0;
finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => {
if (imageIdx < images.length) {
const img = images[imageIdx]!;
imageIdx++;
return `![${altText}](${img.filename})`;
}
return match;
});
return { markdown, images, pageCount, costUsd, requestId: jobId };
if (pageCount === 0) {
pageCount = Math.max(1, images.length);
}
const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD;
return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId };
}
}
@@ -187,8 +216,16 @@ function sleep(ms: number): Promise<void> {
});
}
/** Fallback page count: count markdown page separators if docInfo is absent. */
function countMarkdownPages(markdown: string): number {
const matches = markdown.match(/\n---\n/g);
return matches !== null ? matches.length + 1 : 1;
/** Derive a clean filename from the alt text or URL. */
function extractFilename(altText: string, url: string, index: number): string {
// Try alt text first (docmind often puts the original filename).
if (altText !== "" && altText.length < 100) {
const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_");
if (cleaned.length > 0) return cleaned;
}
// Fall back to URL path.
const urlPath = new URL(url).pathname;
const base = basename(urlPath);
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
+59
View File
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly organizationId: string;
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
@@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
if (enabledTools.has("convert_pdf_to_md")) {
const adapter = createPdfToMdBundleAdapter({
secrets: options.secretEnvelope,
client: new AliyunDocmindClient(),
prisma: options.prisma,
});
tools.push(
tool(
"convert_pdf_to_md",
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
{
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
},
async (args) => {
try {
const result = await adapter.invoke({
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
inputPath: args.input_path,
outputDir: args.output_dir,
prisma: options.prisma,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
};
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
);
}
return instructions.join(" ");
}
+6 -1
View File
@@ -14,6 +14,7 @@ import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
sendText,
sendTextMessage,
@@ -93,6 +94,7 @@ interface TriggerDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly secretEnvelope: LocalSecretEnvelope;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
@@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text.
const deliveredFiles: string[] = [];
const card = new StreamingAgentCard({
runId: run.id,
rt,
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
patchIntervalMs: undefined,
maxMessageLength: undefined,
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
projectId,
organizationId: siloOrganizationId,
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions,
approvalManager,
tools: cphHubMcpToolsForRole(roleTools),
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
onDelivered: (path) => {
deliveredFiles.push(path);
},
+1
View File
@@ -162,6 +162,7 @@ export async function startHub(): Promise<void> {
prisma,
settings: runtimeSettings,
logger: app.log,
secretEnvelope,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
+279 -118
View File
@@ -9,13 +9,14 @@
* external-capability consumption (PDF→MD, ASR, …) is attributed correctly,
* not just the main model loop. A run with no cost-bearing fact is
* `runsWithoutCost` — ADR-0022: missing cost ≠ zero.
*
* Breakdown buckets keep kind / provider / model / capability / unit, so the
* admin UI can separate model tokens from non-token external meters instead of
* collapsing everything into a single input/output token total.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
export interface ProjectUsageRow {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
export interface UsageTotals {
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
@@ -24,21 +25,50 @@ export interface ProjectUsageRow {
readonly costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
}
/** One ledger slice: kind + provider + model + capability + unit. */
export interface UsageBreakdownRow {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly factCount: number;
readonly factsWithCost: number;
readonly factsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
/** Sum of quantity when unit is non-null; null when this bucket is token-only. */
readonly quantity: number | null;
readonly costUsd: number | null;
}
export interface UsageReport {
readonly from: string | null;
readonly to: string | null;
readonly projects: readonly ProjectUsageRow[];
readonly totals: {
readonly runCount: number;
readonly runsWithCost: number;
readonly runsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
};
readonly totals: UsageTotals;
readonly breakdown: readonly UsageBreakdownRow[];
}
export interface ProjectUsageReport extends ProjectUsageRow {
readonly from: string | null;
readonly to: string | null;
readonly breakdown: readonly UsageBreakdownRow[];
}
type FactRow = {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly quantity: unknown;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
@@ -49,26 +79,91 @@ type RunWithFacts = {
readonly usageFacts: readonly FactRow[];
};
/** A run is "recorded with cost" if any of its facts carries a known costUsd. */
function runHasRecordedCost(facts: readonly FactRow[]): boolean {
return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined);
}
type MutableTotals = {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
type MutableBreakdown = {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
};
type MutableProjectRow = MutableTotals & {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
};
const FACT_SELECT = {
kind: true,
provider: true,
model: true,
capabilityId: true,
unit: true,
quantity: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
} as const;
/** Decimal→number for Prisma Decimal; null/undefined → null. */
function factCostUsdToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
interface RunRollup {
function factQuantityToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
function breakdownKey(f: FactRow): string {
return [
f.kind,
f.provider,
f.model ?? "",
f.capabilityId ?? "",
f.unit ?? "",
].join("\u0000");
}
function emptyMutableTotals(): MutableTotals {
return {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
};
}
function addCost(existing: number | null, add: number): number {
return (existing ?? 0) + add;
}
function rollupRunTokensAndCost(facts: readonly FactRow[]): {
readonly hasCost: boolean;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
}
function rollupRun(facts: readonly FactRow[]): RunRollup {
} {
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
@@ -79,12 +174,118 @@ function rollupRun(facts: readonly FactRow[]): RunRollup {
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
hasCost = true;
costUsd = (costUsd ?? 0) + c;
costUsd = addCost(costUsd, c);
}
}
return { hasCost, inputTokens, outputTokens, costUsd };
}
function accumulateBreakdown(
buckets: Map<string, MutableBreakdown>,
facts: readonly FactRow[],
): void {
for (const f of facts) {
const key = breakdownKey(f);
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = {
kind: f.kind,
provider: f.provider,
model: f.model,
capabilityId: f.capabilityId,
unit: f.unit,
factCount: 0,
factsWithCost: 0,
factsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
quantity: null,
costUsd: null,
};
buckets.set(key, bucket);
}
bucket.factCount += 1;
bucket.inputTokens += f.inputTokens ?? 0;
bucket.outputTokens += f.outputTokens ?? 0;
const qty = factQuantityToNumber(f.quantity);
if (qty !== null) {
bucket.quantity = (bucket.quantity ?? 0) + qty;
}
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
bucket.factsWithCost += 1;
bucket.costUsd = addCost(bucket.costUsd, c);
} else {
bucket.factsWithoutCost += 1;
}
}
}
function freezeBreakdown(buckets: Map<string, MutableBreakdown>): UsageBreakdownRow[] {
return [...buckets.values()]
.map((b) => ({
kind: b.kind,
provider: b.provider,
model: b.model,
capabilityId: b.capabilityId,
unit: b.unit,
factCount: b.factCount,
factsWithCost: b.factsWithCost,
factsWithoutCost: b.factsWithoutCost,
inputTokens: b.inputTokens,
outputTokens: b.outputTokens,
quantity: b.quantity,
costUsd: b.costUsd,
}))
.sort((a, b) => {
const kindCmp = a.kind.localeCompare(b.kind);
if (kindCmp !== 0) return kindCmp;
const provCmp = a.provider.localeCompare(b.provider);
if (provCmp !== 0) return provCmp;
const capA = a.capabilityId ?? "";
const capB = b.capabilityId ?? "";
const capCmp = capA.localeCompare(capB);
if (capCmp !== 0) return capCmp;
const modelA = a.model ?? "";
const modelB = b.model ?? "";
return modelA.localeCompare(modelB);
});
}
function freezeTotals(t: MutableTotals): UsageTotals {
return {
runCount: t.runCount,
runsWithCost: t.runsWithCost,
runsWithoutCost: t.runsWithoutCost,
inputTokens: t.inputTokens,
outputTokens: t.outputTokens,
costUsd: t.costUsd,
};
}
function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void {
const roll = rollupRunTokensAndCost(facts);
totals.runCount += 1;
if (roll.hasCost) {
totals.runsWithCost += 1;
totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0);
} else {
totals.runsWithoutCost += 1;
}
totals.inputTokens += roll.inputTokens;
totals.outputTokens += roll.outputTokens;
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: freezeTotals(emptyMutableTotals()),
breakdown: [],
};
}
export async function getOrgUsage(
prisma: PrismaClient,
input: {
@@ -108,7 +309,12 @@ export async function getOrgUsage(
}
const projectIds = projects.map((p) => p.id);
const runWhere: Prisma.AgentRunWhereInput = {
// Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
// a run is in or out of the window based on when it started, and all of its
// facts come along. Filtering facts by occurredAt independently would let a
// run contribute partial cost to a window it doesn't belong to.
const runs = await prisma.agentRun.findMany({
where: {
projectId: { in: projectIds },
...(input.from !== undefined || input.to !== undefined
? {
@@ -118,91 +324,50 @@ export async function getOrgUsage(
},
}
: {}),
};
// Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
// a run is in or out of the window based on when it started, and all of its
// facts come along. Filtering facts by occurredAt independently would let a
// run contribute partial cost to a window it doesn't belong to.
const runs = await prisma.agentRun.findMany({
where: runWhere,
},
select: {
projectId: true,
usageFacts: {
select: { inputTokens: true, outputTokens: true, costUsd: true },
select: FACT_SELECT,
orderBy: { occurredAt: "asc" },
},
},
});
type MutableRow = {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
const byProject = new Map<string, MutableRow>();
const byProject = new Map<string, MutableProjectRow>();
for (const p of projects) {
byProject.set(p.id, {
projectId: p.id,
projectName: p.name,
folderId: p.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
...emptyMutableTotals(),
});
}
const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs as readonly RunWithFacts[]) {
const row = byProject.get(run.projectId);
if (row === undefined) continue;
const roll = rollupRun(run.usageFacts);
row.runCount += 1;
if (roll.hasCost) {
row.runsWithCost += 1;
row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0);
} else {
row.runsWithoutCost += 1;
}
row.inputTokens += roll.inputTokens;
row.outputTokens += roll.outputTokens;
applyRunToTotals(row, run.usageFacts);
accumulateBreakdown(breakdownBuckets, run.usageFacts);
}
const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
projectId: r.projectId,
projectName: r.projectName,
folderId: r.folderId,
runCount: r.runCount,
runsWithCost: r.runsWithCost,
runsWithoutCost: r.runsWithoutCost,
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costUsd: r.costUsd,
...freezeTotals(r),
}));
let runCount = 0;
let runsWithCost = 0;
let runsWithoutCost = 0;
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
const totals = emptyMutableTotals();
for (const row of projectsOut) {
runCount += row.runCount;
runsWithCost += row.runsWithCost;
runsWithoutCost += row.runsWithoutCost;
inputTokens += row.inputTokens;
outputTokens += row.outputTokens;
totals.runCount += row.runCount;
totals.runsWithCost += row.runsWithCost;
totals.runsWithoutCost += row.runsWithoutCost;
totals.inputTokens += row.inputTokens;
totals.outputTokens += row.outputTokens;
if (row.costUsd !== null) {
costUsd = (costUsd ?? 0) + row.costUsd;
totals.costUsd = addCost(totals.costUsd, row.costUsd);
}
}
@@ -210,14 +375,8 @@ export async function getOrgUsage(
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
projects: projectsOut,
totals: {
runCount,
runsWithCost,
runsWithoutCost,
inputTokens,
outputTokens,
costUsd,
},
totals: freezeTotals(totals),
breakdown: freezeBreakdown(breakdownBuckets),
};
}
@@ -229,7 +388,7 @@ export async function getProjectUsage(
readonly from?: Date | undefined;
readonly to?: Date | undefined;
},
): Promise<ProjectUsageRow> {
): Promise<ProjectUsageReport> {
const project = await prisma.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId },
select: { id: true, name: true, folderId: true },
@@ -237,39 +396,41 @@ export async function getProjectUsage(
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const report = await getOrgUsage(prisma, {
organizationId: input.organizationId,
from: input.from,
to: input.to,
const runs = await prisma.agentRun.findMany({
where: {
projectId: project.id,
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
},
select: {
usageFacts: {
select: FACT_SELECT,
orderBy: { occurredAt: "asc" },
},
},
});
const row = report.projects.find((p) => p.projectId === project.id);
return (
row ?? {
const totals = emptyMutableTotals();
const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs) {
applyRunToTotals(totals, run.usageFacts as readonly FactRow[]);
accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]);
}
return {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
}
);
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
},
...freezeTotals(totals),
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
breakdown: freezeBreakdown(breakdownBuckets),
};
}
+6 -6
View File
@@ -170,7 +170,7 @@ describe("admin auth + org API guards", () => {
try {
const res = await app.inject({
method: "GET",
url: "/auth/feishu?returnTo=/admin/org/test-default",
url: "/auth/feishu?returnTo=/admin",
});
expect(res.statusCode).toBe(302);
const location = res.headers.location;
@@ -233,7 +233,7 @@ describe("admin auth + org API guards", () => {
try {
const nonce = "nonce-test-1";
const state = signOAuthState(
{ nonce, returnTo: "/admin/org/test-default" },
{ nonce, returnTo: "/admin" },
SESSION_SECRET,
);
const res = await app.inject({
@@ -242,7 +242,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin/org/test-default");
expect(res.headers.location).toBe("/admin");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -293,7 +293,7 @@ describe("admin auth + org API guards", () => {
try {
const start = await app.inject({
method: "GET",
url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings",
url: "/auth/feishu/test-default?returnTo=/admin/settings",
});
expect(start.statusCode).toBe(302);
const authorize = new URL(String(start.headers.location));
@@ -308,7 +308,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: nonceCookie },
});
expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("/admin/org/test-default/settings");
expect(callback.headers.location).toBe("/admin/settings");
const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
expect(me.statusCode).toBe(200);
@@ -346,7 +346,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe("/admin/org/test-default");
expect(defaultCallback.headers.location).toBe("/admin");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
@@ -118,6 +118,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
+31 -1
View File
@@ -71,7 +71,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
occurredAt: new Date(startedAt.getTime() + i * 1000),
kind: f.kind ?? "model_completion",
provider: f.provider ?? "openrouter",
model: f.model ?? "mock-model",
model: f.model !== undefined ? f.model : "mock-model",
inputTokens: f.inputTokens ?? null,
outputTokens: f.outputTokens ?? null,
costUsd: f.costUsd ?? null,
@@ -158,6 +158,32 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(report.totals.inputTokens).toBe(100);
expect(report.totals.outputTokens).toBe(50);
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
// ADR-0026 breakdown: model tokens vs external non-token meter must not collapse.
expect(report.breakdown).toHaveLength(2);
const modelBucket = report.breakdown.find((b) => b.kind === "model_completion");
const capBucket = report.breakdown.find((b) => b.kind === "external_capability");
expect(modelBucket).toMatchObject({
provider: "openrouter",
model: "mock-model",
capabilityId: null,
inputTokens: 100,
outputTokens: 50,
quantity: null,
costUsd: 0.02,
factCount: 1,
});
expect(capBucket).toMatchObject({
provider: "mineru",
model: null,
capabilityId: "pdf_to_md_bundle",
unit: "pages",
quantity: 12,
inputTokens: 0,
outputTokens: 0,
costUsd: 0.015,
factCount: 1,
});
});
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
@@ -180,6 +206,8 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(row.projectId).toBe("proj-x");
expect(row.runCount).toBe(1);
expect(row.costUsd).toBeCloseTo(0.1, 8);
expect(row.breakdown).toHaveLength(1);
expect(row.breakdown[0]).toMatchObject({ kind: "model_completion", costUsd: 0.1, inputTokens: 1 });
});
it("returns an empty report for an org with no projects", async () => {
@@ -187,5 +215,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => {
expect(report.projects).toHaveLength(0);
expect(report.totals.runCount).toBe(0);
expect(report.totals.costUsd).toBeNull();
expect(report.breakdown).toEqual([]);
});
});
+9 -6
View File
@@ -68,14 +68,14 @@ describe("session cookie signing", () => {
describe("oauth state signing", () => {
it("round-trips state with returnTo", () => {
const token = signOAuthState(
{ nonce: "abc", returnTo: "/admin/org/acme" },
{ nonce: "abc", returnTo: "/admin" },
SECRET,
600,
1_700_000_000,
);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "abc",
returnTo: "/admin/org/acme",
returnTo: "/admin",
exp: 1_700_000_600,
});
});
@@ -83,13 +83,13 @@ describe("oauth state signing", () => {
it("binds state to an Organization and connection as one inseparable scope", () => {
const token = signOAuthState({
nonce: "scoped",
returnTo: "/admin/org/acme",
returnTo: "/admin",
organizationId: "org-acme",
connectionId: "connection-acme",
}, SECRET, 600, 1_700_000_000);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "scoped",
returnTo: "/admin/org/acme",
returnTo: "/admin",
organizationId: "org-acme",
connectionId: "connection-acme",
exp: 1_700_000_600,
@@ -98,8 +98,11 @@ describe("oauth state signing", () => {
});
describe("sanitizeReturnTo", () => {
it("allows admin paths", () => {
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme");
it("allows admin paths and rewrites legacy org slug prefixes", () => {
expect(sanitizeReturnTo("/admin")).toBe("/admin");
expect(sanitizeReturnTo("/admin/usage")).toBe("/admin/usage");
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin");
expect(sanitizeReturnTo("/admin/org/acme/usage")).toBe("/admin/usage");
});
it("blocks open redirects", () => {
+1
View File
@@ -23,6 +23,7 @@ describe("isSiloHttpRateLimitExempt", () => {
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/members")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
+3
View File
@@ -29,6 +29,9 @@ structure Identifiers where
TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type
/-- Agent 配置 folder 标识(`OPEN` 表示;ADR-0028 透明组织节点,skill/role 共用一棵树;
与 project explorer 的 `FolderId` 是不同实体,两棵树互不引用)。 -/
AgentConfigFolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@bot` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type
+34
View File
@@ -12,6 +12,10 @@ Role 的执行面(model/prompt/tools/skill 内容)变更时,该 role 的 active
Run 时只读加载 role 选中的 skill 不可变版本到 run-scoped 目录,
run 结束后删除(ADR-0018)。Skill 管理是 org-scoped;存储机制 `OPEN`。
管理面分组:skill 与 role 共用一棵 org 内可嵌套 folder 树(ADR-0028)。folder 是透明
组织节点——只做后台导航/分组,不进入身份、解析与授权;name/roleId 唯一性、role→skill
绑定、run 加载与 slash 命令均与 folder 无关。
-/
namespace Spec.System
@@ -30,6 +34,9 @@ structure AgentRole where
tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String
/-- 管理面分组 folder(`PINNED`, ADR-0028;`none` 表示未分类)。透明节点,见
`AgentConfigFolder`——不进入 role 的身份与解析,roleId 仍 org 内唯一。 -/
folder : Option I.AgentConfigFolderId
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where
@@ -43,6 +50,9 @@ structure AgentSkill where
contentDigest : String
/-- 描述(`OPEN`)。 -/
description : String
/-- 管理面分组 folder(`PINNED`, ADR-0028;`none` 表示未分类)。透明节点,见
`AgentConfigFolder`——不进入 skill 的身份与解析,name 仍 org 内唯一。 -/
folder : Option I.AgentConfigFolderId
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where
@@ -57,4 +67,28 @@ structure AgentRoleSkillBinding where
/-- 排序(`PINNED`)。 -/
sortOrder : Nat
/-- Agent 配置 folder(`PINNED`, org-scoped, ADR-0028):skill 与 role **共用**的一棵
可嵌套 folder 树的节点,仅承担管理面的导航与分组。它是透明组织节点(与 ADR-0021
project Folder 同例):不是权限资源、不持有 grants;不进入 skill name / roleId 的身份
与解析——name/roleId 仍在 org 内唯一、与 folder 无关;role→skill 绑定、run 的 skill
快照加载与飞书 slash 命令均不引用 folder。folder 归属变更是 label 类变更
(ADR-0017),不归档会话。folder 仅在为空(无子 folder、无挂载的 skill/role)时可删
(`PINNED`)——挂载物的级动归置规则未定,故先禁止。 -/
structure AgentConfigFolder where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- folder 标识(`OPEN` 表示)。 -/
id : I.AgentConfigFolderId
/-- 父 folder;`none` 为根层(`PINNED`,可嵌套)。 -/
parent : Option I.AgentConfigFolderId
/-- Skill/role 的 folder 归属良构性(`PINNED`, ADR-0028):每个 skill/role 至多归属于
一个 folder,且只能归属于与其**同 org** 的 folder;`none`(未分类)总是良构。
移动 skill/role 进出 folder 不改变其自身授权与解析。 -/
def AgentConfigFolderPlacement.WellScoped
(organization : I.OrganizationId)
(placement : Option I.AgentConfigFolderId)
(folderOrg : I.AgentConfigFolderId Option I.OrganizationId) : Prop :=
folderId, placement = some folderId folderOrg folderId = some organization
end Spec.System