forked from EduCraft/curriculum-project-hub
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33de8901e7 |
@@ -1,12 +1,12 @@
|
||||
name: checker check
|
||||
|
||||
# Builds and lints the Rust implementation crates under crates/ (the rule-based
|
||||
# lesson checker).
|
||||
# checker that "stands in Lean's position" at product runtime).
|
||||
#
|
||||
# This is an INTERNAL gate on the implementation's own health
|
||||
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
|
||||
# decision-to-implementation conformance gate — implementations align to the
|
||||
# ADRs by human review, not by CI. See the repo README.
|
||||
# Like spec-check, this is an INTERNAL gate on the implementation's own health
|
||||
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
|
||||
# spec-to-implementation conformance gate — implementations align to the Lean
|
||||
# contract by human review, not by CI. See the repo README.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
name: hub check
|
||||
|
||||
# Builds, type-checks, and tests the Hub TS package under hub/.
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half.
|
||||
# This is an INTERNAL gate on the Hub's own
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half
|
||||
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
|
||||
# health, like checker-check is for the Rust half.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hub-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
hub-check:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,9 +20,8 @@ jobs:
|
||||
POSTGRES_USER: paradigm
|
||||
POSTGRES_PASSWORD: paradigm
|
||||
POSTGRES_DB: cph_hub_test
|
||||
# Avoid host-port binds: concurrent hub-check jobs on the shared
|
||||
# runner raced on published 5432/15432 ("port is already allocated").
|
||||
# Reach the service by Docker DNS name from the job container instead.
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
|
||||
--health-interval 5s
|
||||
@@ -37,33 +33,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain (for cph binary)
|
||||
uses: dtolnay/rust-toolchain@1.92.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-hub-check-${{ runner.os }}-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
hub/package-lock.json
|
||||
hub/admin-web/package-lock.json
|
||||
cache-dependency-path: hub/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm ci --prefix admin-web
|
||||
run: npm ci
|
||||
|
||||
- name: Audit production Node dependencies
|
||||
run: npm run audit:production
|
||||
@@ -76,10 +54,8 @@ jobs:
|
||||
node <<'NODE'
|
||||
const net = require("node:net");
|
||||
const deadline = Date.now() + 60000;
|
||||
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
|
||||
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
|
||||
function tryConnect() {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
@@ -87,7 +63,7 @@ jobs:
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
if (Date.now() > deadline) {
|
||||
console.error(`Postgres did not become reachable at ${host}:${port}`);
|
||||
console.error("Postgres did not become reachable at 127.0.0.1:5432");
|
||||
process.exit(1);
|
||||
}
|
||||
setTimeout(tryConnect, 1000);
|
||||
@@ -114,41 +90,19 @@ jobs:
|
||||
run: |
|
||||
cd ..
|
||||
cargo install --path crates/cph-cli --locked
|
||||
# Make cph available to the unprivileged sandbox user below.
|
||||
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
|
||||
|
||||
- name: Prove real Claude SDK Bash sandbox boundary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Nested act/docker runners often disallow unprivileged user
|
||||
# namespaces, which bwrap requires once CapEff is cleared. Skip the
|
||||
# live proof there; unit + non-sandbox integration still gate.
|
||||
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
|
||||
if ! unshare --user true 2>/dev/null; then
|
||||
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
|
||||
exit 0
|
||||
fi
|
||||
if ! id cphci >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash cphci
|
||||
fi
|
||||
install -d -o cphci -g cphci -m 0700 /w/t
|
||||
REPO_ROOT="$(cd .. && pwd)"
|
||||
NODE_BIN_DIR="$(dirname "$(command -v node)")"
|
||||
NPX_BIN="$(command -v npx)"
|
||||
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
|
||||
/usr/bin/setpriv \
|
||||
--reuid=cphci --regid=cphci --init-groups \
|
||||
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
|
||||
--no-new-privs \
|
||||
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
|
||||
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
|
||||
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
|
||||
CPH_SANDBOX_TEST_ROOT=/w/t \
|
||||
/usr/bin/setpriv --no-new-privs \
|
||||
npx vitest run test/integration/agent-sandbox-linux.test.ts
|
||||
|
||||
- name: Run unit tests
|
||||
run: npx vitest run test/unit
|
||||
|
||||
# Integration tests need PostgreSQL + cph. cph is installed above.
|
||||
# PostgreSQL is the job service container reachable as `postgres`.
|
||||
# PostgreSQL is set up as a service container below.
|
||||
- name: Run integration tests (mock provider, real prisma + cph)
|
||||
run: |
|
||||
npx prisma migrate deploy --schema prisma/schema.prisma
|
||||
@@ -156,8 +110,7 @@ jobs:
|
||||
--exclude test/integration/real-model.test.ts \
|
||||
--exclude test/integration/agent-sandbox-linux.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
|
||||
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
|
||||
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
|
||||
|
||||
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
|
||||
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: spec check
|
||||
|
||||
# Builds the Lean semantic master spec under spec/.
|
||||
# This is an INTERNAL well-formedness gate (does the contract type-check?),
|
||||
# NOT a spec-to-implementation conformance gate — implementations align to the
|
||||
# contract by human review, not by CI. See repo README.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
spec-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: leanprover/lean-action@v1
|
||||
with:
|
||||
lake-package-directory: spec
|
||||
+4
-4
@@ -1,3 +1,7 @@
|
||||
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
|
||||
.lake/
|
||||
**/.lake/
|
||||
|
||||
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
|
||||
/target
|
||||
**/*.pdf
|
||||
@@ -15,7 +19,3 @@ node_modules/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
# Local operator notes / specs (not product source)
|
||||
/spec/
|
||||
/需求整理-*.md
|
||||
|
||||
@@ -29,7 +29,7 @@ workload brakes.
|
||||
|
||||
The full current-state inventory, accepted behavior, and release evidence are
|
||||
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
|
||||
with the durable decision in ADR-0022. Numerical
|
||||
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
|
||||
ceilings remain open until production-like calibration.
|
||||
|
||||
The implementation frontier is:
|
||||
|
||||
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
|
||||
## Question
|
||||
|
||||
After the readiness investigations and resulting fixes are resolved, can one
|
||||
repeatable release procedure prove build/test health, deploy a clean
|
||||
repeatable release procedure prove build/test/spec health, deploy a clean
|
||||
production-like environment, exercise critical tenant and agent journeys,
|
||||
verify observability and recovery, and either roll forward or roll back safely?
|
||||
|
||||
@@ -8,7 +8,7 @@ Blocked by: 04
|
||||
|
||||
Separate or unify run-bound audit entries, pre-run security/permission events,
|
||||
structured messages, and operational recovery events without weakening
|
||||
the pinned AuditEntry-to-run relation. Decide durability,
|
||||
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
|
||||
failure, retention, and query semantics; then enforce referential integrity and
|
||||
observable/recoverable writes instead of silently swallowing lost evidence.
|
||||
Do not merge these customer Project/Run records with ADR-0023's already-decided
|
||||
|
||||
+3
-1
@@ -40,7 +40,9 @@ an off-host recovery key, an incident and reason, and issues only an expiring
|
||||
Emergency Platform Grant.
|
||||
|
||||
The complete accepted decision and implementation divergences are in
|
||||
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
|
||||
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
|
||||
The pinned semantic invariants are in
|
||||
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
|
||||
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
|
||||
|
||||
Exact numeric session/invitation/step-up limits and browser mechanics remain
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
# AGENTS.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
|
||||
`Spec.System.Organization`)。
|
||||
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
|
||||
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
|
||||
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
|
||||
不接收 org provider credential(见 ADR-0024)。
|
||||
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
|
||||
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
|
||||
`Spec.System.Capacity`)。
|
||||
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
|
||||
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
|
||||
双因子的离线恢复流程(见 ADR-0023)。
|
||||
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
|
||||
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
|
||||
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
|
||||
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
|
||||
@@ -32,10 +36,6 @@
|
||||
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
|
||||
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
|
||||
运行时能力(见 ADR-0018)。
|
||||
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
|
||||
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
|
||||
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
|
||||
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
|
||||
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
|
||||
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
|
||||
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
|
||||
@@ -44,12 +44,12 @@
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# CLAUDE.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
|
||||
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
|
||||
|
||||
## 安装 `cph` 命令行
|
||||
|
||||
@@ -33,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
|
||||
```
|
||||
README.md ← 本文件:总览 + 宪法(下面 5 条)
|
||||
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
|
||||
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
|
||||
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
|
||||
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
|
||||
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
|
||||
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
|
||||
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
|
||||
cph-check / cph-cli ← checker 本体 + `cph` 命令行
|
||||
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
|
||||
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
|
||||
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
|
||||
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
|
||||
(exporter/ …) ← 将来的其他部件,平级于 crates/
|
||||
(exporter/ …) ← 将来的其他部件,平级于 spec/
|
||||
```
|
||||
|
||||
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
|
||||
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
|
||||
未来部件(如 exporter)复用,而非各自重造。
|
||||
|
||||
## 宪法
|
||||
|
||||
这 4 条是本仓库的协作约定,是一切工作的前提。
|
||||
这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
|
||||
|
||||
1. **角色 —— ADR 是决策真相。**
|
||||
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档。
|
||||
1. **角色 —— Lean 是研发侧的上游参照。**
|
||||
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。
|
||||
|
||||
2. **对齐机制 —— 人肉承载,无机器兜底。**
|
||||
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
|
||||
2. **对齐机制 —— Lean 只做上游参照。**
|
||||
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
|
||||
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
|
||||
|
||||
3. **形态 —— 自包含。**
|
||||
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定。
|
||||
3. **资产性 —— 由 review 纪律承载,无机器兜底。**
|
||||
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。
|
||||
|
||||
4. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
4. **形态 —— 它是人机共识的契约。**
|
||||
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。
|
||||
|
||||
5. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
|
||||
|
||||
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
|
||||
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
|
||||
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
# crates/
|
||||
|
||||
These crates implement the rule-based lesson checker whose semantics are
|
||||
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
|
||||
ADR-0005)
|
||||
These crates implement the rule-based lesson checker that aligns to the
|
||||
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
|
||||
laid out per ADR-0008 (declarative `manifest.toml` + per-element
|
||||
`element.toml`), validates structure and content, and emits diagnostics.
|
||||
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
|
||||
|
||||
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
|
||||
|
||||
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
|
||||
///
|
||||
/// **PINNED to `warning` by ADR-0005:** when a
|
||||
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
|
||||
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
|
||||
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
|
||||
/// `(kind, target)` pair has no render rule the checker reports that the element
|
||||
/// is ignored under that target and **does not block the export**. Naming the
|
||||
/// severity as a const makes "it is a warning, not an error" a greppable
|
||||
/// fact rather than an inline literal.
|
||||
/// severity as a const makes "it is a warning, not an error" a greppable,
|
||||
/// alignable fact rather than an inline literal.
|
||||
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
|
||||
|
||||
/// The result of running [`check`] (or the check phases of [`build`]).
|
||||
@@ -55,12 +57,12 @@ impl CheckReport {
|
||||
|
||||
/// Whether any collected diagnostic is `Error`-severity.
|
||||
///
|
||||
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
|
||||
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
|
||||
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
|
||||
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
|
||||
/// kept greppable here so a reviewer can tie the orchestrator's gate to
|
||||
/// the ADR.
|
||||
/// **Legality decision (spec alignment).** `!has_errors()` is the
|
||||
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
|
||||
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
|
||||
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
|
||||
/// gate enforcing this alignment (repo constitution); it is kept greppable
|
||||
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
|
||||
+15
-10
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Every other crate in the workspace depends on these types to report
|
||||
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
|
||||
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
|
||||
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
|
||||
//! optional [`SourceSpan`] pointing back at the offending source, and a
|
||||
//! [`Diagnostic`] tying them together with a human message and a fix hint.
|
||||
//!
|
||||
@@ -17,27 +17,32 @@ use serde::Serialize;
|
||||
|
||||
/// Severity of a diagnostic.
|
||||
///
|
||||
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
|
||||
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
|
||||
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
|
||||
/// exactly:
|
||||
///
|
||||
/// ```text
|
||||
/// warning | error
|
||||
/// inductive Severity where
|
||||
/// | warning
|
||||
/// | error
|
||||
/// ```
|
||||
///
|
||||
/// This two-valued shape is a **contract decision**, not an accident: the
|
||||
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
|
||||
/// This two-valued shape is a **contract decision**, not an accident: the Lean
|
||||
/// module pins `Severity` to exactly `warning | error` and states the finer
|
||||
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
|
||||
/// do **not** add an info/note level here. `error` blocks (the artifact is
|
||||
/// invalid); `warning` does not block (the artifact still exports, but with
|
||||
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
|
||||
///
|
||||
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
|
||||
/// constitution); it is maintained by review, which is why the decision is
|
||||
/// documented here rather than only in the ADR.
|
||||
/// There is no CI gate enforcing this alignment (see the repo constitution);
|
||||
/// it is maintained by review, which is why this correspondence is documented
|
||||
/// here rather than only in the spec.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Severity {
|
||||
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
|
||||
/// element. ADR-0010 `warning`.
|
||||
/// element. Mirrors Lean `Severity.warning`.
|
||||
Warning,
|
||||
/// Blocking: the artifact is invalid. ADR-0010 `error`.
|
||||
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
|
||||
Error,
|
||||
}
|
||||
|
||||
|
||||
+38
-25
@@ -3,7 +3,9 @@
|
||||
//! This crate is the **loader**, not the full checker. It reads
|
||||
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
|
||||
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
|
||||
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
|
||||
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
|
||||
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
|
||||
//! teaching semantics.
|
||||
//!
|
||||
//! Scope boundaries (deliberately staying in lane):
|
||||
//! - It validates **structure** only: manifest shape, element.toml shape, and
|
||||
@@ -21,7 +23,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An ordered, in-memory lesson loaded from an engineering file.
|
||||
///
|
||||
/// `parts` is an ordered
|
||||
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
|
||||
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
|
||||
/// is declarative" — the `[[parts]]` array order is the single source of truth).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -84,8 +86,9 @@ pub struct TargetConfig {
|
||||
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
|
||||
pub steps: Vec<Step>,
|
||||
/// The **render-coverage declaration**: which element kinds this target
|
||||
/// renders. Realizes ADR-0011's "render
|
||||
/// coverage is a declaration, not a payload": the declaration keeps *which
|
||||
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
|
||||
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
|
||||
/// coverage is a declaration, not a payload": the contract keeps *which
|
||||
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
|
||||
/// while the rendering "how" lives in the template/steps.
|
||||
///
|
||||
@@ -99,10 +102,13 @@ pub struct TargetConfig {
|
||||
|
||||
/// The artifact an export target produces (ADR-0009/0011).
|
||||
///
|
||||
/// **Pinned by ADR-0011** as an ADT with fields:
|
||||
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
|
||||
///
|
||||
/// ```text
|
||||
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
|
||||
/// inductive Artifact where
|
||||
/// | singleFile (filepath : String)
|
||||
/// | fileTree (root : String) (outputs : String)
|
||||
/// ```
|
||||
///
|
||||
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
|
||||
@@ -114,20 +120,20 @@ pub struct TargetConfig {
|
||||
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
|
||||
/// [`Artifact::FileTree`].
|
||||
///
|
||||
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
|
||||
/// implementation alignment (see the repo constitution) — it is maintained by
|
||||
/// review, which is why the decision is documented here.
|
||||
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
|
||||
/// alignment (see the repo constitution) — it is maintained by review, which is
|
||||
/// why the correspondence is documented here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum Artifact {
|
||||
/// One bundled document landing at `filepath` (relative to the engineering
|
||||
/// root). ADR-0011 `singleFile`. The default artifact shape.
|
||||
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
|
||||
SingleFile {
|
||||
/// Where the single product is written (relative to the engineering
|
||||
/// root), e.g. `build/student.pdf`.
|
||||
filepath: PathBuf,
|
||||
},
|
||||
/// A set of files under `root` matching the `outputs` glob. ADR-0011
|
||||
/// `fileTree`.
|
||||
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
|
||||
/// `Artifact.fileTree`.
|
||||
FileTree {
|
||||
/// The output directory (relative to the engineering root).
|
||||
root: PathBuf,
|
||||
@@ -150,10 +156,14 @@ impl Artifact {
|
||||
|
||||
/// One typed build step (ADR-0011).
|
||||
///
|
||||
/// **Pinned by ADR-0011** as an ADT:
|
||||
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
|
||||
///
|
||||
/// ```text
|
||||
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
|
||||
/// inductive Step where
|
||||
/// | typstCompile (template : String)
|
||||
/// | shell (run : String)
|
||||
/// | assembleMarkdown (field : String)
|
||||
/// ```
|
||||
///
|
||||
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
|
||||
@@ -173,20 +183,20 @@ impl Artifact {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum Step {
|
||||
/// Compile a template file (relative to the engineering root) into the
|
||||
/// artifact; the framework injects the manifest. ADR-0011
|
||||
/// `typstCompile`.
|
||||
/// artifact; the framework injects the manifest. Mirrors Lean
|
||||
/// `Step.typstCompile`.
|
||||
TypstCompile {
|
||||
/// The template file to compile as main, e.g. `exports/student.typ`.
|
||||
template: PathBuf,
|
||||
},
|
||||
/// Run a shell command — the escape hatch. ADR-0011 `shell`.
|
||||
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
|
||||
Shell {
|
||||
/// The command line to run.
|
||||
run: String,
|
||||
},
|
||||
/// Assemble a single-file markdown deliverable by concatenating each
|
||||
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
|
||||
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
|
||||
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
|
||||
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
|
||||
/// framework owns the read/concatenate/write itself.
|
||||
AssembleMarkdown {
|
||||
/// The per-element markdown content field to assemble (e.g. `slides`,
|
||||
@@ -216,11 +226,12 @@ pub struct Project {
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
///
|
||||
/// The *canonical* model whose
|
||||
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
|
||||
/// `authors` is always a list. The authoring-surface form (string-or-array
|
||||
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
|
||||
/// at the load boundary. No
|
||||
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
|
||||
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
|
||||
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Info {
|
||||
/// Lesson title.
|
||||
@@ -229,6 +240,7 @@ pub struct Info {
|
||||
/// so this is a list, not a single name. Empty when `[info]` declares no
|
||||
/// `author`. The on-disk `author` accepts either a bare string (one author)
|
||||
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
|
||||
/// Mirrors Lean `Info.authors : List String`.
|
||||
pub authors: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -278,7 +290,8 @@ struct RawProject {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The authoring-surface `[info]`: the raw form that exists for
|
||||
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
|
||||
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
|
||||
/// fill-in convenience, normalized into the canonical [`Info`] at the load
|
||||
/// boundary. Not the form the rest of the model traffics in.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -288,7 +301,7 @@ struct RawInfo {
|
||||
}
|
||||
|
||||
/// On-disk `author`: either a single name (`author = "…"`) or a list
|
||||
/// (`author = ["…", "…"]`). A fill-in convenience whose
|
||||
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
|
||||
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
|
||||
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -299,7 +312,7 @@ enum RawAuthor {
|
||||
}
|
||||
|
||||
impl RawAuthor {
|
||||
/// Flatten to the ordered author list: a single
|
||||
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
|
||||
/// name becomes a one-element list; a list passes through verbatim.
|
||||
fn into_vec(self) -> Vec<String> {
|
||||
match self {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# ADR 0028: Agent Configuration Folder Tree
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
|
||||
configuration, managed without process restarts. The org admin surfaces for
|
||||
them (`/admin/roles` and `/admin/skills`) render every
|
||||
role/skill as one large editor card in a single flat list. As an Organization
|
||||
accumulates roles and skills, the management pages degrade into an endless
|
||||
scroll with no grouping affordance.
|
||||
|
||||
The project explorer already solves the analogous problem for projects with
|
||||
transparent folders (ADR-0021): org-scoped navigation nodes that are not
|
||||
permission resources. Roles and skills need the same affordance, but their
|
||||
semantics differ from projects in one crucial way: skill names and role IDs
|
||||
are referenced by role→skill bindings, run-time skill snapshot loading, and
|
||||
Feishu slash commands. Any grouping mechanism must not leak into those
|
||||
resolution paths.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
|
||||
skills:
|
||||
|
||||
- One folder tree per Organization is shared by both roles and skills (e.g. a
|
||||
"高三化学组" folder groups that team's roles and its skills together). It is
|
||||
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
|
||||
trees are managed independently and never reference each other.
|
||||
- Folders are **transparent organization nodes**, following the ADR-0021
|
||||
project-folder precedent: they exist for management-surface navigation and
|
||||
grouping only, are not permission resources, and hold no grants.
|
||||
- Folder membership is **not part of role/skill identity or resolution**:
|
||||
- skill `name` and role `roleId` remain unique per Organization regardless
|
||||
of folder membership;
|
||||
- role→skill bindings, run admission's frozen role snapshot, run-scoped
|
||||
skill loading, and Feishu slash commands never reference folders.
|
||||
- Each role/skill sits in at most one folder; membership is optional
|
||||
(unfiled items remain first-class). Folders nest arbitrarily.
|
||||
- Folder assignment is a label-class change in the ADR-0017 sense: it never
|
||||
archives Agent sessions, because the execution surface (model, prompt,
|
||||
tools, skill content) is untouched.
|
||||
- A folder can be deleted only when empty — no child folders, no roles, no
|
||||
skills. Relocating items out of a folder is an explicit user action, so no
|
||||
orphan-placement rule is needed yet.
|
||||
- Admin web renders both pages as a left folder tree plus the item list of
|
||||
the selected folder ("all" and "unfiled" included). The host-console CLI is
|
||||
unchanged: folder management lives in the web surface, and CLI
|
||||
`upsert-role`/`install-skill` never touch folder assignment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
|
||||
`parentId`, delete restricted while referenced) plus nullable `folderId` on
|
||||
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
|
||||
delete, though the service refuses to delete non-empty folders).
|
||||
- The spec pins the transparency and single-membership semantics in
|
||||
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
|
||||
not re-derive them differently (e.g. path-style names or per-folder
|
||||
uniqueness).
|
||||
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
|
||||
that skip session archival by construction.
|
||||
- Moving a role/skill between folders changes nothing about authorization,
|
||||
run resolution, or audit-visible configuration lineage beyond the folder
|
||||
assignment event itself.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
|
||||
per-item select for now.
|
||||
- Folder-level usage aggregation for roles/skills is deferred (project
|
||||
folders already aggregate usage under ADR-0021; agent configuration has no
|
||||
usage dimension yet).
|
||||
- CLI flags for folder assignment are deferred until a console workflow asks
|
||||
for them.
|
||||
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
|
||||
for now: the Organization keeps exactly one active default role
|
||||
(ADR-0017/0018 invariant) regardless of folder structure.
|
||||
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
|
||||
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
|
||||
| 以应用身份发送消息 | `im:message:send_as_bot` |
|
||||
| 读取触发消息和线程上下文 | `im:message:readonly` |
|
||||
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
|
||||
| 获取与上传图片或文件 | `im:resource` |
|
||||
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
||||
| 获取用户基本信息 | `contact:user.base:readonly` |
|
||||
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
||||
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
|
||||
|
||||
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
||||
|
||||
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
|
||||
|
||||
|
||||
## 4. 配置事件与卡片回调
|
||||
|
||||
进入“事件与回调”。
|
||||
|
||||
+3
-20
@@ -20,14 +20,12 @@ 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="150"
|
||||
# HUB_AGENT_MAX_TURNS=25
|
||||
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
|
||||
HUB_AGENT_MAX_RUN_SECONDS="1800"
|
||||
HUB_AGENT_MAX_RUN_SECONDS="900"
|
||||
HUB_HTTP_BODY_LIMIT_BYTES="1048576"
|
||||
HUB_MAX_FILES_PER_MESSAGE="20"
|
||||
HUB_MAX_FILES_PER_MESSAGE="8"
|
||||
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"
|
||||
|
||||
@@ -40,24 +38,9 @@ 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
+7
-96
@@ -7,9 +7,6 @@
|
||||
"": {
|
||||
"name": "admin-web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
@@ -29,38 +26,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.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.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -898,72 +881,6 @@
|
||||
"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",
|
||||
@@ -1851,12 +1768,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,8 +29,5 @@
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,16 +168,6 @@ export interface FeishuApplicationConnection {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityConnection {
|
||||
id: string;
|
||||
capabilityId: string;
|
||||
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
||||
activeVersion: number | null;
|
||||
keyId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageTotals {
|
||||
runCount: number;
|
||||
runsWithCost: number;
|
||||
@@ -193,81 +183,13 @@ export interface ProjectUsageRow extends UsageTotals {
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
|
||||
export interface UsageBreakdownRow {
|
||||
kind: string;
|
||||
provider: string;
|
||||
model: string | null;
|
||||
capabilityId: string | null;
|
||||
unit: string | null;
|
||||
factCount: number;
|
||||
factsWithCost: number;
|
||||
factsWithoutCost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
quantity: number | null;
|
||||
costUsd: number | null;
|
||||
}
|
||||
|
||||
export interface UsageReport {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
projects: ProjectUsageRow[];
|
||||
totals: UsageTotals;
|
||||
breakdown: UsageBreakdownRow[];
|
||||
}
|
||||
|
||||
export interface ProjectUsageReport extends ProjectUsageRow {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
breakdown: UsageBreakdownRow[];
|
||||
}
|
||||
|
||||
export interface UsageFactRow {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
kind: string;
|
||||
provider: string;
|
||||
model: string | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
costUsd: number | null;
|
||||
costSource: string;
|
||||
capabilityId: string | null;
|
||||
correlationId: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRunRow {
|
||||
id: string;
|
||||
status: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
costUsd: number | null;
|
||||
costSource: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
error: string | null;
|
||||
usageFacts: UsageFactRow[];
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
id: string;
|
||||
provider: string;
|
||||
roleId: string;
|
||||
model: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt: string | null;
|
||||
project: { id: string; name: string };
|
||||
runs: SessionRunRow[];
|
||||
}
|
||||
|
||||
|
||||
export type CapacityDimension =
|
||||
| 'requestRate'
|
||||
| 'requestBodySize'
|
||||
@@ -317,7 +239,6 @@ export interface AgentRoleRow {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skillNames: readonly string[];
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -330,14 +251,6 @@ 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 {
|
||||
@@ -410,7 +323,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<{ projectId: string; folderId: string | null; workspaceDir: string; name: string }>,
|
||||
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
|
||||
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
|
||||
renameProject: (slug: string, projectId: string, name: string) =>
|
||||
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
|
||||
@@ -430,8 +343,6 @@ export const api = {
|
||||
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
|
||||
sessions: SessionSummary[];
|
||||
}>,
|
||||
session: (slug: string, sessionId: string) =>
|
||||
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
|
||||
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set('from', params.from);
|
||||
@@ -440,16 +351,6 @@ export const api = {
|
||||
const qs = q.toString();
|
||||
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
|
||||
},
|
||||
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set('from', params.from);
|
||||
if (params?.to) q.set('to', params.to);
|
||||
const qs = q.toString();
|
||||
return get(
|
||||
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
|
||||
) as Promise<ProjectUsageReport>;
|
||||
},
|
||||
|
||||
|
||||
providerConnections: (slug: string) =>
|
||||
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
|
||||
@@ -480,32 +381,6 @@ export const api = {
|
||||
disableFeishuApplication: (slug: string) =>
|
||||
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
|
||||
|
||||
capabilityConnections: (slug: string) =>
|
||||
get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
|
||||
capabilityConnection: (slug: string, capabilityId: string) =>
|
||||
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
|
||||
connection: CapabilityConnection | null;
|
||||
}>,
|
||||
rotateCapabilityConnection: (
|
||||
slug: string,
|
||||
capabilityId: string,
|
||||
body:
|
||||
| { 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) =>
|
||||
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
|
||||
|
||||
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
|
||||
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
|
||||
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
|
||||
@@ -535,21 +410,4 @@ 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;
|
||||
}>,
|
||||
};
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AgentConfigFolderRow } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
/**
|
||||
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
|
||||
* Transparent grouping only: selection filters the item list, it never
|
||||
* affects role/skill identity or run resolution.
|
||||
*/
|
||||
let {
|
||||
folders,
|
||||
selected,
|
||||
counts,
|
||||
totalCount,
|
||||
unfiledCount,
|
||||
onselect,
|
||||
oncreate,
|
||||
onrename,
|
||||
ondelete,
|
||||
}: {
|
||||
folders: AgentConfigFolderRow[];
|
||||
/** 'all' | 'unfiled' | folder id */
|
||||
selected: string;
|
||||
/** item count per folder id (roles or skills, depending on the page) */
|
||||
counts: Record<string, number>;
|
||||
totalCount: number;
|
||||
unfiledCount: number;
|
||||
onselect: (id: string) => void;
|
||||
oncreate: (parentId: string | null) => void;
|
||||
onrename: (folder: AgentConfigFolderRow) => void;
|
||||
ondelete: (folder: AgentConfigFolderRow) => void;
|
||||
} = $props();
|
||||
|
||||
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
|
||||
|
||||
let collapsed = $state<ReadonlySet<string>>(new Set());
|
||||
|
||||
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
|
||||
const rows: Row[] = [];
|
||||
const walk = (parentId: string | null, depth: number) => {
|
||||
const siblings = list
|
||||
.filter((f) => f.parentId === parentId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const folder of siblings) {
|
||||
const hasChildren = list.some((f) => f.parentId === folder.id);
|
||||
rows.push({ folder, depth, hasChildren });
|
||||
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(null, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
const rows = $derived(flatten(folders, collapsed));
|
||||
|
||||
function toggleCollapse(folderId: string) {
|
||||
const next = new Set(collapsed);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
collapsed = next;
|
||||
}
|
||||
|
||||
function childFolderCount(folderId: string): number {
|
||||
return folders.filter((f) => f.parentId === folderId).length;
|
||||
}
|
||||
|
||||
const rowClass = (active: boolean) =>
|
||||
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
|
||||
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
|
||||
}`;
|
||||
</script>
|
||||
|
||||
<nav class="saas-card p-2" aria-label="文件夹导航">
|
||||
<div class="flex items-center justify-between px-2 py-1.5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary-700 hover:text-primary-900"
|
||||
onclick={() => oncreate(null)}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
|
||||
<span class="w-3.5"></span>
|
||||
<span class="min-w-0 flex-1 truncate">全部</span>
|
||||
<span class="saas-badge-neutral">{totalCount}</span>
|
||||
</button>
|
||||
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
|
||||
<span class="w-3.5"></span>
|
||||
<span class="min-w-0 flex-1 truncate">未分类</span>
|
||||
<span class="saas-badge-neutral">{unfiledCount}</span>
|
||||
</button>
|
||||
|
||||
{#each rows as row (row.folder.id)}
|
||||
{@const itemCount = counts[row.folder.id] ?? 0}
|
||||
{@const childCount = childFolderCount(row.folder.id)}
|
||||
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
|
||||
{#if row.hasChildren}
|
||||
<button
|
||||
type="button"
|
||||
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
|
||||
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCollapse(row.folder.id);
|
||||
}}
|
||||
>
|
||||
{collapsed.has(row.folder.id) ? '▸' : '▾'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="w-3.5 shrink-0"></span>
|
||||
{/if}
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
|
||||
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||||
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
|
||||
{#if itemCount > 0}
|
||||
<span class="saas-badge-neutral">{itemCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
|
||||
title="在此新建子文件夹"
|
||||
aria-label="在 {row.folder.name} 内新建子文件夹"
|
||||
onclick={() => oncreate(row.folder.id)}
|
||||
>
|
||||
<Icon name="folder-plus" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
|
||||
title="重命名 / 移动"
|
||||
aria-label="重命名或移动 {row.folder.name}"
|
||||
onclick={() => onrename(row.folder)}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
|
||||
aria-label="删除 {row.folder.name}"
|
||||
disabled={itemCount > 0 || childCount > 0}
|
||||
onclick={() => ondelete(row.folder)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if folders.length === 0}
|
||||
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="space-y-0.5">
|
||||
{#each childProjects as p (p.id)}
|
||||
<a
|
||||
href={`/admin/projects/${p.id}`}
|
||||
href={`/admin/org/${slug}/projects/${p.id}`}
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
|
||||
>
|
||||
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from 'bits-ui';
|
||||
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import 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';
|
||||
@@ -15,23 +14,15 @@
|
||||
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 = {
|
||||
@@ -53,8 +44,6 @@
|
||||
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) => {
|
||||
@@ -64,69 +53,12 @@
|
||||
{} as Record<string, typeof TOOL_OPTIONS>,
|
||||
);
|
||||
|
||||
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 modelItems = $derived([
|
||||
{ value: '', label: '(使用平台默认模型)' },
|
||||
...models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })),
|
||||
]);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
||||
|
||||
function skillsDirty(): boolean {
|
||||
const a = [...selectedSkills].sort();
|
||||
@@ -173,24 +105,6 @@
|
||||
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">
|
||||
@@ -200,12 +114,6 @@
|
||||
{#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">
|
||||
@@ -221,13 +129,7 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="saas-label">默认模型</p>
|
||||
<SearchableSelectField
|
||||
items={modelItems}
|
||||
bind:value={defaultModel}
|
||||
placeholder="选择模型…"
|
||||
searchPlaceholder="搜索模型名称或 ID"
|
||||
emptyText="无匹配模型"
|
||||
/>
|
||||
<SelectField items={modelItems} bind:value={defaultModel} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@@ -264,40 +166,19 @@
|
||||
<div class="mt-4">
|
||||
<span class="saas-label">技能绑定</span>
|
||||
{#if skills.length === 0}
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板(ADR-0018)。</p>
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
||||
{:else}
|
||||
<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>
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{#each skillItems as s}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.value)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<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,26 +2,19 @@
|
||||
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 };
|
||||
@@ -35,10 +28,6 @@
|
||||
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'));
|
||||
@@ -163,53 +152,6 @@
|
||||
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)) {
|
||||
@@ -236,12 +178,6 @@
|
||||
{#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">
|
||||
@@ -356,20 +292,9 @@
|
||||
</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 || zipImporting}>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||
{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,15 +12,12 @@ 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) */
|
||||
|
||||
@@ -29,52 +29,6 @@ export function fmtNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const USAGE_KIND_LABELS: Record<string, string> = {
|
||||
model_completion: '模型完成',
|
||||
external_capability: '外部能力',
|
||||
tool_proxy: '工具代理',
|
||||
};
|
||||
|
||||
const METER_UNIT_LABELS: Record<string, string> = {
|
||||
pages: '页',
|
||||
audio_seconds: '音频秒',
|
||||
invocations: '次调用',
|
||||
};
|
||||
|
||||
export function usageKindLabel(kind: string): string {
|
||||
return USAGE_KIND_LABELS[kind] ?? kind;
|
||||
}
|
||||
|
||||
export function meterUnitLabel(unit: string | null | undefined): string {
|
||||
if (!unit) return '—';
|
||||
return METER_UNIT_LABELS[unit] ?? unit;
|
||||
}
|
||||
|
||||
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
|
||||
if (quantity === null || quantity === undefined) return '—';
|
||||
const u = meterUnitLabel(unit);
|
||||
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
|
||||
}
|
||||
|
||||
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
|
||||
const hasIn = input !== null && input !== undefined;
|
||||
const hasOut = output !== null && output !== undefined;
|
||||
if (!hasIn && !hasOut) return '—';
|
||||
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
|
||||
}
|
||||
|
||||
export function runStatusLabel(status: string): string {
|
||||
const key = status.toUpperCase();
|
||||
if (key === 'COMPLETED') return '完成';
|
||||
if (key === 'FAILED') return '失败';
|
||||
if (key === 'CANCELED') return '取消';
|
||||
if (key === 'TIMED_OUT') return '超时';
|
||||
if (key === 'RUNNING') return '运行中';
|
||||
if (key === 'QUEUED') return '排队';
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
export function orgRoleLabel(role: string): string {
|
||||
const key = role.toUpperCase() as OrgRole;
|
||||
return ORG_ROLE_LABELS[key] ?? role;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MeResponse, OrgMembership } from './api';
|
||||
|
||||
/** Alpha Silo host prefix: <slug>.educraft[.dev].… */
|
||||
export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null {
|
||||
const host = hostname.toLowerCase();
|
||||
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
|
||||
if (!org) return false;
|
||||
const role = String(org.role ?? '').toUpperCase();
|
||||
return role === 'OWNER' || role === 'ADMIN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tenancy for this browser session.
|
||||
* Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership.
|
||||
*/
|
||||
export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null {
|
||||
if (!me || me.organizations.length === 0) return null;
|
||||
const host = hostOrgSlug();
|
||||
if (host) {
|
||||
const byHost = me.organizations.find((o) => o.slug === host);
|
||||
if (byHost) return byHost;
|
||||
}
|
||||
const q = new URLSearchParams(search).get('org')?.trim();
|
||||
if (q) {
|
||||
const byQuery = me.organizations.find((o) => o.slug === q);
|
||||
if (byQuery) return byQuery;
|
||||
}
|
||||
const admin = me.organizations.find((o) => isOrgAdmin(o));
|
||||
return admin ?? me.organizations[0] ?? null;
|
||||
}
|
||||
|
||||
/** SPA paths no longer embed org slug (subdomain carries tenancy). */
|
||||
export function adminPath(rest: string = ''): string {
|
||||
const cleaned = rest.replace(/^\/+/, '');
|
||||
return cleaned === '' ? '/admin' : `/admin/${cleaned}`;
|
||||
}
|
||||
@@ -35,9 +35,12 @@ export async function loadSession(): Promise<void> {
|
||||
/**
|
||||
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
|
||||
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
|
||||
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
|
||||
*/
|
||||
export function resolveLoginOrgSlug(): string | null {
|
||||
const path = window.location.pathname.split('/').filter(Boolean);
|
||||
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
|
||||
return decodeURIComponent(path[2]);
|
||||
}
|
||||
const q = new URLSearchParams(window.location.search).get('org');
|
||||
if (q && q.trim() !== '') return q.trim();
|
||||
|
||||
@@ -45,12 +48,6 @@ export function resolveLoginOrgSlug(): string | null {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
|
||||
if (m?.[1]) return m[1];
|
||||
|
||||
// Legacy path while old bookmarks still land briefly before redirect.
|
||||
const path = window.location.pathname.split('/').filter(Boolean);
|
||||
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
|
||||
return decodeURIComponent(path[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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,6 +32,3 @@ 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');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { page } from '$app/state';
|
||||
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
|
||||
import type { OrgMembership } from '$lib/api';
|
||||
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
|
||||
import { orgRoleLabel } from '$lib/format';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
@@ -21,7 +20,6 @@
|
||||
|
||||
const navItems = [
|
||||
{ key: 'overview', label: '概览', icon: 'overview' as const },
|
||||
{ key: 'usage', label: '用量', icon: 'overview' as const },
|
||||
{ key: 'members', label: '成员', icon: 'members' as const },
|
||||
{ key: 'teams', label: '团队', icon: 'teams' as const },
|
||||
{ key: 'projects', label: '项目', icon: 'projects' as const },
|
||||
@@ -30,52 +28,68 @@
|
||||
{ key: 'skills', label: '技能', icon: 'roles' as const },
|
||||
{ key: 'roles', label: '角色', icon: 'roles' as const },
|
||||
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
|
||||
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
|
||||
];
|
||||
|
||||
function isAdmin(org: OrgMembership): boolean {
|
||||
const role = String(org.role ?? '').toUpperCase();
|
||||
return role === 'OWNER' || role === 'ADMIN';
|
||||
}
|
||||
|
||||
function orgSlugFromPath(): string | null {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
|
||||
return decodeURIComponent(parts[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOnProjectRoute(): boolean {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
|
||||
}
|
||||
|
||||
function memberships(): OrgMembership[] {
|
||||
return $session.me?.organizations ?? [];
|
||||
}
|
||||
|
||||
function adminOrgs(): OrgMembership[] {
|
||||
return memberships().filter((o) => isOrgAdmin(o));
|
||||
return memberships().filter(isAdmin);
|
||||
}
|
||||
|
||||
function currentOrg(): OrgMembership | null {
|
||||
return resolveOrg($session.me, page.url.search);
|
||||
const slug = orgSlugFromPath();
|
||||
if (!slug) return null;
|
||||
return memberships().find((o) => o.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
function isOnProjectRoute(): boolean {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
// /admin/projects or /admin/projects/:id
|
||||
return parts[0] === 'admin' && parts[1] === 'projects';
|
||||
function pickHomeOrg(): OrgMembership | null {
|
||||
const admin = adminOrgs()[0];
|
||||
if (admin) return admin;
|
||||
return memberships()[0] ?? null;
|
||||
}
|
||||
|
||||
function activeKey(): string {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] !== 'admin') return '';
|
||||
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
|
||||
return parts[1] ?? 'overview';
|
||||
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
|
||||
return parts[3] ?? 'overview';
|
||||
}
|
||||
|
||||
function navHref(key: string): string {
|
||||
if (key === 'overview') return adminPath();
|
||||
return adminPath(key);
|
||||
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
|
||||
if (!slug) return '/';
|
||||
if (key === 'overview') return `/admin/org/${slug}`;
|
||||
return `/admin/org/${slug}/${key}`;
|
||||
}
|
||||
|
||||
function pageTitle(): string {
|
||||
const key = activeKey();
|
||||
if (key === 'overview' || key === '') return '概览';
|
||||
if (key === 'sessions') return '会话详情';
|
||||
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
|
||||
}
|
||||
|
||||
function switchOrg(nextSlug: string) {
|
||||
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
|
||||
// Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
|
||||
const url = new URL(page.url.href);
|
||||
url.searchParams.set('org', nextSlug);
|
||||
void goto(`${url.pathname}${url.search}`, { replaceState: true });
|
||||
void goto(`/admin/org/${nextSlug}`);
|
||||
}
|
||||
|
||||
function handleLogout(e: Event) {
|
||||
@@ -98,23 +112,33 @@
|
||||
$effect(() => {
|
||||
if ($session.loading || !$session.me) return;
|
||||
|
||||
const path = page.url.pathname;
|
||||
// Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
|
||||
const slug = orgSlugFromPath();
|
||||
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
|
||||
|
||||
const org = currentOrg();
|
||||
// Org admins: route to their first admin org if none matched as admin.
|
||||
const admins = adminOrgs();
|
||||
|
||||
if (org && isOrgAdmin(org)) {
|
||||
if (matched && isAdmin(matched)) {
|
||||
redirecting = false;
|
||||
return;
|
||||
}
|
||||
if (!matched && admins.length > 0) {
|
||||
const target = `/admin/org/${admins[0].slug}`;
|
||||
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
||||
redirecting = true;
|
||||
void goto(target, { replaceState: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Member only: allow project routes; bounce admin-only surfaces to projects.
|
||||
if (org && !isOrgAdmin(org)) {
|
||||
// Members (non-admin): project pages are open to project MANAGE holders;
|
||||
// the org overview and other admin-only surfaces are not for them.
|
||||
if (matched && !isAdmin(matched)) {
|
||||
redirecting = false;
|
||||
if (!isOnProjectRoute()) {
|
||||
const target = adminPath('projects');
|
||||
if (path !== target) {
|
||||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||||
const onOverview = parts.length === 3; // /admin/org/:slug
|
||||
if (onOverview) {
|
||||
const target = `/admin/org/${matched.slug}/projects`;
|
||||
if (page.url.pathname !== target) {
|
||||
redirecting = true;
|
||||
void goto(target, { replaceState: true });
|
||||
}
|
||||
@@ -122,11 +146,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
|
||||
if (!org && memberships().length > 0) {
|
||||
const home = admins[0] ?? memberships()[0]!;
|
||||
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
|
||||
if (path !== target && !path.startsWith(`${target}/`)) {
|
||||
// No matched org and no admin orgs: route a member to their first org's
|
||||
// projects page so they can reach project MANAGE surfaces.
|
||||
if (!matched && memberships().length > 0) {
|
||||
const home = memberships()[0];
|
||||
const target = `/admin/org/${home.slug}/projects`;
|
||||
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
||||
redirecting = true;
|
||||
void goto(target, { replaceState: true });
|
||||
}
|
||||
@@ -147,14 +172,14 @@
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p class="text-sm">加载中…</p>
|
||||
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $session.error}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold"
|
||||
>
|
||||
!
|
||||
</div>
|
||||
@@ -167,7 +192,7 @@
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
|
||||
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold"
|
||||
>
|
||||
CPH
|
||||
</div>
|
||||
@@ -176,7 +201,7 @@
|
||||
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
|
||||
{:else if currentOrg() && isAdmin(currentOrg()!)}
|
||||
{@const org = currentOrg()!}
|
||||
{@const me = $session.me!}
|
||||
<div class="saas-shell">
|
||||
@@ -201,19 +226,17 @@
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
|
||||
<div class="truncate text-xs text-surface-600">{org.name}</div>
|
||||
<div class="truncate text-xs text-surface-600">Curriculum Hub</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if me.organizations.length > 1}
|
||||
<div class="px-3 pb-3">
|
||||
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
|
||||
<Icon name="org" class="h-3.5 w-3.5" />
|
||||
组织
|
||||
</div>
|
||||
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
|
||||
<div class="px-3 pb-3">
|
||||
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
|
||||
<Icon name="org" class="h-3.5 w-3.5" />
|
||||
组织
|
||||
</div>
|
||||
{/if}
|
||||
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
|
||||
@@ -283,13 +306,13 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
|
||||
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
|
||||
{@const org = currentOrg()!}
|
||||
{@const me = $session.me!}
|
||||
<div class="saas-shell">
|
||||
<div class="saas-main">
|
||||
<header class="saas-topbar">
|
||||
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
|
||||
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
|
||||
<Icon name="menu" class="h-5 w-5" />
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
@@ -318,7 +341,7 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
|
||||
{:else if currentOrg() && !isAdmin(currentOrg()!)}
|
||||
{@const denied = currentOrg()!}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
@@ -327,7 +350,7 @@
|
||||
组织 <strong>{denied.name}</strong>(/{denied.slug})中你的角色是
|
||||
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
|
||||
</p>
|
||||
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
|
||||
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
|
||||
{#if memberships().length > 1}
|
||||
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
|
||||
<div class="mb-4">
|
||||
@@ -338,14 +361,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else if memberships().length > 0}
|
||||
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
|
||||
{@const denied = pickHomeOrg()!}
|
||||
<div class="saas-status-panel">
|
||||
<div class="saas-status-card">
|
||||
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
||||
<p class="mb-5 text-sm text-surface-700">
|
||||
即将进入 <strong>{denied.name}</strong>(/{denied.slug})的项目。
|
||||
</p>
|
||||
<a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
|
||||
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a>
|
||||
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
return r === 'OWNER' || r === 'ADMIN';
|
||||
});
|
||||
const target = admin ?? me.organizations[0];
|
||||
return target ? `/admin` : null;
|
||||
return target ? `/admin/org/${target.slug}` : null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type CapabilityConnection } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { Label } from 'bits-ui';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
|
||||
type CapKind = 'docmind' | 'pbank';
|
||||
const KNOWN_CAPABILITIES = [
|
||||
{
|
||||
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());
|
||||
let loading = $state(true);
|
||||
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);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.capabilityConnections(slug);
|
||||
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(capId: string, 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() {
|
||||
editingCap = null;
|
||||
}
|
||||
|
||||
async function save(capId: string) {
|
||||
saving = true;
|
||||
try {
|
||||
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;
|
||||
toastSuccess('能力凭据已保存');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(capId: string) {
|
||||
if (!confirm('停用后该能力将不可用,确定停用?')) return;
|
||||
disabling = capId;
|
||||
try {
|
||||
const result = await api.disableCapabilityConnection(slug, capId);
|
||||
connections.set(capId, result);
|
||||
connections = new Map(connections);
|
||||
toastSuccess('已停用能力连接');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
disabling = null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): string {
|
||||
if (status === 'ACTIVE') return 'saas-badge-primary';
|
||||
if (status === 'DISABLED') return 'saas-badge-error';
|
||||
return 'saas-badge-muted';
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'ACTIVE') return '已启用';
|
||||
if (status === 'DISABLED') return '已停用';
|
||||
return '草稿';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="外部能力"
|
||||
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each KNOWN_CAPABILITIES as cap}
|
||||
{@const conn = connections.get(cap.id)}
|
||||
<div class="saas-card-pad">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="saas-section-title">{cap.label}</h3>
|
||||
{#if conn}
|
||||
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
|
||||
{:else}
|
||||
<span class="saas-badge-muted">未配置</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
|
||||
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if conn?.status === 'ACTIVE'}
|
||||
<button
|
||||
class="saas-btn-ghost text-sm"
|
||||
onclick={() => disable(cap.id)}
|
||||
disabled={disabling === cap.id}
|
||||
>
|
||||
{disabling === cap.id ? '停用中…' : '停用'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="saas-btn-primary text-sm"
|
||||
onclick={() => startEdit(cap.id, cap.kind)}
|
||||
disabled={editingCap === cap.id}
|
||||
>
|
||||
{conn ? '轮换凭据' : '配置凭据'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if conn}
|
||||
<dl class="space-y-1.5 text-sm text-surface-700">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">版本</dt>
|
||||
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">密钥 ID</dt>
|
||||
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-surface-500">更新时间</dt>
|
||||
<dd>{fmtDate(conn.updatedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if editingCap === cap.id}
|
||||
<div class="mt-4 border-t border-surface-100 pt-4">
|
||||
{#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>
|
||||
{: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>
|
||||
{/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}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Legacy bookmarks: /admin/org/:slug[/...] → /admin[/...]
|
||||
* Tenancy lives on the silo host, not the path.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
|
||||
onMount(() => {
|
||||
const raw = page.params.path ?? '';
|
||||
const segments = raw.split('/').filter(Boolean);
|
||||
// Drop the old org slug (first segment) when present.
|
||||
const rest = segments.length > 0 ? segments.slice(1).join('/') : '';
|
||||
const target = rest === '' ? '/admin' : `/admin/${rest}`;
|
||||
const q = page.url.search;
|
||||
void goto(`${target}${q}`, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="saas-status-panel">
|
||||
<p class="text-sm text-surface-600">正在重定向到新地址…</p>
|
||||
</div>
|
||||
+4
-47
@@ -2,7 +2,6 @@
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMembership } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import StatCard from '$lib/components/StatCard.svelte';
|
||||
@@ -11,8 +10,7 @@
|
||||
import SwitchControl from '$lib/components/SwitchControl.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
|
||||
let orgSlug = $derived(orgFromSession?.slug ?? '');
|
||||
let orgSlug = $derived(page.params.slug ?? '');
|
||||
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
|
||||
|
||||
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
|
||||
@@ -96,56 +94,19 @@
|
||||
<div class="mb-4 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="saas-section-title">用量概览</h2>
|
||||
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
|
||||
<p class="saas-muted">全组织智能体运行汇总</p>
|
||||
</div>
|
||||
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
|
||||
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
|
||||
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 ≠ $0" />
|
||||
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
|
||||
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
|
||||
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
|
||||
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
|
||||
</div>
|
||||
|
||||
{#if usage.breakdown.length > 0}
|
||||
<div class="saas-card overflow-hidden mb-6">
|
||||
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top)</h3>
|
||||
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
|
||||
</div>
|
||||
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>供应方</th>
|
||||
<th>模型 / 能力</th>
|
||||
<th>次数</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
|
||||
<tr>
|
||||
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
|
||||
<td class="font-mono text-xs">{row.provider}</td>
|
||||
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
|
||||
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
|
||||
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
|
||||
@@ -168,11 +129,7 @@
|
||||
<tbody>
|
||||
{#each usage.projects as p}
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
|
||||
{p.projectName}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-medium">{p.projectName}</td>
|
||||
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
|
||||
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
|
||||
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
|
||||
+1
-4
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
|
||||
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
|
||||
+1
-4
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type FeishuApplicationConnection } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { Label } from 'bits-ui';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -10,8 +8,7 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let connection = $state<FeishuApplicationConnection | null>(null);
|
||||
let loading = $state(true);
|
||||
+1
-4
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type OrgMember } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -12,8 +10,7 @@
|
||||
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 ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
|
||||
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
|
||||
|
||||
+4
-5
@@ -2,7 +2,6 @@
|
||||
import { page } from '$app/state';
|
||||
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import FolderTree from '$lib/components/FolderTree.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
@@ -14,8 +13,8 @@
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
|
||||
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
|
||||
|
||||
let data = $state<ExplorerData | null>(null);
|
||||
@@ -88,7 +87,7 @@
|
||||
projectName = '';
|
||||
projectFolder = '';
|
||||
showProjectModal = false;
|
||||
window.location.href = `/admin/projects/${res.projectId}`;
|
||||
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -208,7 +207,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each myProjects as p}
|
||||
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
|
||||
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
|
||||
<td class="font-medium">{p.name}</td>
|
||||
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
|
||||
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
|
||||
+5
-89
@@ -7,19 +7,8 @@
|
||||
type TeamRow,
|
||||
type SessionSummary,
|
||||
type ExplorerData,
|
||||
type ProjectUsageReport,
|
||||
} from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import {
|
||||
fmtCost,
|
||||
fmtDate,
|
||||
fmtNum,
|
||||
fmtQuantity,
|
||||
fmtTokens,
|
||||
permissionRoleLabel,
|
||||
usageKindLabel,
|
||||
} from '$lib/format';
|
||||
import { fmtDate, permissionRoleLabel } from '$lib/format';
|
||||
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
@@ -29,8 +18,7 @@
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
const projectId = $derived(page.params.projectId ?? '');
|
||||
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
|
||||
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
|
||||
@@ -38,7 +26,6 @@
|
||||
let proj = $state<ProjectDetail | null>(null);
|
||||
let access = $state<TeamAccessEntry[]>([]);
|
||||
let sessions = $state<SessionSummary[]>([]);
|
||||
let projectUsage = $state<ProjectUsageReport | null>(null);
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let explorer = $state<ExplorerData | null>(null);
|
||||
let loading = $state(true);
|
||||
@@ -62,18 +49,14 @@
|
||||
// Team list is needed for grant UI whenever the actor has project MANAGE
|
||||
// (org admin or member). Sessions/explorer stay org-admin oversight only.
|
||||
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
|
||||
const [s, t, e, u] = await Promise.all([
|
||||
const [s, t, e] = await Promise.all([
|
||||
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
|
||||
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
|
||||
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
|
||||
p.actorIsOrgAdmin
|
||||
? api.projectUsage(slug, projectId)
|
||||
: Promise.resolve(null as ProjectUsageReport | null),
|
||||
]);
|
||||
sessions = s.sessions;
|
||||
teams = t.teams;
|
||||
explorer = e;
|
||||
projectUsage = u;
|
||||
if (p.actorIsOrgAdmin) {
|
||||
moveFolder = p.folderId ?? '';
|
||||
}
|
||||
@@ -112,7 +95,7 @@
|
||||
if (!confirm(`归档项目 ${proj?.name}?`)) return;
|
||||
try {
|
||||
await api.archiveProject(slug, projectId);
|
||||
window.location.href = `/admin/projects`;
|
||||
window.location.href = `/admin/org/${slug}/projects`;
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -173,7 +156,7 @@
|
||||
{:else if proj}
|
||||
<div class="mb-2">
|
||||
<a
|
||||
href={`/admin/projects`}
|
||||
href={`/admin/org/${slug}/projects`}
|
||||
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
@@ -286,67 +269,9 @@
|
||||
</div>
|
||||
|
||||
{#if actorIsOrgAdmin}
|
||||
{#if projectUsage}
|
||||
<div class="saas-card overflow-hidden mb-6">
|
||||
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">项目用量分账</h3>
|
||||
<p class="saas-muted mt-0.5 text-xs">
|
||||
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
|
||||
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
|
||||
</p>
|
||||
</div>
|
||||
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
|
||||
</div>
|
||||
{#if projectUsage.breakdown.length === 0}
|
||||
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>供应方</th>
|
||||
<th>模型 / 能力</th>
|
||||
<th>次数</th>
|
||||
<th>计量</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each projectUsage.breakdown as row}
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
|
||||
>
|
||||
{usageKindLabel(row.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{row.provider}</td>
|
||||
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
|
||||
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
|
||||
<td class="tabular-nums text-xs">
|
||||
{#if row.unit}
|
||||
{fmtQuantity(row.quantity, row.unit)}
|
||||
{:else}
|
||||
{fmtTokens(row.inputTokens, row.outputTokens)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold">智能体会话</h3>
|
||||
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
|
||||
</div>
|
||||
{#if sessions.length === 0}
|
||||
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
|
||||
@@ -358,7 +283,6 @@
|
||||
<th>模型</th>
|
||||
<th>运行次数</th>
|
||||
<th>更新</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -368,14 +292,6 @@
|
||||
<td class="font-mono text-xs">{s.model}</td>
|
||||
<td class="tabular-nums">{s.runCount}</td>
|
||||
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
|
||||
<td class="text-right">
|
||||
<a
|
||||
class="text-sm text-primary-700 hover:underline"
|
||||
href={`/admin/sessions/${s.id}`}
|
||||
>
|
||||
详情
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type ProviderConnectionRow } from '$lib/api';
|
||||
import { fmtDate, providerModeLabel } from '$lib/format';
|
||||
import { Label } from 'bits-ui';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let connections = $state<ProviderConnectionRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let providerId = $state('');
|
||||
let baseUrl = $state('');
|
||||
let authToken = $state('');
|
||||
let anthropicApiKey = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.providerConnections(slug);
|
||||
connections = res.connections;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRotate(row: ProviderConnectionRow) {
|
||||
providerId = row.providerId;
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
anthropicApiKey = '';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
providerId = '';
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
anthropicApiKey = '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const id = providerId.trim();
|
||||
if (id === '') {
|
||||
toastError('请填写供应方 ID');
|
||||
return;
|
||||
}
|
||||
const url = baseUrl.trim();
|
||||
const token = authToken.trim();
|
||||
if (url === '' || token === '') {
|
||||
toastError('接口地址与访问令牌均为必填');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
|
||||
baseUrl: url,
|
||||
authToken: token,
|
||||
};
|
||||
const key = anthropicApiKey.trim();
|
||||
if (key !== '') body.anthropicApiKey = key;
|
||||
try {
|
||||
await api.rotateProviderConnection(slug, id, body);
|
||||
toastSuccess('凭据已轮换');
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="模型供应方"
|
||||
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card overflow-hidden mb-6">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
|
||||
</div>
|
||||
{#if connections.length === 0}
|
||||
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应方</th>
|
||||
<th>凭据模式</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>更新于</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each connections as row}
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{row.providerId}</td>
|
||||
<td>{providerModeLabel(row.mode)}</td>
|
||||
<td>{row.status}</td>
|
||||
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
|
||||
<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>
|
||||
{:else}
|
||||
<span class="text-xs text-surface-500">平台管理</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
|
||||
<p class="saas-muted mb-4">
|
||||
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
|
||||
</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" />
|
||||
</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} />
|
||||
</div>
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
||||
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
<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-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import RoleCard from '$lib/components/RoleCard.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let roles = $state<AgentRoleRow[]>([]);
|
||||
let models = $state<AgentModelRow[]>([]);
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let newRoleId = $state('');
|
||||
let newLabel = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
|
||||
roles = r.roles;
|
||||
skills = s.skills;
|
||||
// Model fetch hits the provider API and may fail or be slow; load it
|
||||
// independently so roles remain editable even without a model list.
|
||||
models = [];
|
||||
api.agentModels(slug)
|
||||
.then((m) => { models = m.models; })
|
||||
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const roleId = newRoleId.trim();
|
||||
const label = newLabel.trim();
|
||||
if (roleId === '' || label === '') {
|
||||
toastError('角色 ID 与显示名均为必填');
|
||||
return;
|
||||
}
|
||||
if (roles.some((r) => r.roleId === roleId)) {
|
||||
toastError(`角色 ID 已存在:${roleId}`);
|
||||
return;
|
||||
}
|
||||
adding = true;
|
||||
try {
|
||||
const created = await api.upsertAgentRole(slug, roleId, { label });
|
||||
roles = [...roles, created];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleUpdated(updated: AgentRoleRow) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
|
||||
if (updated.isDefault) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
|
||||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="角色 ID(如 draft)"
|
||||
bind:value={newRoleId}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="显示名(如 草稿)"
|
||||
bind:value={newLabel}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.roleId)}
|
||||
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SkillEditor from '$lib/components/SkillEditor.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showNewSkill = $state(false);
|
||||
let newSkillName = $state('');
|
||||
let newSkillVersion = $state('0.1.0');
|
||||
let newSkillDescription = $state('');
|
||||
let creating = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.agentSkills(slug);
|
||||
skills = res.skills;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSkill() {
|
||||
const name = newSkillName.trim();
|
||||
if (name === '') {
|
||||
toastError('技能名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||||
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||
return;
|
||||
}
|
||||
const version = newSkillVersion.trim();
|
||||
if (version === '') {
|
||||
toastError('版本号不能为空');
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
try {
|
||||
const manifest = buildManifest(name, newSkillDescription.trim());
|
||||
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||||
const result = await api.installAgentSkill(slug, name, { version, files });
|
||||
toastSuccess(`技能 ${result.name} 已创建`);
|
||||
newSkillName = '';
|
||||
newSkillDescription = '';
|
||||
showNewSkill = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildManifest(name: string, description: string): string {
|
||||
const desc = description === '' ? name : description;
|
||||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||
}
|
||||
|
||||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||||
load();
|
||||
}
|
||||
|
||||
function onDisabled(_name: string) {
|
||||
load();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="技能"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card-pad mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="saas-section-title">新建技能</h2>
|
||||
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
||||
{showNewSkill ? '取消' : '+ 新建'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showNewSkill}
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="技能名(如 typst-help)"
|
||||
bind:value={newSkillName}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="版本号"
|
||||
bind:value={newSkillVersion}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="描述"
|
||||
bind:value={newSkillDescription}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||||
{creating ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">
|
||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if skills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each skills as skill (skill.id)}
|
||||
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
+1
-4
@@ -2,8 +2,6 @@
|
||||
import { Collapsible } from 'bits-ui';
|
||||
import { page } from '$app/state';
|
||||
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
@@ -11,8 +9,7 @@
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const slug = $derived(page.params.slug ?? '');
|
||||
|
||||
let teams = $state<TeamRow[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api, type ProviderConnectionRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { fmtDate, providerModeLabel } from '$lib/format';
|
||||
import { Label } from 'bits-ui';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
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 ?? '');
|
||||
|
||||
let connections = $state<ProviderConnectionRow[]>([]);
|
||||
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('');
|
||||
|
||||
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;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.providerConnections(slug);
|
||||
connections = res.connections;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 === '') {
|
||||
showFormError('请填写供应方 ID', targetProviderId);
|
||||
return;
|
||||
}
|
||||
const url = baseUrl.trim();
|
||||
const token = authToken.trim();
|
||||
if (url === '' || token === '') {
|
||||
showFormError('接口地址与访问令牌均为必填', targetProviderId);
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const saved = await api.rotateProviderConnection(slug, id, body);
|
||||
resetForm();
|
||||
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
|
||||
await load();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
formState =
|
||||
intent === 'rotate'
|
||||
? { kind: 'rotate', providerId: id, error: message }
|
||||
: { kind: 'new', error: message };
|
||||
toastError(message);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="模型供应方"
|
||||
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="saas-card overflow-hidden mb-6">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
|
||||
</div>
|
||||
{#if connections.length === 0}
|
||||
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应方</th>
|
||||
<th>凭据模式</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>更新于</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each connections as row}
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{row.providerId}</td>
|
||||
<td>{providerModeLabel(row.mode)}</td>
|
||||
<td>{row.status}</td>
|
||||
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
|
||||
<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)}
|
||||
disabled={saving}
|
||||
aria-label={`开始轮换供应方 ${row.providerId}`}
|
||||
>
|
||||
开始轮换
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-surface-500">平台管理</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card-pad">
|
||||
<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"
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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={cancelOrClear} disabled={saving}>
|
||||
{rotationId ? '取消轮换' : '清空'}
|
||||
</button>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,328 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import RoleCard from '$lib/components/RoleCard.svelte';
|
||||
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
|
||||
let roles = $state<AgentRoleRow[]>([]);
|
||||
let models = $state<AgentModelRow[]>([]);
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let folders = $state<AgentConfigFolderRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
|
||||
let selectedFolder = $state<string>('all');
|
||||
|
||||
let newRoleId = $state('');
|
||||
let newLabel = $state('');
|
||||
let adding = $state(false);
|
||||
|
||||
let showFolderModal = $state(false);
|
||||
let folderModalMode = $state<'create' | 'rename'>('create');
|
||||
let folderModalId = $state<string | null>(null);
|
||||
let folderName = $state('');
|
||||
let folderParent = $state('');
|
||||
let savingFolder = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
|
||||
roles = r.roles;
|
||||
skills = s.skills;
|
||||
folders = f.folders;
|
||||
// Model fetch hits the provider API and may fail or be slow; load it
|
||||
// independently so roles remain editable even without a model list.
|
||||
models = [];
|
||||
api.agentModels(slug)
|
||||
.then((m) => { models = m.models; })
|
||||
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const r of roles) {
|
||||
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
|
||||
const visibleRoles = $derived(
|
||||
selectedFolder === 'all'
|
||||
? roles
|
||||
: selectedFolder === 'unfiled'
|
||||
? roles.filter((r) => !r.folderId)
|
||||
: roles.filter((r) => r.folderId === selectedFolder),
|
||||
);
|
||||
|
||||
function folderPath(f: AgentConfigFolderRow): string {
|
||||
const parts: string[] = [f.name];
|
||||
let cur: AgentConfigFolderRow | undefined = f;
|
||||
while (cur?.parentId) {
|
||||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
|
||||
function subtreeIds(folderId: string): Set<string> {
|
||||
const ids = new Set<string>([folderId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const f of folders) {
|
||||
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
|
||||
ids.add(f.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const folderItems = $derived([
|
||||
{ value: '', label: '(未分类)' },
|
||||
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
|
||||
]);
|
||||
|
||||
const moveTargetItems = $derived.by(() => {
|
||||
if (folderModalMode !== 'rename' || folderModalId === null) {
|
||||
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
|
||||
}
|
||||
const excluded = subtreeIds(folderModalId);
|
||||
return [
|
||||
{ value: '', label: '(根)' },
|
||||
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
|
||||
];
|
||||
});
|
||||
|
||||
async function add() {
|
||||
const roleId = newRoleId.trim();
|
||||
const label = newLabel.trim();
|
||||
if (roleId === '' || label === '') {
|
||||
toastError('角色 ID 与显示名均为必填');
|
||||
return;
|
||||
}
|
||||
if (roles.some((r) => r.roleId === roleId)) {
|
||||
toastError(`角色 ID 已存在:${roleId}`);
|
||||
return;
|
||||
}
|
||||
adding = true;
|
||||
try {
|
||||
const created = await api.upsertAgentRole(slug, roleId, { label });
|
||||
let folderId: string | null = null;
|
||||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||||
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
|
||||
folderId = selectedFolder;
|
||||
}
|
||||
roles = [...roles, { ...created, folderId }];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleUpdated(updated: AgentRoleRow) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
|
||||
if (updated.isDefault) {
|
||||
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
|
||||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
|
||||
}
|
||||
|
||||
function onRoleFolderChanged(roleId: string, folderId: string | null) {
|
||||
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
|
||||
}
|
||||
|
||||
function openCreateFolder(parentId: string | null) {
|
||||
folderModalMode = 'create';
|
||||
folderModalId = null;
|
||||
folderName = '';
|
||||
folderParent = parentId ?? '';
|
||||
showFolderModal = true;
|
||||
}
|
||||
|
||||
function openRenameFolder(folder: AgentConfigFolderRow) {
|
||||
folderModalMode = 'rename';
|
||||
folderModalId = folder.id;
|
||||
folderName = folder.name;
|
||||
folderParent = folder.parentId ?? '';
|
||||
showFolderModal = true;
|
||||
}
|
||||
|
||||
async function submitFolderModal() {
|
||||
const name = folderName.trim();
|
||||
if (name === '') {
|
||||
toastError('文件夹名称不能为空');
|
||||
return;
|
||||
}
|
||||
savingFolder = true;
|
||||
try {
|
||||
if (folderModalMode === 'create') {
|
||||
await api.createAgentConfigFolder(slug, {
|
||||
name,
|
||||
...(folderParent !== '' ? { parentId: folderParent } : {}),
|
||||
});
|
||||
toastSuccess('文件夹已创建');
|
||||
} else if (folderModalId !== null) {
|
||||
await api.patchAgentConfigFolder(slug, folderModalId, {
|
||||
name,
|
||||
parentId: folderParent === '' ? null : folderParent,
|
||||
});
|
||||
toastSuccess('文件夹已更新');
|
||||
}
|
||||
showFolderModal = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
savingFolder = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folder: AgentConfigFolderRow) {
|
||||
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
|
||||
try {
|
||||
await api.deleteAgentConfigFolder(slug, folder.id);
|
||||
if (selectedFolder === folder.id) selectedFolder = 'all';
|
||||
toastSuccess('文件夹已删除');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
|
||||
<div class="h-fit lg:sticky lg:top-4">
|
||||
<AgentConfigFolderNav
|
||||
{folders}
|
||||
selected={selectedFolder}
|
||||
{counts}
|
||||
totalCount={roles.length}
|
||||
{unfiledCount}
|
||||
onselect={(id) => (selectedFolder = id)}
|
||||
oncreate={openCreateFolder}
|
||||
onrename={openRenameFolder}
|
||||
ondelete={deleteFolder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="角色 ID(如 draft)"
|
||||
bind:value={newRoleId}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="显示名(如 草稿)"
|
||||
bind:value={newLabel}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
</div>
|
||||
{:else if visibleRoles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each visibleRoles as r (r.roleId)}
|
||||
<RoleCard
|
||||
{r}
|
||||
{models}
|
||||
{skills}
|
||||
{slug}
|
||||
{folders}
|
||||
{folderItems}
|
||||
onupdated={onRoleUpdated}
|
||||
onskillschanged={onRoleSkillsChanged}
|
||||
onfolderchanged={onRoleFolderChanged}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
|
||||
<label class="saas-label" for="agent-folder-name">名称</label>
|
||||
<input
|
||||
id="agent-folder-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={folderName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') submitFolderModal();
|
||||
}}
|
||||
/>
|
||||
<p class="saas-label">父文件夹</p>
|
||||
<div class="mb-4">
|
||||
<SelectField items={moveTargetItems} bind:value={folderParent} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
|
||||
{savingFolder ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import {
|
||||
fmtCost,
|
||||
fmtDate,
|
||||
fmtNum,
|
||||
fmtQuantity,
|
||||
fmtTokens,
|
||||
runStatusLabel,
|
||||
usageKindLabel,
|
||||
} from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
const sessionId = $derived(page.params.sessionId ?? '');
|
||||
|
||||
let detail = $state<SessionDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let expandedRunId = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
if (!slug || !sessionId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
detail = await api.session(slug, sessionId);
|
||||
if (detail.runs.length > 0) {
|
||||
expandedRunId = detail.runs[0]!.id;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
const key = status.toUpperCase();
|
||||
if (key === 'COMPLETED') return 'saas-badge-success';
|
||||
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
|
||||
return 'saas-badge-primary';
|
||||
}
|
||||
|
||||
function factMeter(f: UsageFactRow): string {
|
||||
if (f.unit) return fmtQuantity(f.quantity, f.unit);
|
||||
if (f.inputTokens !== null || f.outputTokens !== null) {
|
||||
return fmtTokens(f.inputTokens, f.outputTokens);
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
function factSource(f: UsageFactRow): string {
|
||||
if (f.capabilityId) return f.capabilityId;
|
||||
if (f.model) return f.model;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function runCostHint(run: SessionRunRow): string {
|
||||
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
|
||||
if (f.costUsd === null) return acc;
|
||||
return (acc ?? 0) + f.costUsd;
|
||||
}, null);
|
||||
const cache = run.costUsd;
|
||||
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
|
||||
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
|
||||
}
|
||||
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
|
||||
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
|
||||
return '成本未知';
|
||||
}
|
||||
|
||||
function toggleRun(id: string) {
|
||||
expandedRunId = expandedRunId === id ? null : id;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug && sessionId) void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if detail}
|
||||
<div class="mb-2">
|
||||
<a
|
||||
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
|
||||
href={`/admin/projects/${detail.project.id}`}
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
返回项目 {detail.project.name}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
title={detail.title?.trim() || '未命名会话'}
|
||||
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
|
||||
/>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-surface-600">会话 ID</dt>
|
||||
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-surface-600">项目</dt>
|
||||
<dd class="mt-0.5">
|
||||
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
|
||||
{detail.project.name}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-surface-600">创建 / 更新</dt>
|
||||
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
|
||||
</div>
|
||||
{#if detail.archivedAt}
|
||||
<div>
|
||||
<dt class="text-surface-600">已归档</dt>
|
||||
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<dt class="text-surface-600">运行数</dt>
|
||||
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h2 class="saas-section-title">运行与计费事实</h2>
|
||||
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
|
||||
</div>
|
||||
|
||||
{#if detail.runs.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each detail.runs as run (run.id)}
|
||||
{@const open = expandedRunId === run.id}
|
||||
<div class="saas-card overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
|
||||
onclick={() => toggleRun(run.id)}
|
||||
>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
|
||||
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
|
||||
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
|
||||
</div>
|
||||
<div class="text-xs text-surface-700">
|
||||
{fmtDate(run.startedAt)}
|
||||
{#if run.finishedAt}
|
||||
→ {fmtDate(run.finishedAt)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
|
||||
{#if run.error}
|
||||
<div class="text-xs text-error-700">{run.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-sm">
|
||||
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
|
||||
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
|
||||
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="border-t border-surface-200">
|
||||
{#if run.usageFacts.length === 0}
|
||||
<div class="px-5 py-4">
|
||||
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>类型</th>
|
||||
<th>供应方</th>
|
||||
<th>模型 / 能力</th>
|
||||
<th>计量</th>
|
||||
<th>成本</th>
|
||||
<th>来源</th>
|
||||
<th>关联 ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each run.usageFacts as fact}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
|
||||
<td>
|
||||
<span
|
||||
class={fact.kind === 'external_capability'
|
||||
? 'saas-badge-primary'
|
||||
: 'saas-badge-success'}
|
||||
>
|
||||
{usageKindLabel(fact.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{fact.provider}</td>
|
||||
<td class="font-mono text-xs">{factSource(fact)}</td>
|
||||
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
|
||||
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
|
||||
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
|
||||
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
|
||||
{fact.correlationId ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,408 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import { 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 [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
|
||||
skills = s.skills;
|
||||
folders = f.folders;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const s of skills) {
|
||||
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
|
||||
const visibleSkills = $derived(
|
||||
selectedFolder === 'all'
|
||||
? skills
|
||||
: selectedFolder === 'unfiled'
|
||||
? skills.filter((s) => !s.folderId)
|
||||
: skills.filter((s) => s.folderId === selectedFolder),
|
||||
);
|
||||
|
||||
function folderPath(f: AgentConfigFolderRow): string {
|
||||
const parts: string[] = [f.name];
|
||||
let cur: AgentConfigFolderRow | undefined = f;
|
||||
while (cur?.parentId) {
|
||||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
|
||||
function subtreeIds(folderId: string): Set<string> {
|
||||
const ids = new Set<string>([folderId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const f of folders) {
|
||||
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
|
||||
ids.add(f.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const folderItems = $derived([
|
||||
{ value: '', label: '(未分类)' },
|
||||
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
|
||||
]);
|
||||
|
||||
const moveTargetItems = $derived.by(() => {
|
||||
if (folderModalMode !== 'rename' || folderModalId === null) {
|
||||
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
|
||||
}
|
||||
const excluded = subtreeIds(folderModalId);
|
||||
return [
|
||||
{ value: '', label: '(根)' },
|
||||
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
|
||||
];
|
||||
});
|
||||
|
||||
async function createSkill() {
|
||||
const name = newSkillName.trim();
|
||||
if (name === '') {
|
||||
toastError('技能名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
||||
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||
return;
|
||||
}
|
||||
const version = newSkillVersion.trim();
|
||||
if (version === '') {
|
||||
toastError('版本号不能为空');
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
try {
|
||||
const manifest = buildManifest(name, newSkillDescription.trim());
|
||||
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
|
||||
const result = await api.installAgentSkill(slug, name, { version, files });
|
||||
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||||
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
|
||||
}
|
||||
toastSuccess(`技能 ${result.name} 已创建`);
|
||||
newSkillName = '';
|
||||
newSkillDescription = '';
|
||||
showNewSkill = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildManifest(name: string, description: string): string {
|
||||
const desc = description === '' ? name : description;
|
||||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function onDisabled(_name: string) {
|
||||
load();
|
||||
}
|
||||
|
||||
function onSkillFolderChanged(name: string, folderId: string | null) {
|
||||
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
|
||||
}
|
||||
|
||||
function openCreateFolder(parentId: string | null) {
|
||||
folderModalMode = 'create';
|
||||
folderModalId = null;
|
||||
folderName = '';
|
||||
folderParent = parentId ?? '';
|
||||
showFolderModal = true;
|
||||
}
|
||||
|
||||
function openRenameFolder(folder: AgentConfigFolderRow) {
|
||||
folderModalMode = 'rename';
|
||||
folderModalId = folder.id;
|
||||
folderName = folder.name;
|
||||
folderParent = folder.parentId ?? '';
|
||||
showFolderModal = true;
|
||||
}
|
||||
|
||||
async function submitFolderModal() {
|
||||
const name = folderName.trim();
|
||||
if (name === '') {
|
||||
toastError('文件夹名称不能为空');
|
||||
return;
|
||||
}
|
||||
savingFolder = true;
|
||||
try {
|
||||
if (folderModalMode === 'create') {
|
||||
await api.createAgentConfigFolder(slug, {
|
||||
name,
|
||||
...(folderParent !== '' ? { parentId: folderParent } : {}),
|
||||
});
|
||||
toastSuccess('文件夹已创建');
|
||||
} else if (folderModalId !== null) {
|
||||
await api.patchAgentConfigFolder(slug, folderModalId, {
|
||||
name,
|
||||
parentId: folderParent === '' ? null : folderParent,
|
||||
});
|
||||
toastSuccess('文件夹已更新');
|
||||
}
|
||||
showFolderModal = false;
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
savingFolder = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folder: AgentConfigFolderRow) {
|
||||
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
|
||||
try {
|
||||
await api.deleteAgentConfigFolder(slug, folder.id);
|
||||
if (selectedFolder === folder.id) selectedFolder = 'all';
|
||||
toastSuccess('文件夹已删除');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title="技能"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<LoadingState />
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
|
||||
<div class="h-fit lg:sticky lg:top-4">
|
||||
<AgentConfigFolderNav
|
||||
{folders}
|
||||
selected={selectedFolder}
|
||||
{counts}
|
||||
totalCount={skills.length}
|
||||
{unfiledCount}
|
||||
onselect={(id) => (selectedFolder = id)}
|
||||
oncreate={openCreateFolder}
|
||||
onrename={openRenameFolder}
|
||||
ondelete={deleteFolder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="saas-card-pad mb-6 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>
|
||||
|
||||
{#if skills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||
</div>
|
||||
{:else if visibleSkills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each visibleSkills as skill (skill.id)}
|
||||
<SkillEditor
|
||||
{slug}
|
||||
{skill}
|
||||
{folderItems}
|
||||
oninstalled={onInstalled}
|
||||
ondisabled={onDisabled}
|
||||
onfolderchanged={onSkillFolderChanged}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
|
||||
<label class="saas-label" for="agent-folder-name">名称</label>
|
||||
<input
|
||||
id="agent-folder-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={folderName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') submitFolderModal();
|
||||
}}
|
||||
/>
|
||||
<p class="saas-label">父文件夹</p>
|
||||
<div class="mb-4">
|
||||
<SelectField items={moveTargetItems} bind:value={folderParent} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
|
||||
{savingFolder ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -1,241 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import {
|
||||
fmtCost,
|
||||
fmtDateOnly,
|
||||
fmtNum,
|
||||
fmtQuantity,
|
||||
fmtTokens,
|
||||
usageKindLabel,
|
||||
} from '$lib/format';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import StatCard from '$lib/components/StatCard.svelte';
|
||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
|
||||
let usage = $state<UsageReport | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let from = $state('');
|
||||
let to = $state('');
|
||||
|
||||
function toIsoStart(dateLocal: string): string | undefined {
|
||||
if (!dateLocal) return undefined;
|
||||
const d = new Date(`${dateLocal}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||
}
|
||||
|
||||
function toIsoEnd(dateLocal: string): string | undefined {
|
||||
if (!dateLocal) return undefined;
|
||||
const d = new Date(`${dateLocal}T23:59:59.999`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!slug) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
usage = await api.usage(slug, {
|
||||
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
|
||||
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRange() {
|
||||
from = '';
|
||||
to = '';
|
||||
void load();
|
||||
}
|
||||
|
||||
function sourceLabel(row: UsageBreakdownRow): string {
|
||||
if (row.capabilityId) return row.capabilityId;
|
||||
if (row.model) return row.model;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function meterCell(row: UsageBreakdownRow): string {
|
||||
if (row.unit) return fmtQuantity(row.quantity, row.unit);
|
||||
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
|
||||
return '—';
|
||||
}
|
||||
|
||||
function meterHint(row: UsageBreakdownRow): string {
|
||||
if (row.unit) return '非 token 计量';
|
||||
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
|
||||
return '无计量';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (slug) void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading && !usage}
|
||||
<LoadingState />
|
||||
{:else if error && !usage}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else if usage}
|
||||
<PageHeader
|
||||
title="用量报告"
|
||||
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
|
||||
/>
|
||||
|
||||
<div class="saas-card-pad mb-6">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="saas-label" for="usage-from">从</label>
|
||||
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="saas-label" for="usage-to">到</label>
|
||||
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
|
||||
</div>
|
||||
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
|
||||
{loading ? '加载中…' : '应用筛选'}
|
||||
</button>
|
||||
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
|
||||
清除
|
||||
</button>
|
||||
{#if usage.from || usage.to}
|
||||
<p class="saas-muted grow text-right text-xs">
|
||||
窗口:
|
||||
{usage.from ? fmtDateOnly(usage.from) : '—'}
|
||||
→
|
||||
{usage.to ? fmtDateOnly(usage.to) : '—'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="mt-3 text-sm text-error-700">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
|
||||
<StatCard
|
||||
label="有成本 / 无成本"
|
||||
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
|
||||
hint="无成本 = 成本未知,不是 $0"
|
||||
/>
|
||||
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
|
||||
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
|
||||
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
|
||||
<StatCard
|
||||
label="分账条目"
|
||||
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
|
||||
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden mb-6">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
|
||||
<p class="saas-muted mt-0.5 text-xs">
|
||||
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
|
||||
</p>
|
||||
</div>
|
||||
{#if usage.breakdown.length === 0}
|
||||
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>供应方</th>
|
||||
<th>模型 / 能力</th>
|
||||
<th>次数</th>
|
||||
<th>计量</th>
|
||||
<th>有成本 / 未知</th>
|
||||
<th>成本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each usage.breakdown as row}
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class={row.kind === 'external_capability'
|
||||
? 'saas-badge-primary'
|
||||
: row.kind === 'model_completion'
|
||||
? 'saas-badge-success'
|
||||
: 'saas-badge-primary'}
|
||||
>
|
||||
{usageKindLabel(row.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{row.provider}</td>
|
||||
<td class="font-mono text-xs">{sourceLabel(row)}</td>
|
||||
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
|
||||
<td class="tabular-nums">
|
||||
<div>{meterCell(row)}</div>
|
||||
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
|
||||
</td>
|
||||
<td class="tabular-nums text-surface-700">
|
||||
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
|
||||
</td>
|
||||
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="saas-card overflow-hidden">
|
||||
<div class="border-b border-surface-200 px-5 py-3">
|
||||
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
|
||||
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
|
||||
</div>
|
||||
{#if usage.projects.length === 0}
|
||||
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>运行</th>
|
||||
<th>有成本 / 未知</th>
|
||||
<th>in / out tokens</th>
|
||||
<th>成本</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each usage.projects as p}
|
||||
<tr>
|
||||
<td class="font-medium">{p.projectName}</td>
|
||||
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
|
||||
<td class="tabular-nums text-surface-700">
|
||||
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
|
||||
</td>
|
||||
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
|
||||
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
|
||||
<td class="text-right">
|
||||
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
|
||||
查看项目
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -406,8 +406,7 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.saas-select-trigger,
|
||||
.saas-combobox-input {
|
||||
.saas-select-trigger {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
@@ -426,25 +425,14 @@
|
||||
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-combobox-input:focus {
|
||||
.saas-select-trigger[data-state='open'] {
|
||||
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-combobox-input:disabled {
|
||||
.saas-select-trigger[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -455,15 +443,9 @@
|
||||
|
||||
.saas-select-content {
|
||||
z-index: 70;
|
||||
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));
|
||||
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
|
||||
width: var(--bits-select-anchor-width);
|
||||
min-width: var(--bits-select-anchor-width);
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--color-surface-400);
|
||||
|
||||
@@ -85,7 +85,7 @@ REMOTE
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web SPA)"
|
||||
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
|
||||
set -euo pipefail
|
||||
flock /var/lock/cph-hub-release-publish bash -c '
|
||||
@@ -95,10 +95,8 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
||||
exit 0
|
||||
fi
|
||||
cd "$HUB_DIR"
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev
|
||||
npm ci --include=dev --prefix admin-web
|
||||
test -x node_modules/.bin/tsc
|
||||
test -x admin-web/node_modules/.bin/vite
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||
npm ci --prefix admin-web
|
||||
npm run audit:production
|
||||
npm run build
|
||||
test -f admin-web/build/index.html
|
||||
|
||||
@@ -60,12 +60,10 @@ if [ "$release_ready" = false ]; then
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
# 2. Install deps (including build-time dev deps) for hub + admin-web,
|
||||
# audit hub prod, build tsc + SPA, mark complete. NODE_ENV=production may be
|
||||
# inherited by the remote shell, so --include=dev is intentional here.
|
||||
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
||||
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev && npm ci --include=dev --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
|
||||
@@ -164,19 +164,17 @@ 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=150
|
||||
HUB_AGENT_MAX_TURNS=25
|
||||
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 "150"
|
||||
seed_default MAX_TURNS "25"
|
||||
seed_default MAX_CONCURRENT_RUNS "4"
|
||||
seed_default MAX_RUN_SECONDS "1800"
|
||||
seed_default MAX_RUN_SECONDS "900"
|
||||
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
|
||||
seed_default MAX_FILES_PER_MESSAGE "20"
|
||||
seed_default MAX_FILES_PER_MESSAGE "8"
|
||||
seed_default MAX_FILE_BYTES "26214400"
|
||||
seed_default HTTP_REQUESTS_PER_MINUTE "120"
|
||||
seed_default FEISHU_EVENTS_PER_MINUTE "120"
|
||||
|
||||
Generated
+71
-95
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.31",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||
@@ -247,22 +247,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -271,9 +271,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -284,9 +284,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -297,15 +297,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -313,15 +310,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -329,15 +323,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -345,15 +336,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -361,9 +349,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -374,9 +362,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -437,34 +425,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"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==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1058,13 +1034,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
|
||||
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
@@ -1093,13 +1069,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -2709,9 +2685,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2823,9 +2799,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -3023,9 +2999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"version": "4.12.28",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
|
||||
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -3130,9 +3106,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -3661,9 +3637,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3921,9 +3897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3941,7 +3917,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.42",
|
||||
"version": "0.0.31",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"axios": "1.18.1"
|
||||
}
|
||||
},
|
||||
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
|
||||
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
|
||||
"scripts": {
|
||||
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json && npm run admin:build",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
|
||||
-- model: the platform administration control plane is a separate identity/session/
|
||||
-- audit surface, intentionally not built
|
||||
-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
|
||||
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
|
||||
-- reader — no guard, route, or service queries it for an authorization decision —
|
||||
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
// Prisma schema for Curriculum Project Hub.
|
||||
//
|
||||
// Aligns to ADR-0001..0004, 0017. Key divergences from the
|
||||
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||
// legacy teaching-material-host-service schema, each deliberate:
|
||||
//
|
||||
// - AgentSession is provider/model-bound. Provider runtime cursors such as
|
||||
@@ -11,8 +11,8 @@
|
||||
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
|
||||
// user/chat targets into one binding table.
|
||||
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
|
||||
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is
|
||||
// open — add states without a schema migration war).
|
||||
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
|
||||
// completeness OPEN — add states without a schema migration war).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
@@ -47,7 +47,6 @@ model Organization {
|
||||
capabilityConnections OrganizationCapabilityConnection[]
|
||||
agentSkills OrganizationAgentSkill[]
|
||||
agentRoles OrganizationAgentRole[]
|
||||
agentConfigFolders OrganizationAgentConfigFolder[]
|
||||
projectGroupBindings ProjectGroupBinding[]
|
||||
auditEntries AuditEntry[] @relation("organizationAudit")
|
||||
projectSearchDocuments ProjectSearchDocument[]
|
||||
@@ -62,7 +61,7 @@ enum OrganizationStatus {
|
||||
}
|
||||
|
||||
/// Org-scoped membership role. Distinct from project PermissionRole and from
|
||||
/// the platform administrator surface (ADR-0023),
|
||||
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
|
||||
/// which is a separate control plane not modeled in alpha (ADR-0025).
|
||||
model OrganizationMembership {
|
||||
id String @id @default(cuid())
|
||||
@@ -96,19 +95,16 @@ model OrganizationAgentSkill {
|
||||
version String
|
||||
description String?
|
||||
contentDigest String
|
||||
folderId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
disabledAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
roleBindings OrganizationAgentRoleSkill[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
@@unique([organizationId, id])
|
||||
@@index([organizationId, disabledAt])
|
||||
@@index([organizationId, folderId])
|
||||
@@index([contentDigest])
|
||||
}
|
||||
|
||||
@@ -125,20 +121,17 @@ model OrganizationAgentRole {
|
||||
tools Json?
|
||||
sortOrder Int @default(0)
|
||||
isDefault Boolean @default(false)
|
||||
folderId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
disabledAt DateTime?
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
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`
|
||||
@@ -158,29 +151,6 @@ 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,11 +26,7 @@ const client = new DocmindClient.default({
|
||||
} as never);
|
||||
|
||||
const fileStream = createReadStream(pdfPath);
|
||||
const runtime = new RuntimeOptions({
|
||||
connectTimeout: 15_000,
|
||||
// httpx defaults to 3000ms; OSS upload of multi-MB PDFs needs far more.
|
||||
readTimeout: 5 * 60_000,
|
||||
});
|
||||
const runtime = new RuntimeOptions({});
|
||||
|
||||
console.log("Submitting job...");
|
||||
const submitResp = await client.submitDocParserJobAdvance(
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: pdf-to-md
|
||||
description: >
|
||||
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
|
||||
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 (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 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 **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
|
||||
|
||||
### 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 `input_path` + `output_dir`.
|
||||
|
||||
### PDF already in the workspace
|
||||
|
||||
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.
|
||||
- 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 generated markdown (or a zip you
|
||||
assemble) back to the user if they requested delivery.
|
||||
|
||||
## Output
|
||||
|
||||
Per `output_dir`:
|
||||
|
||||
- `document.md` — the main markdown file
|
||||
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
|
||||
|
||||
## Cost
|
||||
|
||||
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,10 +15,6 @@
|
||||
* `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";
|
||||
@@ -247,121 +243,4 @@ 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -432,16 +432,16 @@ async function resolvePostLoginRedirect(
|
||||
select: { organization: { select: { slug: true, name: true } } },
|
||||
});
|
||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||
const orgRoot = "/admin";
|
||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||
// Default / missing returnTo sanitizes to "/admin". Always land in the org
|
||||
// admin SPA (not the legacy static "close this tab" complete page).
|
||||
if (returnTo === "/admin") {
|
||||
return orgRoot;
|
||||
}
|
||||
return normalizeAdminReturnTo(returnTo) ?? orgRoot;
|
||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||
}
|
||||
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
||||
return normalizeAdminReturnTo(returnTo) ?? "/admin";
|
||||
return returnTo;
|
||||
}
|
||||
const membership = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
@@ -454,7 +454,7 @@ async function resolvePostLoginRedirect(
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (membership !== null) {
|
||||
return "/admin";
|
||||
return `/admin/org/${membership.organization.slug}`;
|
||||
}
|
||||
// Member-only or no org: still land on a shell page (SPA will explain).
|
||||
const any = await prisma.organizationMembership.findFirst({
|
||||
@@ -463,7 +463,7 @@ async function resolvePostLoginRedirect(
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (any !== null) {
|
||||
return "/admin/projects";
|
||||
return `/admin/org/${any.organization.slug}`;
|
||||
}
|
||||
return "/admin/login?error=no_organization";
|
||||
}
|
||||
@@ -489,18 +489,7 @@ export function sanitizeReturnTo(raw: string): string {
|
||||
if (!raw.startsWith("/admin")) {
|
||||
return "/admin";
|
||||
}
|
||||
return normalizeAdminReturnTo(raw) ?? "/admin";
|
||||
}
|
||||
|
||||
/** Map legacy `/admin/org/:slug[...]` bookmarks onto slugless `/admin[...]` paths. */
|
||||
export function normalizeAdminReturnTo(path: string): string | null {
|
||||
if (!path.startsWith("/admin")) return null;
|
||||
const legacy = path.match(/^\/admin\/org\/[^/]+(\/.*)?$/);
|
||||
if (legacy) {
|
||||
const rest = legacy[1] ?? "";
|
||||
return rest === "" ? "/admin" : `/admin${rest}`;
|
||||
}
|
||||
return path;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* ADR-0027: Admin routes for organization-scoped capability connections.
|
||||
* GET /api/org/:orgSlug/capability-connections — list all
|
||||
* GET /api/org/:orgSlug/capability-connections/:capId — read one
|
||||
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
|
||||
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
CapabilityConnectionService,
|
||||
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";
|
||||
|
||||
export interface CapabilityConnectionRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly readinessProbe?: CapabilityReadinessProbe;
|
||||
}
|
||||
|
||||
export async function registerCapabilityConnectionRoutes(
|
||||
app: FastifyInstance,
|
||||
config: CapabilityConnectionRouteConfig,
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const connections =
|
||||
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 {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { connections: await connections.list(auth.organization.id) };
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { connection: await connections.read(auth.organization.id, capabilityId) };
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const credential = parseCredentialBody(capabilityId, request.body);
|
||||
const result = await connections.rotate({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
credential,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
connectionId: result.id,
|
||||
status: result.status,
|
||||
secretVersion: result.activeVersion,
|
||||
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
|
||||
const { created, ...metadata } = result;
|
||||
return reply.status(created ? 201 : 200).send(metadata);
|
||||
} catch (error) {
|
||||
const facts = error instanceof CapabilityReadinessError
|
||||
? {
|
||||
errorCode: error.code,
|
||||
failureCategory: error.category,
|
||||
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
|
||||
}
|
||||
: { errorCode: "capability_connection_write_failed" };
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const result = await connections.disable({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
actorUserId: auth.user.id,
|
||||
});
|
||||
request.log.info({
|
||||
organizationId: auth.organization.id,
|
||||
capabilityId,
|
||||
connectionId: result.id,
|
||||
status: result.status,
|
||||
}, "Capability Connection disabled");
|
||||
return reply.send(result);
|
||||
} catch (error) {
|
||||
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
|
||||
return handleRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function 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>;
|
||||
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 {
|
||||
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,12 +155,7 @@ export async function registerExplorerRoutes(
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
});
|
||||
return reply.status(201).send({
|
||||
projectId: result.projectId,
|
||||
folderId: result.folderId,
|
||||
workspaceDir: result.workspaceDir,
|
||||
name: body.name,
|
||||
});
|
||||
return reply.status(201).send({ id: result.projectId, name: body.name });
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
|
||||
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
|
||||
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
|
||||
|
||||
export interface OrgRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -32,7 +30,6 @@ export interface OrgRouteConfig {
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly providerReadinessProbe?: ProviderReadinessProbe;
|
||||
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
|
||||
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
|
||||
}
|
||||
|
||||
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
|
||||
@@ -141,12 +138,4 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
? { readinessProbe: config.feishuConnectionReadinessProbe }
|
||||
: {}),
|
||||
});
|
||||
await registerCapabilityConnectionRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
...(config.capabilityReadinessProbe !== undefined
|
||||
? { readinessProbe: config.capabilityReadinessProbe }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
+11
-256
@@ -18,7 +18,6 @@ export interface AgentRoleRow {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly skillNames: readonly string[];
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -31,17 +30,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,213 +80,9 @@ 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;
|
||||
@@ -389,7 +173,7 @@ export class OrganizationAgentConfiguration {
|
||||
where: { id: skill.id },
|
||||
data: { disabledAt: new Date() },
|
||||
});
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
await archiveRoleSessions(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -487,7 +271,7 @@ export class OrganizationAgentConfiguration {
|
||||
},
|
||||
});
|
||||
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
|
||||
await invalidateRoleSessionClaudeIds(
|
||||
await archiveRoleSessions(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
@@ -595,7 +379,7 @@ export class OrganizationAgentConfiguration {
|
||||
if (activeDefaultCount !== 1) {
|
||||
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
|
||||
}
|
||||
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -665,7 +449,7 @@ export class OrganizationAgentConfiguration {
|
||||
})),
|
||||
});
|
||||
}
|
||||
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
|
||||
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
@@ -688,22 +472,7 @@ export class OrganizationAgentConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
async function archiveRoleSessions(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
roleIds: readonly string[],
|
||||
@@ -713,36 +482,24 @@ async function invalidateRoleSessionClaudeIds(
|
||||
where: {
|
||||
roleId: { in: [...new Set(roleIds)] },
|
||||
project: { organizationId },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, metadata: true },
|
||||
select: { id: true, archivedAt: 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: { metadata: { ...rest, userResumable: false } },
|
||||
data: {
|
||||
...(session.archivedAt === null ? { archivedAt } : {}),
|
||||
metadata: { ...metadata, 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`);
|
||||
@@ -773,7 +530,6 @@ 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 };
|
||||
}>;
|
||||
@@ -793,6 +549,5 @@ function toRoleRow(role: {
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
folderId: role.folderId,
|
||||
};
|
||||
}
|
||||
|
||||
+46
-84
@@ -1,13 +1,11 @@
|
||||
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";
|
||||
@@ -16,11 +14,6 @@ export const CPH_HUB_MCP_TOOL_IDS = [
|
||||
"feishu_read_context",
|
||||
"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];
|
||||
@@ -30,65 +23,46 @@ export interface ClaudeSdkToolConfig {
|
||||
readonly allowedTools: readonly string[];
|
||||
}
|
||||
|
||||
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"],
|
||||
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"]],
|
||||
// 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"],
|
||||
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_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([
|
||||
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
|
||||
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
|
||||
["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"]],
|
||||
]);
|
||||
|
||||
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 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"],
|
||||
["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"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
|
||||
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
|
||||
]);
|
||||
|
||||
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
|
||||
if (roleTools === undefined) {
|
||||
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
|
||||
return {
|
||||
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
|
||||
@@ -100,12 +74,13 @@ export function claudeSdkToolConfigForRole(
|
||||
const allowedTools: string[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
|
||||
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
|
||||
pushUnique(builtIns, tool);
|
||||
pushUnique(allowedTools, tool);
|
||||
}
|
||||
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) {
|
||||
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
|
||||
}
|
||||
}
|
||||
@@ -113,37 +88,24 @@ export function claudeSdkToolConfigForRole(
|
||||
return { tools: builtIns, allowedTools };
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
|
||||
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
|
||||
|
||||
const tools: CphHubMcpToolId[] = ["todo_write"];
|
||||
const tools: CphHubMcpToolId[] = [];
|
||||
for (const roleTool of roleTools) {
|
||||
assertSupportedRoleTool(roleTool);
|
||||
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
|
||||
pushUnique(tools, mcpTool);
|
||||
}
|
||||
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
|
||||
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function roleToolsAllow(
|
||||
roleTools: readonly string[] | null | undefined,
|
||||
roleTool: string,
|
||||
): boolean {
|
||||
if (roleTools === undefined || roleTools === null) return true;
|
||||
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
|
||||
if (roleTools === undefined) return true;
|
||||
for (const configured of roleTools) {
|
||||
assertSupportedRoleTool(configured);
|
||||
if (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;
|
||||
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+14
-113
@@ -24,10 +24,10 @@
|
||||
* denied by default and re-opened only for the workspace plus named system
|
||||
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
|
||||
* subprocess gets a minimal environment and SDK credential protection removes
|
||||
* provider secrets from Bash. This upholds the workspace-bounded file-op
|
||||
* invariant (ADR-0018) without re-implementing the
|
||||
* provider secrets from Bash. This upholds `AgentFileOp.Authorized`
|
||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||
* is the mechanism, the ADR pins the invariant.
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
*/
|
||||
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
@@ -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 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: "finish" };
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
@@ -140,9 +140,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
let cleanupSecurity = async (): Promise<void> => {};
|
||||
try {
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
// 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 toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
const workspaceRoot = req.project.workspaceRoot?.trim();
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") {
|
||||
throw new Error("Agent run requires the configured workspace root");
|
||||
@@ -156,42 +154,14 @@ 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;
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
const options: QueryOptions = {
|
||||
cwd: security.cwd,
|
||||
tools: toolsOption,
|
||||
allowedTools: allowedToolsOption,
|
||||
disallowedTools: [...disallowedToolsOption],
|
||||
// `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],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
@@ -208,16 +178,7 @@ 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,
|
||||
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,
|
||||
},
|
||||
settings: { disableBundledSkills: true },
|
||||
...(hasSkills && security.skillPluginRoot !== undefined
|
||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||
: {}),
|
||||
@@ -237,25 +198,13 @@ 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: promptForAgent,
|
||||
prompt: req.prompt,
|
||||
options,
|
||||
});
|
||||
|
||||
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
|
||||
// Track tool start timestamps for duration calculation
|
||||
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) {
|
||||
@@ -276,10 +225,6 @@ 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") {
|
||||
@@ -301,7 +246,6 @@ 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,
|
||||
@@ -335,14 +279,12 @@ 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: meta?.name ?? toolUseId,
|
||||
toolName: toolUseId,
|
||||
result: resultText,
|
||||
isError,
|
||||
...(meta?.input !== undefined ? { input: meta.input } : {}),
|
||||
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -391,39 +333,6 @@ 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 {
|
||||
@@ -452,11 +361,3 @@ 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, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
@@ -21,20 +21,6 @@ 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 = [
|
||||
@@ -136,7 +122,6 @@ 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
|
||||
@@ -145,25 +130,6 @@ 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,
|
||||
@@ -177,7 +143,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
|
||||
allowWrite: [workspaceDir],
|
||||
// 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.
|
||||
@@ -247,30 +213,9 @@ 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 = [
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* 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: a message_id for trigger_message/status_card/reply, or a thread_id for thread."),
|
||||
id: z.string().describe("The anchor id (message id or run id)."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
/**
|
||||
* ADR-0027: Organization-scoped capability connection service. Manages the
|
||||
* lifecycle (rotate / read / disable) of capability credentials stored in
|
||||
* ADR-0024 encrypted envelopes with purpose="capability".
|
||||
*
|
||||
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
|
||||
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { 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<string>(CAPABILITY_IDS);
|
||||
|
||||
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 {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly credential: CapabilityCredentialInput;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionMetadata {
|
||||
readonly id: string;
|
||||
readonly capabilityId: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly activeVersion: number | null;
|
||||
readonly keyId: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
|
||||
readonly created: boolean;
|
||||
}
|
||||
|
||||
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
|
||||
|
||||
export class CapabilityConnectionService {
|
||||
private readonly readinessProbe: CapabilityReadinessProbe;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
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.capabilityId, input.credential);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
});
|
||||
await this.readinessProbe(payload);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
const connection = await tx.organizationCapabilityConnection.upsert({
|
||||
where: {
|
||||
organizationId_capabilityId: {
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: input.capabilityId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
capabilityId: input.capabilityId,
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
||||
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: {
|
||||
activeSecretVersion: true,
|
||||
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
||||
},
|
||||
});
|
||||
if (locked.organizationId !== input.organizationId) {
|
||||
throw new Error("Capability Connection scope changed during rotation");
|
||||
}
|
||||
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||
const secretVersionId = randomUUID();
|
||||
const envelope = this.secrets.encryptJson(
|
||||
{
|
||||
purpose: "capability",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: locked.id,
|
||||
secretVersionId,
|
||||
},
|
||||
payload,
|
||||
);
|
||||
const now = new Date();
|
||||
const secretVersion = await tx.capabilityCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId: locked.id,
|
||||
version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
});
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
await tx.capabilityCredentialVersion.update({
|
||||
where: { id: locked.activeSecretVersion.id },
|
||||
data: { retiredAt: now },
|
||||
});
|
||||
}
|
||||
const activated = await tx.organizationCapabilityConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersion.id,
|
||||
activatedAt: now,
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: version === 1 ? "capability.created" : "capability.rotated",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
capabilityId: input.capabilityId,
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
keyId: envelope.keyId,
|
||||
secretKind: payload.kind,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
|
||||
created: version === 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
|
||||
const connections = await this.prisma.organizationCapabilityConnection.findMany({
|
||||
where: { organizationId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
orderBy: { capabilityId: "asc" },
|
||||
});
|
||||
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
|
||||
}
|
||||
|
||||
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
|
||||
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
|
||||
where: { organizationId, capabilityId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
|
||||
}
|
||||
|
||||
async disable(input: {
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly actorUserId: string;
|
||||
}): Promise<CapabilityConnectionMetadata> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireCapabilityAdmin(tx, input);
|
||||
const connection = await tx.organizationCapabilityConnection.findFirst({
|
||||
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (connection === null) throw new Error("Capability Connection not found");
|
||||
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
|
||||
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
|
||||
const disabled = await tx.organizationCapabilityConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: { status: "DISABLED", disabledAt: new Date() },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "capability.disabled",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
capabilityId: input.capabilityId,
|
||||
previousStatus: locked.status,
|
||||
status: "DISABLED",
|
||||
},
|
||||
},
|
||||
});
|
||||
return toMetadata(disabled, locked.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential(
|
||||
capabilityId: string,
|
||||
input: CapabilityCredentialInput,
|
||||
): CapabilitySecretPayload {
|
||||
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
|
||||
throw new Error(`unsupported capabilityId: ${capabilityId}`);
|
||||
}
|
||||
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(
|
||||
connection: {
|
||||
readonly id: string;
|
||||
readonly capabilityId: string;
|
||||
readonly status: string;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
secret: { readonly version: number; readonly keyId: string } | null,
|
||||
): CapabilityConnectionMetadata {
|
||||
return {
|
||||
id: connection.id,
|
||||
capabilityId: connection.capabilityId,
|
||||
status: connection.status as CapabilityConnectionMetadata["status"],
|
||||
activeVersion: secret?.version ?? null,
|
||||
keyId: secret?.keyId ?? null,
|
||||
createdAt: connection.createdAt,
|
||||
updatedAt: connection.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireCapabilityAdmin(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { readonly organizationId: string; readonly actorUserId: string },
|
||||
): Promise<void> {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const membership = await tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.actorUserId,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} must not be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
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,19 +12,17 @@ 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 type ResolvedCapabilityCredential = CapabilitySecretPayload & {
|
||||
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
readonly capabilityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active capability credential for an organization. Throws
|
||||
@@ -56,19 +54,17 @@ export async function resolveCapabilityCredential(
|
||||
connectionId: connection.id,
|
||||
secretVersionId: version.id,
|
||||
};
|
||||
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}`,
|
||||
);
|
||||
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}`);
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
capabilityId: connection.capabilityId,
|
||||
...payload,
|
||||
schemaVersion: 1,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
endpoint: payload.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 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 type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
|
||||
|
||||
export class CapabilityReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "configuration" | "http",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CapabilityReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
|
||||
* with a dummy id. The API will return:
|
||||
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
|
||||
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
|
||||
* - network error → unreachable
|
||||
*/
|
||||
export 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);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { authorization: authHeader, accept: "application/json" },
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_unreachable",
|
||||
"docmind credential readiness check could not reach the API",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new CapabilityReadinessError(
|
||||
"capability_readiness_rejected",
|
||||
`docmind credential rejected: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
@@ -19,10 +19,9 @@ import $DocmindClient, {
|
||||
QueryDocParserStatusRequest,
|
||||
} from "@alicloud/docmind-api20220711";
|
||||
import { RuntimeOptions } from "@alicloud/tea-util";
|
||||
import { createReadStream, type ReadStream } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import type { DocmindCapabilitySecretPayload } from "./types.js";
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
|
||||
/** A single extracted image downloaded from the markdown's OSS image URLs. */
|
||||
export interface DocmindExtractedImage {
|
||||
@@ -44,7 +43,7 @@ export interface DocmindParseOptions {
|
||||
}
|
||||
|
||||
export interface CapabilityProviderClient {
|
||||
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
|
||||
}
|
||||
|
||||
export class DocmindClientError extends Error {
|
||||
@@ -62,33 +61,11 @@ 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: 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);
|
||||
|
||||
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
|
||||
const config: DocmindConfig = {
|
||||
endpoint: credential.endpoint,
|
||||
accessKeyId: credential.accessKeyId,
|
||||
@@ -98,21 +75,22 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
|
||||
} as DocmindConfig;
|
||||
const client = new $DocmindClient.default(config);
|
||||
|
||||
// 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).
|
||||
// 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);
|
||||
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
|
||||
fileUrlObject: fileStream,
|
||||
fileName,
|
||||
outputFormat: ["markdown"],
|
||||
formulaEnhancement: true,
|
||||
});
|
||||
const runtime = createDocmindRuntimeOptions();
|
||||
const runtime = new RuntimeOptions({});
|
||||
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",
|
||||
@@ -251,28 +229,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* 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,7 +23,6 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
asDocmindSecret,
|
||||
type CapabilityAdapter,
|
||||
type CapabilityInvocationInput,
|
||||
type CapabilityInvocationResult,
|
||||
@@ -64,135 +63,6 @@ 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 {
|
||||
@@ -213,7 +83,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
|
||||
// 3. Call the backing service.
|
||||
let result;
|
||||
try {
|
||||
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
|
||||
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
|
||||
} catch (e) {
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
|
||||
+7
-123
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* ADR-0027: External capability types shared across the adapter layer.
|
||||
*
|
||||
* A capability is a platform-registered, org-enabled external service
|
||||
* A capability is a platform-registered, org-enabled document/media transform
|
||||
* 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 when applicable (AgentSurface,
|
||||
* ADR-0018), and records consumption on a UsageFact (ADR-0026).
|
||||
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
|
||||
* and records consumption on a UsageFact (ADR-0026).
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
|
||||
@@ -13,7 +13,6 @@ 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];
|
||||
@@ -28,7 +27,6 @@ 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. */
|
||||
@@ -61,7 +59,7 @@ export interface CapabilityConsumption {
|
||||
readonly model: string | null;
|
||||
readonly inputTokens: number | null;
|
||||
readonly outputTokens: number | null;
|
||||
/** Non-token meter (page count, audio seconds, request count). */
|
||||
/** Non-token meter (page count, audio seconds). */
|
||||
readonly quantity: number;
|
||||
readonly unit: string;
|
||||
/** USD cost if the service reported one; null = unknown (ADR-0022). */
|
||||
@@ -82,39 +80,15 @@ export interface CapabilityAdapter {
|
||||
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
|
||||
}
|
||||
|
||||
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
|
||||
export interface DocmindCapabilitySecretPayload {
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
|
||||
export interface CapabilitySecretPayload {
|
||||
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) {
|
||||
@@ -125,93 +99,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ADR-0022 capacity dimensions.
|
||||
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by
|
||||
* ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
|
||||
* The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
|
||||
* capacity testing. This module is the single source of the dimension set shared
|
||||
* by the platform-ceiling config and the org capacity-policy service.
|
||||
*/
|
||||
|
||||
@@ -250,7 +250,6 @@ async function initializeSilo(
|
||||
data: {
|
||||
organizationId: input.organization.id,
|
||||
name: "Inbox",
|
||||
kind: "SYSTEM_INBOX",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
+17
-151
@@ -2,10 +2,9 @@
|
||||
* Feishu interactive card builder for agent run output.
|
||||
*
|
||||
* Produces card JSON with:
|
||||
* 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)
|
||||
* 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)
|
||||
*
|
||||
* Adapted from openclaw-lark's builder.ts, simplified for our
|
||||
* message.patch-based approach (no CardKit 2.0 streaming_mode).
|
||||
@@ -13,9 +12,6 @@
|
||||
*/
|
||||
|
||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||
import type { AgentTodoItem } from "../../agent/todoList.js";
|
||||
import { todoProgressSummary } from "../../agent/todoList.js";
|
||||
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -42,8 +38,6 @@ 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",
|
||||
@@ -53,26 +47,10 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
|
||||
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,39 +58,26 @@ function isTodoToolName(toolName: string): boolean {
|
||||
export function buildAgentCard(params: {
|
||||
phase: CardPhase;
|
||||
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, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const elements: unknown[] = [];
|
||||
|
||||
// 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());
|
||||
}
|
||||
// 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 === "")) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
}
|
||||
|
||||
// Reasoning panel
|
||||
if (reasoningText !== undefined && reasoningText !== "") {
|
||||
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) {
|
||||
if (phase === "streaming" && text === "") {
|
||||
// Still thinking: show reasoning inline
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -125,11 +90,12 @@ export function buildAgentCard(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Main answer: either materialized segments (markdown + Feishu-hosted images)
|
||||
// or a single markdown block.
|
||||
const answerElements = buildAnswerElements(text, contentSegments);
|
||||
if (answerElements.length > 0) {
|
||||
elements.push(...answerElements);
|
||||
// Main text content
|
||||
if (text !== "") {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
});
|
||||
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -194,62 +160,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -491,50 +401,6 @@ function escapeMarkdown(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||
}
|
||||
|
||||
function buildAnswerElements(
|
||||
text: string,
|
||||
contentSegments: readonly CardContentSegment[] | undefined,
|
||||
): unknown[] {
|
||||
if (contentSegments !== undefined && contentSegments.length > 0) {
|
||||
const elements: unknown[] = [];
|
||||
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,
|
||||
alt: { tag: "plain_text", content: segment.alt },
|
||||
mode: "fit_horizontal",
|
||||
preview: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (segment.content === "" || remaining <= 0) continue;
|
||||
// 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",
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
if (text === "") return [];
|
||||
const safe = maskMarkdownImagesForStreaming(text);
|
||||
if (safe === "") return [];
|
||||
return [{
|
||||
tag: "markdown",
|
||||
content: truncateText(safe, MAX_TEXT_LENGTH),
|
||||
}];
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
@@ -18,13 +18,10 @@
|
||||
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
||||
* 5. finish(finalText) — flush + transition to complete card
|
||||
* 6. fail(errorText) — flush + transition to error card
|
||||
*
|
||||
* On finish, markdown image references (``) are downloaded /
|
||||
* read, uploaded to Feishu as message images, and embedded as native card
|
||||
* `img` elements so external URLs never hit Feishu content-security checks.
|
||||
*/
|
||||
|
||||
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
||||
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
|
||||
import { sendCard, patchCard, sendText } from "../client.js";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
||||
import {
|
||||
startToolUseTraceRun,
|
||||
@@ -34,17 +31,6 @@ 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,
|
||||
materializeAnswerSegments,
|
||||
sendImageMessage,
|
||||
} from "../outboundImages.js";
|
||||
|
||||
export interface StreamingCardSink {
|
||||
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
||||
@@ -58,11 +44,6 @@ export interface StreamingCardOptions {
|
||||
readonly sendOptions?: SendMessageOptions | undefined;
|
||||
readonly patchIntervalMs: number | undefined;
|
||||
readonly maxMessageLength: number | undefined;
|
||||
/** Project workspace root; required to resolve local image paths. */
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
/** Project workspace directory; required to resolve local image paths. */
|
||||
readonly workspaceDir?: string | undefined;
|
||||
readonly maxImageBytes?: number | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||
@@ -71,7 +52,6 @@ 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();
|
||||
@@ -85,9 +65,6 @@ export class StreamingAgentCard {
|
||||
private readonly sendOptions: SendMessageOptions | undefined;
|
||||
private readonly patchIntervalMs: number;
|
||||
private readonly maxMessageLength: number;
|
||||
private readonly workspaceRoot: string | undefined;
|
||||
private readonly workspaceDir: string | undefined;
|
||||
private readonly maxImageBytes: number | undefined;
|
||||
|
||||
constructor(options: StreamingCardOptions) {
|
||||
this.runId = options.runId;
|
||||
@@ -96,9 +73,6 @@ export class StreamingAgentCard {
|
||||
this.sendOptions = options.sendOptions;
|
||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||
this.workspaceRoot = options.workspaceRoot;
|
||||
this.workspaceDir = options.workspaceDir;
|
||||
this.maxImageBytes = options.maxImageBytes;
|
||||
startToolUseTraceRun(this.runId);
|
||||
}
|
||||
|
||||
@@ -129,64 +103,28 @@ 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;
|
||||
readonly isError?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): 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 =
|
||||
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
|
||||
this.text = answerText;
|
||||
|
||||
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
|
||||
rt: this.rt,
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
workspaceDir: this.workspaceDir,
|
||||
maxImageBytes: this.maxImageBytes,
|
||||
});
|
||||
if (unresolved.length > 0) {
|
||||
this.rt.logger.warn(
|
||||
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
|
||||
"some answer images could not be uploaded to Feishu",
|
||||
);
|
||||
}
|
||||
|
||||
let cardUpdated = true;
|
||||
if (answerText.length > 0 || segments.length > 0) {
|
||||
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
|
||||
let updated = true;
|
||||
if (this.text.length > 0) {
|
||||
this.text = appendFooter(this.text, footerText);
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
|
||||
// No streaming text was sent. If we never created a card, send one now.
|
||||
this.text = fallbackWithFooter;
|
||||
updated = await this.flushCard("complete", this.text);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
cardUpdated = await this.flushCard("complete", "", isError, []);
|
||||
// Patch the existing card with the final text.
|
||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
||||
}
|
||||
if (!cardUpdated) {
|
||||
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||
await this.deliverPlainFallback(segments, answerText);
|
||||
}
|
||||
// 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) {
|
||||
if (!updated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
}
|
||||
} finally {
|
||||
@@ -228,32 +166,16 @@ export class StreamingAgentCard {
|
||||
return this.flushCard(this.currentPhase(), this.text);
|
||||
}
|
||||
|
||||
private async flushCard(
|
||||
phase: CardPhase,
|
||||
text: string,
|
||||
isError = false,
|
||||
contentSegments?: readonly CardContentSegment[],
|
||||
): Promise<boolean> {
|
||||
// During live streaming, strip image URLs so Feishu never fetches remote
|
||||
// ranks mid-run. Materialized segments are only used on the complete pass.
|
||||
const displayText =
|
||||
phase === "complete" && contentSegments !== undefined
|
||||
? text
|
||||
: maskMarkdownImagesForStreaming(text);
|
||||
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
|
||||
const chunks = splitAtBoundary(text, this.maxMessageLength);
|
||||
const firstChunk = chunks[0];
|
||||
if (firstChunk === undefined) return true;
|
||||
|
||||
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
|
||||
const firstChunk = chunks[0] ?? "";
|
||||
// When we have segments (complete+images), keep first-card complete content
|
||||
// on segments only; overflow text (rare) falls back to plain chunked cards.
|
||||
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
||||
const card = buildAgentCard({
|
||||
phase,
|
||||
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
|
||||
contentSegments: contentSegments !== undefined && contentSegments.length > 0
|
||||
? contentSegments
|
||||
: undefined,
|
||||
text: firstChunk,
|
||||
reasoningText: this.reasoningText || undefined,
|
||||
todos: this.todos.length > 0 ? this.todos : undefined,
|
||||
toolUseSteps,
|
||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||
isError,
|
||||
@@ -264,37 +186,12 @@ export class StreamingAgentCard {
|
||||
if (this.currentMessageId === null) {
|
||||
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
||||
let updated = this.currentMessageId !== null;
|
||||
// Send overflow chunks as new messages (rare for agent output). Segments
|
||||
// already include the whole answer; only plain text overflows.
|
||||
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const overflowCard = buildAgentCard({
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
interrupted: this.interrupted,
|
||||
runId: undefined,
|
||||
});
|
||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||
updated = updated && overflowMessageId !== null;
|
||||
this.currentMessageId = overflowMessageId;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
||||
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||
// Send overflow chunks as new messages (rare for agent output)
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const overflowCard = buildAgentCard({
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
todos: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
@@ -305,50 +202,27 @@ export class StreamingAgentCard {
|
||||
updated = updated && overflowMessageId !== null;
|
||||
this.currentMessageId = overflowMessageId;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async deliverPlainFallback(
|
||||
segments: readonly CardContentSegment[],
|
||||
answerText: string,
|
||||
): Promise<boolean> {
|
||||
const textParts: string[] = [];
|
||||
const imageKeys: string[] = [];
|
||||
if (segments.length > 0) {
|
||||
for (const segment of segments) {
|
||||
if (segment.type === "markdown") {
|
||||
if (segment.content.trim() !== "") textParts.push(segment.content);
|
||||
} else {
|
||||
imageKeys.push(segment.imgKey);
|
||||
}
|
||||
return updated;
|
||||
} else {
|
||||
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
||||
// For overflow, create new messages
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
const overflowCard = buildAgentCard({
|
||||
phase,
|
||||
text: chunk,
|
||||
reasoningText: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError,
|
||||
interrupted: this.interrupted,
|
||||
runId: undefined,
|
||||
});
|
||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||
updated = updated && overflowMessageId !== null;
|
||||
this.currentMessageId = overflowMessageId;
|
||||
}
|
||||
} else if (answerText.trim() !== "") {
|
||||
textParts.push(maskMarkdownImagesForStreaming(answerText));
|
||||
return updated;
|
||||
}
|
||||
|
||||
let any = false;
|
||||
if (textParts.length > 0) {
|
||||
const messageId = await sendLongText(
|
||||
this.rt,
|
||||
this.chatId,
|
||||
textParts.join("\n\n"),
|
||||
this.sendOptions,
|
||||
);
|
||||
any = messageId !== null;
|
||||
}
|
||||
for (const imageKey of imageKeys) {
|
||||
try {
|
||||
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
|
||||
any = any || messageId !== null;
|
||||
} catch (error) {
|
||||
this.rt.logger.warn(
|
||||
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
|
||||
"standalone image fallback failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
private currentPhase(): CardPhase {
|
||||
@@ -361,5 +235,5 @@ export class StreamingAgentCard {
|
||||
function appendFooter(text: string, footerText: string): string {
|
||||
if (footerText === "") return text;
|
||||
if (text === "") return footerText;
|
||||
return `${text}\n\n${footerText}`;
|
||||
return `${text.trimEnd()}\n\n${footerText}`;
|
||||
}
|
||||
|
||||
@@ -316,8 +316,7 @@ export async function sendCard(
|
||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -335,7 +334,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed");
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-32
@@ -1,8 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { ToolContext } from "../agent/tools.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
import type { FeishuRuntime } from "./client.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
|
||||
|
||||
export interface FeishuMessageResourceArgs {
|
||||
readonly messageId: string;
|
||||
@@ -14,11 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
|
||||
readonly workspaceRoot: string;
|
||||
readonly botCli: FeishuBotCli;
|
||||
readonly maxFileBytes?: number | undefined;
|
||||
};
|
||||
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
|
||||
|
||||
interface MessageLookupResult {
|
||||
readonly data?: {
|
||||
@@ -56,29 +51,14 @@ export async function downloadFeishuMessageResource(
|
||||
"inbox",
|
||||
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
|
||||
);
|
||||
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;
|
||||
}
|
||||
const savePath = await downloadMessageFile(
|
||||
rt,
|
||||
args.messageId,
|
||||
args.fileKey,
|
||||
context.workspaceRoot,
|
||||
context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
args.resourceType,
|
||||
);
|
||||
return { ...args, path: savePath };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
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";
|
||||
import type { ApprovalManager } from "./approval.js";
|
||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
createPdfToMdBundleAdapter,
|
||||
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;
|
||||
readonly chatId: string;
|
||||
readonly projectId: string;
|
||||
readonly organizationId: string;
|
||||
readonly runId: string;
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
readonly workspaceDir: string;
|
||||
@@ -34,16 +20,9 @@ export interface FileDeliveryToolOptions {
|
||||
readonly approvalManager: ApprovalManager;
|
||||
readonly onDelivered?: (path: string) => void;
|
||||
readonly tools?: readonly CphHubMcpToolId[] | undefined;
|
||||
readonly prisma: PrismaClient;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
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>> = [];
|
||||
|
||||
@@ -161,7 +140,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 using the Organization bot identity. 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. 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."),
|
||||
@@ -189,8 +168,6 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
boundChatId: options.chatId,
|
||||
workspaceRoot,
|
||||
workspaceDir: options.workspaceDir,
|
||||
botCli,
|
||||
maxFileBytes: options.maxFileBytes,
|
||||
},
|
||||
options.rt,
|
||||
);
|
||||
@@ -253,205 +230,6 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
);
|
||||
}
|
||||
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
const adapter = createPdfToMdBundleAdapter({
|
||||
secrets: options.secretEnvelope,
|
||||
client: new AliyunDocmindClient(),
|
||||
prisma: options.prisma,
|
||||
});
|
||||
tools.push(
|
||||
tool(
|
||||
"convert_pdf_to_md",
|
||||
"Convert 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().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({
|
||||
...base,
|
||||
inputPath: args.input_path,
|
||||
outputDir: args.output_dir,
|
||||
});
|
||||
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
|
||||
for (const artifact of result.artifacts) {
|
||||
lines.push(` - ${artifact.path} (${artifact.kind})`);
|
||||
}
|
||||
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
||||
};
|
||||
}
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const 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",
|
||||
@@ -462,68 +240,11 @@ 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 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.",
|
||||
"Use send_file when the user asks to receive, resend, download, or attach a file.",
|
||||
"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.",
|
||||
);
|
||||
@@ -539,34 +260,5 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
if (enabledTools.has("request_approval")) {
|
||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
||||
}
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
instructions.push(
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF (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(" ");
|
||||
}
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
/**
|
||||
* Resolve markdown image references in agent answers into Feishu-hosted
|
||||
* image_keys so cards can embed them without remote URLs (which trip Feishu
|
||||
* content-security controls).
|
||||
*/
|
||||
import { isIP } from "node:net";
|
||||
import type { FeishuRuntime } from "./client.js";
|
||||
import { withRetry } from "./client.js";
|
||||
import {
|
||||
WorkspaceFileBoundaryError,
|
||||
readWorkspaceFileNoFollow,
|
||||
} from "../security/workspaceFiles.js";
|
||||
|
||||
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
|
||||
|
||||
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 }
|
||||
| { readonly type: "image"; readonly imgKey: string; readonly alt: string };
|
||||
|
||||
export interface MarkdownImageRef {
|
||||
readonly fullMatch: string;
|
||||
readonly alt: string;
|
||||
readonly src: string;
|
||||
readonly index: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
export interface OutboundImageContext {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly workspaceRoot?: string | undefined;
|
||||
readonly workspaceDir?: string | undefined;
|
||||
readonly maxImageBytes?: number | undefined;
|
||||
readonly maxImages?: number | undefined;
|
||||
readonly fetchImpl?: typeof fetch | undefined;
|
||||
}
|
||||
|
||||
type ImageCreateResponse = {
|
||||
image_key?: string;
|
||||
data?: { image_key?: string };
|
||||
} | null;
|
||||
|
||||
type MessageCreateResponse = {
|
||||
message_id?: string;
|
||||
data?: { message_id?: string };
|
||||
} | null;
|
||||
|
||||
/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
|
||||
export function maskMarkdownImagesForStreaming(text: string): string {
|
||||
return rewriteMarkdownImagesOutsideCode(text, (ref) => {
|
||||
const alt = ref.alt.trim();
|
||||
return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
|
||||
});
|
||||
}
|
||||
|
||||
export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
|
||||
const blocked = blockedRanges(text);
|
||||
const refs: MarkdownImageRef[] = [];
|
||||
IMAGE_MARKDOWN_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
|
||||
const index = match.index;
|
||||
if (blocked.some((range) => index >= range.start && index < range.end)) continue;
|
||||
const fullMatch = match[0];
|
||||
const alt = match[1] ?? "";
|
||||
const rawSrc = (match[2] ?? "").trim();
|
||||
const src = stripUrlTitle(rawSrc);
|
||||
if (src === "") continue;
|
||||
refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload reachable markdown images and split the answer into card segments
|
||||
* (markdown + Feishu img elements). Unresolved images become visible alt text.
|
||||
*/
|
||||
export async function materializeAnswerSegments(
|
||||
text: string,
|
||||
ctx: OutboundImageContext,
|
||||
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
|
||||
const refs = findMarkdownImagesOutsideCode(text);
|
||||
if (refs.length === 0) {
|
||||
return {
|
||||
segments: text === "" ? [] : [{ type: "markdown", content: text }],
|
||||
unresolved: [],
|
||||
};
|
||||
}
|
||||
|
||||
const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
|
||||
const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
|
||||
const keyBySrc = new Map<string, string>();
|
||||
const unresolved: string[] = [];
|
||||
const uniqueSrcs: string[] = [];
|
||||
for (const ref of refs) {
|
||||
if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
|
||||
}
|
||||
|
||||
for (const src of uniqueSrcs.slice(0, maxImages)) {
|
||||
try {
|
||||
const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
|
||||
if (bytes === null) {
|
||||
unresolved.push(src);
|
||||
continue;
|
||||
}
|
||||
const imageKey = await uploadMessageImage(ctx.rt, bytes);
|
||||
keyBySrc.set(src, imageKey);
|
||||
} catch (error) {
|
||||
ctx.rt.logger.warn(
|
||||
{ src, err: error instanceof Error ? error.message : String(error) },
|
||||
"outbound image materialize failed",
|
||||
);
|
||||
unresolved.push(src);
|
||||
}
|
||||
}
|
||||
for (const src of uniqueSrcs.slice(maxImages)) {
|
||||
unresolved.push(src);
|
||||
}
|
||||
|
||||
const segments: CardContentSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const ref of refs) {
|
||||
if (ref.index > cursor) {
|
||||
pushMarkdown(segments, text.slice(cursor, ref.index));
|
||||
}
|
||||
const imageKey = keyBySrc.get(ref.src);
|
||||
if (imageKey !== undefined) {
|
||||
segments.push({
|
||||
type: "image",
|
||||
imgKey: imageKey,
|
||||
alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
|
||||
});
|
||||
} else {
|
||||
const alt = ref.alt.trim();
|
||||
pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
|
||||
}
|
||||
cursor = ref.index + ref.length;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
pushMarkdown(segments, text.slice(cursor));
|
||||
}
|
||||
return { segments, unresolved };
|
||||
}
|
||||
|
||||
export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
|
||||
if (image.byteLength === 0) {
|
||||
throw new Error("image is empty");
|
||||
}
|
||||
if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
|
||||
throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
|
||||
};
|
||||
const res = await withRetry(async () =>
|
||||
client.im.v1.image.create({
|
||||
data: { image_type: "message", image },
|
||||
}),
|
||||
);
|
||||
const imageKey = res?.image_key ?? res?.data?.image_key;
|
||||
if (imageKey === undefined || imageKey === "") {
|
||||
throw new Error("Feishu image upload response is missing image_key");
|
||||
}
|
||||
return imageKey;
|
||||
}
|
||||
|
||||
/** Send a standalone image message (fallback if card embed is unavailable). */
|
||||
export async function sendImageMessage(
|
||||
rt: FeishuRuntime,
|
||||
chatId: string,
|
||||
imageKey: string,
|
||||
options?: { readonly replyToMessageId?: string | undefined },
|
||||
): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: (p: unknown) => Promise<MessageCreateResponse>;
|
||||
reply: (p: unknown) => Promise<MessageCreateResponse>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const replyTo = options?.replyToMessageId;
|
||||
if (replyTo !== undefined && replyTo !== "") {
|
||||
const res = await client.im.v1.message.reply({
|
||||
path: { message_id: replyTo },
|
||||
data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
|
||||
});
|
||||
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||
}
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "image",
|
||||
content: JSON.stringify({ image_key: imageKey }),
|
||||
},
|
||||
});
|
||||
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||
}
|
||||
|
||||
export async function resolveOutboundImageBytes(
|
||||
src: string,
|
||||
ctx: OutboundImageContext,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null> {
|
||||
if (isRemoteUrl(src)) {
|
||||
return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
|
||||
}
|
||||
const root = ctx.workspaceRoot?.trim();
|
||||
const dir = ctx.workspaceDir?.trim();
|
||||
if (root === undefined || root === "" || dir === undefined || dir === "") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
|
||||
return file.data;
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteMarkdownImagesOutsideCode(
|
||||
text: string,
|
||||
replace: (ref: MarkdownImageRef) => string,
|
||||
): string {
|
||||
const refs = findMarkdownImagesOutsideCode(text);
|
||||
if (refs.length === 0) return text;
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
for (const ref of refs) {
|
||||
out += text.slice(cursor, ref.index);
|
||||
out += replace(ref);
|
||||
cursor = ref.index + ref.length;
|
||||
}
|
||||
out += text.slice(cursor);
|
||||
return out;
|
||||
}
|
||||
|
||||
function pushMarkdown(segments: CardContentSegment[], content: string): void {
|
||||
if (content === "") return;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last !== undefined && last.type === "markdown") {
|
||||
segments[segments.length - 1] = { type: "markdown", content: last.content + content };
|
||||
return;
|
||||
}
|
||||
segments.push({ type: "markdown", content });
|
||||
}
|
||||
|
||||
function blockedRanges(text: string): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
FENCED_CODE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
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;
|
||||
}
|
||||
|
||||
function stripUrlTitle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
// Markdown optional title: url "title" or url 'title'
|
||||
const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
|
||||
return (titled?.[1] ?? trimmed).trim();
|
||||
}
|
||||
|
||||
function isRemoteUrl(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteImage(
|
||||
src: string,
|
||||
fetchImpl: typeof fetch,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (!isPublicHttpHost(url.hostname)) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
// 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: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
// One safe redirect hop to another public http(s) host.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (location === null || location === "") return null;
|
||||
let redirected: URL;
|
||||
try {
|
||||
redirected = new URL(location, url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
|
||||
if (!isPublicHttpHost(redirected.hostname)) return null;
|
||||
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: IMAGE_FETCH_HEADERS,
|
||||
});
|
||||
return readImageBody(second, maxBytes);
|
||||
}
|
||||
return readImageBody(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
if (
|
||||
contentType !== "" &&
|
||||
!contentType.startsWith("image/") &&
|
||||
!contentType.includes("octet-stream") &&
|
||||
(contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length") ?? "NaN");
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
|
||||
return buf;
|
||||
}
|
||||
|
||||
function isPublicHttpHost(hostname: string): boolean {
|
||||
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||
if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
|
||||
return false;
|
||||
}
|
||||
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
|
||||
return false;
|
||||
}
|
||||
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
const ipVersion = isIP(unbracketed);
|
||||
if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
|
||||
if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const parts = ip.split(".").map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return true;
|
||||
}
|
||||
const a = parts[0]!;
|
||||
const b = parts[1]!;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||
if (a >= 224) return true; // multicast / reserved
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIPv6(ip: string): boolean {
|
||||
const normalized = ip.toLowerCase();
|
||||
if (normalized === "::1") return true;
|
||||
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
|
||||
if (normalized.startsWith("fe80:")) return true; // link-local
|
||||
const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
|
||||
if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
|
||||
return false;
|
||||
}
|
||||
+5
-10
@@ -7,10 +7,8 @@
|
||||
*
|
||||
* - `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. `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`.
|
||||
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
|
||||
* `parent_message_id` filter; we map "thread" to that.
|
||||
*
|
||||
* 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
|
||||
@@ -71,14 +69,11 @@ export async function readFeishuContext(
|
||||
return JSON.stringify(compact(msg));
|
||||
}
|
||||
case "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`.
|
||||
// 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.
|
||||
const res = await api.list({
|
||||
params: {
|
||||
container_id_type: "thread",
|
||||
container_id_type: "message_id",
|
||||
container_id: args.id,
|
||||
page_size: 50,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
removeWorkspaceFileIfUnchangedNoFollow,
|
||||
type WorkspaceFileWriteResult,
|
||||
} from "../security/workspaceFiles.js";
|
||||
import type { FeishuBotCli } from "./botCli.js";
|
||||
import { downloadMessageFile, type FeishuRuntime } from "./client.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(
|
||||
botCli: FeishuBotCli,
|
||||
rt: FeishuRuntime,
|
||||
messageId: string,
|
||||
requests: readonly MessageResourceStageRequest[],
|
||||
workspaceRoot: string,
|
||||
@@ -49,18 +49,16 @@ export async function stageMessageResources(
|
||||
try {
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
for (const [index, request] of requests.entries()) {
|
||||
// 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({
|
||||
const stagedPath = await downloadMessageFile(
|
||||
rt,
|
||||
messageId,
|
||||
fileKey: request.fileKey,
|
||||
resourceType: request.resourceType,
|
||||
request.fileKey,
|
||||
workspaceRoot,
|
||||
workspaceDir: stagingRoot,
|
||||
workspaceRelativePath: `resource-${index}`,
|
||||
maxBytes: limits?.maxBytesPerFile,
|
||||
});
|
||||
stagingRoot,
|
||||
`resource-${index}`,
|
||||
request.resourceType,
|
||||
limits?.maxBytesPerFile,
|
||||
);
|
||||
resources.push({
|
||||
resourceType: request.resourceType,
|
||||
workspaceRelativePath: request.workspaceRelativePath,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 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)}...`;
|
||||
}
|
||||
+13
-65
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 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. On finish,
|
||||
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
|
||||
* calls (with trace panel) → streaming answer text → final card. 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.
|
||||
@@ -15,7 +14,6 @@ import { join } from "node:path";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import {
|
||||
sendText,
|
||||
sendTextMessage,
|
||||
@@ -41,7 +39,6 @@ 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";
|
||||
@@ -54,7 +51,6 @@ 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";
|
||||
@@ -97,7 +93,6 @@ interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly settings: RuntimeSettings;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
@@ -116,8 +111,6 @@ 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 {
|
||||
@@ -306,13 +299,8 @@ 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(
|
||||
botCli,
|
||||
rt,
|
||||
msg,
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
@@ -498,7 +486,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// Streaming agent card: single interactive card through the full run
|
||||
// lifecycle (thinking → tool calls → streaming text → complete).
|
||||
// Shows tool-use trace panel + reasoning panel + answer text.
|
||||
const deliveredFiles: string[] = [];
|
||||
const card = new StreamingAgentCard({
|
||||
runId: run.id,
|
||||
rt,
|
||||
@@ -506,15 +493,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
sendOptions,
|
||||
patchIntervalMs: undefined,
|
||||
maxMessageLength: undefined,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
|
||||
});
|
||||
const deliveredFiles: string[] = [];
|
||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||
rt,
|
||||
chatId,
|
||||
projectId,
|
||||
organizationId: siloOrganizationId,
|
||||
runId: run.id,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
@@ -522,8 +506,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
sendOptions,
|
||||
approvalManager,
|
||||
tools: cphHubMcpToolsForRole(roleTools),
|
||||
prisma: deps.prisma,
|
||||
secretEnvelope: deps.secretEnvelope,
|
||||
onDelivered: (path) => {
|
||||
deliveredFiles.push(path);
|
||||
},
|
||||
@@ -589,7 +571,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: event.input,
|
||||
input: undefined,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
@@ -608,24 +590,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
agentExecution
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
|
||||
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 =
|
||||
const finalText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
: result.status === "failed" && result.error !== undefined && outcome.notice === undefined
|
||||
: result.status === "failed" && result.error !== undefined
|
||||
? `\u5904\u7406\u5931\u8D25: ${result.error}`
|
||||
: result.text;
|
||||
const finalText = appendTeacherNotice(baseText, outcome.notice);
|
||||
await card.finish(finalText, { interrupted, isError: outcome.isError });
|
||||
await card.finish(finalText, { interrupted });
|
||||
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
|
||||
if (metadataPatch !== null) {
|
||||
await deps.prisma.agentSession.update({
|
||||
@@ -689,36 +660,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
initializedSkills: [...(result.initializedSkillIds ?? [])],
|
||||
},
|
||||
});
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
await removeProcessingReaction();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const removedProcessingReaction = await removeProcessingReaction();
|
||||
if (removedProcessingReaction) {
|
||||
await addReaction(rt, msg.message_id, "CrossMark");
|
||||
}
|
||||
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)}`,
|
||||
),
|
||||
);
|
||||
await card.fail(e instanceof Error ? e.message : String(e));
|
||||
try {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
@@ -1877,9 +1826,8 @@ 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 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.";
|
||||
"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.";
|
||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||
}
|
||||
|
||||
@@ -1960,7 +1908,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function stageTriggerMessageResources(
|
||||
botCli: FeishuBotCli,
|
||||
rt: FeishuRuntime,
|
||||
msg: MessageReceiveEvent["message"],
|
||||
workspaceRoot: string,
|
||||
limits?: TriggerDeps["resourceLimits"],
|
||||
@@ -1994,7 +1942,7 @@ async function stageTriggerMessageResources(
|
||||
}
|
||||
}
|
||||
return stageMessageResources(
|
||||
botCli,
|
||||
rt,
|
||||
msg.message_id,
|
||||
requests,
|
||||
workspaceRoot,
|
||||
|
||||
+9
-52
@@ -1,7 +1,7 @@
|
||||
import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
||||
@@ -118,22 +118,15 @@ 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. 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" },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
||||
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");
|
||||
await prisma.agentRun.updateMany({
|
||||
where: { status: "ACTIVE" },
|
||||
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
let feishuRuntime: FeishuRuntime | undefined;
|
||||
let feishuRuntime: { readonly isListenerReady?: () => boolean } | 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() });
|
||||
@@ -169,7 +162,6 @@ export async function startHub(): Promise<void> {
|
||||
prisma,
|
||||
settings: runtimeSettings,
|
||||
logger: app.log,
|
||||
secretEnvelope,
|
||||
projectWorkspaceRoot,
|
||||
publicBaseUrl,
|
||||
siloOrganizationId: siloOrganization.id,
|
||||
@@ -178,7 +170,7 @@ export async function startHub(): Promise<void> {
|
||||
allowLegacyFeishuIdentity: false,
|
||||
maxFeishuEventsPerMinute: feishuEventsPerMinute,
|
||||
});
|
||||
const runtime = await startFeishuListenerWithClient(
|
||||
feishuRuntime = await startFeishuListenerWithClient(
|
||||
feishuConfig,
|
||||
larkClient,
|
||||
app.log,
|
||||
@@ -193,8 +185,6 @@ 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,39 +192,6 @@ 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);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Org capacity policy service (ADR-0022).
|
||||
* Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
|
||||
*
|
||||
* Stores per-Organization lower `organizationLimit` overrides per
|
||||
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be ≤ the
|
||||
* platform ceiling for that dimension. The effective limit (min of the two)
|
||||
* `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the
|
||||
* platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
|
||||
* is the value capacity admission should use; dimensions with no org override
|
||||
* fall back to the platform ceiling.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Organization membership management for org admin (ADR-0021).
|
||||
*
|
||||
* Role rules (product pin, not yet recorded in an ADR):
|
||||
* Role rules (product pin, not yet in Lean):
|
||||
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
|
||||
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
|
||||
* 3. Cannot revoke or demote the last remaining OWNER.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user