forked from EduCraft/curriculum-project-hub
Merge remote-tracking branch 'educraft/main' into merge/educraft-cph
# Conflicts: # .gitignore # hub/.env.example # hub/deploy/deploy_fleet_release.sh # hub/deploy/deploy_platform.sh # hub/test/integration/helpers.ts
This commit is contained in:
+22
-3
@@ -20,12 +20,16 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
|
||||
|
||||
# Alpha Silo safety limits. max turns may use its default; every other value is
|
||||
# mandatory in production and should be calibrated on the target host.
|
||||
# HUB_AGENT_MAX_TURNS=25
|
||||
HUB_AGENT_MAX_TURNS="150"
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
|
||||
HUB_AGENT_MAX_RUN_SECONDS="900"
|
||||
HUB_AGENT_MAX_RUN_SECONDS="1800"
|
||||
# 文件库上传把内容放在 JSON body 里,所以 body limit 与
|
||||
# HUB_FILELIB_MAX_FILE_BYTES 串联(见下方注释),不能降回 1 MiB。
|
||||
HUB_HTTP_BODY_LIMIT_BYTES="73400320"
|
||||
HUB_MAX_FILES_PER_MESSAGE="8"
|
||||
HUB_MAX_FILES_PER_MESSAGE="20"
|
||||
HUB_MAX_FILE_BYTES="26214400"
|
||||
# Max concurrent Alibaba Docmind jobs for one convert_pdf_to_md batch (1-8).
|
||||
HUB_PDF_TO_MD_MAX_CONCURRENT="3"
|
||||
HUB_HTTP_REQUESTS_PER_MINUTE="120"
|
||||
HUB_FEISHU_EVENTS_PER_MINUTE="120"
|
||||
|
||||
@@ -44,9 +48,24 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
|
||||
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
|
||||
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
|
||||
|
||||
# Optional tenant-local Typst package roots. When configured, the agent
|
||||
# sandbox forwards these exact paths to Typst. The preinstalled package path is
|
||||
# read-only; the cache path is the only additional write location.
|
||||
# Keep the preinstalled package path outside the release tree and provision it
|
||||
# with the namespace layout expected by Typst, for example:
|
||||
# <root>/paradigm/paradigm-templates/0.2.20/
|
||||
# Use a separate service-writable cache path when runtime dependencies may be
|
||||
# downloaded; do not make the immutable preinstalled directory the cache.
|
||||
# TYPST_PACKAGE_PATH="/srv/curriculum-project-hub/typst-packages/org-a"
|
||||
# TYPST_PACKAGE_CACHE_PATH="/var/cache/cph-hub/org-a/typst"
|
||||
|
||||
# This process is pinned to exactly one Organization. Feishu credentials are
|
||||
# resolved from that Organization's encrypted ACTIVE connection.
|
||||
HUB_SILO_ORGANIZATION_ID=""
|
||||
# Absolute path to the bot-only lark-cli binary used by Agent Feishu tools.
|
||||
# The CLI is invoked by Hub with a disposable HOME and the ACTIVE Feishu
|
||||
# Application Connection; the App Secret is never put in Agent argv/env.
|
||||
HUB_FEISHU_CLI_BIN="/usr/local/bin/lark-cli"
|
||||
HUB_SYSTEMD_UNIT="cph-hub-example.service"
|
||||
|
||||
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
|
||||
|
||||
Generated
+96
-7
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "admin-web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
@@ -26,24 +29,38 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"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==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -881,6 +898,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
@@ -1768,6 +1851,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
||||
@@ -29,5 +29,8 @@
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ export interface AgentRoleRow {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skillNames: readonly string[];
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -329,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 {
|
||||
@@ -401,7 +410,7 @@ export const api = {
|
||||
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 }>,
|
||||
post(`${orgBase(slug)}/projects`, body) as Promise<{ projectId: string; folderId: string | null; workspaceDir: 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 }),
|
||||
@@ -480,7 +489,18 @@ export const api = {
|
||||
rotateCapabilityConnection: (
|
||||
slug: string,
|
||||
capabilityId: string,
|
||||
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
|
||||
body:
|
||||
| { kind?: 'docmind'; accessKeyId: string; accessKeySecret: string; endpoint: string }
|
||||
| {
|
||||
kind: 'pbank';
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
rightsStatus?: string;
|
||||
rightsHolder?: string;
|
||||
rightsScope?: string;
|
||||
rightsNote?: string;
|
||||
},
|
||||
) =>
|
||||
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
|
||||
disableCapabilityConnection: (slug: string, capabilityId: string) =>
|
||||
@@ -515,4 +535,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>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from 'bits-ui';
|
||||
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import 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,23 @@
|
||||
models,
|
||||
skills,
|
||||
slug,
|
||||
folders,
|
||||
folderItems,
|
||||
onupdated,
|
||||
onskillschanged,
|
||||
onfolderchanged,
|
||||
}: {
|
||||
r: AgentRoleRow;
|
||||
models: AgentModelRow[];
|
||||
skills: AgentSkillRow[];
|
||||
slug: string;
|
||||
/** ADR-0028 shared management tree — skill picker groups only; not binding identity */
|
||||
folders: AgentConfigFolderRow[];
|
||||
/** 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 +53,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,12 +64,69 @@
|
||||
{} 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 })));
|
||||
function folderPathLabel(folderId: string): string {
|
||||
const folder = folders.find((f) => f.id === folderId);
|
||||
if (!folder) return '(未知文件夹)';
|
||||
const parts: string[] = [folder.name];
|
||||
let cur: AgentConfigFolderRow | undefined = folder;
|
||||
while (cur?.parentId) {
|
||||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
/** Group picker by management folder; bindings remain skill name (ADR-0028). */
|
||||
const skillGroups = $derived.by(() => {
|
||||
type Group = { key: string; label: string; skills: AgentSkillRow[] };
|
||||
const byFolder = new Map<string | null, AgentSkillRow[]>();
|
||||
for (const s of skills) {
|
||||
const key = s.folderId;
|
||||
const list = byFolder.get(key) ?? [];
|
||||
list.push(s);
|
||||
byFolder.set(key, list);
|
||||
}
|
||||
for (const list of byFolder.values()) {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const filed = [...byFolder.entries()]
|
||||
.filter((e): e is [string, AgentSkillRow[]] => e[0] !== null)
|
||||
.map(([id, list]) => ({ key: id, label: folderPathLabel(id), skills: list }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const unfiled = byFolder.get(null);
|
||||
const groups: Group[] = [...filed];
|
||||
if (unfiled && unfiled.length > 0) {
|
||||
groups.push({ key: 'unfiled', label: '未分类', skills: unfiled });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function groupSelectedCount(groupSkills: AgentSkillRow[]): number {
|
||||
return groupSkills.filter((s) => selectedSkills.includes(s.name)).length;
|
||||
}
|
||||
|
||||
function toggleGroup(groupSkills: AgentSkillRow[], checked: boolean) {
|
||||
const names = new Set(groupSkills.map((s) => s.name));
|
||||
if (checked) {
|
||||
const next = new Set(selectedSkills);
|
||||
for (const n of names) next.add(n);
|
||||
selectedSkills = [...next];
|
||||
} else {
|
||||
selectedSkills = selectedSkills.filter((n) => !names.has(n));
|
||||
}
|
||||
}
|
||||
|
||||
function skillsDirty(): boolean {
|
||||
const a = [...selectedSkills].sort();
|
||||
@@ -105,6 +173,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 +200,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 +221,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">
|
||||
@@ -166,19 +264,40 @@
|
||||
<div class="mt-4">
|
||||
<span class="saas-label">技能绑定</span>
|
||||
{#if skills.length === 0}
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板(ADR-0018)。</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{#each skillItems as s}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.value)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.label}</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
{#each skillGroups as group (group.key)}
|
||||
<div class="border border-surface-300">
|
||||
<label class="flex cursor-pointer items-center gap-2 border-b border-surface-200 bg-surface-100 px-2 py-1.5 text-sm">
|
||||
<CheckboxControl
|
||||
checked={groupSelectedCount(group.skills) === group.skills.length}
|
||||
onchange={(checked) => toggleGroup(group.skills, checked)}
|
||||
/>
|
||||
<span class="text-xs font-semibold text-surface-700">{group.label}</span>
|
||||
<span class="text-[10px] text-surface-500">
|
||||
{groupSelectedCount(group.skills)}/{group.skills.length}
|
||||
</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 gap-0.5 p-1 sm:grid-cols-2">
|
||||
{#each group.skills as s (s.name)}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.name)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked
|
||||
? [...selectedSkills, s.name]
|
||||
: selectedSkills.filter((x) => x !== s.name);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.name}</span>
|
||||
{#if s.version}
|
||||
<span class="text-[10px] text-surface-500">v{s.version}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
@@ -2,19 +2,26 @@
|
||||
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { parseSkillZip } from '$lib/skillZip';
|
||||
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 +35,10 @@
|
||||
let dirty = $state(false);
|
||||
let newFilePath = $state('');
|
||||
let showNewFile = $state(false);
|
||||
let folderValue = $state(skill.folderId ?? '');
|
||||
let savingFolder = $state(false);
|
||||
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||||
let zipImporting = $state(false);
|
||||
|
||||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||
@@ -152,6 +163,53 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function importZip(fileList: FileList | null) {
|
||||
const file = fileList?.[0];
|
||||
if (!file) return;
|
||||
const trimmedVersion = version.trim();
|
||||
if (trimmedVersion === '') {
|
||||
toastError('请先填写版本号再导入 zip');
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
return;
|
||||
}
|
||||
zipImporting = true;
|
||||
try {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const parsed = parseSkillZip(buf);
|
||||
if (parsed.name !== skill.name) {
|
||||
throw new Error(`zip 内技能名 "${parsed.name}" 与当前技能 "${skill.name}" 不一致`);
|
||||
}
|
||||
files = parsed.files.map((f) => ({ path: f.path, content: f.content }));
|
||||
selectedPath = files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null;
|
||||
if (parsed.description !== null) description = parsed.description;
|
||||
dirty = true;
|
||||
toastSuccess(`已载入 zip(${files.length} 个文件),请保存以写入`);
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
zipImporting = false;
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
@@ -178,6 +236,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">
|
||||
@@ -292,9 +356,20 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty || zipImporting}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<label class="saas-btn-ghost cursor-pointer {zipImporting ? 'pointer-events-none opacity-50' : ''}">
|
||||
{zipImporting ? '导入中…' : '从 zip 替换'}
|
||||
<input
|
||||
bind:this={zipInputEl}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
class="hidden"
|
||||
disabled={zipImporting || saving}
|
||||
onchange={(e) => importZip(e.currentTarget.files)}
|
||||
/>
|
||||
</label>
|
||||
{#if !skill.disabledAt}
|
||||
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||
禁用
|
||||
|
||||
@@ -12,12 +12,15 @@ export const TOOL_OPTIONS: ToolOption[] = [
|
||||
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
|
||||
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
|
||||
{ id: 'web_search', label: 'WebSearch', group: '网络' },
|
||||
{ id: 'todo', label: '任务清单 (todo_write)', group: '规划' },
|
||||
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
|
||||
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
|
||||
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
|
||||
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
|
||||
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
|
||||
{ id: 'request_approval', label: '请求审批', group: '飞书' },
|
||||
{ id: 'convert_pdf_to_md', label: 'PDF→Markdown', group: '能力' },
|
||||
{ id: 'pbank', label: '题库 (PBank)', group: '能力' },
|
||||
];
|
||||
|
||||
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel) */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import type { SkillFileEntry } from '$lib/api';
|
||||
|
||||
/** Mirror hub/src/agent/skillStore.ts limits (ADR-0018). */
|
||||
const MAX_SKILL_FILES = 512;
|
||||
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export interface ParsedSkillZip {
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly files: readonly SkillFileEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a skill package zip. Root of the archive IS the skill directory
|
||||
* (must contain SKILL.md directly — no wrapping folder).
|
||||
*/
|
||||
export function parseSkillZip(data: Uint8Array): ParsedSkillZip {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
entries = unzipSync(data, {
|
||||
filter: (file) => !file.name.endsWith('/'),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('无法解析 zip 文件');
|
||||
}
|
||||
|
||||
const files: SkillFileEntry[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const [rawPath, bytes] of Object.entries(entries)) {
|
||||
const path = normalizeZipPath(rawPath);
|
||||
if (path === null) continue;
|
||||
totalBytes += bytes.byteLength;
|
||||
if (totalBytes > MAX_SKILL_BYTES) {
|
||||
throw new Error(`技能总大小超过 ${MAX_SKILL_BYTES / (1024 * 1024)} MiB 上限`);
|
||||
}
|
||||
if (files.length >= MAX_SKILL_FILES) {
|
||||
throw new Error(`技能文件数超过 ${MAX_SKILL_FILES} 上限`);
|
||||
}
|
||||
// Text-only alpha path — API encodes content as UTF-8 strings.
|
||||
try {
|
||||
files.push({ path, content: decodeUtf8Strict(bytes) });
|
||||
} catch {
|
||||
throw new Error(`文件不是合法 UTF-8 文本:${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error('zip 为空或不含可用文件');
|
||||
}
|
||||
|
||||
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||||
if (!manifest) {
|
||||
throw new Error('zip 根目录必须包含 SKILL.md(根即 skill 目录,不要多包一层文件夹)');
|
||||
}
|
||||
|
||||
const name = parseFrontmatterField(manifest.content, 'name');
|
||||
if (name === null || name === '') {
|
||||
throw new Error('SKILL.md frontmatter 缺少 name');
|
||||
}
|
||||
if (!SKILL_NAME_PATTERN.test(name)) {
|
||||
throw new Error('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||
}
|
||||
|
||||
const description = parseFrontmatterField(manifest.content, 'description');
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return { name, description, files };
|
||||
}
|
||||
|
||||
function normalizeZipPath(raw: string): string | null {
|
||||
let path = raw.replace(/\\/g, '/');
|
||||
// Drop zip noise and absolute/parent escapes before other checks.
|
||||
if (path.startsWith('__MACOSX/') || path.includes('/__MACOSX/')) return null;
|
||||
const base = path.split('/').pop() ?? '';
|
||||
if (base === '.DS_Store' || base.startsWith('._')) return null;
|
||||
if (path.startsWith('/') || path.includes('\0')) {
|
||||
throw new Error(`非法文件路径:${raw}`);
|
||||
}
|
||||
// Strip a single leading "./"
|
||||
if (path.startsWith('./')) path = path.slice(2);
|
||||
const parts = path.split('/').filter((p) => p !== '' && p !== '.');
|
||||
if (parts.length === 0 || parts.some((p) => p === '..')) {
|
||||
throw new Error(`非法文件路径:${raw}`);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function parseFrontmatterField(manifest: string, key: string): string | null {
|
||||
const match = new RegExp(`^${key}:\\s*['"]?([^'"\\r\\n]+)['"]?\\s*$`, 'm').exec(manifest);
|
||||
return match ? match[1]!.trim() : null;
|
||||
}
|
||||
|
||||
function decodeUtf8Strict(bytes: Uint8Array): string {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
}
|
||||
@@ -32,3 +32,6 @@ export function toastSuccess(message: string): void {
|
||||
export function toastError(message: string): void {
|
||||
pushToast(message, 'error', 5000);
|
||||
}
|
||||
export function toastInfo(message: string): void {
|
||||
pushToast(message, 'info');
|
||||
}
|
||||
|
||||
@@ -13,9 +13,26 @@
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
|
||||
type CapKind = 'docmind' | 'pbank';
|
||||
const KNOWN_CAPABILITIES = [
|
||||
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
|
||||
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
|
||||
{
|
||||
id: 'pdf_to_md_bundle',
|
||||
kind: 'docmind' as const,
|
||||
label: 'PDF → Markdown',
|
||||
description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)'
|
||||
},
|
||||
{
|
||||
id: 'audio_video_to_text',
|
||||
kind: 'docmind' as const,
|
||||
label: '音视频 → 文本',
|
||||
description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)'
|
||||
},
|
||||
{
|
||||
id: 'pbank',
|
||||
kind: 'pbank' as const,
|
||||
label: '题库 (PBank)',
|
||||
description: '搜索/拉取 Paradigm 题库题目与源工程;Agent 通过 pbank_* 工具访问,凭据不下发到 Agent 进程'
|
||||
}
|
||||
] as const;
|
||||
|
||||
let connections = $state<Map<string, CapabilityConnection>>(new Map());
|
||||
@@ -23,9 +40,17 @@
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let editingCap = $state<string | null>(null);
|
||||
let editingKind = $state<CapKind>('docmind');
|
||||
let accessKeyId = $state('');
|
||||
let accessKeySecret = $state('');
|
||||
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
|
||||
let baseUrl = $state('https://pbank.paradigm-edu.net/api');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let rightsStatus = $state('owned');
|
||||
let rightsHolder = $state('Paradigm Education');
|
||||
let rightsScope = $state('internal teaching-material production');
|
||||
let rightsNote = $state('');
|
||||
let saving = $state(false);
|
||||
let disabling = $state<string | null>(null);
|
||||
|
||||
@@ -42,11 +67,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(capId: string) {
|
||||
function startEdit(capId: string, kind: CapKind) {
|
||||
editingCap = capId;
|
||||
editingKind = kind;
|
||||
accessKeyId = '';
|
||||
accessKeySecret = '';
|
||||
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
|
||||
baseUrl = 'https://pbank.paradigm-edu.net/api';
|
||||
username = '';
|
||||
password = '';
|
||||
rightsStatus = 'owned';
|
||||
rightsHolder = 'Paradigm Education';
|
||||
rightsScope = 'internal teaching-material production';
|
||||
rightsNote = '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -54,17 +87,36 @@
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
let result: CapabilityConnection;
|
||||
if (editingKind === 'docmind') {
|
||||
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
|
||||
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
|
||||
return;
|
||||
}
|
||||
result = await api.rotateCapabilityConnection(slug, capId, {
|
||||
kind: 'docmind',
|
||||
accessKeyId: accessKeyId.trim(),
|
||||
accessKeySecret: accessKeySecret.trim(),
|
||||
endpoint: endpoint.trim()
|
||||
});
|
||||
} else {
|
||||
if (baseUrl.trim() === '' || username.trim() === '' || password.trim() === '') {
|
||||
toastError('Base URL、用户名、密码均为必填');
|
||||
return;
|
||||
}
|
||||
result = await api.rotateCapabilityConnection(slug, capId, {
|
||||
kind: 'pbank',
|
||||
baseUrl: baseUrl.trim(),
|
||||
username: username.trim(),
|
||||
password: password.trim(),
|
||||
...(rightsStatus.trim() !== '' ? { rightsStatus: rightsStatus.trim() } : {}),
|
||||
...(rightsHolder.trim() !== '' ? { rightsHolder: rightsHolder.trim() } : {}),
|
||||
...(rightsScope.trim() !== '' ? { rightsScope: rightsScope.trim() } : {}),
|
||||
...(rightsNote.trim() !== '' ? { rightsNote: rightsNote.trim() } : {})
|
||||
});
|
||||
}
|
||||
connections.set(capId, result);
|
||||
connections = new Map(connections);
|
||||
editingCap = null;
|
||||
@@ -110,7 +162,7 @@
|
||||
|
||||
<PageHeader
|
||||
title="外部能力"
|
||||
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
|
||||
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
@@ -147,7 +199,7 @@
|
||||
{/if}
|
||||
<button
|
||||
class="saas-btn-primary text-sm"
|
||||
onclick={() => startEdit(cap.id)}
|
||||
onclick={() => startEdit(cap.id, cap.kind)}
|
||||
disabled={editingCap === cap.id}
|
||||
>
|
||||
{conn ? '轮换凭据' : '配置凭据'}
|
||||
@@ -174,23 +226,77 @@
|
||||
|
||||
{#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} />
|
||||
{#if cap.kind === 'docmind'}
|
||||
<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>
|
||||
<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} />
|
||||
{:else}
|
||||
<p class="saas-muted mb-3 text-sm">
|
||||
Paradigm 题库登录凭据。激活前会探测 /login;凭据仅写入信封新版本。
|
||||
</p>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-base-{cap.id}">API Base URL</Label.Root>
|
||||
<input id="pbank-base-{cap.id}" class="saas-input font-mono text-sm" bind:value={baseUrl} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-user-{cap.id}">用户名</Label.Root>
|
||||
<input id="pbank-user-{cap.id}" class="saas-input font-mono text-sm" bind:value={username} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-pass-{cap.id}">密码</Label.Root>
|
||||
<input id="pbank-pass-{cap.id}" class="saas-input" type="password" bind:value={password} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-rights-status-{cap.id}">权利状态</Label.Root>
|
||||
<input
|
||||
id="pbank-rights-status-{cap.id}"
|
||||
class="saas-input font-mono text-sm"
|
||||
bind:value={rightsStatus}
|
||||
placeholder="owned | exclusive_license | licensed_adapt | unknown"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-rights-holder-{cap.id}">权利主体</Label.Root>
|
||||
<input
|
||||
id="pbank-rights-holder-{cap.id}"
|
||||
class="saas-input text-sm"
|
||||
bind:value={rightsHolder}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-rights-scope-{cap.id}">使用范围</Label.Root>
|
||||
<input id="pbank-rights-scope-{cap.id}" class="saas-input text-sm" bind:value={rightsScope} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="pbank-rights-note-{cap.id}">权利说明(可选)</Label.Root>
|
||||
<textarea
|
||||
id="pbank-rights-note-{cap.id}"
|
||||
class="saas-input min-h-20 text-sm"
|
||||
bind:value={rightsNote}
|
||||
></textarea>
|
||||
</div>
|
||||
</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>
|
||||
{/if}
|
||||
<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}>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
projectName = '';
|
||||
projectFolder = '';
|
||||
showProjectModal = false;
|
||||
window.location.href = `/admin/projects/${res.id}`;
|
||||
window.location.href = `/admin/projects/${res.projectId}`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api, type ProviderConnectionRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
@@ -8,7 +9,12 @@
|
||||
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';
|
||||
import { toastError, toastInfo, toastSuccess } from '$lib/toast';
|
||||
|
||||
type FormState =
|
||||
| { kind: 'new'; error?: string }
|
||||
| { kind: 'rotate'; providerId: string; error?: string }
|
||||
| { kind: 'saving'; intent: 'new' | 'rotate'; providerId?: string };
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
@@ -17,11 +23,23 @@
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let formState = $state<FormState>({ kind: 'new' });
|
||||
let providerId = $state('');
|
||||
let baseUrl = $state('');
|
||||
let authToken = $state('');
|
||||
let anthropicApiKey = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
const rotationId = $derived(
|
||||
formState.kind === 'rotate'
|
||||
? formState.providerId
|
||||
: formState.kind === 'saving' && formState.intent === 'rotate'
|
||||
? (formState.providerId ?? null)
|
||||
: null
|
||||
);
|
||||
const saving = $derived(formState.kind === 'saving');
|
||||
const formError = $derived(
|
||||
formState.kind === 'new' || formState.kind === 'rotate' ? (formState.error ?? null) : null
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
@@ -36,48 +54,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startRotate(row: ProviderConnectionRow) {
|
||||
async function startRotate(row: ProviderConnectionRow) {
|
||||
formState = { kind: 'rotate', providerId: row.providerId };
|
||||
providerId = row.providerId;
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
anthropicApiKey = '';
|
||||
await tick();
|
||||
const el = document.getElementById('base-url');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el?.focus();
|
||||
toastInfo(`已开始轮换 ${row.providerId},请填写新接口地址与访问令牌`);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState = { kind: 'new' };
|
||||
providerId = '';
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
anthropicApiKey = '';
|
||||
}
|
||||
|
||||
function cancelOrClear() {
|
||||
const wasRotating = rotationId !== null;
|
||||
resetForm();
|
||||
if (wasRotating) toastInfo('已取消轮换');
|
||||
}
|
||||
|
||||
function showFormError(message: string, targetProviderId: string | null) {
|
||||
formState =
|
||||
targetProviderId === null
|
||||
? { kind: 'new', error: message }
|
||||
: { kind: 'rotate', providerId: targetProviderId, error: message };
|
||||
toastError(message);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const targetProviderId = formState.kind === 'rotate' ? formState.providerId : null;
|
||||
const intent = targetProviderId === null ? 'new' : 'rotate';
|
||||
const id = providerId.trim();
|
||||
if (id === '') {
|
||||
toastError('请填写供应方 ID');
|
||||
showFormError('请填写供应方 ID', targetProviderId);
|
||||
return;
|
||||
}
|
||||
const url = baseUrl.trim();
|
||||
const token = authToken.trim();
|
||||
if (url === '' || token === '') {
|
||||
toastError('接口地址与访问令牌均为必填');
|
||||
showFormError('接口地址与访问令牌均为必填', targetProviderId);
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
|
||||
baseUrl: url,
|
||||
authToken: token,
|
||||
};
|
||||
const key = anthropicApiKey.trim();
|
||||
if (key !== '') body.anthropicApiKey = key;
|
||||
formState =
|
||||
intent === 'rotate'
|
||||
? { kind: 'saving', intent, providerId: id }
|
||||
: { kind: 'saving', intent };
|
||||
try {
|
||||
await api.rotateProviderConnection(slug, id, body);
|
||||
toastSuccess('凭据已轮换');
|
||||
const saved = await api.rotateProviderConnection(slug, id, body);
|
||||
resetForm();
|
||||
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
formState =
|
||||
intent === 'rotate'
|
||||
? { kind: 'rotate', providerId: id, error: message }
|
||||
: { kind: 'new', error: message };
|
||||
toastError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +172,14 @@
|
||||
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
|
||||
<td>
|
||||
{#if row.mode === 'BYOK'}
|
||||
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
|
||||
<button
|
||||
class="saas-btn-ghost px-2! py-1! text-xs"
|
||||
onclick={() => startRotate(row)}
|
||||
disabled={saving}
|
||||
aria-label={`开始轮换供应方 ${row.providerId}`}
|
||||
>
|
||||
开始轮换
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-surface-500">平台管理</span>
|
||||
{/if}
|
||||
@@ -139,33 +193,78 @@
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
|
||||
<p class="saas-muted mb-4">
|
||||
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
|
||||
<h3 class="saas-section-title mb-1">
|
||||
{rotationId ? `轮换凭据 · ${rotationId}` : '新建 BYOK 凭据'}
|
||||
</h3>
|
||||
<p class="saas-muted mb-4" role="status">
|
||||
{#if saving}
|
||||
正在验证新凭据;验证通过后才会切换版本,请勿重复提交。
|
||||
{:else if rotationId}
|
||||
已选择供应方 {rotationId}。点击“开始轮换”只打开此表单;填写新接口地址和访问令牌后,点击“验证并保存”才会生效。
|
||||
{:else}
|
||||
填写供应方 ID、接口地址和访问令牌,保存前会先验证凭据。平台托管连接不在此处管理。
|
||||
{/if}
|
||||
</p>
|
||||
<div class="grid gap-5">
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
|
||||
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
|
||||
<input
|
||||
id="provider-id"
|
||||
class="saas-input font-mono text-sm"
|
||||
bind:value={providerId}
|
||||
placeholder="openrouter"
|
||||
readonly={rotationId !== null}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if rotationId}
|
||||
<p class="mt-1 text-xs text-surface-500">
|
||||
轮换目标已锁定为 {rotationId}。如需新建其他供应方,请先取消轮换。
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
||||
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
|
||||
<input
|
||||
id="base-url"
|
||||
class="saas-input"
|
||||
placeholder="https://openrouter.ai/api"
|
||||
bind:value={baseUrl}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
||||
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
|
||||
<input
|
||||
id="auth-token"
|
||||
class="saas-input"
|
||||
type="password"
|
||||
bind:value={authToken}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
|
||||
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
|
||||
<input
|
||||
id="anthropic-key"
|
||||
class="saas-input"
|
||||
type="password"
|
||||
bind:value={anthropicApiKey}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if formError}
|
||||
<div role="alert" class="border border-error-200 bg-error-50 px-4 py-3 text-sm text-error-700">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<div class="flex-1"></div>
|
||||
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
|
||||
<button class="saas-btn-ghost" onclick={cancelOrClear} disabled={saving}>
|
||||
{rotationId ? '取消轮换' : '清空'}
|
||||
</button>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
{saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
|
||||
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';
|
||||
@@ -8,6 +8,9 @@
|
||||
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));
|
||||
@@ -16,20 +19,32 @@
|
||||
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] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
|
||||
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 = [];
|
||||
@@ -43,6 +58,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -57,7 +132,12 @@
|
||||
adding = true;
|
||||
try {
|
||||
const created = await api.upsertAgentRole(slug, roleId, { label });
|
||||
roles = [...roles, created];
|
||||
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('角色已创建');
|
||||
@@ -79,6 +159,68 @@
|
||||
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();
|
||||
});
|
||||
@@ -86,7 +228,7 @@
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
@@ -94,39 +236,93 @@
|
||||
{: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();
|
||||
}}
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
<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}
|
||||
{folders}
|
||||
{folderItems}
|
||||
onupdated={onRoleUpdated}
|
||||
onskillschanged={onRoleSkillsChanged}
|
||||
onfolderchanged={onRoleFolderChanged}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
<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>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.roleId)}
|
||||
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
|
||||
{/each}
|
||||
<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>
|
||||
{/if}
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -1,34 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { parseSkillZip } from '$lib/skillZip';
|
||||
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 showZipUpload = $state(false);
|
||||
let zipVersion = $state('0.1.0');
|
||||
let zipUploading = $state(false);
|
||||
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
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 res = await api.agentSkills(slug);
|
||||
skills = res.skills;
|
||||
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 {
|
||||
@@ -36,6 +57,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 === '') {
|
||||
@@ -56,6 +137,9 @@
|
||||
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 = '';
|
||||
@@ -73,6 +157,37 @@
|
||||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||
}
|
||||
|
||||
async function uploadZip(fileList: FileList | null) {
|
||||
const file = fileList?.[0];
|
||||
if (!file) return;
|
||||
const version = zipVersion.trim();
|
||||
if (version === '') {
|
||||
toastError('版本号不能为空');
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
return;
|
||||
}
|
||||
zipUploading = true;
|
||||
try {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const parsed = parseSkillZip(buf);
|
||||
const result = await api.installAgentSkill(slug, parsed.name, {
|
||||
version,
|
||||
files: parsed.files,
|
||||
});
|
||||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||||
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
|
||||
}
|
||||
toastSuccess(`技能 ${result.name} 已从 zip 安装(${parsed.files.length} 个文件)`);
|
||||
showZipUpload = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
zipUploading = false;
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||||
load();
|
||||
}
|
||||
@@ -81,6 +196,68 @@
|
||||
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();
|
||||
});
|
||||
@@ -88,7 +265,7 @@
|
||||
|
||||
<PageHeader
|
||||
title="技能"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
@@ -96,49 +273,136 @@
|
||||
{: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 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>
|
||||
{#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>
|
||||
<div class="saas-card-pad mb-6 space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="saas-section-title">新建技能</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary-700 hover:text-primary-900"
|
||||
onclick={() => {
|
||||
showZipUpload = !showZipUpload;
|
||||
if (showZipUpload) showNewSkill = false;
|
||||
}}
|
||||
>
|
||||
{showZipUpload ? '取消上传' : '上传 zip'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary-700 hover:text-primary-900"
|
||||
onclick={() => {
|
||||
showNewSkill = !showNewSkill;
|
||||
if (showNewSkill) showZipUpload = false;
|
||||
}}
|
||||
>
|
||||
{showNewSkill ? '取消' : '+ 空白模板'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if showZipUpload}
|
||||
<div class="grid gap-3 sm:grid-cols-[8rem_1fr_auto]">
|
||||
<input class="saas-input text-sm" placeholder="版本号" bind:value={zipVersion} disabled={zipUploading} />
|
||||
<input
|
||||
bind:this={zipInputEl}
|
||||
class="saas-input text-sm file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium"
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
disabled={zipUploading}
|
||||
onchange={(e) => uploadZip(e.currentTarget.files)}
|
||||
/>
|
||||
<span class="self-center text-xs text-surface-600">{zipUploading ? '安装中…' : '选择 .zip'}</span>
|
||||
</div>
|
||||
<p class="text-xs text-surface-600">
|
||||
zip 根目录即为 skill(须直接含 SKILL.md)。名称取自 manifest;仅 UTF-8 文本;当前选中管理文件夹时会自动归入。覆盖同 name 会更新内容(可能使绑定角色会话不可安全恢复)。
|
||||
</p>
|
||||
{/if}
|
||||
{#if showNewSkill}
|
||||
<div class="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="text-xs text-surface-600">
|
||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">
|
||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
|
||||
{#if skills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||
<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>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each skills as skill (skill.id)}
|
||||
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
|
||||
{/each}
|
||||
<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>
|
||||
{/if}
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -86,7 +86,7 @@ REMOTE
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
|
||||
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web & filelib-web SPAs)"
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
|
||||
set -euo pipefail
|
||||
flock /var/lock/cph-hub-release-publish bash -c '
|
||||
@@ -96,9 +96,12 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
||||
exit 0
|
||||
fi
|
||||
cd "$HUB_DIR"
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||
npm ci --prefix admin-web
|
||||
npm ci --prefix filelib-web
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev
|
||||
npm ci --include=dev --prefix admin-web
|
||||
npm ci --include=dev --prefix filelib-web
|
||||
test -x node_modules/.bin/tsc
|
||||
test -x admin-web/node_modules/.bin/vite
|
||||
test -x filelib-web/node_modules/.bin/vite
|
||||
npm run audit:production
|
||||
npm run build
|
||||
test -f admin-web/build/index.html
|
||||
|
||||
@@ -60,11 +60,13 @@ if [ "$release_ready" = false ]; then
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
|
||||
# 2. Install deps (including build-time dev deps) for hub + both SPAs, audit
|
||||
# hub prod, build tsc + SPAs, mark complete. NODE_ENV=production may be
|
||||
# inherited by the remote shell, so --include=dev is intentional here.
|
||||
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
|
||||
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev && npm ci --include=dev --prefix admin-web && npm ci --include=dev --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
|
||||
@@ -164,17 +164,19 @@ DATABASE_URL=
|
||||
HUB_SILO_ORGANIZATION_ID=
|
||||
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
|
||||
CPH_BIN=$CPH_BIN_DEFAULT
|
||||
HUB_FEISHU_CLI_BIN=/usr/local/bin/lark-cli
|
||||
HOST=$HOST
|
||||
PORT=$PORT
|
||||
HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
|
||||
HUB_PUBLIC_BASE_URL=
|
||||
HUB_SESSION_SECRET=
|
||||
HUB_AGENT_MAX_TURNS=25
|
||||
HUB_AGENT_MAX_TURNS=150
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS=
|
||||
HUB_AGENT_MAX_RUN_SECONDS=
|
||||
HUB_HTTP_BODY_LIMIT_BYTES=
|
||||
HUB_MAX_FILES_PER_MESSAGE=
|
||||
HUB_MAX_FILE_BYTES=
|
||||
HUB_PDF_TO_MD_MAX_CONCURRENT=3
|
||||
HUB_HTTP_REQUESTS_PER_MINUTE=
|
||||
HUB_FEISHU_EVENTS_PER_MINUTE=
|
||||
HUB_FEISHU_LISTENER_ENABLED=true
|
||||
|
||||
@@ -367,11 +367,11 @@ seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
|
||||
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
|
||||
seed_default DEFAULT_ROLE_ID "draft"
|
||||
seed_default DEFAULT_ROLE_LABEL "智能助手"
|
||||
seed_default MAX_TURNS "25"
|
||||
seed_default MAX_TURNS "150"
|
||||
seed_default MAX_CONCURRENT_RUNS "4"
|
||||
seed_default MAX_RUN_SECONDS "900"
|
||||
seed_default MAX_RUN_SECONDS "1800"
|
||||
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
|
||||
seed_default MAX_FILES_PER_MESSAGE "8"
|
||||
seed_default MAX_FILES_PER_MESSAGE "20"
|
||||
seed_default MAX_FILE_BYTES "26214400"
|
||||
seed_default HTTP_REQUESTS_PER_MINUTE "120"
|
||||
seed_default FEISHU_EVENTS_PER_MINUTE "120"
|
||||
|
||||
Generated
+95
-71
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.42",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||
@@ -248,22 +248,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
|
||||
"integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz",
|
||||
"integrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -272,9 +272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz",
|
||||
"integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -285,9 +285,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz",
|
||||
"integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -298,12 +298,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz",
|
||||
"integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -311,12 +314,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz",
|
||||
"integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.217.tgz",
|
||||
"integrity": "sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -324,12 +330,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz",
|
||||
"integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -337,12 +346,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz",
|
||||
"integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.217.tgz",
|
||||
"integrity": "sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -350,9 +362,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz",
|
||||
"integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -363,9 +375,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
|
||||
"version": "0.3.217",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz",
|
||||
"integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -426,22 +438,34 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"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==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1128,13 +1152,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
|
||||
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -1172,13 +1196,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -2807,9 +2831,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2921,9 +2945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
|
||||
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
|
||||
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -3138,9 +3162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.28",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
|
||||
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -3243,9 +3267,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -3819,9 +3843,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4095,9 +4119,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4115,7 +4139,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.42",
|
||||
"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 CASCADE 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Allow Organization wipe/cascade to clear nested agent-config folders.
|
||||
-- Service layer still refuses non-empty folder deletes (ADR-0028); this only
|
||||
-- unblocks parent-row removal during org teardown and test resetDb.
|
||||
ALTER TABLE "OrganizationAgentConfigFolder" DROP CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey";
|
||||
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,7 @@ model Organization {
|
||||
capabilityConnections OrganizationCapabilityConnection[]
|
||||
agentSkills OrganizationAgentSkill[]
|
||||
agentRoles OrganizationAgentRole[]
|
||||
agentConfigFolders OrganizationAgentConfigFolder[]
|
||||
projectGroupBindings ProjectGroupBinding[]
|
||||
auditEntries AuditEntry[] @relation("organizationAudit")
|
||||
projectSearchDocuments ProjectSearchDocument[]
|
||||
@@ -96,16 +97,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)
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -122,17 +126,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)
|
||||
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`
|
||||
@@ -152,6 +159,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: Cascade)
|
||||
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 {
|
||||
|
||||
@@ -26,7 +26,11 @@ const client = new DocmindClient.default({
|
||||
} as never);
|
||||
|
||||
const fileStream = createReadStream(pdfPath);
|
||||
const runtime = new RuntimeOptions({});
|
||||
const runtime = new RuntimeOptions({
|
||||
connectTimeout: 15_000,
|
||||
// httpx defaults to 3000ms; OSS upload of multi-MB PDFs needs far more.
|
||||
readTimeout: 5 * 60_000,
|
||||
});
|
||||
|
||||
console.log("Submitting job...");
|
||||
const submitResp = await client.submitDocParserJobAdvance(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: pbank-problem-report
|
||||
description: >
|
||||
Use when a teacher wants Paradigm PBank (题库) problems as lesson examples,
|
||||
asks to search PBank by outline, map candidates to outline parts, inspect
|
||||
downloaded PBank source zips including images, or produce a Markdown
|
||||
selection/adaptation report.
|
||||
---
|
||||
|
||||
# PBank Problem Report
|
||||
|
||||
Use the `cph_hub` MCP tools `pbank_search_problems`, `pbank_get_problem`, and
|
||||
`pbank_get_many_problems` to support problem selection from Paradigm PBank.
|
||||
The normal output is a Markdown report for the teacher to review before any
|
||||
lesson source is changed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Understand the current lesson before searching.
|
||||
- Read the lesson outline, plan, or main source files in the current workspace.
|
||||
- Identify the teaching parts, target concepts, required difficulty, and what kind of examples are needed.
|
||||
|
||||
2. Search PBank with narrow queries.
|
||||
- Use `pbank_search_problems` with Chinese keywords from the outline.
|
||||
- Prefer several focused searches over one broad search.
|
||||
- Keep the initial result set small, then fetch only plausible candidates.
|
||||
|
||||
3. Fetch source projects for candidates.
|
||||
- Use `pbank_get_problem` or `pbank_get_many_problems`.
|
||||
- Keep `includeProjects: true`, `materializeProjects: true`, and `includeAssetImages: true` unless the user explicitly asks otherwise.
|
||||
- Read the returned `rights` object first. Treat it as the authoritative rights signal for all content returned by these tools.
|
||||
- Read returned `files`, `assets`, `zipPath`, and `extractDir`. Images usually live under `fig/` in the extracted source tree.
|
||||
- When image or PDF assets matter, cite their workspace-relative paths under `.pbank-sources/...`.
|
||||
|
||||
4. Produce a Markdown report instead of editing the lesson by default.
|
||||
- Save it in the current project workspace as `pbank-candidate-report.md` unless the user gives another path.
|
||||
- Do not copy problems into the lesson, rewrite the lesson, or write back to PBank unless the user explicitly asks.
|
||||
|
||||
5. When the teacher explicitly asks for adaptation, follow the rights signal.
|
||||
- If `rights.derivativeUseAllowed` is `true`, you may quote, rewrite, adapt, and reorganize the returned PBank content into the current teaching-material project.
|
||||
- Do not refuse on generic copyright grounds when the tools say derivative use is allowed.
|
||||
- If `rights.derivativeUseAllowed` is `false`, stay read-only and ask the teacher/admin to confirm rights first.
|
||||
|
||||
## Report Shape
|
||||
|
||||
Use concise Chinese. Include:
|
||||
|
||||
- Lesson outline parts and the example need for each part.
|
||||
- A candidate table with PBank title/id/link, matched outline part, fit score, directly usable material, required modifications, assets, and recommendation.
|
||||
- Per-problem notes explaining the mathematical/physical idea, why it matches, and what needs to be changed for this lesson.
|
||||
- A short final recommendation: which problems to use directly, which to adapt, and which to discard.
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not invent problem statements, diagrams, or answers. Base every claim on PBank metadata or extracted source files.
|
||||
- Treat `.pbank-sources/` as a local read cache. It is safe to read from and reference in reports.
|
||||
- Keep PBank itself read-only. The teacher's workflow is selection and adaptation inside the current lesson project, not modifying the bank.
|
||||
- Treat rights metadata as authoritative. Do not override `rights.derivativeUseAllowed: true` with a generic copyright refusal.
|
||||
- If tools fail because no ACTIVE `pbank` capability connection exists, tell the user an org admin must configure 题库 credentials under 外部能力.
|
||||
- If no candidate fits, say that directly and record the searches tried.
|
||||
@@ -2,62 +2,95 @@
|
||||
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.
|
||||
Handles single or multiple PDFs (concurrent batch), Feishu attachments, and
|
||||
local workspace files. 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.
|
||||
Use this skill when the user asks to convert a PDF (or several PDFs) to
|
||||
Markdown, extract text from a PDF, or turn PDF documents 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:
|
||||
The `convert_pdf_to_md` tool (provided by the in-process `cph_hub` MCP server)
|
||||
calls Alibaba Cloud Document Mind to parse each 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
|
||||
- Writes a single `document.md` file plus image files **per** `output_dir`
|
||||
|
||||
There is **no** workspace `.mcp.json` source file. MCP tools are injected by
|
||||
Hub at run start. Do not look for MCP or skill source under workspace
|
||||
`.claude/` — those paths are sandbox stubs (often character devices) and are
|
||||
not readable constitution.
|
||||
|
||||
## Where this skill text lives
|
||||
|
||||
Prefer the Skill tool when the runtime offers it. If you need to re-read these
|
||||
instructions with Read:
|
||||
|
||||
- Workspace copy (always under cwd): `.cph/runtime-skills/pdf-to-md/SKILL.md`
|
||||
- Absolute path env: `$CPH_RUNTIME_SKILLS_DIR/pdf-to-md/SKILL.md`
|
||||
|
||||
## Workflow
|
||||
|
||||
### PDF from a Feishu message
|
||||
### One 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.
|
||||
3. Use `convert_pdf_to_md` with `input_path` + `output_dir`.
|
||||
|
||||
### PDF already in the workspace
|
||||
|
||||
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
|
||||
1. Use `convert_pdf_to_md` with `input_path` and `output_dir`.
|
||||
|
||||
### Multiple PDFs (concurrent)
|
||||
|
||||
1. Download or locate every PDF in the workspace first.
|
||||
2. Call **`convert_pdf_to_md` once** with:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "input_path": "sources/a.pdf", "output_dir": "md/a" },
|
||||
{ "input_path": "sources/b.pdf", "output_dir": "md/b" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. Hub submits Docmind jobs with bounded concurrency (default 3, max 8;
|
||||
optional `concurrency` argument). Prefer this over N sequential tool calls.
|
||||
4. **Each item must use a distinct `output_dir`** — the tool always writes
|
||||
`document.md` inside that directory; shared dirs overwrite each other.
|
||||
5. Partial failure returns per-file OK/FAIL lines; re-run only failed items.
|
||||
|
||||
## 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.
|
||||
PDFs yourself with Read, Bash, Python, or any other method.
|
||||
- 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.
|
||||
- After conversion, use `send_file` to send generated markdown (or a zip you
|
||||
assemble) back to the user if they requested delivery.
|
||||
|
||||
## Output
|
||||
|
||||
The tool returns a list of generated files:
|
||||
Per `output_dir`:
|
||||
|
||||
- `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.
|
||||
Billed per page (0.04 CNY/page ≈ $0.0056/page for enhanced formula mode).
|
||||
Each successful file records its own usage fact on the run ledger.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
|
||||
import {
|
||||
CapabilityConnectionService,
|
||||
type CapabilityCredentialInput,
|
||||
} from "../../capability/capabilityConnectionService.js";
|
||||
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
|
||||
import { secretKindForCapability } from "../../capability/types.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
|
||||
config: CapabilityConnectionRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const connections = new CapabilityConnectionService(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
config.readinessProbe,
|
||||
);
|
||||
const connections =
|
||||
config.readinessProbe === undefined
|
||||
? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
|
||||
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
|
||||
|
||||
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
|
||||
try {
|
||||
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
|
||||
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 credential = parseCredentialBody(capabilityId, request.body);
|
||||
const result = await connections.rotate({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
...body,
|
||||
credential,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
@@ -113,19 +116,57 @@ export async function registerCapabilityConnectionRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
|
||||
function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput {
|
||||
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`);
|
||||
}
|
||||
const expectedKind = secretKindForCapability(capabilityId);
|
||||
const kind =
|
||||
body.kind === "docmind" || body.kind === "pbank"
|
||||
? body.kind
|
||||
: expectedKind;
|
||||
|
||||
if (kind !== expectedKind) {
|
||||
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`);
|
||||
}
|
||||
|
||||
if (kind === "docmind") {
|
||||
return {
|
||||
kind: "docmind",
|
||||
accessKeyId: requireStringField(body, "accessKeyId"),
|
||||
accessKeySecret: requireStringField(body, "accessKeySecret"),
|
||||
endpoint: requireStringField(body, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const rightsStatus = optionalStringField(body, "rightsStatus");
|
||||
const rightsHolder = optionalStringField(body, "rightsHolder");
|
||||
const rightsScope = optionalStringField(body, "rightsScope");
|
||||
const rightsNote = optionalStringField(body, "rightsNote");
|
||||
return {
|
||||
accessKeyId: body["accessKeyId"] as string,
|
||||
accessKeySecret: body["accessKeySecret"] as string,
|
||||
endpoint: body["endpoint"] as string,
|
||||
kind: "pbank",
|
||||
baseUrl: requireStringField(body, "baseUrl"),
|
||||
username: requireStringField(body, "username"),
|
||||
password: requireStringField(body, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function requireStringField(body: Record<string, unknown>, name: string): string {
|
||||
const value = body[name];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined {
|
||||
const value = body[name];
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,12 @@ export async function registerExplorerRoutes(
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
});
|
||||
return reply.status(201).send({ id: result.projectId, name: body.name });
|
||||
return reply.status(201).send({
|
||||
projectId: result.projectId,
|
||||
folderId: result.folderId,
|
||||
workspaceDir: result.workspaceDir,
|
||||
name: body.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
|
||||
+256
-11
@@ -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;
|
||||
@@ -173,7 +389,7 @@ export class OrganizationAgentConfiguration {
|
||||
where: { id: skill.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await archiveRoleSessions(
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -271,7 +487,7 @@ export class OrganizationAgentConfiguration {
|
||||
},
|
||||
});
|
||||
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
|
||||
await archiveRoleSessions(
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -379,7 +595,7 @@ export class OrganizationAgentConfiguration {
|
||||
if (activeDefaultCount !== 1) {
|
||||
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
|
||||
}
|
||||
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -449,7 +665,7 @@ export class OrganizationAgentConfiguration {
|
||||
})),
|
||||
});
|
||||
}
|
||||
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -472,7 +688,22 @@ export class OrganizationAgentConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveRoleSessions(
|
||||
/**
|
||||
* Invalidate the provider session cursor (e.g. `claudeSessionId`) for every
|
||||
* active session of the given roles, WITHOUT archiving the session.
|
||||
*
|
||||
* Execution-surface changes (role model/systemPrompt/tools, skill content or
|
||||
* binding changes) make a stale provider session cursor unsafe to resume: the
|
||||
* prior turns were produced under a different config. But the conversation
|
||||
* history itself (AgentMessage rows) is still valuable and the logical Hub
|
||||
* session should stay continuous — the next run re-seeds context from the
|
||||
* transcript instead of resuming the old provider session. So we drop only
|
||||
* the cursor, not the session.
|
||||
*
|
||||
* `userResumable` is cleared because the session is no longer backed by a
|
||||
* live provider cursor the user can drop back into.
|
||||
*/
|
||||
async function invalidateRoleSessionClaudeIds(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
roleIds: readonly string[],
|
||||
@@ -482,24 +713,36 @@ async function archiveRoleSessions(
|
||||
where: {
|
||||
roleId: { in: [...new Set(roleIds)] },
|
||||
project: { organizationId },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, archivedAt: true, metadata: true },
|
||||
select: { id: true, metadata: true },
|
||||
});
|
||||
const archivedAt = new Date();
|
||||
for (const session of sessions) {
|
||||
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
|
||||
? session.metadata as Prisma.JsonObject
|
||||
: {};
|
||||
const { claudeSessionId: _drop, ...rest } = metadata;
|
||||
await tx.agentSession.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
...(session.archivedAt === null ? { archivedAt } : {}),
|
||||
metadata: { ...metadata, userResumable: false },
|
||||
},
|
||||
data: { metadata: { ...rest, userResumable: false } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
@@ -530,6 +773,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 +793,6 @@ function toRoleRow(role: {
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
folderId: role.folderId,
|
||||
};
|
||||
}
|
||||
|
||||
+80
-45
@@ -1,11 +1,13 @@
|
||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
] as const;
|
||||
|
||||
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
|
||||
@@ -15,6 +17,10 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
"feishu_download_resource",
|
||||
"request_approval",
|
||||
"convert_pdf_to_md",
|
||||
"pbank_search_problems",
|
||||
"pbank_get_problem",
|
||||
"pbank_get_many_problems",
|
||||
"todo_write",
|
||||
] as const;
|
||||
|
||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||
@@ -24,48 +30,65 @@ export interface ClaudeSdkToolConfig {
|
||||
readonly allowedTools: readonly string[];
|
||||
}
|
||||
|
||||
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
||||
["read_file", ["Read"]],
|
||||
["write_file", ["Write"]],
|
||||
["list_files", ["Glob"]],
|
||||
["search_files", ["Grep"]],
|
||||
["bash", ["Bash"]],
|
||||
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
|
||||
read_file: ["Read"],
|
||||
write_file: ["Write", "Edit"],
|
||||
list_files: ["Glob"],
|
||||
search_files: ["Grep"],
|
||||
bash: ["Bash"],
|
||||
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
|
||||
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
|
||||
// would need a separate command-policy layer.
|
||||
["cph_check", ["Bash"]],
|
||||
["cph_build", ["Bash"]],
|
||||
["web_fetch", ["WebFetch"]],
|
||||
["web_search", ["WebSearch"]],
|
||||
["Read", ["Read"]],
|
||||
["Write", ["Write"]],
|
||||
["Bash", ["Bash"]],
|
||||
["Glob", ["Glob"]],
|
||||
["Grep", ["Grep"]],
|
||||
["WebFetch", ["WebFetch"]],
|
||||
["WebSearch", ["WebSearch"]],
|
||||
]);
|
||||
cph_check: ["Bash"],
|
||||
cph_build: ["Bash"],
|
||||
web_fetch: ["WebFetch"],
|
||||
web_search: ["WebSearch"],
|
||||
todo: ["TodoWrite"],
|
||||
TodoWrite: ["TodoWrite"],
|
||||
Read: ["Read"],
|
||||
Write: ["Write"],
|
||||
Edit: ["Edit"],
|
||||
Bash: ["Bash"],
|
||||
Glob: ["Glob"],
|
||||
Grep: ["Grep"],
|
||||
WebFetch: ["WebFetch"],
|
||||
WebSearch: ["WebSearch"],
|
||||
};
|
||||
|
||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||
["send_file", "send_file"],
|
||||
["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 ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
|
||||
send_file: ["send_file"],
|
||||
feishu_read_context: ["feishu_read_context"],
|
||||
feishu_download_resource: ["feishu_download_resource"],
|
||||
request_approval: ["request_approval"],
|
||||
convert_pdf_to_md: ["convert_pdf_to_md"],
|
||||
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
|
||||
pbank_search_problems: ["pbank_search_problems"],
|
||||
pbank_get_problem: ["pbank_get_problem"],
|
||||
pbank_get_many_problems: ["pbank_get_many_problems"],
|
||||
todo: ["todo_write"],
|
||||
TodoWrite: ["todo_write"],
|
||||
todo_write: ["todo_write"],
|
||||
"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"],
|
||||
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
|
||||
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
|
||||
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
|
||||
"mcp__cph_hub__todo_write": ["todo_write"],
|
||||
};
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
|
||||
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
|
||||
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
|
||||
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
|
||||
]);
|
||||
|
||||
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
|
||||
if (roleTools === undefined) {
|
||||
export function claudeSdkToolConfigForRole(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
): ClaudeSdkToolConfig {
|
||||
// DB/runtime "unrestricted" is JSON null; treat the same as undefined.
|
||||
if (roleTools === undefined || roleTools === null) {
|
||||
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
|
||||
return {
|
||||
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
|
||||
@@ -77,13 +100,12 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
|
||||
const allowedTools: string[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
|
||||
pushUnique(builtIns, tool);
|
||||
pushUnique(allowedTools, tool);
|
||||
}
|
||||
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) {
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
|
||||
}
|
||||
}
|
||||
@@ -91,24 +113,37 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
|
||||
return { tools: builtIns, allowedTools };
|
||||
}
|
||||
|
||||
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
|
||||
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
export function cphHubMcpToolsForRole(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
): readonly CphHubMcpToolId[] {
|
||||
// Always expose hub-side todo_write so progress cards work even when the
|
||||
// native Claude TodoWrite tool is not registered in headless agent mode.
|
||||
if (roleTools === undefined || roleTools === null) {
|
||||
return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
}
|
||||
|
||||
const tools: CphHubMcpToolId[] = [];
|
||||
const tools: CphHubMcpToolId[] = ["todo_write"];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
pushUnique(tools, mcpTool);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
|
||||
if (roleTools === undefined) return true;
|
||||
export function roleToolsAllow(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
roleTool: string,
|
||||
): boolean {
|
||||
if (roleTools === undefined || roleTools === null) return true;
|
||||
for (const configured of roleTools) {
|
||||
assertSupportedRoleTool(configured);
|
||||
if (configured === roleTool) return true;
|
||||
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
|
||||
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
|
||||
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
|
||||
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
|
||||
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+110
-11
@@ -47,7 +47,7 @@ export type StreamEvent =
|
||||
| { readonly type: "thinking-delta"; readonly text: string }
|
||||
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
|
||||
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly input?: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "finish" };
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
@@ -140,7 +140,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
let cleanupSecurity = async (): Promise<void> => {};
|
||||
try {
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
// Role tools JSON null means the default single-agent tool set, not "deny all".
|
||||
const roleToolIds = req.tools === null ? undefined : req.tools;
|
||||
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
|
||||
const workspaceRoot = req.project.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
throw new Error("Agent run requires the configured workspace root");
|
||||
@@ -154,14 +156,42 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
});
|
||||
cleanupSecurity = security.cleanup;
|
||||
const hasSkills = security.skillIds.length > 0;
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
// Always use an explicit tool list — never the claude_code preset.
|
||||
// The preset registers Agent/SendMessage/Task multi-agent machinery.
|
||||
// Concurrent background agents abort with reason "background", and the
|
||||
// Claude Agent SDK maps that to toolDenialKind "cancelled" with:
|
||||
// "The user doesn't want to take this action right now..."
|
||||
// which freezes Bash mid-run while Read/Glob continue to work.
|
||||
// Hub "unrestricted" means the default single-agent built-ins + MCP, not
|
||||
// the full interactive Claude product surface.
|
||||
const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const);
|
||||
const toolsOption: QueryOptions["tools"] = uniqueTools([
|
||||
...toolConfig.tools,
|
||||
"TodoWrite",
|
||||
...skillExtras,
|
||||
]);
|
||||
const allowedToolsOption = uniqueTools([
|
||||
...toolConfig.allowedTools,
|
||||
"TodoWrite",
|
||||
"mcp__cph_hub__todo_write",
|
||||
...skillExtras,
|
||||
]);
|
||||
// Hard deny multi-agent orchestration even if a future preset/skills path
|
||||
// reintroduces them — bypassPermissions would otherwise auto-allow them.
|
||||
const disallowedToolsOption = [
|
||||
"Agent",
|
||||
"SendMessage",
|
||||
"TeamCreate",
|
||||
"Task",
|
||||
"ScheduleWakeup",
|
||||
] as const;
|
||||
|
||||
const options: QueryOptions = {
|
||||
cwd: security.cwd,
|
||||
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
||||
// list still has to expose the Skill dispatcher itself.
|
||||
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
tools: toolsOption,
|
||||
allowedTools: allowedToolsOption,
|
||||
disallowedTools: [...disallowedToolsOption],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
@@ -178,7 +208,16 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
// The project workspace is untrusted input. Do not load user/project
|
||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
settings: {
|
||||
disableBundledSkills: true,
|
||||
todoFeatureEnabled: true,
|
||||
// Sessions are resumed across runs (ADR-0017). Without auto-compact
|
||||
// the SDK jsonl grows unboundedly — a long-lived project session hit
|
||||
// 31 MB / 1995 lines, making every API call resend the entire history
|
||||
// and inflating a "change a title" task to 22 minutes. Let the SDK
|
||||
// compact automatically when the context window fills.
|
||||
autoCompactEnabled: true,
|
||||
},
|
||||
...(hasSkills && security.skillPluginRoot !== undefined
|
||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||
: {}),
|
||||
@@ -198,13 +237,25 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
if (req.abortController !== undefined) options.abortController = req.abortController;
|
||||
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
|
||||
|
||||
// When there is no provider session cursor to resume (first run, or after a
|
||||
// role/skill/model config change invalidated claudeSessionId), re-seed the
|
||||
// conversation from this Hub session's prior AgentMessage rows. This keeps
|
||||
// the logical session continuous across config changes and restarts — the
|
||||
// agent still "remembers" the earlier turns even though the SDK starts a
|
||||
// fresh provider session. The resume path above is preferred when available
|
||||
// (it carries tool calls/results natively and avoids re-sending tokens).
|
||||
const promptForAgent = req.resumeSessionId === undefined
|
||||
? await withSessionHistory(req, req.prompt)
|
||||
: req.prompt;
|
||||
|
||||
const conversation = query({
|
||||
prompt: req.prompt,
|
||||
prompt: promptForAgent,
|
||||
options,
|
||||
});
|
||||
|
||||
// Track tool start timestamps for duration calculation
|
||||
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
|
||||
const toolStartTimestamps = new Map<string, number>();
|
||||
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
|
||||
|
||||
for await (const message of conversation) {
|
||||
switch (message.type) {
|
||||
@@ -225,6 +276,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||
const toolUseId = evt.content_block.id;
|
||||
toolStartTimestamps.set(toolUseId, Date.now());
|
||||
toolMetaByUseId.set(toolUseId, {
|
||||
name: evt.content_block.name,
|
||||
input: undefined,
|
||||
});
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
|
||||
}
|
||||
if (evt.type === "content_block_stop") {
|
||||
@@ -246,6 +301,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const durationMs = toolStartTimestamps.has(block.id)
|
||||
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
|
||||
: undefined;
|
||||
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
|
||||
onStream?.({
|
||||
type: "tool-end",
|
||||
toolName: block.name,
|
||||
@@ -279,12 +335,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const isError = block.is_error === true;
|
||||
const resultText = extractToolResultText(block.content);
|
||||
const durationMs = toolStartTimestamps.get(toolUseId);
|
||||
const meta = toolMetaByUseId.get(toolUseId);
|
||||
onStream?.({
|
||||
type: "tool-result",
|
||||
toolUseId,
|
||||
toolName: toolUseId,
|
||||
toolName: meta?.name ?? toolUseId,
|
||||
result: resultText,
|
||||
isError,
|
||||
...(meta?.input !== undefined ? { input: meta.input } : {}),
|
||||
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -333,6 +391,39 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed a run's prompt with this Hub session's prior conversation when the
|
||||
* SDK cannot resume a provider session (no `resumeSessionId`). Pulls prior
|
||||
* `AgentMessage` rows for this session — excluding the current run's own user
|
||||
* message, which was already persisted before `runAgent` called this — and
|
||||
* frames them as `<session_history>` so the model treats them as prior turns,
|
||||
* not new instructions. The current run's prompt follows as the live request.
|
||||
*
|
||||
* Best-effort: if the history query fails, the run proceeds with the bare
|
||||
* prompt rather than aborting. A cap (`MAX_HISTORY_TURNS`) bounds token cost;
|
||||
* older turns beyond the cap are dropped, preserving the most recent context.
|
||||
*/
|
||||
async function withSessionHistory(req: RunRequest, prompt: string): Promise<string> {
|
||||
const MAX_HISTORY_TURNS = 40;
|
||||
try {
|
||||
const messages = await req.prisma.agentMessage.findMany({
|
||||
where: { sessionId: req.sessionId, runId: { not: req.runId } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { role: true, content: true },
|
||||
take: MAX_HISTORY_TURNS * 2, // user+assistant per turn
|
||||
});
|
||||
if (messages.length === 0) return prompt;
|
||||
const turns: string[] = [];
|
||||
for (const message of messages) {
|
||||
const label = message.role === "assistant" ? "Assistant" : "User";
|
||||
turns.push(`${label}: ${message.content}`);
|
||||
}
|
||||
return `<session_history>\nThis is the prior conversation in this session, replayed because the provider session could not be resumed. Treat these as earlier turns you produced or received.\n\n${turns.join("\n\n")}\n</session_history>\n\n${prompt}`;
|
||||
} catch {
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
|
||||
if (content === "") return;
|
||||
try {
|
||||
@@ -361,3 +452,11 @@ function extractToolResultText(content: unknown): string {
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function uniqueTools(tools: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const tool of tools) {
|
||||
if (!out.includes(tool)) out.push(tool);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
@@ -21,6 +21,20 @@ const SAFE_HOST_ENV_KEYS = [
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"CPH_BIN",
|
||||
// Host egress is often only reachable via a local forward proxy. Without
|
||||
// these, sandboxed Bash/curl times out on public HTTPS (ADR-0018: network
|
||||
// open ≠ direct routing). Values come from the trusted service environment.
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"NODE_USE_ENV_PROXY",
|
||||
"TYPST_PACKAGE_PATH",
|
||||
"TYPST_PACKAGE_CACHE_PATH",
|
||||
] as const;
|
||||
|
||||
const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
@@ -122,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
|
||||
const selectedSkills = input.skills ?? [];
|
||||
const skillPlugin = selectedSkills.length === 0
|
||||
? null
|
||||
@@ -130,6 +145,25 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
runId: input.runId,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
// Mirror selected skills under the workspace so the agent can Read SKILL.md
|
||||
// without guessing the opaque host plugin UUID path. Workspace `.claude/` and
|
||||
// `.mcp.json` are Claude sandbox stubs — not skill/MCP source of truth.
|
||||
const runtimeSkillsRel = join(".cph", "runtime-skills");
|
||||
const runtimeSkillsAbs = join(workspaceDir, runtimeSkillsRel);
|
||||
await rm(runtimeSkillsAbs, { recursive: true, force: true });
|
||||
await mkdir(runtimeSkillsAbs, { recursive: true, mode: 0o700 });
|
||||
if (skillPlugin !== null) {
|
||||
const pluginSkillsRoot = join(skillPlugin.root, "skills");
|
||||
const skillNames = await readdir(pluginSkillsRoot);
|
||||
for (const skillName of skillNames) {
|
||||
await cp(join(pluginSkillsRoot, skillName), join(runtimeSkillsAbs, skillName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
env.CPH_RUNTIME_SKILLS_DIR = runtimeSkillsAbs;
|
||||
env.CPH_RUNTIME_SKILLS_REL = runtimeSkillsRel;
|
||||
}
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
@@ -143,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
|
||||
// Reject every write path by default, then re-open only the canonical
|
||||
// workspace. This prevents bubblewrap's ordinary temp exceptions from
|
||||
// turning an unauthorized path into a successful ephemeral write.
|
||||
@@ -213,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
|
||||
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
|
||||
platformPaths.push(resolve(cphBin));
|
||||
}
|
||||
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
|
||||
return [...new Set(platformPaths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
function hostTypstCacheWritePaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]);
|
||||
}
|
||||
|
||||
function configuredTypstPackagePaths(
|
||||
env: Readonly<Record<string, string | undefined>>,
|
||||
names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[],
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const name of names) {
|
||||
const packagePath = env[name]?.trim();
|
||||
if (packagePath === undefined || packagePath === "") continue;
|
||||
if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`);
|
||||
const canonical = resolve(packagePath);
|
||||
if (canonical === "/") throw new Error(`${name} must not be the filesystem root`);
|
||||
paths.push(canonical);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const home = homedir();
|
||||
const paths = [
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Parse agent checklist tools into a stable model for Feishu card progress.
|
||||
*
|
||||
* Supports:
|
||||
* - Claude TodoWrite (and hub mcp todo_write): full-list replace
|
||||
* - Claude TaskCreate / TaskUpdate tools used in headless agent mode
|
||||
*/
|
||||
|
||||
export type AgentTodoStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface AgentTodoItem {
|
||||
/** Present for Task* tools; optional for whole-list TodoWrite payloads. */
|
||||
readonly id: string | undefined;
|
||||
readonly content: string;
|
||||
readonly status: AgentTodoStatus;
|
||||
/** Present-tense label while the item is active, when the model supplies it. */
|
||||
readonly activeForm: string | undefined;
|
||||
}
|
||||
|
||||
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
|
||||
|
||||
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
|
||||
export function isTodoWriteTool(toolName: string): boolean {
|
||||
const lower = toolName.toLowerCase();
|
||||
return (
|
||||
toolName === "TodoWrite" ||
|
||||
toolName.endsWith("__TodoWrite") ||
|
||||
lower === "todo_write" ||
|
||||
lower.endsWith("__todo_write")
|
||||
);
|
||||
}
|
||||
|
||||
export function isTaskChecklistTool(toolName: string): boolean {
|
||||
const base = stripToolSuffix(toolName);
|
||||
return (
|
||||
base === "TaskCreate" ||
|
||||
base === "TaskUpdate" ||
|
||||
base === "TaskList" ||
|
||||
base === "TaskGet" ||
|
||||
base === "TaskStop" ||
|
||||
base === "TaskOutput"
|
||||
);
|
||||
}
|
||||
|
||||
export function isChecklistProgressTool(toolName: string): boolean {
|
||||
return isTodoWriteTool(toolName) || isTaskChecklistTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full todo list from a TodoWrite / todo_write tool_use input.
|
||||
* Returns null when the payload is not a usable body.
|
||||
*/
|
||||
export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
if (!("todos" in input)) return null;
|
||||
const rawTodos = (input as { todos?: unknown }).todos;
|
||||
if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null;
|
||||
|
||||
const todos: AgentTodoItem[] = [];
|
||||
for (const raw of rawTodos) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const content = typeof record.content === "string" ? record.content.trim() : "";
|
||||
if (content === "") continue;
|
||||
const statusRaw = typeof record.status === "string" ? record.status : "pending";
|
||||
const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus)
|
||||
? (statusRaw as AgentTodoStatus)
|
||||
: "pending";
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
const id =
|
||||
typeof record.id === "string" && record.id.trim() !== "" ? record.id.trim() : undefined;
|
||||
todos.push({ id, content, status, activeForm });
|
||||
}
|
||||
return todos.length === 0 ? null : todos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a Task* / TodoWrite tool event into the running checklist.
|
||||
* Returns null when the event does not change checklist state.
|
||||
*/
|
||||
export function applyChecklistToolEvent(
|
||||
current: readonly AgentTodoItem[],
|
||||
params: {
|
||||
readonly toolName: string;
|
||||
readonly input: unknown;
|
||||
readonly result: unknown;
|
||||
readonly toolUseId?: string | undefined;
|
||||
},
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (isTodoWriteTool(params.toolName)) {
|
||||
return parseTodoWriteInput(params.input);
|
||||
}
|
||||
|
||||
const baseName = stripToolSuffix(params.toolName);
|
||||
if (baseName === "TaskCreate") {
|
||||
return applyTaskCreate(current, params.input, params.result, params.toolUseId);
|
||||
}
|
||||
if (baseName === "TaskUpdate") {
|
||||
return applyTaskUpdate(current, params.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly inProgress: number;
|
||||
} {
|
||||
let completed = 0;
|
||||
let inProgress = 0;
|
||||
for (const todo of todos) {
|
||||
if (todo.status === "completed") completed += 1;
|
||||
else if (todo.status === "in_progress") inProgress += 1;
|
||||
}
|
||||
return { completed, total: todos.length, inProgress };
|
||||
}
|
||||
|
||||
function stripToolSuffix(toolName: string): string {
|
||||
// SDK sometimes emits TaskCreate_0 sequential copies in the card title path.
|
||||
const bare = toolName.includes("__") ? (toolName.split("__").pop() ?? toolName) : toolName;
|
||||
return bare.replace(/_\d+$/, "");
|
||||
}
|
||||
|
||||
function applyTaskCreate(
|
||||
current: readonly AgentTodoItem[],
|
||||
input: unknown,
|
||||
result: unknown,
|
||||
toolUseId: string | undefined,
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
const subject =
|
||||
typeof record.subject === "string"
|
||||
? record.subject.trim()
|
||||
: typeof record.description === "string"
|
||||
? record.description.trim()
|
||||
: "";
|
||||
if (subject === "") return null;
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
const idFromResult = extractTaskIdFromResult(result);
|
||||
const provisionalId = toolUseId ?? `task-${current.length + 1}`;
|
||||
const id = idFromResult ?? provisionalId;
|
||||
|
||||
// Replace any provisional row for this tool use or same pending subject.
|
||||
const without = current.filter(
|
||||
(t) =>
|
||||
t.id !== provisionalId &&
|
||||
t.id !== toolUseId &&
|
||||
!(t.content === subject && t.status === "pending" && t.id !== id),
|
||||
);
|
||||
const existing = without.find((t) => taskIdsMatch(t.id, id));
|
||||
if (existing !== undefined) {
|
||||
return without.map((t) =>
|
||||
taskIdsMatch(t.id, id)
|
||||
? {
|
||||
id,
|
||||
content: subject,
|
||||
status: existing.status,
|
||||
activeForm: activeForm ?? existing.activeForm,
|
||||
}
|
||||
: t,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...without,
|
||||
{
|
||||
id,
|
||||
content: subject,
|
||||
status: "pending",
|
||||
activeForm,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTaskUpdate(
|
||||
current: readonly AgentTodoItem[],
|
||||
input: unknown,
|
||||
): readonly AgentTodoItem[] | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
const taskId =
|
||||
typeof record.taskId === "string"
|
||||
? record.taskId.trim()
|
||||
: typeof record.id === "string"
|
||||
? record.id.trim()
|
||||
: "";
|
||||
if (taskId === "") return null;
|
||||
|
||||
const statusRaw = typeof record.status === "string" ? record.status : undefined;
|
||||
if (statusRaw === "deleted") {
|
||||
const next = current.filter((t) => !taskIdsMatch(t.id, taskId));
|
||||
return next.length === current.length ? null : next;
|
||||
}
|
||||
|
||||
const status: AgentTodoStatus | undefined =
|
||||
statusRaw !== undefined && STATUSES.has(statusRaw as AgentTodoStatus)
|
||||
? (statusRaw as AgentTodoStatus)
|
||||
: undefined;
|
||||
const subject =
|
||||
typeof record.subject === "string" && record.subject.trim() !== ""
|
||||
? record.subject.trim()
|
||||
: undefined;
|
||||
const activeForm =
|
||||
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
|
||||
? record.activeForm.trim()
|
||||
: undefined;
|
||||
|
||||
if (status === undefined && subject === undefined && activeForm === undefined) return null;
|
||||
|
||||
let found = false;
|
||||
const next = current.map((todo) => {
|
||||
if (!taskIdsMatch(todo.id, taskId)) return todo;
|
||||
found = true;
|
||||
return {
|
||||
id: todo.id ?? taskId,
|
||||
content: subject ?? todo.content,
|
||||
status: status ?? todo.status,
|
||||
activeForm: activeForm ?? todo.activeForm,
|
||||
};
|
||||
});
|
||||
if (found) return next;
|
||||
|
||||
// Update arrived before create (or id mismatch): synthesize a row so the
|
||||
// teacher still sees lifecycle updates.
|
||||
if (subject === undefined && status === undefined) return null;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: taskId,
|
||||
content: subject ?? `任务 ${taskId}`,
|
||||
status: status ?? "pending",
|
||||
activeForm,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractTaskIdFromResult(result: unknown): string | undefined {
|
||||
const text =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: result !== null && typeof result === "object" && "content" in result
|
||||
? String((result as { content: unknown }).content)
|
||||
: "";
|
||||
if (text === "") return undefined;
|
||||
const hash = text.match(/Task\s*#\s*([0-9A-Za-z_-]+)/i);
|
||||
if (hash?.[1]) return hash[1];
|
||||
const bare = text.match(/\bid\s*[:=]\s*["']?([0-9A-Za-z_-]+)/i);
|
||||
if (bare?.[1]) return bare[1];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function taskIdsMatch(a: string | undefined, b: string): boolean {
|
||||
if (a === undefined) return false;
|
||||
return normalizeTaskId(a) === normalizeTaskId(b);
|
||||
}
|
||||
|
||||
function normalizeTaskId(id: string | undefined): string {
|
||||
if (id === undefined) return "";
|
||||
return id.trim().replace(/^#/, "");
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function feishuContextTool(
|
||||
inputSchema: z.object({
|
||||
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||
id: z.string().describe("The anchor id (message id or run id)."),
|
||||
id: z.string().describe("The anchor id: a message_id for trigger_message/status_card/reply, or a thread_id for thread."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
|
||||
@@ -10,22 +10,41 @@ 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";
|
||||
import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
|
||||
import {
|
||||
CAPABILITY_IDS,
|
||||
type CapabilitySecretPayload,
|
||||
type DocmindCapabilitySecretPayload,
|
||||
type PbankCapabilitySecretPayload,
|
||||
secretKindForCapability,
|
||||
} 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"]);
|
||||
const KNOWN_CAPABILITY_IDS = new Set<string>(CAPABILITY_IDS);
|
||||
|
||||
export interface CapabilityCredentialInput {
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
export type CapabilityCredentialInput =
|
||||
| {
|
||||
readonly kind: "docmind";
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: "pbank";
|
||||
readonly baseUrl: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly rightsStatus?: string;
|
||||
readonly rightsHolder?: string;
|
||||
readonly rightsScope?: string;
|
||||
readonly rightsNote?: string;
|
||||
};
|
||||
|
||||
export interface RotateCapabilityInput extends CapabilityCredentialInput {
|
||||
export interface RotateCapabilityInput {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly credential: CapabilityCredentialInput;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionMetadata {
|
||||
@@ -45,25 +64,24 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
|
||||
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
|
||||
|
||||
export class CapabilityConnectionService {
|
||||
private readonly readinessProbe: CapabilityReadinessProbe;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
|
||||
) {}
|
||||
|
||||
readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential,
|
||||
) {
|
||||
this.readinessProbe = readinessProbe;
|
||||
}
|
||||
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
|
||||
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
|
||||
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
|
||||
}
|
||||
const payload = validateCredential(input);
|
||||
const payload = validateCredential(input.capabilityId, input.credential);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
});
|
||||
await this.readinessProbe({
|
||||
endpoint: payload.endpoint,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
});
|
||||
await this.readinessProbe(payload);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
@@ -142,6 +160,7 @@ export class CapabilityConnectionService {
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
keyId: envelope.keyId,
|
||||
secretKind: payload.kind,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -209,14 +228,47 @@ export class CapabilityConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
|
||||
function validateCredential(
|
||||
capabilityId: string,
|
||||
input: CapabilityCredentialInput,
|
||||
): CapabilitySecretPayload {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${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 };
|
||||
const expectedKind = secretKindForCapability(capabilityId);
|
||||
if (input.kind !== expectedKind) {
|
||||
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`);
|
||||
}
|
||||
|
||||
if (input.kind === "docmind") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: nonEmpty(input.accessKeyId, "accessKeyId"),
|
||||
accessKeySecret: nonEmpty(input.accessKeySecret, "accessKeySecret"),
|
||||
endpoint: nonEmpty(input.endpoint, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = normalizeBaseUrl(nonEmpty(input.baseUrl, "baseUrl"));
|
||||
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://")) {
|
||||
throw new Error("baseUrl must be an absolute http(s) URL");
|
||||
}
|
||||
const rightsStatus = optional(input.rightsStatus);
|
||||
const rightsHolder = optional(input.rightsHolder);
|
||||
const rightsScope = optional(input.rightsScope);
|
||||
const rightsNote = optional(input.rightsNote);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl,
|
||||
username: nonEmpty(input.username, "username"),
|
||||
password: nonEmpty(input.password, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toMetadata(
|
||||
@@ -264,3 +316,13 @@ function nonEmpty(value: string, label: string): string {
|
||||
if (trimmed === "") throw new Error(`${label} must not be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function optional(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -12,17 +12,19 @@ import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
CapabilityConnectionUnavailable,
|
||||
normalizeCapabilitySecretPayload,
|
||||
secretKindForCapability,
|
||||
type CapabilitySecretPayload,
|
||||
type CapabilityId,
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_PURPOSE = "capability";
|
||||
|
||||
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
|
||||
export type ResolvedCapabilityCredential = CapabilitySecretPayload & {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the active capability credential for an organization. Throws
|
||||
@@ -54,17 +56,19 @@ export async function resolveCapabilityCredential(
|
||||
connectionId: connection.id,
|
||||
secretVersionId: version.id,
|
||||
};
|
||||
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||
if (payload.schemaVersion !== 1) {
|
||||
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
|
||||
const payload = normalizeCapabilitySecretPayload(
|
||||
secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1),
|
||||
);
|
||||
const expectedKind = secretKindForCapability(input.capabilityId);
|
||||
if (payload.kind !== expectedKind) {
|
||||
throw new Error(
|
||||
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
capabilityId: connection.capabilityId,
|
||||
schemaVersion: 1,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
endpoint: payload.endpoint,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
* ADR-0027: Capability readiness probes. Validate credentials before
|
||||
* activation. Docmind uses QueryDocParserStatus; PBank uses /login.
|
||||
*/
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
|
||||
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
export interface CapabilityReadinessInput {
|
||||
readonly endpoint: string;
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
}
|
||||
|
||||
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
|
||||
export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
|
||||
|
||||
export class CapabilityReadinessError extends Error {
|
||||
constructor(
|
||||
@@ -33,7 +26,7 @@ export class CapabilityReadinessError extends Error {
|
||||
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
|
||||
* - network error → unreachable
|
||||
*/
|
||||
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
|
||||
export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecretPayload): Promise<void> {
|
||||
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
|
||||
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
|
||||
|
||||
@@ -61,6 +54,75 @@ export const probeDocmindCredential: CapabilityReadinessProbe = async (input) =>
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe PBank by logging in and ensuring a token is returned. */
|
||||
export async function probePbankCredentialPayload(input: PbankCapabilitySecretPayload): Promise<void> {
|
||||
const baseUrl = input.baseUrl.replace(/\/+$/, "");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: input.username, password: input.password }),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unreachable",
|
||||
"PBank credential readiness check could not reach the API",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
const data: unknown = await response.json().catch(() => null);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`PBank credential rejected: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`PBank login failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof data !== "object" ||
|
||||
data === null ||
|
||||
!("token" in data) ||
|
||||
typeof data.token !== "string" ||
|
||||
data.token.trim() === ""
|
||||
) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
"PBank login did not return a token",
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Default readiness probe: dispatch by secret kind. */
|
||||
export const probeCapabilityCredential: CapabilityReadinessProbe = async (payload) => {
|
||||
if (payload.kind === "docmind") {
|
||||
await probeDocmindCredentialPayload(payload);
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "pbank") {
|
||||
await probePbankCredentialPayload(payload);
|
||||
return;
|
||||
}
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unsupported",
|
||||
"unsupported capability secret kind",
|
||||
"configuration",
|
||||
);
|
||||
};
|
||||
|
||||
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
|
||||
|
||||
@@ -19,9 +19,10 @@ import $DocmindClient, {
|
||||
QueryDocParserStatusRequest,
|
||||
} from "@alicloud/docmind-api20220711";
|
||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
import { basename } from "node:path";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
import type { DocmindCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
/** A single extracted image downloaded from the markdown's OSS image URLs. */
|
||||
export interface DocmindExtractedImage {
|
||||
@@ -43,7 +44,7 @@ export interface DocmindParseOptions {
|
||||
}
|
||||
|
||||
export interface CapabilityProviderClient {
|
||||
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
}
|
||||
|
||||
export class DocmindClientError extends Error {
|
||||
@@ -61,11 +62,33 @@ export class DocmindClientError extends Error {
|
||||
const COST_PER_PAGE_USD = 0.0056;
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 5 * 60_000;
|
||||
/**
|
||||
* httpx (tea transport) defaults read/connect timeout to 3000ms when unset.
|
||||
* SubmitDocParserJobAdvance uploads the PDF to OSS; multi-MB files routinely
|
||||
* exceed 3s on the silo host (production: ReadTimeout(3000) on
|
||||
* docmind-api-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com).
|
||||
*/
|
||||
export const DOCMIND_CONNECT_TIMEOUT_MS = 15_000;
|
||||
/** Allow slow / large PDF OSS uploads up to the same bound as job polling. */
|
||||
export const DOCMIND_READ_TIMEOUT_MS = POLL_TIMEOUT_MS;
|
||||
|
||||
/** RuntimeOptions for Docmind SDK calls that may upload or wait on the wire. */
|
||||
export function createDocmindRuntimeOptions(): RuntimeOptions {
|
||||
return new RuntimeOptions({
|
||||
connectTimeout: DOCMIND_CONNECT_TIMEOUT_MS,
|
||||
readTimeout: DOCMIND_READ_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
|
||||
|
||||
export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
// Open first so missing local inputs fail closed before touching the SDK.
|
||||
// Unhandled createReadStream('error') previously crashed the Hub process.
|
||||
const fileName = basename(options.inputFilePath);
|
||||
const fileStream = await openLocalFileStream(options.inputFilePath);
|
||||
|
||||
const config: DocmindConfig = {
|
||||
endpoint: credential.endpoint,
|
||||
accessKeyId: credential.accessKeyId,
|
||||
@@ -75,22 +98,21 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
} as DocmindConfig;
|
||||
const client = new $DocmindClient.default(config);
|
||||
|
||||
// 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 fileStream = createReadStream(options.inputFilePath);
|
||||
// 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 advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
||||
fileUrlObject: fileStream,
|
||||
fileName,
|
||||
outputFormat: ["markdown"],
|
||||
formulaEnhancement: true,
|
||||
});
|
||||
const runtime = new RuntimeOptions({});
|
||||
const runtime = createDocmindRuntimeOptions();
|
||||
let submitResponse;
|
||||
try {
|
||||
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
|
||||
} catch (e) {
|
||||
fileStream.destroy();
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"docmind_unreachable",
|
||||
@@ -229,3 +251,28 @@ function extractFilename(altText: string, url: string, index: number): string {
|
||||
if (base !== "" && base !== "/") return base;
|
||||
return `image_${index + 1}.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a local file as a ReadStream only after the fd is successfully open.
|
||||
* createReadStream() emits asynchronous 'error' for missing paths; without a
|
||||
* listener that becomes an unhandled EventEmitter error and exits Node.
|
||||
*/
|
||||
async function openLocalFileStream(path: string): Promise<ReadStream> {
|
||||
const stream = createReadStream(path);
|
||||
try {
|
||||
await once(stream, "open");
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
throw new DocmindClientError(`input file not found: ${path}`, "docmind_rejected");
|
||||
}
|
||||
throw new DocmindClientError(err.message, "docmind_unreachable");
|
||||
}
|
||||
// After open, residual stream errors must not become unhandled and crash Hub.
|
||||
stream.on("error", () => {
|
||||
// The Aliyun SDK / destroy path owns consumption failures after open.
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* ADR-0027: pbank capability — Paradigm 题库 search/fetch tools for Agent runs.
|
||||
*
|
||||
* Invariants:
|
||||
* 1. Credential isolation — org ACTIVE connection is resolved in Hub; credentials
|
||||
* never reach the Agent process (ADR-0024/0027).
|
||||
* 2. Workspace containment — materialize path is always under the run workspace
|
||||
* (ADR-0018 AgentSurface).
|
||||
* 3. Mandatory fact — each successful tool call writes ≥1 UsageFact with
|
||||
* kind=external_capability and unit=requests (cost unknown unless reported).
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import {
|
||||
extractProblemId,
|
||||
HttpPbankClient,
|
||||
pbankRightsFromCredential,
|
||||
type PbankClient,
|
||||
} from "./pbankClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
asPbankSecret,
|
||||
type PbankCapabilitySecretPayload,
|
||||
} from "./types.js";
|
||||
|
||||
export const PBANK_CAPABILITY_ID = "pbank" as const;
|
||||
const PROVIDER_ID = "paradigm_pbank";
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
const MAX_PROJECT_BYTES = 80 * 1024 * 1024;
|
||||
const MAX_PROJECT_TEXT_BYTES = 120 * 1024;
|
||||
const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024;
|
||||
const MAX_INLINE_ASSET_BYTES = 1024 * 1024;
|
||||
const MAX_INLINE_ASSETS = 4;
|
||||
const CACHE_DIR_NAME = ".pbank-sources";
|
||||
|
||||
export class CapabilityPathEscape extends Error {
|
||||
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||
this.name = "CapabilityPathEscape";
|
||||
}
|
||||
}
|
||||
|
||||
export interface PbankServiceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secrets: LocalSecretEnvelope;
|
||||
readonly client?: PbankClient;
|
||||
}
|
||||
|
||||
export interface PbankToolContext {
|
||||
readonly organizationId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export interface PbankSearchArgs {
|
||||
readonly q?: string | undefined;
|
||||
readonly keywords?: readonly string[] | undefined;
|
||||
readonly pageNum?: number | undefined;
|
||||
readonly pageSize?: number | undefined;
|
||||
}
|
||||
|
||||
export interface PbankGetProblemArgs {
|
||||
readonly urlOrId: string;
|
||||
readonly includeProjects?: boolean | undefined;
|
||||
readonly materializeProjects?: boolean | undefined;
|
||||
readonly includeAssetImages?: boolean | undefined;
|
||||
readonly includeOccurrences?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PbankGetManyArgs {
|
||||
readonly urlsOrIds: readonly string[];
|
||||
readonly includeProjects?: boolean | undefined;
|
||||
readonly materializeProjects?: boolean | undefined;
|
||||
readonly includeAssetImages?: boolean | undefined;
|
||||
readonly includeOccurrences?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PbankToolResult {
|
||||
readonly data: unknown;
|
||||
readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[];
|
||||
}
|
||||
|
||||
interface TokenCacheEntry {
|
||||
readonly token: string;
|
||||
readonly expiresAt: number;
|
||||
readonly password: string;
|
||||
readonly username: string;
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
export interface PbankService {
|
||||
searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult>;
|
||||
getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult>;
|
||||
getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult>;
|
||||
}
|
||||
|
||||
export function createPbankService(deps: PbankServiceDeps): PbankService {
|
||||
const client = deps.client ?? new HttpPbankClient();
|
||||
const tokenCache = new Map<string, TokenCacheEntry>();
|
||||
|
||||
return {
|
||||
async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult> {
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const token = await loginCached(client, tokenCache, credential);
|
||||
const pageNum = clampInt(args.pageNum ?? 1, 1, 10_000);
|
||||
const pageSize = clampInt(args.pageSize ?? 10, 1, 50);
|
||||
const result = await client.searchProblems(credential, token, {
|
||||
q: args.q,
|
||||
keywords: args.keywords,
|
||||
pageNum,
|
||||
pageSize,
|
||||
});
|
||||
const data = {
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
...(typeof result === "object" && result !== null ? result : { result }),
|
||||
};
|
||||
await writeUsageFact(deps.prisma, ctx.runId, "search", 1);
|
||||
return { data, inlineImages: [] };
|
||||
},
|
||||
|
||||
async getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult> {
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const bundle = await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, args);
|
||||
await writeUsageFact(deps.prisma, ctx.runId, extractProblemId(args.urlOrId), 1);
|
||||
return toToolResult(bundle);
|
||||
},
|
||||
|
||||
async getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult> {
|
||||
if (args.urlsOrIds.length === 0) {
|
||||
throw new Error("urlsOrIds must not be empty");
|
||||
}
|
||||
if (args.urlsOrIds.length > MAX_BATCH_SIZE) {
|
||||
throw new Error(`urlsOrIds exceeds max batch size ${MAX_BATCH_SIZE}`);
|
||||
}
|
||||
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
||||
const problems = [];
|
||||
for (const urlOrId of args.urlsOrIds) {
|
||||
problems.push(
|
||||
await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, {
|
||||
urlOrId,
|
||||
includeProjects: args.includeProjects,
|
||||
materializeProjects: args.materializeProjects,
|
||||
includeAssetImages: args.includeAssetImages,
|
||||
includeOccurrences: args.includeOccurrences,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await writeUsageFact(deps.prisma, ctx.runId, "batch", problems.length);
|
||||
return toToolResult({
|
||||
count: problems.length,
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
problems,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function resolvePbankCredential(
|
||||
deps: PbankServiceDeps,
|
||||
organizationId: string,
|
||||
): Promise<PbankCapabilitySecretPayload & { connectionId: string }> {
|
||||
const resolved = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
|
||||
organizationId,
|
||||
capabilityId: PBANK_CAPABILITY_ID,
|
||||
});
|
||||
const secret = asPbankSecret(resolved);
|
||||
return { ...secret, connectionId: resolved.connectionId };
|
||||
}
|
||||
|
||||
async function loginCached(
|
||||
client: PbankClient,
|
||||
cache: Map<string, TokenCacheEntry>,
|
||||
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = cache.get(credential.connectionId);
|
||||
if (
|
||||
cached !== undefined &&
|
||||
cached.expiresAt - 60_000 > now &&
|
||||
cached.username === credential.username &&
|
||||
cached.password === credential.password &&
|
||||
cached.baseUrl === credential.baseUrl
|
||||
) {
|
||||
return cached.token;
|
||||
}
|
||||
const login = await client.login(credential);
|
||||
cache.set(credential.connectionId, {
|
||||
token: login.token,
|
||||
expiresAt: login.expiresAt,
|
||||
username: credential.username,
|
||||
password: credential.password,
|
||||
baseUrl: credential.baseUrl,
|
||||
});
|
||||
return login.token;
|
||||
}
|
||||
|
||||
async function getProblemBundle(
|
||||
client: PbankClient,
|
||||
cache: Map<string, TokenCacheEntry>,
|
||||
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
||||
workspaceDir: string,
|
||||
args: PbankGetProblemArgs,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const id = extractProblemId(args.urlOrId);
|
||||
const token = await loginCached(client, cache, credential);
|
||||
const problem = await client.getProblem(credential, token, id);
|
||||
const result: Record<string, unknown> = {
|
||||
id,
|
||||
source: `${credential.baseUrl.replace(/\/+$/, "")}/problem/${id}`,
|
||||
rights: pbankRightsFromCredential(credential),
|
||||
problem,
|
||||
};
|
||||
|
||||
const includeProjects = args.includeProjects !== false;
|
||||
const materializeProjects = args.materializeProjects !== false;
|
||||
const includeAssetImages = args.includeAssetImages !== false;
|
||||
if (includeProjects) {
|
||||
const projects: Record<string, unknown> = {};
|
||||
for (const target of ["problem", "answer"] as const) {
|
||||
try {
|
||||
projects[target] = await downloadAndMaterializeProject({
|
||||
client,
|
||||
credential,
|
||||
token,
|
||||
id,
|
||||
target,
|
||||
workspaceDir,
|
||||
materialize: materializeProjects,
|
||||
includeAssetImages,
|
||||
});
|
||||
} catch (error) {
|
||||
projects[target] = {
|
||||
target,
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
result.projects = projects;
|
||||
}
|
||||
|
||||
if (args.includeOccurrences === true) {
|
||||
try {
|
||||
result.occurrences = await client.getOccurrences(credential, token, id);
|
||||
} catch (error) {
|
||||
result.occurrences = {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function downloadAndMaterializeProject(input: {
|
||||
readonly client: PbankClient;
|
||||
readonly credential: PbankCapabilitySecretPayload;
|
||||
readonly token: string;
|
||||
readonly id: string;
|
||||
readonly target: "problem" | "answer";
|
||||
readonly workspaceDir: string;
|
||||
readonly materialize: boolean;
|
||||
readonly includeAssetImages: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const downloaded = await input.client.downloadProject(
|
||||
input.credential,
|
||||
input.token,
|
||||
input.id,
|
||||
input.target,
|
||||
);
|
||||
if (downloaded.buffer.byteLength > MAX_PROJECT_BYTES) {
|
||||
throw new Error(`project archive exceeds ${MAX_PROJECT_BYTES} bytes`);
|
||||
}
|
||||
|
||||
if (!isZipBuffer(downloaded.buffer, downloaded.contentType)) {
|
||||
const text = decodeUtf8IfText(downloaded.buffer);
|
||||
if (text !== null) {
|
||||
return {
|
||||
target: input.target,
|
||||
status: "text",
|
||||
bytes: downloaded.buffer.byteLength,
|
||||
content: text.slice(0, MAX_PROJECT_TEXT_BYTES),
|
||||
omitted: text.length > MAX_PROJECT_TEXT_BYTES ? [{ reason: "text truncated" }] : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
target: input.target,
|
||||
status: "binary",
|
||||
bytes: downloaded.buffer.byteLength,
|
||||
contentType: downloaded.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
return readProjectZip(downloaded.buffer, {
|
||||
id: input.id,
|
||||
target: input.target,
|
||||
workspaceDir: input.workspaceDir,
|
||||
materialize: input.materialize,
|
||||
includeAssetImages: input.includeAssetImages,
|
||||
});
|
||||
}
|
||||
|
||||
async function readProjectZip(
|
||||
buffer: Buffer,
|
||||
options: {
|
||||
readonly id: string;
|
||||
readonly target: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly materialize: boolean;
|
||||
readonly includeAssetImages: boolean;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Pure Node unzip (store/deflate). Do not shell out to host `unzip` —
|
||||
// silo service PATH/tooling must not gate 题库 materialize.
|
||||
const entries = listZipEntries(buffer);
|
||||
const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir);
|
||||
const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target));
|
||||
const extractDir = options.materialize ? join(cacheDir, "source") : null;
|
||||
const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null;
|
||||
|
||||
if (options.materialize) {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
if (extractDir !== null) {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
await mkdir(extractDir, { recursive: true });
|
||||
}
|
||||
if (zipPath !== null) await writeFile(zipPath, buffer);
|
||||
}
|
||||
|
||||
const files: Array<Record<string, unknown>> = [];
|
||||
const assets: Array<{
|
||||
path: string;
|
||||
localPath: string | null;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
inlineData?: string;
|
||||
}> = [];
|
||||
const extractedFiles: Array<Record<string, unknown>> = [];
|
||||
const omitted: Array<Record<string, unknown>> = [];
|
||||
let usedTextBytes = 0;
|
||||
let usedExtractedBytes = 0;
|
||||
let inlineAssetCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!safeZipPath(entry.name)) {
|
||||
omitted.push({ path: entry.name, reason: "unsafe path" });
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith("/") || entry.isDirectory) continue;
|
||||
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
|
||||
omitted.push({ path: entry.name, reason: "extracted byte limit reached" });
|
||||
continue;
|
||||
}
|
||||
const maxEntryBytes = Math.max(
|
||||
1,
|
||||
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
|
||||
);
|
||||
try {
|
||||
const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes);
|
||||
usedExtractedBytes += entryBuffer.length;
|
||||
let localPath: string | null = null;
|
||||
if (extractDir !== null) {
|
||||
localPath = join(extractDir, ...entry.name.split(/[\\/]+/));
|
||||
await mkdir(dirname(localPath), { recursive: true });
|
||||
await writeFile(localPath, entryBuffer);
|
||||
extractedFiles.push({
|
||||
path: entry.name,
|
||||
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
bytes: entryBuffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = assetMimeType(entry.name);
|
||||
if (mimeType !== null) {
|
||||
const asset: {
|
||||
path: string;
|
||||
localPath: string | null;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
inlineData?: string;
|
||||
} = {
|
||||
path: entry.name,
|
||||
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
bytes: entryBuffer.length,
|
||||
mimeType,
|
||||
};
|
||||
if (
|
||||
options.includeAssetImages &&
|
||||
isInlineImageMime(mimeType) &&
|
||||
entryBuffer.length <= MAX_INLINE_ASSET_BYTES &&
|
||||
inlineAssetCount < MAX_INLINE_ASSETS
|
||||
) {
|
||||
asset.inlineData = entryBuffer.toString("base64");
|
||||
inlineAssetCount += 1;
|
||||
}
|
||||
assets.push(asset);
|
||||
}
|
||||
|
||||
if (isTextLikePath(entry.name)) {
|
||||
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
|
||||
omitted.push({ path: entry.name, reason: "text byte limit reached" });
|
||||
continue;
|
||||
}
|
||||
const content = decodeUtf8IfText(entryBuffer);
|
||||
if (content === null) {
|
||||
omitted.push({ path: entry.name, reason: "text decode failed" });
|
||||
continue;
|
||||
}
|
||||
usedTextBytes += Buffer.byteLength(content, "utf8");
|
||||
files.push({
|
||||
path: entry.name,
|
||||
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
||||
content,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
omitted.push({
|
||||
path: entry.name,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
target: options.target,
|
||||
status: "downloaded",
|
||||
bytes: buffer.length,
|
||||
zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath),
|
||||
extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir),
|
||||
extractedFiles,
|
||||
files,
|
||||
assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset),
|
||||
inlineAssets: assets
|
||||
.filter((asset) => asset.inlineData !== undefined)
|
||||
.map((asset) => ({
|
||||
path: asset.path,
|
||||
localPath: asset.localPath,
|
||||
bytes: asset.bytes,
|
||||
mimeType: asset.mimeType,
|
||||
data: asset.inlineData,
|
||||
})),
|
||||
omitted,
|
||||
};
|
||||
}
|
||||
|
||||
interface ZipEntryMeta {
|
||||
readonly name: string;
|
||||
readonly method: number;
|
||||
readonly compressedSize: number;
|
||||
readonly uncompressedSize: number;
|
||||
readonly localHeaderOffset: number;
|
||||
readonly isDirectory: boolean;
|
||||
}
|
||||
|
||||
/** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */
|
||||
function listZipEntries(buffer: Buffer): ZipEntryMeta[] {
|
||||
let eocd = -1;
|
||||
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
|
||||
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
|
||||
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("invalid zip: missing end of central directory");
|
||||
|
||||
const totalEntries = buffer.readUInt16LE(eocd + 10);
|
||||
const centralSize = buffer.readUInt32LE(eocd + 12);
|
||||
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
||||
if (centralOffset + centralSize > buffer.length) {
|
||||
throw new Error("invalid zip: central directory out of range");
|
||||
}
|
||||
|
||||
const entries: ZipEntryMeta[] = [];
|
||||
let offset = centralOffset;
|
||||
for (let i = 0; i < totalEntries; i += 1) {
|
||||
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error("invalid zip: bad central directory entry");
|
||||
}
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
||||
const nameLen = buffer.readUInt16LE(offset + 28);
|
||||
const extraLen = buffer.readUInt16LE(offset + 30);
|
||||
const commentLen = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const nameStart = offset + 46;
|
||||
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
||||
entries.push({
|
||||
name,
|
||||
method,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset,
|
||||
isDirectory: name.endsWith("/"),
|
||||
});
|
||||
offset = nameStart + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer {
|
||||
if (entry.uncompressedSize > maxBytes) {
|
||||
throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
const local = entry.localHeaderOffset;
|
||||
if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) {
|
||||
throw new Error("invalid zip: bad local header");
|
||||
}
|
||||
const nameLen = buffer.readUInt16LE(local + 26);
|
||||
const extraLen = buffer.readUInt16LE(local + 28);
|
||||
const dataStart = local + 30 + nameLen + extraLen;
|
||||
const dataEnd = dataStart + entry.compressedSize;
|
||||
if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range");
|
||||
const compressed = buffer.subarray(dataStart, dataEnd);
|
||||
|
||||
if (entry.method === 0) {
|
||||
if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
||||
return Buffer.from(compressed);
|
||||
}
|
||||
if (entry.method === 8) {
|
||||
return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes }));
|
||||
}
|
||||
throw new Error(`unsupported zip compression method ${entry.method}`);
|
||||
}
|
||||
|
||||
|
||||
async function writeUsageFact(
|
||||
prisma: PrismaClient,
|
||||
runId: string,
|
||||
correlationId: string,
|
||||
quantity: number,
|
||||
): Promise<void> {
|
||||
const descriptor = CAPABILITIES[PBANK_CAPABILITY_ID];
|
||||
await prisma.usageFact.create({
|
||||
data: {
|
||||
runId,
|
||||
occurredAt: new Date(),
|
||||
kind: "external_capability",
|
||||
provider: PROVIDER_ID,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
quantity,
|
||||
unit: descriptor.meteringUnit,
|
||||
costUsd: null,
|
||||
costSource: "unknown",
|
||||
capabilityId: PBANK_CAPABILITY_ID,
|
||||
correlationId,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toToolResult(data: unknown): PbankToolResult {
|
||||
const inlineImages: Array<{ data: string; mimeType: string }> = [];
|
||||
collectInlineAssets(data, inlineImages);
|
||||
return { data, inlineImages };
|
||||
}
|
||||
|
||||
function collectInlineAssets(
|
||||
value: unknown,
|
||||
out: Array<{ data: string; mimeType: string }>,
|
||||
): void {
|
||||
if (typeof value !== "object" || value === null) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectInlineAssets(item, out);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Array.isArray(record.inlineAssets)) {
|
||||
for (const asset of record.inlineAssets) {
|
||||
if (typeof asset !== "object" || asset === null) continue;
|
||||
const item = asset as Record<string, unknown>;
|
||||
if (typeof item.data === "string" && typeof item.mimeType === "string") {
|
||||
out.push({ data: item.data, mimeType: item.mimeType });
|
||||
}
|
||||
}
|
||||
delete record.inlineAssets;
|
||||
}
|
||||
if (record.projects !== undefined) collectInlineAssets(record.projects, out);
|
||||
if (Array.isArray(record.problems)) {
|
||||
for (const problem of record.problems) collectInlineAssets(problem, out);
|
||||
}
|
||||
}
|
||||
|
||||
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
|
||||
const resolved = resolve(workspaceDir, requestedPath);
|
||||
const rel = relative(workspaceDir, resolved);
|
||||
if (rel.startsWith("..") || rel === "") {
|
||||
throw new CapabilityPathEscape(requestedPath, workspaceDir);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function toWorkspaceRelative(workspaceDir: string, absolutePath: string): string {
|
||||
const rel = relative(workspaceDir, absolutePath);
|
||||
if (rel.startsWith("..")) {
|
||||
throw new CapabilityPathEscape(absolutePath, workspaceDir);
|
||||
}
|
||||
return rel;
|
||||
}
|
||||
|
||||
function decodeUtf8IfText(buffer: Buffer): string | null {
|
||||
const text = buffer.toString("utf8");
|
||||
const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1);
|
||||
if (replacementRatio > 0.02) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
function isZipBuffer(buffer: Buffer, contentType: string): boolean {
|
||||
if (contentType.includes("zip")) return true;
|
||||
return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b;
|
||||
}
|
||||
|
||||
function isTextLikePath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase();
|
||||
return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) =>
|
||||
lower.endsWith(ext),
|
||||
);
|
||||
}
|
||||
|
||||
function assetMimeType(filePath: string): string | null {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isInlineImageMime(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
|
||||
}
|
||||
|
||||
function safeZipPath(entry: string): boolean {
|
||||
if (entry.includes("\0")) return false;
|
||||
const normalized = entry.replace(/\\/g, "/");
|
||||
if (normalized.startsWith("/") || normalized.includes("://")) return false;
|
||||
for (const part of normalized.split("/")) {
|
||||
if (part === ".." || part === "") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function safePathSegment(value: string): string {
|
||||
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
if (cleaned === "" || cleaned === "." || cleaned === "..") return "item";
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
const n = Math.trunc(value);
|
||||
if (n < min) return min;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Paradigm PBank (题库) HTTP client.
|
||||
*
|
||||
* Credentials are injected per call from the org capability connection
|
||||
* (ADR-0024/0027) — never read from process env and never passed to the Agent.
|
||||
*/
|
||||
import type { PbankCapabilitySecretPayload } from "./types.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://pbank.paradigm-edu.net/api";
|
||||
const POSITIVE_RIGHTS_STATUSES = new Set(["owned", "exclusive_license", "licensed_adapt"]);
|
||||
|
||||
export interface PbankRights {
|
||||
readonly status: string;
|
||||
readonly holder: string;
|
||||
readonly scope: string;
|
||||
readonly note: string;
|
||||
readonly derivativeUseAllowed: boolean;
|
||||
readonly source: "operator_confirmed";
|
||||
}
|
||||
|
||||
export interface PbankLoginResult {
|
||||
readonly token: string;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface PbankSearchInput {
|
||||
readonly q?: string | undefined;
|
||||
readonly keywords?: readonly string[] | undefined;
|
||||
readonly pageNum: number;
|
||||
readonly pageSize: number;
|
||||
}
|
||||
|
||||
export interface PbankProjectDownload {
|
||||
readonly buffer: Buffer;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export class PbankClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "pbank_unreachable" | "pbank_rejected" | "pbank_invalid_response",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PbankClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface PbankClient {
|
||||
login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult>;
|
||||
searchProblems(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
input: PbankSearchInput,
|
||||
): Promise<unknown>;
|
||||
getProblem(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown>;
|
||||
getOccurrences(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown>;
|
||||
downloadProject(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
target: "problem" | "answer",
|
||||
): Promise<PbankProjectDownload>;
|
||||
}
|
||||
|
||||
export function pbankRightsFromCredential(credential: PbankCapabilitySecretPayload): PbankRights {
|
||||
const status = normalizeRightsStatus(credential.rightsStatus ?? "unknown");
|
||||
const holder = (credential.rightsHolder ?? "").trim() || "Paradigm Education";
|
||||
const scope = (credential.rightsScope ?? "").trim() || "internal teaching-material production";
|
||||
const note =
|
||||
(credential.rightsNote ?? "").trim() ||
|
||||
"All content returned by this capability is operator-confirmed as owned by Paradigm Education or sufficiently licensed for excerpting, rewriting, and adaptation within current teaching-material projects.";
|
||||
return {
|
||||
status,
|
||||
holder,
|
||||
scope,
|
||||
note,
|
||||
derivativeUseAllowed: POSITIVE_RIGHTS_STATUSES.has(status),
|
||||
source: "operator_confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePbankBaseUrl(value: string | undefined): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (raw === "") return DEFAULT_BASE_URL;
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function extractProblemId(value: string): string {
|
||||
const input = value.trim();
|
||||
if (input === "") throw new PbankClientError("urlOrId is required", "pbank_invalid_response");
|
||||
|
||||
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
|
||||
const direct = input.match(uuidPattern);
|
||||
if (direct !== null) return direct[0]!;
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
for (const key of ["id", "problemId", "problem_id"] as const) {
|
||||
const fromQuery = url.searchParams.get(key);
|
||||
const match = fromQuery?.match(uuidPattern);
|
||||
if (match !== null && match !== undefined) return match[0]!;
|
||||
}
|
||||
} catch {
|
||||
// Not a URL.
|
||||
}
|
||||
|
||||
throw new PbankClientError(`Could not find a problem UUID in: ${input}`, "pbank_invalid_response");
|
||||
}
|
||||
|
||||
export class HttpPbankClient implements PbankClient {
|
||||
async login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult> {
|
||||
const data = await this.requestJson(credential, "/login", {
|
||||
method: "POST",
|
||||
body: { username: credential.username, password: credential.password },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new PbankClientError("PBank login returned non-object", "pbank_invalid_response");
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.token !== "string" || record.token.trim() === "") {
|
||||
throw new PbankClientError("PBank login did not return a token", "pbank_invalid_response");
|
||||
}
|
||||
const expiresAt =
|
||||
typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
|
||||
? record.expiresAt
|
||||
: Date.now() + 30 * 60_000;
|
||||
return { token: record.token, expiresAt };
|
||||
}
|
||||
|
||||
async searchProblems(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
input: PbankSearchInput,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, "/problem/query", {
|
||||
token,
|
||||
query: {
|
||||
q: input.q,
|
||||
keywords: input.keywords !== undefined && input.keywords.length > 0 ? input.keywords.join(",") : undefined,
|
||||
pageNum: input.pageNum,
|
||||
pageSize: input.pageSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getProblem(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}`, { token });
|
||||
}
|
||||
|
||||
async getOccurrences(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
): Promise<unknown> {
|
||||
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}/occurrence`, {
|
||||
token,
|
||||
query: { pageNum: 1, pageSize: 20 },
|
||||
});
|
||||
}
|
||||
|
||||
async downloadProject(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
token: string,
|
||||
id: string,
|
||||
target: "problem" | "answer",
|
||||
): Promise<PbankProjectDownload> {
|
||||
const url = buildUrl(credential.baseUrl, `/problem/${encodeURIComponent(id)}/project/${target}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}`, accept: "*/*" },
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PbankClientError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"pbank_unreachable",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new PbankClientError(
|
||||
`PBank project download failed: ${response.status}`,
|
||||
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return { buffer: Buffer.from(arrayBuffer), contentType };
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
credential: PbankCapabilitySecretPayload,
|
||||
route: string,
|
||||
options: {
|
||||
readonly method?: string;
|
||||
readonly token?: string;
|
||||
readonly body?: unknown;
|
||||
readonly query?: Record<string, string | number | undefined>;
|
||||
readonly timeoutMs?: number;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const url = buildUrl(credential.baseUrl, route, options.query);
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
|
||||
if (options.body !== undefined) headers["content-type"] = "application/json";
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
};
|
||||
if (options.body !== undefined) init.body = JSON.stringify(options.body);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new PbankClientError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"pbank_unreachable",
|
||||
);
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new PbankClientError(
|
||||
extractErrorMessage(data, `PBank API request failed: ${response.status}`),
|
||||
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
baseUrl: string,
|
||||
route: string,
|
||||
query?: Record<string, string | number | undefined>,
|
||||
): string {
|
||||
const url = new URL(route.replace(/^\/+/, ""), `${normalizePbankBaseUrl(baseUrl)}/`);
|
||||
if (query !== undefined) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: unknown, fallback: string): string {
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.message === "string" && record.message !== "") return record.message;
|
||||
if (typeof record.error === "string" && record.error !== "") return record.error;
|
||||
}
|
||||
if (typeof data === "string" && data !== "") return data;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeRightsStatus(value: string): string {
|
||||
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
if (normalized === "") return "unknown";
|
||||
return normalized;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
asDocmindSecret,
|
||||
type CapabilityAdapter,
|
||||
type CapabilityInvocationInput,
|
||||
type CapabilityInvocationResult,
|
||||
@@ -63,6 +64,135 @@ export interface PdfToMdBundleDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** Default max concurrent Docmind jobs for one convert_pdf_to_md batch call. */
|
||||
export const DEFAULT_PDF_TO_MD_CONCURRENCY = 3;
|
||||
/** Hard ceiling for agent-requested concurrency (also clamps env). */
|
||||
export const MAX_PDF_TO_MD_CONCURRENCY = 8;
|
||||
/** Max PDFs accepted in one batch tool call. */
|
||||
export const MAX_PDF_TO_MD_BATCH_ITEMS = 32;
|
||||
|
||||
export function clampPdfToMdConcurrency(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const n = Math.trunc(value);
|
||||
if (n < 1) return 1;
|
||||
if (n > MAX_PDF_TO_MD_CONCURRENCY) return MAX_PDF_TO_MD_CONCURRENCY;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Read HUB_PDF_TO_MD_MAX_CONCURRENT (default 3, max 8). */
|
||||
export function readPdfToMdConcurrency(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): number {
|
||||
const raw = env["HUB_PDF_TO_MD_MAX_CONCURRENT"]?.trim();
|
||||
if (raw === undefined || raw === "") return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new Error(`HUB_PDF_TO_MD_MAX_CONCURRENT must be a positive integer, got ${raw}`);
|
||||
}
|
||||
return clampPdfToMdConcurrency(parsed);
|
||||
}
|
||||
|
||||
export interface PdfToMdBatchItem {
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
}
|
||||
|
||||
export type PdfToMdBatchItemResult =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly result: CapabilityInvocationResult;
|
||||
}
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run worker over items with bounded parallelism. Order of results matches
|
||||
* input order. Rejects in worker are not swallowed — caller should catch.
|
||||
*/
|
||||
export async function mapPool<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
async function runWorker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await worker(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple PDFs with bounded concurrency. Each item is attribute-
|
||||
* independent (own paths + own UsageFact). Failures are per-item and do not
|
||||
* cancel siblings; order matches `items`.
|
||||
*/
|
||||
export async function invokePdfToMdBatch(
|
||||
adapter: CapabilityAdapter,
|
||||
base: Omit<CapabilityInvocationInput, "inputPath" | "outputDir">,
|
||||
items: readonly PdfToMdBatchItem[],
|
||||
concurrency: number = DEFAULT_PDF_TO_MD_CONCURRENCY,
|
||||
): Promise<PdfToMdBatchItemResult[]> {
|
||||
if (items.length === 0) {
|
||||
throw new Error("pdf_to_md batch requires at least one item");
|
||||
}
|
||||
if (items.length > MAX_PDF_TO_MD_BATCH_ITEMS) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch supports at most ${MAX_PDF_TO_MD_BATCH_ITEMS} items per call (got ${items.length})`,
|
||||
);
|
||||
}
|
||||
const seenOutputDirs = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.inputPath.trim() === "" || item.outputDir.trim() === "") {
|
||||
throw new Error("pdf_to_md batch items require non-empty inputPath and outputDir");
|
||||
}
|
||||
const key = item.outputDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (seenOutputDirs.has(key)) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch items must use distinct output_dir values; duplicate: ${item.outputDir}`,
|
||||
);
|
||||
}
|
||||
seenOutputDirs.add(key);
|
||||
}
|
||||
const limit = clampPdfToMdConcurrency(concurrency);
|
||||
return mapPool(items, limit, async (item) => {
|
||||
try {
|
||||
const result = await adapter.invoke({
|
||||
...base,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
|
||||
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
|
||||
return {
|
||||
@@ -83,7 +213,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
|
||||
// 3. Call the backing service.
|
||||
let result;
|
||||
try {
|
||||
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
|
||||
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
|
||||
} catch (e) {
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
|
||||
+123
-7
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* ADR-0027: External capability types shared across the adapter layer.
|
||||
*
|
||||
* A capability is a platform-registered, org-enabled document/media transform
|
||||
* A capability is a platform-registered, org-enabled external service
|
||||
* invoked as a side effect of an AgentRun. The adapter resolves the org's
|
||||
* active capability connection, calls the backing service via an injectable
|
||||
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
|
||||
* and records consumption on a UsageFact (ADR-0026).
|
||||
* client, writes output into the run's workspace when applicable (AgentSurface,
|
||||
* ADR-0018), and records consumption on a UsageFact (ADR-0026).
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
export const CAPABILITY_IDS = [
|
||||
"pdf_to_md_bundle",
|
||||
"audio_video_to_text",
|
||||
"pbank",
|
||||
] as const;
|
||||
|
||||
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
|
||||
@@ -27,6 +28,7 @@ export interface CapabilityDescriptor {
|
||||
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
|
||||
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
|
||||
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
|
||||
pbank: { id: "pbank", meteringUnit: "requests" },
|
||||
};
|
||||
|
||||
/** Input passed to a capability adapter invocation. */
|
||||
@@ -59,7 +61,7 @@ export interface CapabilityConsumption {
|
||||
readonly model: string | null;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
/** Non-token meter (page count, audio seconds). */
|
||||
/** Non-token meter (page count, audio seconds, request count). */
|
||||
readonly quantity: number;
|
||||
readonly unit: string;
|
||||
/** USD cost if the service reported one; null = unknown (ADR-0022). */
|
||||
@@ -80,15 +82,39 @@ export interface CapabilityAdapter {
|
||||
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
|
||||
}
|
||||
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
|
||||
export interface CapabilitySecretPayload {
|
||||
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
|
||||
export interface DocmindCapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "docmind";
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paradigm PBank (题库) login credentials.
|
||||
* Rights fields are operator-confirmed license signals echoed to the agent;
|
||||
* the credential secret itself never reaches the agent process (ADR-0024/0027).
|
||||
*/
|
||||
export interface PbankCapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "pbank";
|
||||
readonly baseUrl: string;
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
readonly rightsStatus?: string;
|
||||
readonly rightsHolder?: string;
|
||||
readonly rightsScope?: string;
|
||||
readonly rightsNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Discriminated by `kind`. Legacy envelopes without `kind` are normalized to
|
||||
* `docmind` when accessKey fields are present.
|
||||
*/
|
||||
export type CapabilitySecretPayload = DocmindCapabilitySecretPayload | PbankCapabilitySecretPayload;
|
||||
|
||||
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
|
||||
export class CapabilityConnectionUnavailable extends Error {
|
||||
constructor(readonly capabilityId: string, readonly organizationId: string) {
|
||||
@@ -99,3 +125,93 @@ export class CapabilityConnectionUnavailable extends Error {
|
||||
|
||||
/** Prisma transaction client type alias (for resolver signatures). */
|
||||
export type TxClient = Prisma.TransactionClient;
|
||||
|
||||
/** Map capability id → expected secret kind. */
|
||||
export function secretKindForCapability(capabilityId: string): "docmind" | "pbank" {
|
||||
switch (capabilityId) {
|
||||
case "pdf_to_md_bundle":
|
||||
case "audio_video_to_text":
|
||||
return "docmind";
|
||||
case "pbank":
|
||||
return "pbank";
|
||||
default:
|
||||
throw new Error(`unsupported capabilityId: ${capabilityId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a decrypted envelope payload into a full CapabilitySecretPayload.
|
||||
* Accepts legacy docmind payloads that omit `kind`.
|
||||
*/
|
||||
export function normalizeCapabilitySecretPayload(raw: unknown): CapabilitySecretPayload {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new Error("invalid capability secret payload");
|
||||
}
|
||||
const payload = raw as Record<string, unknown>;
|
||||
if (payload.schemaVersion !== 1) {
|
||||
throw new Error(`unsupported capability secret schemaVersion: ${String(payload.schemaVersion)}`);
|
||||
}
|
||||
|
||||
const kind =
|
||||
payload.kind === "pbank" || payload.kind === "docmind"
|
||||
? payload.kind
|
||||
: typeof payload.accessKeyId === "string"
|
||||
? "docmind"
|
||||
: typeof payload.username === "string"
|
||||
? "pbank"
|
||||
: null;
|
||||
if (kind === null) throw new Error("capability secret payload missing kind");
|
||||
|
||||
if (kind === "docmind") {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: requireString(payload.accessKeyId, "accessKeyId"),
|
||||
accessKeySecret: requireString(payload.accessKeySecret, "accessKeySecret"),
|
||||
endpoint: requireString(payload.endpoint, "endpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const rightsStatus = optionalString(payload.rightsStatus);
|
||||
const rightsHolder = optionalString(payload.rightsHolder);
|
||||
const rightsScope = optionalString(payload.rightsScope);
|
||||
const rightsNote = optionalString(payload.rightsNote);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: requireString(payload.baseUrl, "baseUrl"),
|
||||
username: requireString(payload.username, "username"),
|
||||
password: requireString(payload.password, "password"),
|
||||
...(rightsStatus !== undefined ? { rightsStatus } : {}),
|
||||
...(rightsHolder !== undefined ? { rightsHolder } : {}),
|
||||
...(rightsScope !== undefined ? { rightsScope } : {}),
|
||||
...(rightsNote !== undefined ? { rightsNote } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function asDocmindSecret(payload: CapabilitySecretPayload): DocmindCapabilitySecretPayload {
|
||||
if (payload.kind !== "docmind") {
|
||||
throw new Error(`expected docmind capability secret, got ${payload.kind}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function asPbankSecret(payload: CapabilitySecretPayload): PbankCapabilitySecretPayload {
|
||||
if (payload.kind !== "pbank") {
|
||||
throw new Error(`expected pbank capability secret, got ${payload.kind}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(`${label} must not be empty`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
resolveActiveFeishuApplication,
|
||||
type ResolvedFeishuApplication,
|
||||
} from "../connections/feishuApplicationConnections.js";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
writeNewWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
||||
const DEFAULT_CLI_PATH = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
const SAFE_CLI_ENV_KEYS = [
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"NODE_USE_ENV_PROXY",
|
||||
] as const;
|
||||
|
||||
export interface FeishuBotCliDownloadRequest {
|
||||
readonly messageId: string;
|
||||
readonly fileKey: string;
|
||||
readonly resourceType: "image" | "file";
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly workspaceRelativePath: string;
|
||||
readonly maxBytes?: number | undefined;
|
||||
}
|
||||
|
||||
export interface FeishuBotCli {
|
||||
downloadResource(request: FeishuBotCliDownloadRequest): Promise<string>;
|
||||
}
|
||||
|
||||
export interface FeishuBotCliOptions {
|
||||
readonly organizationId: string;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly binary?: string | undefined;
|
||||
readonly timeoutMs?: number | undefined;
|
||||
readonly resolveCredential?: (() => Promise<ResolvedFeishuApplication>) | undefined;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the real lark-cli as a Hub-owned bot operation.
|
||||
*
|
||||
* The CLI receives the App Secret over stdin and gets a disposable HOME. No
|
||||
* Feishu credential is placed in Agent environment, project files, argv, or
|
||||
* the process-global CLI configuration. The caller still owns project/chat
|
||||
* authorization; this adapter only performs bot-identity transport.
|
||||
*/
|
||||
export function createFeishuBotCli(options: FeishuBotCliOptions): FeishuBotCli {
|
||||
const resolveCredential = options.resolveCredential ?? (() => resolveActiveFeishuApplication(
|
||||
options.prisma,
|
||||
options.secretEnvelope,
|
||||
{ organizationId: options.organizationId },
|
||||
));
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
async downloadResource(request): Promise<string> {
|
||||
const credential = await resolveCredential();
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-feishu-bot-cli-"), { encoding: "utf8" });
|
||||
const home = join(root, "home");
|
||||
await mkdirPrivate(home);
|
||||
const cliEnv = buildCliEnv(home);
|
||||
const cliBinary = options.binary ?? process.env["HUB_FEISHU_CLI_BIN"] ?? "lark-cli";
|
||||
const temporaryName = "resource.bin";
|
||||
const temporaryPath = join(home, temporaryName);
|
||||
|
||||
try {
|
||||
await runCli(
|
||||
cliBinary,
|
||||
["config", "init", "--app-id", credential.appId, "--app-secret-stdin", "--brand", "feishu"],
|
||||
{ cwd: root, env: cliEnv, stdin: `${credential.appSecret}\n`, timeoutMs, label: "config init" },
|
||||
);
|
||||
await runCli(
|
||||
cliBinary,
|
||||
[
|
||||
"im",
|
||||
"+messages-resources-download",
|
||||
"--as",
|
||||
"bot",
|
||||
"--message-id",
|
||||
request.messageId,
|
||||
"--file-key",
|
||||
request.fileKey,
|
||||
"--type",
|
||||
request.resourceType,
|
||||
"--output",
|
||||
temporaryName,
|
||||
],
|
||||
{ cwd: home, env: cliEnv, timeoutMs, label: "resource download" },
|
||||
);
|
||||
|
||||
const metadata = await stat(temporaryPath);
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error("lark-cli resource download did not produce a regular file");
|
||||
}
|
||||
if (request.maxBytes !== undefined && metadata.size > request.maxBytes) {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
`Feishu resource exceeds ${request.maxBytes} bytes: ${request.fileKey}`,
|
||||
request.workspaceRelativePath,
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
|
||||
return await writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
createReadStream(temporaryPath),
|
||||
request.maxBytes,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function mkdirPrivate(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
await chmod(path, 0o700);
|
||||
}
|
||||
|
||||
function buildCliEnv(home: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const name of SAFE_CLI_ENV_KEYS) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
if (env.PATH === undefined || env.PATH.trim() === "") env.PATH = DEFAULT_CLI_PATH;
|
||||
env.HOME = home;
|
||||
env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
env.XDG_CACHE_HOME = join(home, ".cache");
|
||||
env.XDG_STATE_HOME = join(home, ".state");
|
||||
return env;
|
||||
}
|
||||
|
||||
async function runCli(
|
||||
binary: string,
|
||||
args: readonly string[],
|
||||
input: {
|
||||
readonly cwd: string;
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly stdin?: string | undefined;
|
||||
readonly timeoutMs: number;
|
||||
readonly label: string;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(binary, args, {
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outputBytes = 0;
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
finish(new Error(`lark-cli ${input.label} timed out after ${input.timeoutMs}ms`));
|
||||
}, input.timeoutMs);
|
||||
|
||||
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer | string): void => {
|
||||
if (outputBytes >= MAX_COMMAND_OUTPUT_BYTES) return;
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
const remaining = MAX_COMMAND_OUTPUT_BYTES - outputBytes;
|
||||
const bounded = text.slice(0, remaining);
|
||||
outputBytes += Buffer.byteLength(bounded);
|
||||
if (target === "stdout") stdout += bounded;
|
||||
else stderr += bounded;
|
||||
};
|
||||
|
||||
const finish = (error?: Error, result?: CommandResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error !== undefined) reject(error);
|
||||
else resolve(result!);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => appendOutput("stdout", chunk));
|
||||
child.stderr.on("data", (chunk: Buffer | string) => appendOutput("stderr", chunk));
|
||||
child.once("error", (error) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
if (code !== 0) {
|
||||
const detail = (stderr.trim() || stdout.trim()).slice(0, 500);
|
||||
const status = code === null ? signal ?? "signal" : `exit ${code}`;
|
||||
finish(new Error(
|
||||
detail === ""
|
||||
? `lark-cli ${input.label} failed (${status})`
|
||||
: `lark-cli ${input.label} failed (${status}): ${detail}`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
finish(undefined, { stdout, stderr });
|
||||
});
|
||||
child.stdin.end(input.stdin);
|
||||
});
|
||||
}
|
||||
+111
-15
@@ -2,9 +2,10 @@
|
||||
* Feishu interactive card builder for agent run output.
|
||||
*
|
||||
* Produces card JSON with:
|
||||
* 1. A collapsible tool-use panel (tool steps with status, params, results)
|
||||
* 2. A collapsible reasoning panel (thinking text)
|
||||
* 3. The streaming/final answer text (markdown)
|
||||
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
|
||||
* 2. A collapsible tool-use panel (tool steps with status, params, results)
|
||||
* 3. A collapsible reasoning panel (thinking text)
|
||||
* 4. The streaming/final answer text (markdown)
|
||||
*
|
||||
* Adapted from openclaw-lark's builder.ts, simplified for our
|
||||
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
||||
@@ -12,7 +13,9 @@
|
||||
*/
|
||||
|
||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||
import type { CardContentSegment } from "../outboundImages.js";
|
||||
import type { AgentTodoItem } from "../../agent/todoList.js";
|
||||
import { todoProgressSummary } from "../../agent/todoList.js";
|
||||
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -39,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
glob: "search-filled",
|
||||
grep: "search-filled",
|
||||
edit: "edit-filled",
|
||||
todowrite: "todo-filled",
|
||||
todo_write: "todo-filled",
|
||||
send_file: "send-filled",
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
@@ -48,10 +53,26 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
|
||||
return TOOL_ICONS[normalized] ?? "tool-filled";
|
||||
}
|
||||
|
||||
function isTodoToolName(toolName: string): boolean {
|
||||
const lower = toolName.toLowerCase();
|
||||
const bare = lower.includes("__") ? (lower.split("__").pop() ?? lower) : lower;
|
||||
const stripped = bare.replace(/_\d+$/, "");
|
||||
return (
|
||||
stripped === "todowrite" ||
|
||||
stripped === "todo_write" ||
|
||||
stripped === "taskcreate" ||
|
||||
stripped === "taskupdate" ||
|
||||
stripped === "tasklist" ||
|
||||
stripped === "taskget" ||
|
||||
stripped === "taskstop" ||
|
||||
stripped === "taskoutput"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,20 +82,32 @@ export function buildAgentCard(params: {
|
||||
text: string;
|
||||
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||
reasoningText: string | undefined;
|
||||
todos: readonly AgentTodoItem[] | undefined;
|
||||
toolUseSteps: ToolUseTraceStep[];
|
||||
toolUseElapsedMs: number | undefined;
|
||||
isError: boolean | undefined;
|
||||
interrupted: boolean | undefined;
|
||||
runId: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const elements: unknown[] = [];
|
||||
|
||||
// Tool-use panel (always present if there are steps)
|
||||
if (toolUseSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
|
||||
if (todos !== undefined && todos.length > 0) {
|
||||
elements.push(buildTodoPanel(todos, phase !== "complete"));
|
||||
}
|
||||
|
||||
// Tool-use panel (exclude todo tools — already shown as checklist)
|
||||
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
|
||||
if (visibleToolSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (
|
||||
todos === undefined ||
|
||||
todos.length === 0
|
||||
) {
|
||||
if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning panel
|
||||
@@ -161,6 +194,62 @@ function buildInterruptAction(runId: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Todo checklist panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown {
|
||||
const { completed, total, inProgress } = todoProgressSummary(todos);
|
||||
const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`];
|
||||
if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`);
|
||||
const lines = todos.map((todo) => formatTodoLine(todo));
|
||||
return {
|
||||
tag: "collapsible_panel",
|
||||
expanded,
|
||||
header: {
|
||||
title: {
|
||||
tag: "plain_text",
|
||||
content: titleParts.join(" "),
|
||||
text_color: completed === total && total > 0 ? "green" : "grey",
|
||||
text_size: "notation",
|
||||
},
|
||||
vertical_align: "center",
|
||||
icon: {
|
||||
tag: "standard_icon",
|
||||
token: "down-small-ccm_outlined",
|
||||
color: "grey",
|
||||
size: "16px 16px",
|
||||
},
|
||||
icon_position: "right",
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: "grey", corner_radius: "5px" },
|
||||
vertical_spacing: "4px",
|
||||
padding: "8px 8px 8px 8px",
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: lines.join("\n"),
|
||||
text_size: "notation",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatTodoLine(todo: AgentTodoItem): string {
|
||||
const label =
|
||||
todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== ""
|
||||
? todo.activeForm
|
||||
: todo.content;
|
||||
if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`;
|
||||
if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`;
|
||||
return `- [ ] ${escapeMd(todo.content)}`;
|
||||
}
|
||||
|
||||
function escapeMd(text: string): string {
|
||||
return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool-use panel
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -411,6 +500,7 @@ function buildAnswerElements(
|
||||
let remaining = MAX_TEXT_LENGTH;
|
||||
for (const segment of contentSegments) {
|
||||
if (segment.type === "image") {
|
||||
if (segment.imgKey.trim() === "") continue;
|
||||
elements.push({
|
||||
tag: "img",
|
||||
img_key: segment.imgKey,
|
||||
@@ -421,9 +511,13 @@ function buildAnswerElements(
|
||||
continue;
|
||||
}
|
||||
if (segment.content === "" || remaining <= 0) continue;
|
||||
const slice = segment.content.length <= remaining
|
||||
? segment.content
|
||||
: truncateText(segment.content, remaining);
|
||||
// Feishu card markdown rejects  without a Feishu image_key
|
||||
// ("card contains images but no imagekey" / empty image key).
|
||||
const safe = maskMarkdownImagesForStreaming(segment.content);
|
||||
if (safe === "" || remaining <= 0) continue;
|
||||
const slice = safe.length <= remaining
|
||||
? safe
|
||||
: truncateText(safe, remaining);
|
||||
remaining -= slice.length;
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -433,9 +527,11 @@ function buildAnswerElements(
|
||||
return elements;
|
||||
}
|
||||
if (text === "") return [];
|
||||
const safe = maskMarkdownImagesForStreaming(text);
|
||||
if (safe === "") return [];
|
||||
return [{
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
content: truncateText(safe, MAX_TEXT_LENGTH),
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
getToolUseTraceSteps,
|
||||
} from "./trace-store.js";
|
||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||
import {
|
||||
applyChecklistToolEvent,
|
||||
isChecklistProgressTool,
|
||||
type AgentTodoItem,
|
||||
} from "../../agent/todoList.js";
|
||||
import {
|
||||
type CardContentSegment,
|
||||
maskMarkdownImagesForStreaming,
|
||||
@@ -66,6 +71,7 @@ export class StreamingAgentCard {
|
||||
private currentMessageId: string | null = null;
|
||||
private text = "";
|
||||
private reasoningText = "";
|
||||
private todos: readonly AgentTodoItem[] = [];
|
||||
private runStartedAt = Date.now();
|
||||
private toolUseElapsedMs: number | undefined;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
@@ -123,17 +129,31 @@ export class StreamingAgentCard {
|
||||
error: string | undefined;
|
||||
durationMs: number | undefined;
|
||||
}): void {
|
||||
if (isChecklistProgressTool(params.toolName)) {
|
||||
const next = applyChecklistToolEvent(this.todos, {
|
||||
toolName: params.toolName,
|
||||
input: params.input,
|
||||
result: params.result,
|
||||
toolUseId: params.toolUseId,
|
||||
});
|
||||
if (next !== null) this.todos = next;
|
||||
}
|
||||
recordToolUseEnd({ runId: this.runId, ...params });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
async finish(
|
||||
fallbackText: string,
|
||||
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
|
||||
options: {
|
||||
readonly interrupted?: boolean;
|
||||
readonly footerText?: string | undefined;
|
||||
readonly isError?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
await this.flushChain;
|
||||
this.interrupted = options.interrupted === true;
|
||||
const footerText = options.footerText ?? "";
|
||||
const isError = options.isError === true;
|
||||
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
||||
try {
|
||||
let answerText =
|
||||
@@ -153,18 +173,20 @@ export class StreamingAgentCard {
|
||||
);
|
||||
}
|
||||
|
||||
let updated = true;
|
||||
let cardUpdated = true;
|
||||
if (answerText.length > 0 || segments.length > 0) {
|
||||
updated = await this.flushCard("complete", answerText, false, segments);
|
||||
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
updated = await this.flushCard("complete", "", false, []);
|
||||
cardUpdated = await this.flushCard("complete", "", isError, []);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
if (!cardUpdated) {
|
||||
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||
updated = await this.deliverPlainFallback(segments, answerText);
|
||||
await this.deliverPlainFallback(segments, answerText);
|
||||
}
|
||||
if (!updated && this.interrupted) {
|
||||
// Interrupt is terminal; if the live card could not be finalized, always
|
||||
// send an explicit notice so the teacher sees the abort even when plain
|
||||
// text partial delivery succeeded.
|
||||
if (!cardUpdated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
}
|
||||
} finally {
|
||||
@@ -231,6 +253,7 @@ export class StreamingAgentCard {
|
||||
? contentSegments
|
||||
: undefined,
|
||||
reasoningText: this.reasoningText || undefined,
|
||||
todos: this.todos.length > 0 ? this.todos : undefined,
|
||||
toolUseSteps,
|
||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||
isError,
|
||||
@@ -249,6 +272,7 @@ export class StreamingAgentCard {
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
@@ -270,6 +294,7 @@ export class StreamingAgentCard {
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
|
||||
+32
-12
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { ToolContext } from "../agent/tools.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
import type { FeishuRuntime } from "./client.js";
|
||||
|
||||
export interface FeishuMessageResourceArgs {
|
||||
readonly messageId: string;
|
||||
@@ -13,7 +14,11 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
|
||||
readonly workspaceRoot: string;
|
||||
readonly botCli: FeishuBotCli;
|
||||
readonly maxFileBytes?: number | undefined;
|
||||
};
|
||||
|
||||
interface MessageLookupResult {
|
||||
readonly data?: {
|
||||
@@ -51,14 +56,29 @@ export async function downloadFeishuMessageResource(
|
||||
"inbox",
|
||||
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
|
||||
);
|
||||
const savePath = await downloadMessageFile(
|
||||
rt,
|
||||
args.messageId,
|
||||
args.fileKey,
|
||||
context.workspaceRoot,
|
||||
context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
args.resourceType,
|
||||
);
|
||||
return { ...args, path: savePath };
|
||||
try {
|
||||
const savePath = await context.botCli.downloadResource({
|
||||
messageId: args.messageId,
|
||||
fileKey: args.fileKey,
|
||||
resourceType: args.resourceType,
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
workspaceDir: context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
maxBytes: context.maxFileBytes,
|
||||
});
|
||||
return { ...args, path: savePath };
|
||||
} catch (error) {
|
||||
rt.logger.error(
|
||||
{
|
||||
err: error,
|
||||
messageId: args.messageId,
|
||||
fileKey: args.fileKey,
|
||||
resourceType: args.resourceType,
|
||||
boundChatId: context.boundChatId,
|
||||
workspaceDir: context.workspaceDir,
|
||||
},
|
||||
"Feishu bot CLI resource download failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||
import { createFeishuBotCli } from "./botCli.js";
|
||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||
import { downloadFeishuMessageResource } from "./download.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
@@ -9,8 +10,16 @@ import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.j
|
||||
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 {
|
||||
createPdfToMdBundleAdapter,
|
||||
invokePdfToMdBatch,
|
||||
readPdfToMdConcurrency,
|
||||
MAX_PDF_TO_MD_BATCH_ITEMS,
|
||||
type PdfToMdBatchItemResult,
|
||||
} from "../capability/pdfToMdBundle.js";
|
||||
import { AliyunDocmindClient } from "../capability/docmindClient.js";
|
||||
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
|
||||
import { CapabilityConnectionUnavailable } from "../capability/types.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
@@ -30,6 +39,11 @@ export interface FileDeliveryToolOptions {
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
const botCli = createFeishuBotCli({
|
||||
organizationId: options.organizationId,
|
||||
prisma: options.prisma,
|
||||
secretEnvelope: options.secretEnvelope,
|
||||
});
|
||||
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
|
||||
const tools: Array<SdkMcpToolDefinition<any>> = [];
|
||||
|
||||
@@ -147,7 +161,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"feishu_download_resource",
|
||||
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.",
|
||||
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace using the Organization bot identity. Use message_id and file_key returned by feishu_read_context.",
|
||||
{
|
||||
message_id: z.string().describe("The Feishu message id containing the resource."),
|
||||
file_key: z.string().describe("The image_key or file_key from that message's content."),
|
||||
@@ -175,6 +189,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
boundChatId: options.chatId,
|
||||
workspaceRoot,
|
||||
workspaceDir: options.workspaceDir,
|
||||
botCli,
|
||||
maxFileBytes: options.maxFileBytes,
|
||||
},
|
||||
options.rt,
|
||||
);
|
||||
@@ -246,21 +262,54 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
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.",
|
||||
"Convert one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become 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."),
|
||||
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."),
|
||||
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."),
|
||||
items: z.array(z.object({
|
||||
input_path: z.string().describe("Workspace-relative path to one input PDF."),
|
||||
output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."),
|
||||
})).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`),
|
||||
concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."),
|
||||
},
|
||||
async (args) => {
|
||||
const base = {
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
prisma: options.prisma,
|
||||
};
|
||||
try {
|
||||
if (args.items !== undefined && args.items.length > 0) {
|
||||
const batchResults = await invokePdfToMdBatch(
|
||||
adapter,
|
||||
base,
|
||||
args.items.map((item) => ({
|
||||
inputPath: item.input_path,
|
||||
outputDir: item.output_dir,
|
||||
})),
|
||||
args.concurrency ?? readPdfToMdConcurrency(),
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }],
|
||||
...(batchResults.every((item) => item.ok) ? {} : { isError: true }),
|
||||
};
|
||||
}
|
||||
if (args.input_path === undefined || args.input_path.trim() === ""
|
||||
|| args.output_dir === undefined || args.output_dir.trim() === "") {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const result = await adapter.invoke({
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
...base,
|
||||
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) {
|
||||
@@ -282,6 +331,127 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
);
|
||||
}
|
||||
|
||||
const pbankEnabled =
|
||||
enabledTools.has("pbank_search_problems") ||
|
||||
enabledTools.has("pbank_get_problem") ||
|
||||
enabledTools.has("pbank_get_many_problems");
|
||||
if (pbankEnabled) {
|
||||
const pbank = createPbankService({
|
||||
prisma: options.prisma,
|
||||
secrets: options.secretEnvelope,
|
||||
});
|
||||
const pbankCtx = {
|
||||
organizationId: options.organizationId,
|
||||
runId: options.runId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
};
|
||||
|
||||
if (enabledTools.has("pbank_search_problems")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_search_problems",
|
||||
"Search Paradigm PBank (题库) by title/keyword. Returns page metadata, operator-confirmed rights guidance, and matching problem summaries. Requires an ACTIVE org capability connection for `pbank`.",
|
||||
{
|
||||
q: z.string().optional().describe("Search text."),
|
||||
keywords: z.array(z.string()).optional().describe("Exact keywords to filter by."),
|
||||
pageNum: z.number().int().min(1).optional().describe("Page number (default 1)."),
|
||||
pageSize: z.number().int().min(1).max(50).optional().describe("Page size (default 10, max 50)."),
|
||||
},
|
||||
async (args) =>
|
||||
runPbankTool(() =>
|
||||
pbank.searchProblems(pbankCtx, {
|
||||
q: args.q,
|
||||
keywords: args.keywords,
|
||||
pageNum: args.pageNum,
|
||||
pageSize: args.pageSize,
|
||||
}),
|
||||
),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("pbank_get_problem")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_get_problem",
|
||||
"Fetch one PBank problem by URL or UUID. Returns metadata, rights guidance, text-like source files, local zip/extract paths under .pbank-sources/, and optional image assets. Requires ACTIVE org capability `pbank`.",
|
||||
{
|
||||
urlOrId: z.string().min(1).describe("PBank problem URL or UUID."),
|
||||
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
|
||||
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
|
||||
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
|
||||
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
|
||||
},
|
||||
async (args) => runPbankTool(() => pbank.getProblem(pbankCtx, args)),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("pbank_get_many_problems")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"pbank_get_many_problems",
|
||||
"Fetch several PBank problems by URL or UUID. Use when the teacher pastes multiple example links. Returns rights guidance together with each problem. Requires ACTIVE org capability `pbank`.",
|
||||
{
|
||||
urlsOrIds: z.array(z.string().min(1)).min(1).max(20).describe("PBank problem URLs or UUIDs."),
|
||||
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
|
||||
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
|
||||
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
|
||||
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
|
||||
},
|
||||
async (args) => runPbankTool(() => pbank.getManyProblems(pbankCtx, args)),
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (enabledTools.has("todo_write")) {
|
||||
tools.push(
|
||||
tool(
|
||||
"todo_write",
|
||||
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
|
||||
{
|
||||
todos: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
|
||||
status: z
|
||||
.enum(["pending", "in_progress", "completed"])
|
||||
.describe("pending | in_progress | completed"),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.describe("Full replacement list for the checklist (not a patch)."),
|
||||
},
|
||||
async (args) => {
|
||||
const completed = args.todos.filter((t) => t.status === "completed").length;
|
||||
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
|
||||
const lines = args.todos.map((t, i) => {
|
||||
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
|
||||
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
||||
return `${i + 1}. [${mark}] ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const instructions = mcpInstructions(enabledTools);
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
@@ -292,11 +462,68 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string {
|
||||
const ok = results.filter((item) => item.ok);
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const lines = [
|
||||
`Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`,
|
||||
];
|
||||
for (const item of results) {
|
||||
if (item.ok) {
|
||||
const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path;
|
||||
lines.push(
|
||||
`OK ${item.inputPath} → ${item.outputDir}`
|
||||
+ (md !== undefined ? ` (${md})` : "")
|
||||
+ `; pages=${item.result.consumption.quantity}`
|
||||
+ `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`,
|
||||
);
|
||||
for (const artifact of item.result.artifacts) {
|
||||
lines.push(` - ${artifact.path} (${artifact.kind})`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`FAIL ${item.inputPath} → ${item.outputDir}: ${item.error}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function runPbankTool(
|
||||
invoke: () => Promise<PbankToolResult>,
|
||||
): Promise<{
|
||||
content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
>;
|
||||
isError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const result = await invoke();
|
||||
const content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
> = [{ type: "text", text: JSON.stringify(result.data, null, 2) }];
|
||||
for (const image of result.inlineImages) {
|
||||
content.push({ type: "image", data: image.data, mimeType: image.mimeType });
|
||||
}
|
||||
return { content };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof CapabilityConnectionUnavailable
|
||||
? `${error.message}. Ask an org admin to configure the pbank capability connection.`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
return { isError: true, content: [{ type: "text", text: message }] };
|
||||
}
|
||||
}
|
||||
|
||||
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
const instructions: string[] = [];
|
||||
if (enabledTools.has("send_file")) {
|
||||
instructions.push(
|
||||
"Use send_file when the user asks to receive, resend, download, or attach a file.",
|
||||
"Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).",
|
||||
"For inline 图文 answers, put  in the final assistant text instead of send_file; the hub embeds those images in the reply card.",
|
||||
"Do not claim a file was sent unless send_file returns success.",
|
||||
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
|
||||
);
|
||||
@@ -314,10 +541,32 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
}
|
||||
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.",
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.",
|
||||
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.",
|
||||
"For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
enabledTools.has("pbank_search_problems") ||
|
||||
enabledTools.has("pbank_get_problem") ||
|
||||
enabledTools.has("pbank_get_many_problems")
|
||||
) {
|
||||
instructions.push(
|
||||
"Use pbank_search_problems / pbank_get_problem / pbank_get_many_problems for Paradigm PBank (题库) selection.",
|
||||
"Treat the returned rights object as authoritative for derivative use.",
|
||||
"Materialized sources land under workspace-relative .pbank-sources/ — read them; do not invent problem content.",
|
||||
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
|
||||
);
|
||||
}
|
||||
if (enabledTools.has("todo_write")) {
|
||||
instructions.push(
|
||||
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
|
||||
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
|
||||
);
|
||||
}
|
||||
instructions.push(
|
||||
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
|
||||
);
|
||||
return instructions.join(" ");
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ import {
|
||||
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
|
||||
|
||||
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
|
||||
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]*)\)/g;
|
||||
const FENCED_CODE_RE = /```[\s\S]*?```/g;
|
||||
const INLINE_CODE_RE = /`[^`\n]+`/g;
|
||||
const IMAGE_FETCH_HEADERS: Record<string, string> = {
|
||||
accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
// Some CDNs (incl. Wikimedia) reject bare programmatic clients with 400 HTML.
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (compatible; EducraftHub/1.0; +https://educraft.paradigm-edu.net) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
export type CardContentSegment =
|
||||
| { readonly type: "markdown"; readonly content: string }
|
||||
@@ -261,6 +268,14 @@ function blockedRanges(text: string): Array<{ start: number; end: number }> {
|
||||
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
INLINE_CODE_RE.lastIndex = 0;
|
||||
while ((match = INLINE_CODE_RE.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
// Skip inline spans fully inside a fence already recorded above.
|
||||
if (ranges.some((range) => start >= range.start && end <= range.end)) continue;
|
||||
ranges.push({ start, end });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@@ -297,11 +312,13 @@ async function fetchRemoteImage(
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
// Direct fetch: host env may enable NODE_USE_ENV_PROXY; several image CDNs
|
||||
// reject or rewrite traffic through shared egress proxies.
|
||||
const response = await fetchWithoutEnvProxy(fetchImpl, url, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
headers: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
// One safe redirect hop to another public http(s) host.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
@@ -315,11 +332,11 @@ async function fetchRemoteImage(
|
||||
}
|
||||
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
|
||||
if (!isPublicHttpHost(redirected.hostname)) return null;
|
||||
const second = await fetchImpl(redirected, {
|
||||
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
headers: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
return readImageBody(second, maxBytes);
|
||||
}
|
||||
@@ -329,6 +346,29 @@ async function fetchRemoteImage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch without inheriting HTTP(S)_PROXY from the process env for one call.
|
||||
* Restores env immediately so unrelated concurrent work keeps proxy settings.
|
||||
*/
|
||||
async function fetchWithoutEnvProxy(
|
||||
fetchImpl: typeof fetch,
|
||||
url: URL,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
|
||||
const saved: Array<[string, string | undefined]> = proxyKeys.map((key) => [key, process.env[key]]);
|
||||
try {
|
||||
for (const key of proxyKeys) delete process.env[key];
|
||||
return await fetchImpl(url, init);
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
|
||||
if (!response.ok) return null;
|
||||
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||
|
||||
+10
-5
@@ -7,8 +7,10 @@
|
||||
*
|
||||
* - `trigger_message` / `reply`: `message.get` by message_id.
|
||||
* - `status_card`: the run's status card message — same `message.get` by id.
|
||||
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
||||
* `parent_message_id` filter; we map "thread" to that.
|
||||
* - `thread`: lark's thread replies. `im.v1.message.list` with
|
||||
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a
|
||||
* message_id, which Feishu rejects with 230001). The caller supplies the
|
||||
* thread_id via `args.id`; the trigger context exposes it as `thread_id`.
|
||||
*
|
||||
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
|
||||
* we cast through a known request/response shape and return a compact JSON for
|
||||
@@ -69,11 +71,14 @@ export async function readFeishuContext(
|
||||
return JSON.stringify(compact(msg));
|
||||
}
|
||||
case "thread": {
|
||||
// Thread = replies to a parent message. `container_id` is the parent's
|
||||
// message_id; container_id_type=message_id scopes the list to that thread.
|
||||
// Thread = replies to a topic. Feishu's `im.v1.message.list` scopes
|
||||
// thread replies when `container_id_type="thread"` and `container_id`
|
||||
// is the thread_id (NOT a message_id — that is rejected with 230001
|
||||
// "invalid container_id_type"). The caller supplies the thread_id via
|
||||
// `args.id`; the trigger context exposes it as `thread_id`.
|
||||
const res = await api.list({
|
||||
params: {
|
||||
container_id_type: "message_id",
|
||||
container_id_type: "thread",
|
||||
container_id: args.id,
|
||||
page_size: 50,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
removeWorkspaceFileIfUnchangedNoFollow,
|
||||
type WorkspaceFileWriteResult,
|
||||
} from "../security/workspaceFiles.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
|
||||
export interface MessageResourceStageRequest {
|
||||
readonly fileKey: string;
|
||||
@@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult {
|
||||
|
||||
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
|
||||
export async function stageMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
botCli: FeishuBotCli,
|
||||
messageId: string,
|
||||
requests: readonly MessageResourceStageRequest[],
|
||||
workspaceRoot: string,
|
||||
@@ -49,16 +49,18 @@ export async function stageMessageResources(
|
||||
try {
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
for (const [index, request] of requests.entries()) {
|
||||
const stagedPath = await downloadMessageFile(
|
||||
rt,
|
||||
// Bot-identity transport only (ADR-0024): org App Secret stays in Hub,
|
||||
// never crosses into the Agent surface. Staging still lands under the
|
||||
// private .cph-staging tree before publish link into the tenant workspace.
|
||||
const stagedPath = await botCli.downloadResource({
|
||||
messageId,
|
||||
request.fileKey,
|
||||
fileKey: request.fileKey,
|
||||
resourceType: request.resourceType,
|
||||
workspaceRoot,
|
||||
stagingRoot,
|
||||
`resource-${index}`,
|
||||
request.resourceType,
|
||||
limits?.maxBytesPerFile,
|
||||
);
|
||||
workspaceDir: stagingRoot,
|
||||
workspaceRelativePath: `resource-${index}`,
|
||||
maxBytes: limits?.maxBytesPerFile,
|
||||
});
|
||||
resources.push({
|
||||
resourceType: request.resourceType,
|
||||
workspaceRelativePath: request.workspaceRelativePath,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* User-visible run termination copy for Feishu teachers.
|
||||
* Keep messages short, actionable, and free of stack traces.
|
||||
*/
|
||||
|
||||
export interface RunOutcomeNoticeInput {
|
||||
readonly wallTimeExceeded: boolean;
|
||||
readonly interrupted: boolean;
|
||||
readonly resultStatus: string;
|
||||
readonly resultError: string | undefined;
|
||||
readonly maxTurns: number;
|
||||
readonly maxRunSeconds: number;
|
||||
readonly hasPartialText: boolean;
|
||||
}
|
||||
|
||||
export interface RunOutcomeNotice {
|
||||
/** Mark the streaming card as failed (red footer). */
|
||||
readonly isError: boolean;
|
||||
/**
|
||||
* Teacher-facing explanation. Appended after any partial answer text so the
|
||||
* cause of a stop is never silent.
|
||||
*/
|
||||
readonly notice: string | undefined;
|
||||
}
|
||||
|
||||
export function teacherFacingRunOutcome(input: RunOutcomeNoticeInput): RunOutcomeNotice {
|
||||
if (input.wallTimeExceeded) {
|
||||
return {
|
||||
isError: true,
|
||||
notice: noticeLine(
|
||||
input.hasPartialText,
|
||||
`\u23F1 \u4EFB\u52A1\u8D85\u65F6\uFF1A\u5DF2\u8FBE\u5230\u5355\u6B21\u8FD0\u884C\u65F6\u95F4\u4E0A\u9650\uFF08${input.maxRunSeconds} \u79D2\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u91CD\u8BD5\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u8FD0\u884C\u65F6\u957F\u4E0A\u9650\u3002`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.interrupted) {
|
||||
return { isError: false, notice: undefined };
|
||||
}
|
||||
|
||||
if (input.resultStatus === "completed") {
|
||||
return { isError: false, notice: undefined };
|
||||
}
|
||||
|
||||
const err = input.resultError ?? "";
|
||||
if (isMaxTurnsError(err) || input.resultStatus === "length") {
|
||||
return {
|
||||
isError: true,
|
||||
notice: noticeLine(
|
||||
input.hasPartialText,
|
||||
`\u26A0\uFE0F \u4EFB\u52A1\u4E2D\u65AD\uFF1A\u5DF2\u8FBE\u5230\u6700\u5927\u6B65\u9AA4\u6570\uFF08${input.maxTurns} \u8F6E\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u7EE7\u7EED\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u6B65\u9AA4\u4E0A\u9650\u3002`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (err.trim() !== "") {
|
||||
const brief = sanitizeErrorBrief(err);
|
||||
return {
|
||||
isError: true,
|
||||
notice: noticeLine(
|
||||
input.hasPartialText,
|
||||
`\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${brief}\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
notice: noticeLine(
|
||||
input.hasPartialText,
|
||||
"\u274C \u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendTeacherNotice(body: string, notice: string | undefined): string {
|
||||
if (notice === undefined || notice === "") return body;
|
||||
if (body.trim() === "") return notice;
|
||||
return `${body.trimEnd()}\n\n${notice}`;
|
||||
}
|
||||
|
||||
export function isMaxTurnsError(error: string): boolean {
|
||||
const lower = error.toLowerCase();
|
||||
return (
|
||||
lower.includes("maximum number of turns") ||
|
||||
lower.includes("max_turns") ||
|
||||
lower.includes("error_max_turns") ||
|
||||
lower.includes("result_error_max_turns") ||
|
||||
(lower.includes("result_error_during_execution") && lower.includes("turn"))
|
||||
);
|
||||
}
|
||||
|
||||
function noticeLine(hasPartialText: boolean, message: string): string {
|
||||
if (!hasPartialText) return message;
|
||||
return `${message}\n\uFF08\u4E0A\u65B9\u4E3A\u5DF2\u751F\u6210\u7684\u90E8\u5206\u7ED3\u679C\u3002\uFF09`;
|
||||
}
|
||||
|
||||
function sanitizeErrorBrief(error: string): string {
|
||||
const oneLine = error.replace(/\s+/g, " ").trim();
|
||||
// Drop common SDK prefixes for readability.
|
||||
const stripped = oneLine
|
||||
.replace(/^Claude Code returned an error result:\s*/i, "")
|
||||
.replace(/^Error:\s*/i, "");
|
||||
if (stripped.length <= 160) return stripped;
|
||||
return `${stripped.slice(0, 157)}...`;
|
||||
}
|
||||
+56
-12
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Sends a "processing" reaction immediately, then streams a single
|
||||
* interactive card through the full agent run lifecycle: thinking → tool
|
||||
* calls (with trace panel) → streaming answer text → final card. The card
|
||||
* calls (with trace panel) → streaming answer text → final card. On finish,
|
||||
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
|
||||
* shows a collapsible tool-use panel, a collapsible reasoning panel, and
|
||||
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid
|
||||
* spamming the Feishu API.
|
||||
@@ -40,6 +41,7 @@ import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
|
||||
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
|
||||
import { StreamingAgentCard } from "./card/streaming-card.js";
|
||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
import { appendTeacherNotice, teacherFacingRunOutcome } from "./runOutcomeNotice.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
||||
import { ApprovalManager } from "./approval.js";
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
type MessageResourceStageRequest,
|
||||
type StagedMessageResourceBatch,
|
||||
} from "./resourceStaging.js";
|
||||
import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js";
|
||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
|
||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
|
||||
@@ -113,6 +116,8 @@ interface TriggerDeps {
|
||||
readonly allowLegacyFeishuIdentity?: boolean | undefined;
|
||||
/** Alpha Silo aggregate ingress ceiling across message and card events. */
|
||||
readonly maxFeishuEventsPerMinute?: number | undefined;
|
||||
/** Test/injection seam for bot-identity Feishu resource downloads. */
|
||||
readonly feishuBotCli?: FeishuBotCli | undefined;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -301,8 +306,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
senderOpenId,
|
||||
});
|
||||
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
|
||||
const botCli = deps.feishuBotCli ?? createFeishuBotCli({
|
||||
organizationId: deps.siloOrganizationId,
|
||||
prisma: deps.prisma,
|
||||
secretEnvelope: deps.secretEnvelope,
|
||||
});
|
||||
const stagedResources = await stageTriggerMessageResources(
|
||||
rt,
|
||||
botCli,
|
||||
msg,
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
@@ -579,7 +589,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: undefined,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
@@ -598,13 +608,24 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
agentExecution
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
|
||||
const finalText =
|
||||
const hasPartialText = result.text.trim() !== "";
|
||||
const outcome = teacherFacingRunOutcome({
|
||||
wallTimeExceeded,
|
||||
interrupted,
|
||||
resultStatus: result.status,
|
||||
resultError: result.error,
|
||||
maxTurns: runPolicy.maxTurns,
|
||||
maxRunSeconds: runPolicy.maxRunSeconds,
|
||||
hasPartialText,
|
||||
});
|
||||
const baseText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
: result.status === "failed" && result.error !== undefined
|
||||
: result.status === "failed" && result.error !== undefined && outcome.notice === undefined
|
||||
? `\u5904\u7406\u5931\u8D25: ${result.error}`
|
||||
: result.text;
|
||||
await card.finish(finalText, { interrupted });
|
||||
const finalText = appendTeacherNotice(baseText, outcome.notice);
|
||||
await card.finish(finalText, { interrupted, isError: outcome.isError });
|
||||
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
|
||||
if (metadataPatch !== null) {
|
||||
await deps.prisma.agentSession.update({
|
||||
@@ -668,14 +689,36 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
initializedSkills: [...(result.initializedSkillIds ?? [])],
|
||||
},
|
||||
});
|
||||
await removeProcessingReaction();
|
||||
// Mirror the start "Typing" reaction: drop processing, then stamp a
|
||||
// terminal emoji so teachers see done/failed without reading the card.
|
||||
const removedProcessingReaction = await removeProcessingReaction();
|
||||
if (removedProcessingReaction) {
|
||||
await addReaction(
|
||||
rt,
|
||||
msg.message_id,
|
||||
outcome.isError ? "CrossMark" : "CheckMark",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const removedProcessingReaction = await removeProcessingReaction();
|
||||
if (removedProcessingReaction) {
|
||||
await addReaction(rt, msg.message_id, "CrossMark");
|
||||
}
|
||||
await card.fail(e instanceof Error ? e.message : String(e));
|
||||
await card.fail(
|
||||
appendTeacherNotice(
|
||||
"",
|
||||
teacherFacingRunOutcome({
|
||||
wallTimeExceeded: false,
|
||||
interrupted: false,
|
||||
resultStatus: "failed",
|
||||
resultError: e instanceof Error ? e.message : String(e),
|
||||
maxTurns: runPolicy.maxTurns,
|
||||
maxRunSeconds: runPolicy.maxRunSeconds,
|
||||
hasPartialText: false,
|
||||
}).notice ?? `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
@@ -1834,8 +1877,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
|
||||
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
|
||||
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
|
||||
const fileDeliveryPrompt =
|
||||
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
||||
"Do not say a file is attached or sent unless that tool returns success.";
|
||||
"When the user asks for a downloadable file attachment (PDF/DOCX/ZIP/etc.), call the cph_hub send_file tool with the actual existing file path. " +
|
||||
"Do not say a file is attached or sent unless that tool returns success. " +
|
||||
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax  (or a public https image URL). The platform uploads those into the Feishu card. Prefer workspace files over remote URLs.";
|
||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||
}
|
||||
|
||||
@@ -1916,7 +1960,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function stageTriggerMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
botCli: FeishuBotCli,
|
||||
msg: MessageReceiveEvent["message"],
|
||||
workspaceRoot: string,
|
||||
limits?: TriggerDeps["resourceLimits"],
|
||||
@@ -1950,7 +1994,7 @@ async function stageTriggerMessageResources(
|
||||
}
|
||||
}
|
||||
return stageMessageResources(
|
||||
rt,
|
||||
botCli,
|
||||
msg.message_id,
|
||||
requests,
|
||||
workspaceRoot,
|
||||
|
||||
+50
-8
@@ -2,7 +2,7 @@ import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { registerDatabasePlugin } from "./database/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
|
||||
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
||||
@@ -119,15 +119,22 @@ export async function startHub(): Promise<void> {
|
||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||
const bind = readServerBinding();
|
||||
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||
await prisma.projectAgentLock.deleteMany({});
|
||||
await prisma.agentRun.updateMany({
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED. Capture the
|
||||
// killed runs first so we can tell their Feishu chats after the listener is up.
|
||||
const interruptedRuns = await prisma.agentRun.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
await prisma.projectAgentLock.deleteMany({});
|
||||
if (interruptedRuns.length > 0) {
|
||||
await prisma.agentRun.updateMany({
|
||||
where: { id: { in: interruptedRuns.map((run) => run.id) } },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
});
|
||||
}
|
||||
app.log.info({ killedRuns: interruptedRuns.length }, "startup: cleared stale locks + dead runs");
|
||||
|
||||
let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
|
||||
let feishuRuntime: FeishuRuntime | undefined;
|
||||
app.get("/api/healthz", async (_request, reply) => {
|
||||
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
|
||||
@@ -180,7 +187,7 @@ export async function startHub(): Promise<void> {
|
||||
allowLegacyFeishuIdentity: false,
|
||||
maxFeishuEventsPerMinute: feishuEventsPerMinute,
|
||||
});
|
||||
feishuRuntime = await startFeishuListenerWithClient(
|
||||
const runtime = await startFeishuListenerWithClient(
|
||||
feishuConfig,
|
||||
larkClient,
|
||||
app.log,
|
||||
@@ -195,6 +202,8 @@ export async function startHub(): Promise<void> {
|
||||
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled");
|
||||
},
|
||||
);
|
||||
feishuRuntime = runtime;
|
||||
await notifyBoundChatsOfInterruptedRuns(runtime, prisma, interruptedRuns, app.log);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
}
|
||||
@@ -202,6 +211,39 @@ export async function startHub(): Promise<void> {
|
||||
app.log.info({ address }, "hub listening");
|
||||
}
|
||||
|
||||
async function notifyBoundChatsOfInterruptedRuns(
|
||||
rt: FeishuRuntime,
|
||||
db: typeof prisma,
|
||||
interruptedRuns: ReadonlyArray<{ readonly id: string; readonly projectId: string }>,
|
||||
logger: { info: (obj: unknown, msg?: string) => void; warn: (obj: unknown, msg?: string) => void },
|
||||
): Promise<void> {
|
||||
if (interruptedRuns.length === 0) return;
|
||||
const byProject = new Map<string, string[]>();
|
||||
for (const run of interruptedRuns) {
|
||||
const list = byProject.get(run.projectId) ?? [];
|
||||
list.push(run.id);
|
||||
byProject.set(run.projectId, list);
|
||||
}
|
||||
for (const [projectId, runIds] of byProject) {
|
||||
const binding = await db.projectGroupBinding.findFirst({
|
||||
where: { projectId, archivedAt: null },
|
||||
select: { chatId: true },
|
||||
});
|
||||
if (binding === null) continue;
|
||||
const n = runIds.length;
|
||||
const text =
|
||||
n === 1
|
||||
? `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C\u4E0A\u4E00\u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\uFF08run: ${runIds[0]}\uFF09\u3002`
|
||||
: `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C${n} \u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\u3002`;
|
||||
try {
|
||||
await sendText(rt, binding.chatId, text);
|
||||
logger.info({ projectId, chatId: binding.chatId, runIds }, "startup: notified chat of interrupted runs");
|
||||
} catch (error) {
|
||||
logger.warn({ projectId, chatId: binding.chatId, err: String(error) }, "startup: failed to notify chat of interrupted runs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function positiveIntegerEnv(name: string): number {
|
||||
const raw = requireEnv(name);
|
||||
const value = Number(raw);
|
||||
|
||||
@@ -11,9 +11,9 @@ type EnvSource = Env | (() => Env);
|
||||
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||
const DEFAULT_AGENT_MAX_TURNS = 150;
|
||||
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
|
||||
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
|
||||
const DEFAULT_AGENT_MAX_RUN_SECONDS = 1800;
|
||||
|
||||
export interface ProviderRuntimeSettings {
|
||||
readonly id: string;
|
||||
|
||||
@@ -242,7 +242,8 @@ describe("admin auth + org API guards", () => {
|
||||
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("/admin");
|
||||
// New users without membership land on login error; session is still set.
|
||||
expect(res.headers.location).toBe("/admin/login?error=no_organization");
|
||||
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
metadata: {},
|
||||
metadata: { claudeSessionId: "sdk-session-old", userResumable: true },
|
||||
},
|
||||
});
|
||||
await configuration.setRoleSkills({
|
||||
@@ -59,11 +59,12 @@ describe("Organization Agent configuration management", () => {
|
||||
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
|
||||
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
|
||||
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
|
||||
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
|
||||
.resolves.toMatchObject({
|
||||
archivedAt: expect.any(Date),
|
||||
metadata: expect.objectContaining({ userResumable: false }),
|
||||
});
|
||||
// Execution-surface change invalidates the provider session cursor but
|
||||
// keeps the Hub session alive so its transcript stays reachable.
|
||||
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
|
||||
expect(invalidated.archivedAt).toBeNull();
|
||||
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
|
||||
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
|
||||
});
|
||||
|
||||
it("rejects unknown, disabled and cross-Organization skills", async () => {
|
||||
@@ -118,6 +119,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 });
|
||||
|
||||
@@ -157,9 +157,10 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
|
||||
).toBe("completed");
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
|
||||
"cph-runtime:outline",
|
||||
]));
|
||||
const skillIds = new Set(result.initializedSkillIds ?? []);
|
||||
expect(skillIds.has("cph-runtime:outline")).toBe(true);
|
||||
// Workspace-local untrusted skills must never load (ADR-0018).
|
||||
expect([...skillIds].some((id) => id.includes("untrusted"))).toBe(false);
|
||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||
expect(toolResults).toHaveLength(2);
|
||||
const rejectedOptOut = toolResults[0];
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import { createPbankService } from "../../src/capability/pbank.js";
|
||||
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
|
||||
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
|
||||
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
|
||||
|
||||
const CAPABILITY_ID = "pbank";
|
||||
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
|
||||
describe("pbank capability service (ADR-0027)", () => {
|
||||
let workspaceRoot: string;
|
||||
let runId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
await seedTestOrganization();
|
||||
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
|
||||
runId = "run-pbank-test";
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "proj-pbank",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "PBank Test",
|
||||
workspaceDir: workspaceRoot,
|
||||
},
|
||||
});
|
||||
await prisma.agentRun.create({
|
||||
data: {
|
||||
id: runId,
|
||||
projectId: "proj-pbank",
|
||||
entrypoint: "FEISHU",
|
||||
provider: "openrouter",
|
||||
model: "mock-model",
|
||||
status: "ACTIVE",
|
||||
prompt: "search pbank",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
async function seedActiveConnection(): Promise<void> {
|
||||
const payload: PbankCapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: "https://pbank.example/api",
|
||||
username: "teacher",
|
||||
password: "secret-never-log",
|
||||
rightsStatus: "owned",
|
||||
rightsHolder: "Paradigm Education",
|
||||
};
|
||||
const connection = await prisma.organizationCapabilityConnection.create({
|
||||
data: {
|
||||
id: "pbank-conn-1",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
capabilityId: CAPABILITY_ID,
|
||||
status: "ACTIVE",
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const envelope = testSecretEnvelope.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
connectionId: connection.id,
|
||||
secretVersionId: "pbank-sv-1",
|
||||
},
|
||||
payload,
|
||||
);
|
||||
await prisma.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: "pbank-sv-1",
|
||||
connectionId: connection.id,
|
||||
version: 1,
|
||||
envelope: envelope as object,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
});
|
||||
await prisma.organizationCapabilityConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: { activeSecretVersionId: "pbank-sv-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockClient(): PbankClient {
|
||||
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
|
||||
return {
|
||||
login: vi.fn(async () => login),
|
||||
searchProblems: vi.fn(async () => ({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
items: [{ id: PROBLEM_ID, title: "示例题" }],
|
||||
})),
|
||||
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
|
||||
getOccurrences: vi.fn(async () => ({ items: [] })),
|
||||
downloadProject: vi.fn(async () => ({
|
||||
buffer: Buffer.from("# problem\n"),
|
||||
contentType: "text/plain",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it("fails closed when no ACTIVE connection exists", async () => {
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client: mockClient(),
|
||||
});
|
||||
await expect(
|
||||
service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
|
||||
});
|
||||
|
||||
it("searches via org credential and writes UsageFact without leaking password", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.searchProblems(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ q: "函数", pageNum: 1, pageSize: 10 },
|
||||
);
|
||||
|
||||
expect(client.login).toHaveBeenCalledTimes(1);
|
||||
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
|
||||
expect(loginArg.username).toBe("teacher");
|
||||
expect(loginArg.password).toBe("secret-never-log");
|
||||
expect(loginArg.kind).toBe("pbank");
|
||||
|
||||
expect(result.data).toMatchObject({
|
||||
rights: { derivativeUseAllowed: true, status: "owned" },
|
||||
items: [{ id: PROBLEM_ID }],
|
||||
});
|
||||
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
|
||||
|
||||
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0]).toMatchObject({
|
||||
kind: "external_capability",
|
||||
capabilityId: CAPABILITY_ID,
|
||||
provider: "paradigm_pbank",
|
||||
unit: "requests",
|
||||
quantity: expect.anything(),
|
||||
costUsd: null,
|
||||
costSource: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches one problem and attaches rights", async () => {
|
||||
await seedActiveConnection();
|
||||
const client = mockClient();
|
||||
const service = createPbankService({
|
||||
prisma,
|
||||
secrets: testSecretEnvelope,
|
||||
client,
|
||||
});
|
||||
const result = await service.getProblem(
|
||||
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
|
||||
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
|
||||
);
|
||||
expect(result.data).toMatchObject({
|
||||
id: PROBLEM_ID,
|
||||
rights: { derivativeUseAllowed: true },
|
||||
problem: { id: PROBLEM_ID },
|
||||
projects: {
|
||||
problem: { status: "text" },
|
||||
answer: { status: "text" },
|
||||
},
|
||||
});
|
||||
expect(client.getProblem).toHaveBeenCalled();
|
||||
expect(client.downloadProject).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
|
||||
async function seedActiveCapabilityConnection(): Promise<void> {
|
||||
const payload: CapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "docmind",
|
||||
accessKeyId: "LTAI-test-key-id",
|
||||
accessKeySecret: "test-secret-never-log",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
|
||||
@@ -16,7 +16,9 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
const TEST_DATABASE_URL =
|
||||
process.env.DATABASE_URL?.trim() ||
|
||||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
|
||||
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
|
||||
let root: string;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
|
||||
* AI SDK model factory (doGenerate() returns canned responses - no network).
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type {
|
||||
@@ -19,7 +21,21 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||
|
||||
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
|
||||
// Admin routes and agent-config tests need a skill store root; seed once for
|
||||
// the whole vitest process when CI/dev didn't set one.
|
||||
if (
|
||||
(process.env.HUB_SKILL_STORE_ROOT === undefined || process.env.HUB_SKILL_STORE_ROOT.trim() === "") &&
|
||||
(process.env.XDG_STATE_HOME === undefined || process.env.XDG_STATE_HOME.trim() === "")
|
||||
) {
|
||||
process.env.HUB_SKILL_STORE_ROOT = `${tmpdir()}/cph-test-skills`;
|
||||
}
|
||||
if (process.env.HUB_SKILL_STORE_ROOT !== undefined && process.env.HUB_SKILL_STORE_ROOT.trim() !== "") {
|
||||
mkdirSync(process.env.HUB_SKILL_STORE_ROOT, { recursive: true });
|
||||
}
|
||||
export const TEST_DATABASE_URL =
|
||||
process.env.DATABASE_URL?.trim() ||
|
||||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
export const DEFAULT_ORG_ID = "org_test_default";
|
||||
export const TEST_SECRET_KEY_ID = "test-active";
|
||||
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
|
||||
@@ -34,26 +50,29 @@ export const prisma = new PrismaClient({
|
||||
|
||||
/** Truncate all tables before each test for isolation. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
// User and Organization are the aggregate roots for all domain rows; their
|
||||
// declared FK cascades clear projects, search documents, permissions,
|
||||
// sessions and connections without repeatedly truncating pg_trgm indexes.
|
||||
// Event receipts and global audit rows are independent roots.
|
||||
await prisma.$transaction([
|
||||
prisma.feishuEventReceipt.deleteMany(),
|
||||
prisma.auditEntry.deleteMany(),
|
||||
// Permission resource ids are intentionally polymorphic strings, so these
|
||||
// two tables have no FK to Project and must be cleared explicitly.
|
||||
prisma.permissionGrant.deleteMany(),
|
||||
prisma.permissionSettings.deleteMany(),
|
||||
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
|
||||
// cascade above never reaches it. Clear explicitly — closure/membership
|
||||
// first (they FK into MemberGroup), groups last.
|
||||
prisma.memberGroupClosure.deleteMany(),
|
||||
prisma.memberGroupMembership.deleteMany(),
|
||||
prisma.memberGroup.deleteMany(),
|
||||
prisma.user.deleteMany(),
|
||||
prisma.organization.deleteMany(),
|
||||
]);
|
||||
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover
|
||||
// folder trees made deleteMany-based cleanup race Prisma upserts
|
||||
// ("Unique constraint failed on id" while the where-branch saw no row).
|
||||
await prisma.$executeRawUnsafe(`
|
||||
DO $$
|
||||
DECLARE
|
||||
stmt text;
|
||||
BEGIN
|
||||
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|
||||
|| ' RESTART IDENTITY CASCADE'
|
||||
INTO stmt
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> '_prisma_migrations';
|
||||
IF stmt IS NOT NULL THEN
|
||||
EXECUTE stmt;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
const leftover = await prisma.organization.count();
|
||||
if (leftover !== 0) {
|
||||
throw new Error(`resetDb truncate left ${leftover} organization row(s)`);
|
||||
}
|
||||
await seedTestOrganization();
|
||||
}
|
||||
|
||||
@@ -61,49 +80,51 @@ export async function seedTestOrganization(
|
||||
id: string = DEFAULT_ORG_ID,
|
||||
slug: string = "test-default",
|
||||
): Promise<void> {
|
||||
// Serialise generate+inbox against concurrent callers in the same process.
|
||||
// Integration tests share one DB and some files call seed without resetDb.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.organization.upsert({
|
||||
const existing = await tx.organization.findUnique({
|
||||
where: { id },
|
||||
update: {},
|
||||
create: { id, slug, name: "Test Default Organization" },
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.organizationProjectSettings.upsert({
|
||||
where: { organizationId: id },
|
||||
update: {},
|
||||
create: { organizationId: id, membersCanCreateProjects: true },
|
||||
});
|
||||
const defaultRole = await tx.organizationAgentRole.upsert({
|
||||
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
|
||||
update: { label: "草稿", isDefault: true, disabledAt: null },
|
||||
create: {
|
||||
id: `agent_role_draft_${id}`,
|
||||
organizationId: id,
|
||||
roleId: "draft",
|
||||
label: "草稿",
|
||||
sortOrder: 10,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
await tx.organizationAgentRole.updateMany({
|
||||
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
if (existing === null) {
|
||||
await tx.organization.create({
|
||||
data: {
|
||||
id,
|
||||
slug,
|
||||
name: "Test Default Organization",
|
||||
projectSettings: {
|
||||
create: { membersCanCreateProjects: true },
|
||||
},
|
||||
agentRoles: {
|
||||
create: {
|
||||
id: `agent_role_draft_${id}`,
|
||||
roleId: "draft",
|
||||
label: "草稿",
|
||||
sortOrder: 10,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const inbox = await tx.folder.findFirst({
|
||||
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (inbox === null) {
|
||||
await tx.folder.create({
|
||||
data: {
|
||||
id: `folder_inbox_${id}`,
|
||||
organizationId: id,
|
||||
name: "Inbox",
|
||||
kind: "SYSTEM_INBOX",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
const inbox = await prisma.folder.findFirst({
|
||||
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (inbox === null) {
|
||||
await prisma.folder.create({
|
||||
data: {
|
||||
id: `folder_inbox_${id}`,
|
||||
organizationId: id,
|
||||
name: "Inbox",
|
||||
kind: "SYSTEM_INBOX",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
seedProject,
|
||||
seedTestOrganization,
|
||||
silentLogger,
|
||||
testSecretEnvelope,
|
||||
} from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
||||
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
||||
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: DEFAULT_ORG_ID,
|
||||
allowLegacyFeishuIdentity: true,
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
...deps,
|
||||
});
|
||||
}
|
||||
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
where: { id: "proj-post-image" },
|
||||
data: { workspaceDir },
|
||||
});
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
|
||||
}));
|
||||
const imV1 = (rt.client as unknown as {
|
||||
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||
}).im.v1;
|
||||
imV1.messageResource = { get: messageResourceGet };
|
||||
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
Readable.from([Buffer.from("image bytes")]),
|
||||
request.maxBytes,
|
||||
));
|
||||
const feishuBotCli: FeishuBotCli = { downloadResource };
|
||||
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
runAgent,
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
feishuBotCli,
|
||||
});
|
||||
|
||||
await trigger(event, rt);
|
||||
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
});
|
||||
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
params: { type: "image" },
|
||||
path: { message_id: event.message.message_id, file_key: "img-key-1" },
|
||||
});
|
||||
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
|
||||
messageId: event.message.message_id,
|
||||
fileKey: "img-key-1",
|
||||
resourceType: "image",
|
||||
}));
|
||||
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
|
||||
expect(inboxFiles).toHaveLength(1);
|
||||
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
|
||||
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
});
|
||||
const resourceEntered = deferred<void>();
|
||||
const releaseResource = deferred<void>();
|
||||
const messageResourceGet = vi.fn(async () => {
|
||||
const downloadResource = vi.fn(async (request) => {
|
||||
resourceEntered.resolve();
|
||||
await releaseResource.promise;
|
||||
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
|
||||
return writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
Readable.from([Buffer.from("staged image bytes")]),
|
||||
request.maxBytes,
|
||||
);
|
||||
});
|
||||
const imV1 = (rt.client as unknown as {
|
||||
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
|
||||
}).im.v1;
|
||||
imV1.messageResource = { get: messageResourceGet };
|
||||
const feishuBotCli: FeishuBotCli = { downloadResource };
|
||||
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
|
||||
const event: MessageReceiveEvent = {
|
||||
...baseEvent,
|
||||
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
runAgent,
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
feishuBotCli,
|
||||
});
|
||||
|
||||
const pendingTrigger = trigger(event, rt);
|
||||
@@ -1600,7 +1611,9 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs[0]?.status).toBe("CANCELED");
|
||||
});
|
||||
expect(patch).toHaveBeenCalled();
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
await vi.waitFor(() => {
|
||||
expect(rt.sentTexts).toContain("已中断当前运行。");
|
||||
});
|
||||
});
|
||||
|
||||
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
|
||||
import { importSkillDirectory } from "../../src/agent/skillStore.js";
|
||||
|
||||
describe("agent subprocess security policy", () => {
|
||||
const roots: string[] = [];
|
||||
@@ -26,6 +27,13 @@ describe("agent subprocess security policy", () => {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
CPH_BIN: "/usr/local/bin/cph",
|
||||
HTTP_PROXY: "http://127.0.0.1:7890",
|
||||
HTTPS_PROXY: "http://127.0.0.1:7890",
|
||||
ALL_PROXY: "socks5h://127.0.0.1:7890",
|
||||
NO_PROXY: "127.0.0.1,localhost,::1",
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
|
||||
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
|
||||
DATABASE_URL: "postgresql://platform-secret",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
HUB_SESSION_SECRET: "session-secret",
|
||||
@@ -38,9 +46,16 @@ describe("agent subprocess security policy", () => {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
CPH_BIN: "/usr/local/bin/cph",
|
||||
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
|
||||
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
HTTP_PROXY: "http://127.0.0.1:7890",
|
||||
HTTPS_PROXY: "http://127.0.0.1:7890",
|
||||
ALL_PROXY: "socks5h://127.0.0.1:7890",
|
||||
NO_PROXY: "127.0.0.1,localhost,::1",
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
});
|
||||
expect(policy.env).not.toHaveProperty("DATABASE_URL");
|
||||
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
|
||||
@@ -61,7 +76,10 @@ describe("agent subprocess security policy", () => {
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [canonicalWorkspace],
|
||||
allowWrite: expect.arrayContaining([
|
||||
canonicalWorkspace,
|
||||
"/srv/curriculum-project-hub/typst-packages/para-26071100",
|
||||
]),
|
||||
denyRead: ["/"],
|
||||
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
|
||||
},
|
||||
@@ -74,6 +92,59 @@ describe("agent subprocess security policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100";
|
||||
const cacheRoot = "/var/cache/cph-hub/para-26071100/typst";
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
TYPST_PACKAGE_PATH: packageRoot,
|
||||
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
|
||||
},
|
||||
});
|
||||
const canonicalWorkspace = await realpath(workspace);
|
||||
|
||||
expect(policy.env).toMatchObject({
|
||||
TYPST_PACKAGE_PATH: packageRoot,
|
||||
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
|
||||
});
|
||||
expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot]));
|
||||
expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot]));
|
||||
expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot);
|
||||
});
|
||||
|
||||
it("rejects a relative Typst package root instead of silently losing package access", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
TYPST_PACKAGE_PATH: "typst-packages",
|
||||
},
|
||||
})).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute");
|
||||
});
|
||||
|
||||
it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
TYPST_PACKAGE_CACHE_PATH: "/",
|
||||
},
|
||||
})).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root");
|
||||
});
|
||||
|
||||
it("rejects provider environment keys outside the explicit protocol", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
|
||||
@@ -125,6 +196,43 @@ describe("agent subprocess security policy", () => {
|
||||
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
|
||||
});
|
||||
|
||||
|
||||
it("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => {
|
||||
const { root, workspaceRoot, workspace } = await makeWorkspace();
|
||||
const storeRoot = join(root, "skills-store");
|
||||
const skillSource = join(root, "skill-src", "pdf-to-md");
|
||||
await mkdir(skillSource, { recursive: true });
|
||||
await writeFile(
|
||||
join(skillSource, "SKILL.md"),
|
||||
"---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n",
|
||||
);
|
||||
const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot });
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
runId: "run-skills",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
skills: [{
|
||||
name: "pdf-to-md",
|
||||
version: "1",
|
||||
contentDigest: installed.contentDigest,
|
||||
}],
|
||||
hostEnv: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
HUB_SKILL_STORE_ROOT: storeRoot,
|
||||
},
|
||||
});
|
||||
const canonicalWorkspace = await realpath(workspace);
|
||||
const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md");
|
||||
await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items");
|
||||
expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills"));
|
||||
expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills"));
|
||||
expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]);
|
||||
expect(policy.sandbox.filesystem.allowRead).toEqual(
|
||||
expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]),
|
||||
);
|
||||
await policy.cleanup();
|
||||
});
|
||||
|
||||
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
||||
const { root, workspaceRoot } = await makeWorkspace();
|
||||
const outside = join(root, "outside");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AliyunDocmindClient,
|
||||
DocmindClientError,
|
||||
DOCMIND_CONNECT_TIMEOUT_MS,
|
||||
DOCMIND_READ_TIMEOUT_MS,
|
||||
createDocmindRuntimeOptions,
|
||||
} from "../../src/capability/docmindClient.js";
|
||||
|
||||
describe("createDocmindRuntimeOptions", () => {
|
||||
it("overrides httpx's 3s default so PDF OSS uploads can complete", () => {
|
||||
const runtime = createDocmindRuntimeOptions();
|
||||
// Production failure: ReadTimeout(3000) on docmind OSS upload.
|
||||
expect(DOCMIND_CONNECT_TIMEOUT_MS).toBeGreaterThan(3_000);
|
||||
expect(DOCMIND_READ_TIMEOUT_MS).toBeGreaterThan(3_000);
|
||||
expect(runtime.connectTimeout).toBe(DOCMIND_CONNECT_TIMEOUT_MS);
|
||||
expect(runtime.readTimeout).toBe(DOCMIND_READ_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
describe("AliyunDocmindClient local file open", () => {
|
||||
it("rejects a missing input file without crashing the process", async () => {
|
||||
const client = new AliyunDocmindClient();
|
||||
const missing = join(tmpdir(), `docmind-missing-${Date.now()}.pdf`);
|
||||
|
||||
// If createReadStream errors are left unhandled, Vitest aborts the suite
|
||||
// with an unhandled 'error' event instead of reaching this assertion.
|
||||
await expect(client.parse(
|
||||
{
|
||||
accessKeyId: "ak",
|
||||
accessKeySecret: "sk",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
},
|
||||
{ inputFilePath: missing },
|
||||
)).rejects.toBeInstanceOf(DocmindClientError);
|
||||
|
||||
await expect(client.parse(
|
||||
{
|
||||
accessKeyId: "ak",
|
||||
accessKeySecret: "sk",
|
||||
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
|
||||
},
|
||||
{ inputFilePath: missing },
|
||||
)).rejects.toMatchObject({
|
||||
code: "docmind_rejected",
|
||||
message: expect.stringContaining("input file not found"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFeishuBotCli } from "../../src/feishu/botCli.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
|
||||
const fakeCredential = {
|
||||
connectionId: "connection-1",
|
||||
organizationId: "org-1",
|
||||
appId: "cli-test-app",
|
||||
appSecret: "cli-test-secret",
|
||||
botOpenId: "ou-test-bot",
|
||||
verificationToken: "verification-token",
|
||||
encryptKey: "encrypt-key",
|
||||
};
|
||||
|
||||
describe("Feishu bot CLI adapter", () => {
|
||||
itOnLinux("uses bot identity and writes the CLI result into the workspace", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-test-"));
|
||||
const workspaceDir = join(root, "workspace");
|
||||
const binary = join(root, "fake-lark-cli");
|
||||
await mkdir(workspaceDir);
|
||||
await writeFakeCli(binary);
|
||||
try {
|
||||
const cli = createFeishuBotCli({
|
||||
organizationId: "org-1",
|
||||
prisma: {} as PrismaClient,
|
||||
secretEnvelope: {} as LocalSecretEnvelope,
|
||||
binary,
|
||||
resolveCredential: async () => fakeCredential,
|
||||
});
|
||||
|
||||
const result = await cli.downloadResource({
|
||||
messageId: "message-1",
|
||||
fileKey: "file-1",
|
||||
resourceType: "file",
|
||||
workspaceRoot: root,
|
||||
workspaceDir,
|
||||
workspaceRelativePath: "inbox/resource.bin",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
|
||||
await expect(readFile(result, "utf8")).resolves.toBe("resource bytes");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a resource above the configured limit", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-limit-"));
|
||||
const workspaceDir = join(root, "workspace");
|
||||
const binary = join(root, "fake-lark-cli");
|
||||
await mkdir(workspaceDir);
|
||||
await writeFakeCli(binary);
|
||||
try {
|
||||
const cli = createFeishuBotCli({
|
||||
organizationId: "org-1",
|
||||
prisma: {} as PrismaClient,
|
||||
secretEnvelope: {} as LocalSecretEnvelope,
|
||||
binary,
|
||||
resolveCredential: async () => fakeCredential,
|
||||
});
|
||||
|
||||
await expect(cli.downloadResource({
|
||||
messageId: "message-1",
|
||||
fileKey: "file-1",
|
||||
resourceType: "file",
|
||||
workspaceRoot: root,
|
||||
workspaceDir,
|
||||
workspaceRelativePath: "inbox/resource.bin",
|
||||
maxBytes: 4,
|
||||
})).rejects.toMatchObject({ reason: "limit" });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function writeFakeCli(path: string): Promise<void> {
|
||||
await writeFile(path, `#!/usr/bin/env node
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "config" && args[1] === "init") {
|
||||
process.stdin.resume();
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
} else if (args.includes("+messages-resources-download")) {
|
||||
const asIndex = args.indexOf("--as");
|
||||
const outputIndex = args.indexOf("--output");
|
||||
if (asIndex < 0 || args[asIndex + 1] !== "bot" || outputIndex < 0) process.exit(2);
|
||||
writeFileSync(join(process.cwd(), args[outputIndex + 1]), "resource bytes");
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(3);
|
||||
}
|
||||
`);
|
||||
await chmod(path, 0o755);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
||||
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
||||
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
|
||||
|
||||
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
@@ -12,7 +14,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
|
||||
describe("Feishu message resource download", () => {
|
||||
it("exposes the download tool to default and explicitly configured roles", () => {
|
||||
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
|
||||
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
|
||||
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
|
||||
"todo_write",
|
||||
"feishu_download_resource",
|
||||
]);
|
||||
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
|
||||
"mcp__cph_hub__feishu_download_resource",
|
||||
]);
|
||||
@@ -24,14 +29,12 @@ describe("Feishu message resource download", () => {
|
||||
await mkdir(workspaceDir);
|
||||
try {
|
||||
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
|
||||
const messageResourceGet = vi.fn(async () => ({
|
||||
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
|
||||
}));
|
||||
const messageResourceGet = vi.fn();
|
||||
const rt = mockRuntime(messageGet, messageResourceGet);
|
||||
|
||||
const botCli = fakeBotCli();
|
||||
const result = await downloadFeishuMessageResource(
|
||||
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
|
||||
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
|
||||
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli },
|
||||
rt,
|
||||
);
|
||||
|
||||
@@ -41,10 +44,7 @@ describe("Feishu message resource download", () => {
|
||||
);
|
||||
expect(result.path).toMatch(/\.png$/);
|
||||
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
|
||||
expect(messageResourceGet).toHaveBeenCalledWith({
|
||||
params: { type: "image" },
|
||||
path: { message_id: "message-1", file_key: "img-key-1" },
|
||||
});
|
||||
expect(messageResourceGet).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -57,10 +57,14 @@ describe("Feishu message resource download", () => {
|
||||
|
||||
await expect(downloadFeishuMessageResource(
|
||||
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
|
||||
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
|
||||
{
|
||||
boundChatId: "chat-1",
|
||||
workspaceRoot: "/tmp",
|
||||
workspaceDir: "/tmp/project-1",
|
||||
botCli: fakeBotCli(),
|
||||
},
|
||||
rt,
|
||||
)).rejects.toThrow("current project's bound chat");
|
||||
expect(messageResourceGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +93,18 @@ function mockRuntime(
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBotCli(): FeishuBotCli {
|
||||
return {
|
||||
downloadResource: (request) => writeNewWorkspaceFileNoFollow(
|
||||
request.workspaceRoot,
|
||||
request.workspaceDir,
|
||||
request.workspaceRelativePath,
|
||||
Readable.from([Buffer.from("image bytes")]),
|
||||
request.maxBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
|
||||
);
|
||||
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||
});
|
||||
|
||||
it("ignores markdown image examples inside inline code", () => {
|
||||
const text = "例如 `` 这样写,不会当真实图片";
|
||||
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
|
||||
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeAnswerSegments", () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("Feishu reactions", () => {
|
||||
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("adds Typing on start and removes it on success", async () => {
|
||||
it("adds Typing on start and replaces it with CheckMark on success", async () => {
|
||||
const run = deferred<RunResult>();
|
||||
const rt = mockRuntime();
|
||||
const runAgent = vi.fn((req: RunRequest) => {
|
||||
@@ -71,6 +71,7 @@ describe("Feishu reactions", () => {
|
||||
expect(rt.reactionRequests).toEqual([
|
||||
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
|
||||
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
|
||||
{ kind: "add", messageId: "message-1", emoji: "CheckMark", reactionId: "reaction-2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractProblemId,
|
||||
normalizePbankBaseUrl,
|
||||
pbankRightsFromCredential,
|
||||
} from "../../src/capability/pbankClient.js";
|
||||
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
|
||||
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
|
||||
|
||||
describe("pbank client helpers", () => {
|
||||
it("extracts problem UUID from bare id and URL", () => {
|
||||
const id = "01234567-89ab-4def-8abc-0123456789ab";
|
||||
expect(extractProblemId(id)).toBe(id);
|
||||
expect(extractProblemId(`https://pbank.paradigm-edu.net/problem/${id}`)).toBe(id);
|
||||
expect(extractProblemId(`https://pbank.example/x?id=${id}`)).toBe(id);
|
||||
});
|
||||
|
||||
it("normalizes base URL trailing slashes", () => {
|
||||
expect(normalizePbankBaseUrl("https://pbank.paradigm-edu.net/api/")).toBe(
|
||||
"https://pbank.paradigm-edu.net/api",
|
||||
);
|
||||
expect(normalizePbankBaseUrl("")).toBe("https://pbank.paradigm-edu.net/api");
|
||||
});
|
||||
|
||||
it("marks derivative use from operator rights status", () => {
|
||||
const owned: PbankCapabilitySecretPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: "pbank",
|
||||
baseUrl: "https://pbank.paradigm-edu.net/api",
|
||||
username: "u",
|
||||
password: "p",
|
||||
rightsStatus: "owned",
|
||||
};
|
||||
expect(pbankRightsFromCredential(owned).derivativeUseAllowed).toBe(true);
|
||||
|
||||
const unknown: PbankCapabilitySecretPayload = {
|
||||
...owned,
|
||||
rightsStatus: "unknown",
|
||||
};
|
||||
expect(pbankRightsFromCredential(unknown).derivativeUseAllowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role tool mapping for pbank", () => {
|
||||
it("maps umbrella pbank role tool to three MCP tools", () => {
|
||||
expect(cphHubMcpToolsForRole(["pbank"])).toEqual([
|
||||
"todo_write",
|
||||
"pbank_search_problems",
|
||||
"pbank_get_problem",
|
||||
"pbank_get_many_problems",
|
||||
]);
|
||||
const cfg = claudeSdkToolConfigForRole(["pbank", "Read"]);
|
||||
expect(cfg.tools).toContain("Read");
|
||||
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_search_problems");
|
||||
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_problem");
|
||||
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_many_problems");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** Mirrors hub/src/capability/pbank.ts zip reader contracts. */
|
||||
function listZipEntries(buffer: Buffer) {
|
||||
let eocd = -1;
|
||||
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
|
||||
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
|
||||
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("missing eocd");
|
||||
const totalEntries = buffer.readUInt16LE(eocd + 10);
|
||||
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
||||
const entries: Array<{ name: string; method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }> = [];
|
||||
let offset = centralOffset;
|
||||
for (let i = 0; i < totalEntries; i += 1) {
|
||||
const method = buffer.readUInt16LE(offset + 10);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
||||
const nameLen = buffer.readUInt16LE(offset + 28);
|
||||
const extraLen = buffer.readUInt16LE(offset + 30);
|
||||
const commentLen = buffer.readUInt16LE(offset + 32);
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
||||
const nameStart = offset + 46;
|
||||
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
||||
entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
|
||||
offset = nameStart + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function inflateZipEntry(buffer: Buffer, entry: { method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }) {
|
||||
const local = entry.localHeaderOffset;
|
||||
const nameLen = buffer.readUInt16LE(local + 26);
|
||||
const extraLen = buffer.readUInt16LE(local + 28);
|
||||
const dataStart = local + 30 + nameLen + extraLen;
|
||||
const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
|
||||
if (entry.method === 0) return Buffer.from(compressed);
|
||||
if (entry.method === 8) return Buffer.from(inflateRawSync(compressed));
|
||||
throw new Error(`method ${entry.method}`);
|
||||
}
|
||||
|
||||
describe("pbank zip reader contract", () => {
|
||||
it("lists and inflates deflated entries without host unzip", async () => {
|
||||
const zip = await readFile(new URL("./fixtures/pbank-mini.zip", import.meta.url));
|
||||
const entries = listZipEntries(zip);
|
||||
expect(entries.map((e) => e.name).sort()).toEqual(["fig/a.png", "hello.txt"]);
|
||||
const hello = entries.find((e) => e.name === "hello.txt")!;
|
||||
const text = inflateZipEntry(zip, hello).toString("utf8");
|
||||
expect(text).toBe("hello pbank\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clampPdfToMdConcurrency,
|
||||
invokePdfToMdBatch,
|
||||
mapPool,
|
||||
readPdfToMdConcurrency,
|
||||
} from "../../src/capability/pdfToMdBundle.js";
|
||||
import type { CapabilityAdapter, CapabilityInvocationResult } from "../../src/capability/types.js";
|
||||
|
||||
function okResult(label: string, pages: number): CapabilityInvocationResult {
|
||||
return {
|
||||
artifacts: [{ path: `out/${label}/document.md`, kind: "markdown" }],
|
||||
consumption: {
|
||||
provider: "aliyun_docmind",
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
quantity: pages,
|
||||
unit: "pages",
|
||||
costUsd: pages * 0.0056,
|
||||
correlationId: `job-${label}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pdf_to_md batch concurrency helpers", () => {
|
||||
it("mapPool caps in-flight workers", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const values = await mapPool([1, 2, 3, 4, 5], 2, async (item) => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
inFlight -= 1;
|
||||
return item * 10;
|
||||
});
|
||||
expect(values).toEqual([10, 20, 30, 40, 50]);
|
||||
expect(maxInFlight).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps concurrency and reads env", () => {
|
||||
expect(clampPdfToMdConcurrency(99)).toBe(8);
|
||||
expect(clampPdfToMdConcurrency(0)).toBe(1);
|
||||
expect(readPdfToMdConcurrency({})).toBe(3);
|
||||
expect(readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "5" })).toBe(5);
|
||||
expect(() => readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "nope" })).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
it("runs batch items concurrently and preserves order", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const adapter: CapabilityAdapter = {
|
||||
capabilityId: "pdf_to_md_bundle",
|
||||
invoke: vi.fn(async (input) => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
inFlight -= 1;
|
||||
const label = input.inputPath.includes("b") ? "b" : input.inputPath.includes("c") ? "c" : "a";
|
||||
return okResult(label, label === "a" ? 1 : label === "b" ? 2 : 3);
|
||||
}),
|
||||
};
|
||||
|
||||
const batch = await invokePdfToMdBatch(
|
||||
adapter,
|
||||
{
|
||||
runId: "run-1",
|
||||
organizationId: "org-1",
|
||||
projectId: "proj-1",
|
||||
workspaceDir: "/tmp/ws",
|
||||
prisma: {} as never,
|
||||
},
|
||||
[
|
||||
{ inputPath: "a.pdf", outputDir: "out/a" },
|
||||
{ inputPath: "b.pdf", outputDir: "out/b" },
|
||||
{ inputPath: "c.pdf", outputDir: "out/c" },
|
||||
],
|
||||
3,
|
||||
);
|
||||
|
||||
expect(batch.map((item) => item.ok)).toEqual([true, true, true]);
|
||||
expect(maxInFlight).toBe(3);
|
||||
expect(adapter.invoke).toHaveBeenCalledTimes(3);
|
||||
if (batch[0]?.ok && batch[1]?.ok && batch[2]?.ok) {
|
||||
expect(batch[0].result.consumption.correlationId).toBe("job-a");
|
||||
expect(batch[1].result.consumption.correlationId).toBe("job-b");
|
||||
expect(batch[2].result.consumption.correlationId).toBe("job-c");
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates per-item failures without canceling siblings", async () => {
|
||||
const adapter: CapabilityAdapter = {
|
||||
capabilityId: "pdf_to_md_bundle",
|
||||
invoke: vi.fn(async (input) => {
|
||||
if (input.inputPath.includes("bad")) {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return okResult("ok", 1);
|
||||
}),
|
||||
};
|
||||
|
||||
const batch = await invokePdfToMdBatch(
|
||||
adapter,
|
||||
{
|
||||
runId: "run-1",
|
||||
organizationId: "org-1",
|
||||
projectId: "proj-1",
|
||||
workspaceDir: "/tmp/ws",
|
||||
prisma: {} as never,
|
||||
},
|
||||
[
|
||||
{ inputPath: "ok.pdf", outputDir: "out/ok" },
|
||||
{ inputPath: "bad.pdf", outputDir: "out/bad" },
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
expect(batch[0]?.ok).toBe(true);
|
||||
expect(batch[1]?.ok).toBe(false);
|
||||
if (batch[1]?.ok === false) {
|
||||
expect(batch[1].error).toMatch(/boom/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate output dirs before starting work", async () => {
|
||||
const invoke = vi.fn();
|
||||
const adapter: CapabilityAdapter = {
|
||||
capabilityId: "pdf_to_md_bundle",
|
||||
invoke,
|
||||
};
|
||||
await expect(invokePdfToMdBatch(
|
||||
adapter,
|
||||
{
|
||||
runId: "run-1",
|
||||
organizationId: "org-1",
|
||||
projectId: "proj-1",
|
||||
workspaceDir: "/tmp/ws",
|
||||
prisma: {} as never,
|
||||
},
|
||||
[
|
||||
{ inputPath: "a.pdf", outputDir: "out/same" },
|
||||
{ inputPath: "b.pdf", outputDir: "out/same/" },
|
||||
],
|
||||
2,
|
||||
)).rejects.toThrow(/distinct output_dir/);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -34,8 +34,13 @@ describe("Feishu resource staging recovery", () => {
|
||||
|
||||
it("rejects too many resources before contacting Feishu", async () => {
|
||||
const root = await tempRoot();
|
||||
const botCli: FeishuBotCli = {
|
||||
downloadResource: async () => {
|
||||
throw new Error("should not contact Feishu when over limit");
|
||||
},
|
||||
};
|
||||
await expect(stageMessageResources(
|
||||
{} as FeishuRuntime,
|
||||
botCli,
|
||||
"message-1",
|
||||
[
|
||||
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendTeacherNotice,
|
||||
isMaxTurnsError,
|
||||
teacherFacingRunOutcome,
|
||||
} from "../../src/feishu/runOutcomeNotice.js";
|
||||
|
||||
describe("teacherFacingRunOutcome", () => {
|
||||
it("explains max-turn stops with the configured ceiling", () => {
|
||||
const outcome = teacherFacingRunOutcome({
|
||||
wallTimeExceeded: false,
|
||||
interrupted: false,
|
||||
resultStatus: "failed",
|
||||
resultError: "Claude Code returned an error result: Reached maximum number of turns (25)",
|
||||
maxTurns: 150,
|
||||
maxRunSeconds: 1800,
|
||||
hasPartialText: true,
|
||||
});
|
||||
expect(outcome.isError).toBe(true);
|
||||
expect(outcome.notice).toContain("150");
|
||||
expect(outcome.notice).toContain("最大步骤数");
|
||||
expect(outcome.notice).toContain("部分结果");
|
||||
});
|
||||
|
||||
it("explains wall-clock timeouts", () => {
|
||||
const outcome = teacherFacingRunOutcome({
|
||||
wallTimeExceeded: true,
|
||||
interrupted: false,
|
||||
resultStatus: "interrupted",
|
||||
resultError: undefined,
|
||||
maxTurns: 150,
|
||||
maxRunSeconds: 1800,
|
||||
hasPartialText: false,
|
||||
});
|
||||
expect(outcome.isError).toBe(true);
|
||||
expect(outcome.notice).toContain("1800");
|
||||
expect(outcome.notice).toContain("超时");
|
||||
});
|
||||
|
||||
it("is quiet on successful completion", () => {
|
||||
expect(
|
||||
teacherFacingRunOutcome({
|
||||
wallTimeExceeded: false,
|
||||
interrupted: false,
|
||||
resultStatus: "completed",
|
||||
resultError: undefined,
|
||||
maxTurns: 150,
|
||||
maxRunSeconds: 1800,
|
||||
hasPartialText: true,
|
||||
}),
|
||||
).toEqual({ isError: false, notice: undefined });
|
||||
});
|
||||
|
||||
it("appendTeacherNotice joins body and notice", () => {
|
||||
expect(appendTeacherNotice("hello", "bye")).toBe("hello\n\nbye");
|
||||
expect(appendTeacherNotice("", "only")).toBe("only");
|
||||
});
|
||||
|
||||
it("detects max-turn SDK wording", () => {
|
||||
expect(isMaxTurnsError("Reached maximum number of turns (25)")).toBe(true);
|
||||
expect(isMaxTurnsError("result_error_max_turns")).toBe(true);
|
||||
expect(isMaxTurnsError("network glitch")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -113,9 +113,10 @@ describe("runAgent", () => {
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
skills: [],
|
||||
strictMcpConfig: true,
|
||||
settings: { disableBundledSkills: true, todoFeatureEnabled: true, autoCompactEnabled: true },
|
||||
tools: expect.arrayContaining(["Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite"]),
|
||||
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]),
|
||||
disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]),
|
||||
sandbox: expect.objectContaining({
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
@@ -130,6 +131,39 @@ describe("runAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("never exposes multi-agent orchestration tools on unrestricted roles", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "继续",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
tools: null,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
const call = queryMock.mock.calls[0]?.[0] as {
|
||||
options?: { tools?: unknown; disallowedTools?: string[] };
|
||||
} | undefined;
|
||||
expect(call?.options?.tools).toEqual([
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
]);
|
||||
for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) {
|
||||
expect(call?.options?.disallowedTools).toContain(blocked);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not send resume for a fresh Hub session", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
|
||||
|
||||
@@ -183,8 +217,16 @@ describe("runAgent", () => {
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Read", "Bash"],
|
||||
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
|
||||
tools: ["Read", "Bash", "TodoWrite"],
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Bash",
|
||||
"mcp__cph_hub__send_file",
|
||||
"TodoWrite",
|
||||
"mcp__cph_hub__todo_write",
|
||||
],
|
||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -205,8 +247,10 @@ describe("runAgent", () => {
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: [],
|
||||
allowedTools: [],
|
||||
tools: ["TodoWrite"],
|
||||
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
|
||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -234,9 +278,11 @@ describe("runAgent", () => {
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Skill"],
|
||||
tools: ["TodoWrite", "Skill"],
|
||||
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
|
||||
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
||||
skills: ["cph-runtime:typst"],
|
||||
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyChecklistToolEvent,
|
||||
isTodoWriteTool,
|
||||
parseTodoWriteInput,
|
||||
todoProgressSummary,
|
||||
} from "../../src/agent/todoList.js";
|
||||
import { buildAgentCard } from "../../src/feishu/card/builder.js";
|
||||
|
||||
describe("todo list parse", () => {
|
||||
it("accepts TodoWrite payloads", () => {
|
||||
const todos = parseTodoWriteInput({
|
||||
todos: [
|
||||
{ content: "搜题", status: "completed", activeForm: "正在搜题" },
|
||||
{ content: "写报告", status: "in_progress", activeForm: "正在写报告" },
|
||||
{ content: "发卡片", status: "pending" },
|
||||
],
|
||||
});
|
||||
expect(todos).toEqual([
|
||||
{ id: undefined, content: "搜题", status: "completed", activeForm: "正在搜题" },
|
||||
{ id: undefined, content: "写报告", status: "in_progress", activeForm: "正在写报告" },
|
||||
{ id: undefined, content: "发卡片", status: "pending", activeForm: undefined },
|
||||
]);
|
||||
expect(todoProgressSummary(todos!)).toEqual({ completed: 1, total: 3, inProgress: 1 });
|
||||
});
|
||||
|
||||
it("folds TaskCreate + TaskUpdate into a checklist", () => {
|
||||
let todos = applyChecklistToolEvent([], {
|
||||
toolName: "TaskCreate",
|
||||
input: { subject: "说你好", description: "greet" },
|
||||
result: "Task #1 created successfully: 说你好",
|
||||
});
|
||||
expect(todos).toEqual([
|
||||
{ id: "1", content: "说你好", status: "pending", activeForm: undefined },
|
||||
]);
|
||||
|
||||
todos = applyChecklistToolEvent(todos!, {
|
||||
toolName: "TaskCreate_1",
|
||||
input: { subject: "算 1+1", description: "math" },
|
||||
result: "Task #2 created successfully: 算 1+1",
|
||||
});
|
||||
todos = applyChecklistToolEvent(todos!, {
|
||||
toolName: "TaskUpdate",
|
||||
input: { taskId: "1", status: "in_progress", activeForm: "正在问好" },
|
||||
result: "Updated task #1 status",
|
||||
});
|
||||
todos = applyChecklistToolEvent(todos!, {
|
||||
toolName: "TaskUpdate_4",
|
||||
input: { taskId: "1", status: "completed" },
|
||||
result: "Updated task #1 status",
|
||||
});
|
||||
todos = applyChecklistToolEvent(todos!, {
|
||||
toolName: "TaskUpdate",
|
||||
input: { taskId: "2", status: "completed" },
|
||||
result: "Updated task #2 status",
|
||||
});
|
||||
|
||||
expect(todos).toEqual([
|
||||
{ id: "1", content: "说你好", status: "completed", activeForm: "正在问好" },
|
||||
{ id: "2", content: "算 1+1", status: "completed", activeForm: undefined },
|
||||
]);
|
||||
expect(todoProgressSummary(todos!)).toEqual({ completed: 2, total: 2, inProgress: 0 });
|
||||
});
|
||||
|
||||
it("recognizes TodoWrite tool names", () => {
|
||||
expect(isTodoWriteTool("TodoWrite")).toBe(true);
|
||||
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
|
||||
expect(isTodoWriteTool("Bash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent card todo panel", () => {
|
||||
it("renders a progress checklist when todos are present", () => {
|
||||
const card = buildAgentCard({
|
||||
phase: "streaming",
|
||||
text: "",
|
||||
reasoningText: undefined,
|
||||
todos: [
|
||||
{ id: "1", content: "A", status: "completed", activeForm: undefined },
|
||||
{ id: "2", content: "B", status: "in_progress", activeForm: "Doing B" },
|
||||
{ id: "3", content: "C", status: "pending", activeForm: undefined },
|
||||
],
|
||||
toolUseSteps: [
|
||||
{
|
||||
id: "1",
|
||||
seq: 1,
|
||||
toolName: "TaskCreate",
|
||||
toolUseId: "t1",
|
||||
input: {},
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
status: "success",
|
||||
startedAt: 0,
|
||||
finishedAt: 1,
|
||||
durationMs: 1,
|
||||
},
|
||||
],
|
||||
toolUseElapsedMs: 10,
|
||||
isError: undefined,
|
||||
interrupted: undefined,
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const json = JSON.stringify(card);
|
||||
expect(json).toContain("任务进度 1/3");
|
||||
expect(json).toContain("Doing B");
|
||||
expect(json).toContain("collapsible_panel");
|
||||
// TaskCreate noise filtered when checklist present (no separate tool panel if only Task*)
|
||||
expect(json).not.toContain("TaskCreate");
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,10 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
// Integration tests share one DB; run files sequentially to avoid
|
||||
// concurrent truncate/insert races. Unit tests are fast either way.
|
||||
pool: "forks",
|
||||
// Integration tests share one DB. Single worker + sequential files so
|
||||
// TRUNCATE + seed cannot race across files.
|
||||
pool: "threads",
|
||||
maxWorkers: 1,
|
||||
fileParallelism: false,
|
||||
env: { NODE_ENV: "test" },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user