Files
curriculum-project-hub/hub/admin-web/src/lib/api.ts
T
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
  dedup→atomic rename); add importSkillFromFiles (in-memory file list
  ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
  (soft-delete + archive bound role sessions), updateSkillDescription
  (label-only, no archival); refactor installSkill to share
  commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
  /agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
  PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes

Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
  template), per-skill editor
- layout: add 技能 nav item

ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
2026-07-16 01:11:49 +08:00

414 lines
13 KiB
TypeScript

/**
* Thin API client for the org admin backend. Same-origin cookie auth.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
const put = (u: string, b?: unknown) => request('PUT', u, b);
const patch = (u: string, b?: unknown) => request('PATCH', u, b);
const del = (u: string) => request('DELETE', u);
const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`;
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
export interface OrgMember {
userId: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
createdAt: string;
}
export interface TeamRow {
id: string;
slug: string;
name: string;
description: string | null;
memberCount: number;
createdAt: string;
}
export interface TeamMemberRow {
userId: string;
feishuOpenId: string;
displayName: string;
createdAt: string;
}
export interface ExplorerFolder {
id: string;
name: string;
parentId: string | null;
sortKey: string;
projectCount: number;
childFolderCount: number;
}
export interface ExplorerProject {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string; createdAt: string } | null;
}
export interface ExplorerData {
folders: ExplorerFolder[];
projects: ExplorerProject[];
}
export interface ProjectDetail {
id: string;
name: string;
folderId: string | null;
folder: { id: string; name: string } | null;
workspaceDir: string;
createdAt: string;
archivedAt: string | null;
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
binding: { chatId: string; createdAt: string } | null;
actorIsOrgAdmin?: boolean;
actorCanManageProject?: boolean;
}
export interface TeamAccessEntry {
grantId: string;
projectId: string;
organizationId: string;
teamId: string;
teamSlug: string;
teamName: string;
role: 'READ' | 'EDIT' | 'MANAGE';
}
export interface SessionSummary {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
runCount: number;
createdAt: string;
updatedAt: string;
}
export interface ProviderConnectionRow {
id: string;
providerId: string;
mode: 'BYOK' | 'PLATFORM_MANAGED';
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FeishuApplicationConnection {
id: string;
appFingerprint: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
projectId: string;
projectName: string;
folderId: string | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
| 'agentConcurrency'
| 'admissionQueueLength'
| 'admissionQueueWait'
| 'fileSize'
| 'attachmentCount'
| 'archiveExpansion'
| 'projectStorage'
| 'organizationStorage'
| 'memberCount'
| 'projectCount'
| 'teamCount'
| 'folderCount'
| 'sessionCount'
| 'runWallTime'
| 'runTurns'
| 'runToolCalls'
| 'toolWallTime'
| 'runOutputSize'
| 'processMemory'
| 'processCpu'
| 'processCount';
export interface CapacityDimensionRow {
dimension: CapacityDimension;
platformCeiling: number | null;
organizationLimit: number | null;
effective: number | null;
}
export interface CapacityPolicyView {
dimensions: CapacityDimensionRow[];
}
export interface AgentRoleRow {
id: string;
roleId: string;
label: string;
defaultModel: string | null;
systemPrompt: string | null;
tools: readonly string[] | null;
sortOrder: number;
isDefault: boolean;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
}
export interface AgentSkillRow {
id: string;
name: string;
version: string;
description: string | null;
contentDigest: string;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
}
export interface SkillFileEntry {
path: string;
content: string;
}
export interface InstalledSkillResult {
id: string;
name: string;
contentDigest: string;
}
export interface AgentModelRow {
id: string;
label: string;
toolCapable: boolean;
}
// --- API ---
export const api = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
org: (slug: string) =>
get(orgBase(slug)) as Promise<{
organization: { id: string; slug: string; name: string; status: string };
actorRole: string;
}>,
settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>,
setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) =>
patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>,
members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>,
addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) =>
post(`${orgBase(slug)}/members`, body) as Promise<OrgMember>,
setMemberRole: (slug: string, userId: string, role: string) => patch(`${orgBase(slug)}/members/${userId}`, { role }),
revokeMember: (slug: string, userId: string) => post(`${orgBase(slug)}/members/${userId}/revoke`),
teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>,
createTeam: (slug: string, body: { slug: string; name: string; description?: string }) =>
post(`${orgBase(slug)}/teams`, body) as Promise<TeamRow>,
updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) =>
patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise<TeamRow>,
archiveTeam: (slug: string, teamId: string) => post(`${orgBase(slug)}/teams/${teamId}/archive`),
teamMembers: (slug: string, teamId: string) =>
get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>,
addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) =>
post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise<TeamMemberRow>,
revokeTeamMember: (slug: string, teamId: string, userId: string) =>
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
moveProject: (slug: string, projectId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }),
archiveProject: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/archive`),
archiveBinding: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/binding/archive`),
teamAccess: (slug: string, projectId: string) =>
get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>,
grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) =>
put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise<TeamAccessEntry>,
revokeTeamAccess: (slug: string, projectId: string, teamId: string) =>
del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`),
sessions: (slug: string, projectId: string, limit?: number) =>
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[];
}>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.folderId) q.set('folderId', params.folderId);
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
rotateProviderConnection: (
slug: string,
providerId: string,
body: { baseUrl: string; authToken: string; anthropicApiKey?: string },
) =>
put(
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
body,
) as Promise<ProviderConnectionRow>,
feishuApplication: (slug: string) =>
get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{
connection: FeishuApplicationConnection | null;
}>,
rotateFeishuApplication: (
slug: string,
body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
},
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
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>,
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
upsertAgentRole: (
slug: string,
roleId: string,
body: {
label: string;
defaultModel?: string | null;
systemPrompt?: string | null;
tools?: readonly string[] | null;
sortOrder?: number;
isDefault?: boolean;
},
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
skillNames: string[];
}>,
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
agentSkillFiles: (slug: string, name: string) =>
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
};