forked from EduCraft/curriculum-project-hub
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 192cd43245 | |||
| 12628c9233 | |||
| 5df1900ca8 | |||
| df66691d24 | |||
| 73cb0e5b47 | |||
| e21096c642 | |||
| dc2d1c2f9e | |||
| 3f9b60f692 | |||
| 4234ba4c96 | |||
| 15f9443d3d |
@@ -1,12 +1,12 @@
|
||||
name: checker check
|
||||
|
||||
# Builds and lints the Rust implementation crates under crates/ (the rule-based
|
||||
# checker that "stands in Lean's position" at product runtime).
|
||||
# lesson checker).
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: hub check
|
||||
|
||||
# Builds, type-checks, and tests the Hub TS package under hub/.
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half
|
||||
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half.
|
||||
# This is an INTERNAL gate on the Hub's own
|
||||
# health, like checker-check is for the Rust half.
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +1,3 @@
|
||||
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
|
||||
.lake/
|
||||
**/.lake/
|
||||
|
||||
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
|
||||
/target
|
||||
**/*.pdf
|
||||
|
||||
@@ -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 and `Spec.System.Capacity`. Numerical
|
||||
with the durable decision in ADR-0022. 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/spec health, deploy a clean
|
||||
repeatable release procedure prove build/test 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
|
||||
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
|
||||
the 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
|
||||
|
||||
+1
-3
@@ -40,9 +40,7 @@ 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).
|
||||
The pinned semantic invariants are in
|
||||
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
|
||||
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
|
||||
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
|
||||
|
||||
Exact numeric session/invitation/step-up limits and browser mechanics remain
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
# AGENTS.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
|
||||
`Spec.System.Organization`)。
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
|
||||
- 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 / `Spec.System.Organization`)。
|
||||
不接收 org provider credential(见 ADR-0024)。
|
||||
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
|
||||
`Spec.System.Capacity`)。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
|
||||
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
|
||||
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
|
||||
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
|
||||
双因子的离线恢复流程(见 ADR-0023)。
|
||||
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
|
||||
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
|
||||
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
|
||||
@@ -36,10 +32,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` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
|
||||
@@ -48,12 +40,12 @@
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
# CLAUDE.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
|
||||
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
|
||||
|
||||
## 安装 `cph` 命令行
|
||||
|
||||
@@ -33,47 +33,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
|
||||
```
|
||||
README.md ← 本文件:总览 + 宪法(下面 5 条)
|
||||
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
|
||||
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
|
||||
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
|
||||
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
|
||||
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
|
||||
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
|
||||
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
|
||||
cph-check / cph-cli ← checker 本体 + `cph` 命令行
|
||||
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
|
||||
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
|
||||
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
|
||||
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
|
||||
(exporter/ …) ← 将来的其他部件,平级于 spec/
|
||||
(exporter/ …) ← 将来的其他部件,平级于 crates/
|
||||
```
|
||||
|
||||
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
|
||||
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
|
||||
未来部件(如 exporter)复用,而非各自重造。
|
||||
|
||||
## 宪法
|
||||
|
||||
这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
|
||||
这 4 条是本仓库的协作约定,是一切工作的前提。
|
||||
|
||||
1. **角色 —— Lean 是研发侧的上游参照。**
|
||||
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。
|
||||
1. **角色 —— ADR 是决策真相。**
|
||||
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档。
|
||||
|
||||
2. **对齐机制 —— Lean 只做上游参照。**
|
||||
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
|
||||
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
|
||||
2. **对齐机制 —— 人肉承载,无机器兜底。**
|
||||
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
3. **资产性 —— 由 review 纪律承载,无机器兜底。**
|
||||
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。
|
||||
3. **形态 —— 自包含。**
|
||||
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定。
|
||||
|
||||
4. **形态 —— 它是人机共识的契约。**
|
||||
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。
|
||||
|
||||
5. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
4. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
|
||||
## 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。
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
# crates/
|
||||
|
||||
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)
|
||||
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)
|
||||
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,13 +19,11 @@ const DEFAULT_TARGET: &str = "student";
|
||||
|
||||
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
|
||||
///
|
||||
/// **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
|
||||
/// **PINNED to `warning` by 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,
|
||||
/// alignable fact rather than an inline literal.
|
||||
/// severity as a const makes "it is a warning, not an error" a greppable
|
||||
/// fact rather than an inline literal.
|
||||
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
|
||||
|
||||
/// The result of running [`check`] (or the check phases of [`build`]).
|
||||
@@ -57,12 +55,12 @@ impl CheckReport {
|
||||
|
||||
/// Whether any collected diagnostic is `Error`-severity.
|
||||
///
|
||||
/// **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.
|
||||
/// **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.
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
|
||||
+10
-15
@@ -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`]
|
||||
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
|
||||
//! (two-valued, ADR-0010), 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,32 +17,27 @@ use serde::Serialize;
|
||||
|
||||
/// Severity of a diagnostic.
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
|
||||
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
|
||||
/// exactly:
|
||||
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
|
||||
///
|
||||
/// ```text
|
||||
/// inductive Severity where
|
||||
/// | warning
|
||||
/// | error
|
||||
/// warning | error
|
||||
/// ```
|
||||
///
|
||||
/// 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
|
||||
/// This two-valued shape is a **contract decision**, not an accident: the
|
||||
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
|
||||
/// 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 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.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Severity {
|
||||
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
|
||||
/// element. Mirrors Lean `Severity.warning`.
|
||||
/// element. ADR-0010 `warning`.
|
||||
Warning,
|
||||
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
|
||||
/// Blocking: the artifact is invalid. ADR-0010 `error`.
|
||||
Error,
|
||||
}
|
||||
|
||||
|
||||
+25
-38
@@ -3,9 +3,7 @@
|
||||
//! 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`] — mirroring the Lean master's `Lesson = List (Element P)`
|
||||
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
|
||||
//! teaching semantics.
|
||||
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
|
||||
//!
|
||||
//! Scope boundaries (deliberately staying in lane):
|
||||
//! - It validates **structure** only: manifest shape, element.toml shape, and
|
||||
@@ -23,7 +21,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An ordered, in-memory lesson loaded from an engineering file.
|
||||
///
|
||||
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
|
||||
/// `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)]
|
||||
@@ -86,9 +84,8 @@ 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 `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
|
||||
/// renders. Realizes ADR-0011's "render
|
||||
/// coverage is a declaration, not a payload": the declaration keeps *which
|
||||
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
|
||||
/// while the rendering "how" lives in the template/steps.
|
||||
///
|
||||
@@ -102,13 +99,10 @@ pub struct TargetConfig {
|
||||
|
||||
/// The artifact an export target produces (ADR-0009/0011).
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
|
||||
/// **Pinned by ADR-0011** as an ADT with fields:
|
||||
///
|
||||
/// ```text
|
||||
/// inductive Artifact where
|
||||
/// | singleFile (filepath : String)
|
||||
/// | fileTree (root : String) (outputs : String)
|
||||
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
|
||||
/// ```
|
||||
///
|
||||
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
|
||||
@@ -120,20 +114,20 @@ pub struct TargetConfig {
|
||||
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
|
||||
/// [`Artifact::FileTree`].
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum Artifact {
|
||||
/// One bundled document landing at `filepath` (relative to the engineering
|
||||
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
|
||||
/// root). ADR-0011 `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. Mirrors Lean
|
||||
/// `Artifact.fileTree`.
|
||||
/// A set of files under `root` matching the `outputs` glob. ADR-0011
|
||||
/// `fileTree`.
|
||||
FileTree {
|
||||
/// The output directory (relative to the engineering root).
|
||||
root: PathBuf,
|
||||
@@ -156,14 +150,10 @@ impl Artifact {
|
||||
|
||||
/// One typed build step (ADR-0011).
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
|
||||
/// **Pinned by ADR-0011** as an ADT:
|
||||
///
|
||||
/// ```text
|
||||
/// inductive Step where
|
||||
/// | typstCompile (template : String)
|
||||
/// | shell (run : String)
|
||||
/// | assembleMarkdown (field : String)
|
||||
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
|
||||
/// ```
|
||||
///
|
||||
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
|
||||
@@ -183,20 +173,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. Mirrors Lean
|
||||
/// `Step.typstCompile`.
|
||||
/// artifact; the framework injects the manifest. ADR-0011
|
||||
/// `typstCompile`.
|
||||
TypstCompile {
|
||||
/// The template file to compile as main, e.g. `exports/student.typ`.
|
||||
template: PathBuf,
|
||||
},
|
||||
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
|
||||
/// Run a shell command — the escape hatch. ADR-0011 `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. Mirrors
|
||||
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
|
||||
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
|
||||
/// `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`,
|
||||
@@ -226,12 +216,11 @@ pub struct Project {
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
|
||||
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
|
||||
/// 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 — mirroring the Lean `RawInfo` / `RawAuthor` split. No
|
||||
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
|
||||
/// at the load boundary. No
|
||||
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Info {
|
||||
/// Lesson title.
|
||||
@@ -240,7 +229,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -290,8 +278,7 @@ struct RawProject {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
|
||||
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
|
||||
/// The authoring-surface `[info]`: 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)]
|
||||
@@ -301,7 +288,7 @@ struct RawInfo {
|
||||
}
|
||||
|
||||
/// On-disk `author`: either a single name (`author = "…"`) or a list
|
||||
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
|
||||
/// (`author = ["…", "…"]`). 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)]
|
||||
@@ -312,7 +299,7 @@ enum RawAuthor {
|
||||
}
|
||||
|
||||
impl RawAuthor {
|
||||
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
|
||||
/// Flatten to the ordered author list: 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` |
|
||||
| 获取与上传图片或文件 | `im:resource` |
|
||||
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
|
||||
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
||||
| 获取用户基本信息 | `contact:user.base:readonly` |
|
||||
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
||||
@@ -66,6 +66,9 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
|
||||
|
||||
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
||||
|
||||
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
|
||||
|
||||
|
||||
## 4. 配置事件与卡片回调
|
||||
|
||||
进入“事件与回调”。
|
||||
|
||||
@@ -5,6 +5,9 @@ dist/
|
||||
.env.*
|
||||
!.env.example
|
||||
.secrets/
|
||||
.dev-keyring.json
|
||||
.dev-workspaces/
|
||||
.dev-skills/
|
||||
admin-web/node_modules/
|
||||
admin-web/build/
|
||||
admin-web/.svelte-kit/
|
||||
|
||||
@@ -317,7 +317,6 @@ export interface AgentRoleRow {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skillNames: readonly string[];
|
||||
folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -330,14 +329,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 {
|
||||
@@ -524,21 +515,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>
|
||||
@@ -5,7 +5,6 @@
|
||||
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,20 +14,15 @@
|
||||
models,
|
||||
skills,
|
||||
slug,
|
||||
folderItems,
|
||||
onupdated,
|
||||
onskillschanged,
|
||||
onfolderchanged,
|
||||
}: {
|
||||
r: AgentRoleRow;
|
||||
models: AgentModelRow[];
|
||||
skills: AgentSkillRow[];
|
||||
slug: string;
|
||||
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
||||
folderItems: { value: string; label: string }[];
|
||||
onupdated: (updated: AgentRoleRow) => void;
|
||||
onskillschanged: (roleId: string, skillNames: string[]) => void;
|
||||
onfolderchanged: (roleId: string, folderId: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
const initial = {
|
||||
@@ -50,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) => {
|
||||
@@ -61,15 +53,10 @@
|
||||
{} 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})` })),
|
||||
]);
|
||||
|
||||
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
||||
|
||||
@@ -118,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">
|
||||
@@ -145,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">
|
||||
@@ -166,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">
|
||||
|
||||
@@ -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>
|
||||
@@ -3,24 +3,18 @@
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
let {
|
||||
slug,
|
||||
skill,
|
||||
folderItems,
|
||||
oninstalled,
|
||||
ondisabled,
|
||||
onfolderchanged,
|
||||
}: {
|
||||
slug: string;
|
||||
skill: AgentSkillRow;
|
||||
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
||||
folderItems: { value: string; label: string }[];
|
||||
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
|
||||
ondisabled: (name: string) => void;
|
||||
onfolderchanged: (name: string, folderId: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
type FileNode = { path: string; content: string };
|
||||
@@ -34,8 +28,6 @@
|
||||
let dirty = $state(false);
|
||||
let newFilePath = $state('');
|
||||
let showNewFile = $state(false);
|
||||
let folderValue = $state(skill.folderId ?? '');
|
||||
let savingFolder = $state(false);
|
||||
|
||||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||
@@ -160,24 +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;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
@@ -204,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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
|
||||
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -8,9 +8,6 @@
|
||||
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));
|
||||
@@ -19,32 +16,20 @@
|
||||
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)]);
|
||||
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(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 = [];
|
||||
@@ -58,66 +43,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -132,12 +57,7 @@
|
||||
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 }];
|
||||
roles = [...roles, created];
|
||||
newRoleId = '';
|
||||
newLabel = '';
|
||||
toastSuccess('角色已创建');
|
||||
@@ -159,68 +79,6 @@
|
||||
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();
|
||||
});
|
||||
@@ -228,7 +86,7 @@
|
||||
|
||||
<PageHeader
|
||||
title="角色"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
|
||||
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
@@ -236,92 +94,39 @@
|
||||
{: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 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>
|
||||
|
||||
<div>
|
||||
<div class="saas-card-pad mb-6">
|
||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="角色 ID(如 draft)"
|
||||
bind:value={newRoleId}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
class="saas-input"
|
||||
placeholder="显示名(如 草稿)"
|
||||
bind:value={newLabel}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') add();
|
||||
}}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
|
||||
</div>
|
||||
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
</div>
|
||||
{:else if visibleRoles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each visibleRoles as r (r.roleId)}
|
||||
<RoleCard
|
||||
{r}
|
||||
{models}
|
||||
{skills}
|
||||
{slug}
|
||||
{folderItems}
|
||||
onupdated={onRoleUpdated}
|
||||
onskillschanged={onRoleSkillsChanged}
|
||||
onfolderchanged={onRoleFolderChanged}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
|
||||
</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} />
|
||||
{#if roles.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
|
||||
</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>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each roles as r (r.roleId)}
|
||||
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
|
||||
{/each}
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||
import { session } from '$lib/session';
|
||||
import { resolveOrg } from '$lib/org';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -8,42 +8,27 @@
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SkillEditor from '$lib/components/SkillEditor.svelte';
|
||||
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
|
||||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||||
const slug = $derived(org?.slug ?? '');
|
||||
|
||||
let skills = $state<AgentSkillRow[]>([]);
|
||||
let folders = $state<AgentConfigFolderRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
|
||||
let selectedFolder = $state<string>('all');
|
||||
|
||||
let showNewSkill = $state(false);
|
||||
let newSkillName = $state('');
|
||||
let newSkillVersion = $state('0.1.0');
|
||||
let newSkillDescription = $state('');
|
||||
let creating = $state(false);
|
||||
|
||||
let showFolderModal = $state(false);
|
||||
let folderModalMode = $state<'create' | 'rename'>('create');
|
||||
let folderModalId = $state<string | null>(null);
|
||||
let folderName = $state('');
|
||||
let folderParent = $state('');
|
||||
let savingFolder = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
|
||||
skills = s.skills;
|
||||
folders = f.folders;
|
||||
const res = await api.agentSkills(slug);
|
||||
skills = res.skills;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
@@ -51,66 +36,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
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 === '') {
|
||||
@@ -131,9 +56,6 @@
|
||||
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 = '';
|
||||
@@ -159,68 +81,6 @@
|
||||
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();
|
||||
});
|
||||
@@ -228,7 +88,7 @@
|
||||
|
||||
<PageHeader
|
||||
title="技能"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
|
||||
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
@@ -236,100 +96,49 @@
|
||||
{:else if error}
|
||||
<ErrorBanner message={error} onretry={load} />
|
||||
{:else}
|
||||
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
|
||||
<div class="h-fit lg:sticky lg:top-4">
|
||||
<AgentConfigFolderNav
|
||||
{folders}
|
||||
selected={selectedFolder}
|
||||
{counts}
|
||||
totalCount={skills.length}
|
||||
{unfiledCount}
|
||||
onselect={(id) => (selectedFolder = id)}
|
||||
oncreate={openCreateFolder}
|
||||
onrename={openRenameFolder}
|
||||
ondelete={deleteFolder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="saas-card-pad mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="saas-section-title">新建技能</h2>
|
||||
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
||||
{showNewSkill ? '取消' : '+ 新建'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showNewSkill}
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||||
<input
|
||||
class="saas-input font-mono text-sm"
|
||||
placeholder="技能名(如 typst-help)"
|
||||
bind:value={newSkillName}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="版本号"
|
||||
bind:value={newSkillVersion}
|
||||
/>
|
||||
<input
|
||||
class="saas-input text-sm"
|
||||
placeholder="描述"
|
||||
bind:value={newSkillDescription}
|
||||
/>
|
||||
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
|
||||
{creating ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-surface-600">
|
||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if skills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
|
||||
</div>
|
||||
{:else if visibleSkills.length === 0}
|
||||
<div class="saas-card">
|
||||
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each visibleSkills as skill (skill.id)}
|
||||
<SkillEditor
|
||||
{slug}
|
||||
{skill}
|
||||
{folderItems}
|
||||
oninstalled={onInstalled}
|
||||
ondisabled={onDisabled}
|
||||
onfolderchanged={onSkillFolderChanged}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
|
||||
<label class="saas-label" for="agent-folder-name">名称</label>
|
||||
<input
|
||||
id="agent-folder-name"
|
||||
class="saas-input mb-4"
|
||||
bind:value={folderName}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') submitFolderModal();
|
||||
}}
|
||||
/>
|
||||
<p class="saas-label">父文件夹</p>
|
||||
<div class="mb-4">
|
||||
<SelectField items={moveTargetItems} bind:value={folderParent} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
|
||||
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
|
||||
{savingFolder ? '保存中…' : '保存'}
|
||||
<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>
|
||||
</Modal>
|
||||
{#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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,4 @@
|
||||
build
|
||||
.svelte-kit
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
Generated
+2660
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "database-admin",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"bits-ui": "^2.18.1",
|
||||
"prettier": "^3.9.5",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Curriculum Project Hub — 数据库管理后台" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
/* Fallback before CSS bundle */
|
||||
html {
|
||||
font-family: 'Inter', system-ui, 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<title>Database Admin</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="relative min-h-screen overflow-x-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700"
|
||||
>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Thin API client for the database-admin backend. Same-origin cookie auth,
|
||||
* reusing the platform session (`cph_session`) and the admin plane's /api/me.
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(code: string, message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
if (text !== '') {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
|
||||
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const get = (u: string) => request('GET', u);
|
||||
const post = (u: string, b?: unknown) => request('POST', u, b);
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface OrgMembership {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
status: string;
|
||||
role: 'OWNER' | 'ADMIN' | 'MEMBER';
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: {
|
||||
id: string;
|
||||
feishuOpenId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
organizations: OrgMembership[];
|
||||
}
|
||||
|
||||
/** Unauthenticated bootstrap the login page needs: which org to OAuth against + dev toggle. */
|
||||
export interface DatabaseConfig {
|
||||
siloOrganizationSlug: string;
|
||||
devLoginEnabled: boolean;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const api = {
|
||||
me: () => get('/api/me') as Promise<MeResponse>,
|
||||
logout: () => post('/auth/logout'),
|
||||
databaseConfig: () => get('/database/config') as Promise<DatabaseConfig>,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Animated aurora background blobs, shared by both pages (soft pastels on light). -->
|
||||
<div class="pointer-events-none fixed inset-0 overflow-hidden">
|
||||
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
|
||||
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
|
||||
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { OrgMembership } from './api';
|
||||
|
||||
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
|
||||
if (!org) return false;
|
||||
const role = String(org.role ?? '').toUpperCase();
|
||||
return role === 'OWNER' || role === 'ADMIN';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { api, type MeResponse } from './api';
|
||||
|
||||
interface SessionState {
|
||||
loading: boolean;
|
||||
me: MeResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const session = writable<SessionState>({
|
||||
loading: true,
|
||||
me: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export async function loadSession(): Promise<void> {
|
||||
session.update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const me = await api.me();
|
||||
session.set({ loading: false, me, error: null });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status === 401) {
|
||||
session.set({ loading: false, me: null, error: null });
|
||||
void redirectToLogin();
|
||||
return;
|
||||
}
|
||||
session.set({
|
||||
loading: false,
|
||||
me: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the browser to the login page (`/database/admin`). Unlike admin-web we
|
||||
* don't jump straight to Feishu OAuth: the login page reads /database/config
|
||||
* to build the org-scoped link and to show the dev bypass when enabled.
|
||||
*/
|
||||
export async function redirectToLogin(): Promise<void> {
|
||||
const loginPath = `${base}/admin`;
|
||||
if (window.location.pathname === loginPath) return;
|
||||
await goto(loginPath, { replaceState: true });
|
||||
}
|
||||
|
||||
/** Build the org-scoped Feishu OAuth entry point (a backend route, not under base). */
|
||||
export function feishuLoginHref(orgSlug: string, returnTo: string = `${base}/dashboard`): string {
|
||||
return `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.logout();
|
||||
session.set({ loading: false, me: null, error: null });
|
||||
await redirectToLogin();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
onMount(async () => {
|
||||
// /database entry — route to dashboard if signed in, else the login page.
|
||||
try {
|
||||
await api.me();
|
||||
await goto(`${base}/dashboard`, { replaceState: true });
|
||||
} catch {
|
||||
await goto(`${base}/admin`, { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-3 text-slate-400">
|
||||
<svg class="h-7 w-7 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-90"
|
||||
fill="currentColor"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { api, type DatabaseConfig } from '$lib/api';
|
||||
import { feishuLoginHref } from '$lib/session';
|
||||
import Aurora from '$lib/components/Aurora.svelte';
|
||||
|
||||
let config = $state<DatabaseConfig | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Already signed in → straight to the dashboard.
|
||||
try {
|
||||
await api.me();
|
||||
await goto(`${base}/dashboard`, { replaceState: true });
|
||||
return;
|
||||
} catch {
|
||||
// Not signed in (401) or backend unreachable — show the login card.
|
||||
}
|
||||
try {
|
||||
config = await api.databaseConfig();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
});
|
||||
|
||||
const feishuHref = $derived(config ? feishuLoginHref(config.siloOrganizationSlug) : '#');
|
||||
</script>
|
||||
|
||||
<Aurora />
|
||||
|
||||
<main class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
|
||||
<div class="mb-7 text-center">
|
||||
<div
|
||||
class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn"
|
||||
>
|
||||
D
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
无法连接后端:{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href={feishuHref}
|
||||
aria-disabled={config ? 'false' : 'true'}
|
||||
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400 aria-disabled:pointer-events-none aria-disabled:opacity-50"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z" />
|
||||
</svg>
|
||||
使用飞书登录
|
||||
</a>
|
||||
|
||||
{#if config?.devLoginEnabled}
|
||||
<div class="relative my-6 rise-3">
|
||||
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
|
||||
<div class="relative flex justify-center">
|
||||
<span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/database/dev-login"
|
||||
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100"
|
||||
>
|
||||
<span>⚡</span> 一键登录管理员
|
||||
</a>
|
||||
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,96 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source './**/*.{html,js,svelte,ts}';
|
||||
@source '../lib/**/*.{html,js,svelte,ts}';
|
||||
|
||||
/*
|
||||
* Design system for the database-admin SPA. Migrated verbatim (in spirit) from
|
||||
* the previous server-rendered pages in hub/src/database/routes/databaseRoutes.ts:
|
||||
* a light aurora / glassmorphism look with violet→cyan gradients and soft rise-in
|
||||
* entrances. Distinct from admin-web's flat industrial theme on purpose.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(6%, -8%) scale(1.15);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-8%, 6%) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.aurora {
|
||||
filter: blur(80px);
|
||||
animation: aurora 18s ease-in-out infinite;
|
||||
}
|
||||
.rise {
|
||||
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.rise-1 {
|
||||
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.rise-2 {
|
||||
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
|
||||
}
|
||||
.rise-3 {
|
||||
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
|
||||
}
|
||||
.grad-text {
|
||||
background: linear-gradient(120deg, #7c3aed, #0891b2, #4f46e5);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer 6s linear infinite;
|
||||
}
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.glow-btn {
|
||||
box-shadow: 0 12px 32px -10px rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { session, loadSession, logout } from '$lib/session';
|
||||
import { isOrgAdmin } from '$lib/org';
|
||||
import Aurora from '$lib/components/Aurora.svelte';
|
||||
|
||||
onMount(() => {
|
||||
loadSession();
|
||||
});
|
||||
|
||||
// The session is scoped to the silo org, so /api/me returns exactly that org's
|
||||
// membership. Admin gate: only OWNER/ADMIN may use the database console.
|
||||
const me = $derived($session.me);
|
||||
const org = $derived(me?.organizations[0] ?? null);
|
||||
const allowed = $derived(isOrgAdmin(org));
|
||||
const displayName = $derived(me?.user.displayName ?? '');
|
||||
const initial = $derived(displayName.slice(0, 1) || 'U');
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', href: `${base}/dashboard`, active: true, icon: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z' },
|
||||
{ label: '数据表', href: '#', active: false, icon: 'M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z' },
|
||||
{ label: '查询', href: '#', active: false, icon: 'm21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z' },
|
||||
{
|
||||
label: '设置',
|
||||
href: '#',
|
||||
active: false,
|
||||
icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ label: '数据表', value: '—', accent: 'from-violet-200/60 to-transparent' },
|
||||
{ label: '记录数', value: '—', accent: 'from-cyan-200/60 to-transparent' },
|
||||
{ label: '最近查询', value: '—', accent: 'from-indigo-200/60 to-transparent' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<Aurora />
|
||||
|
||||
{#if $session.loading}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-3 text-slate-400">
|
||||
<svg class="h-8 w-8 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-90"
|
||||
fill="currentColor"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $session.error}
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="rise glass w-full max-w-sm rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
|
||||
<h2 class="mb-2 text-lg font-bold text-slate-900">无法连接后端</h2>
|
||||
<p class="mb-5 text-sm text-slate-500">{$session.error}</p>
|
||||
<button
|
||||
class="rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white"
|
||||
onclick={() => loadSession()}>重试</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !allowed}
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="rise glass w-full max-w-md rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
|
||||
<h2 class="mb-2 text-lg font-bold text-slate-900">无权访问</h2>
|
||||
<p class="mb-5 text-sm text-slate-500">数据库管理台仅向组织所有者与管理员开放。</p>
|
||||
<button
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
|
||||
onclick={() => logout()}>退出登录</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative flex min-h-screen">
|
||||
<!-- 左侧菜单栏 -->
|
||||
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
|
||||
<div class="flex items-center gap-3 px-5 py-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn"
|
||||
>
|
||||
D
|
||||
</div>
|
||||
<span class="text-base font-bold grad-text">Database Admin</span>
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class={item.active
|
||||
? 'group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50'
|
||||
: 'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900'}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"><path d={item.icon} /></svg
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white"
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
|
||||
<p class="truncate text-sm font-medium text-slate-700">{displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="flex flex-1 flex-col">
|
||||
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-slate-900">概览</h1>
|
||||
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => logout()}
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-8">
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
{#each stats as s, i}
|
||||
<div
|
||||
class="rise-{i +
|
||||
1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br {s.accent} opacity-0 transition group-hover:opacity-100"></div>
|
||||
<p class="relative text-sm text-slate-500">{s.label}</p>
|
||||
<p class="relative mt-2 text-3xl font-bold text-slate-900">{s.value}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400"
|
||||
>
|
||||
内容区占位 · 后续数据端点挂在 /database/* 并加 guard
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
|
||||
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
|
||||
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,22 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: false,
|
||||
}),
|
||||
// This SPA is served under /database by the Hub. admin-web owns the
|
||||
// root asset paths (/_app, /favicon.svg); a base path moves this app's
|
||||
// assets to /database/_app/* so the two builds never collide.
|
||||
paths: { base: '/database' },
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8788',
|
||||
'/auth': 'http://127.0.0.1:8788',
|
||||
// Backend owns the /database/* HTTP surface (login page, dashboard,
|
||||
// data routes). Proxy it in dev so those paths hit the real server
|
||||
// instead of the SPA fallback.
|
||||
'/database': 'http://127.0.0.1:8788',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -82,10 +82,11 @@ REMOTE
|
||||
rsync -az --delete \
|
||||
--exclude node_modules --exclude dist --exclude .env \
|
||||
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
|
||||
--exclude database-admin/node_modules --exclude database-admin/build --exclude database-admin/.svelte-kit \
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
|
||||
echo "[fleet] npm ci + build (tsc + admin-web & database-admin SPAs)"
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
|
||||
set -euo pipefail
|
||||
flock /var/lock/cph-hub-release-publish bash -c '
|
||||
@@ -97,9 +98,11 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
||||
cd "$HUB_DIR"
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||
npm ci --prefix admin-web
|
||||
npm ci --prefix database-admin
|
||||
npm run audit:production
|
||||
npm run build
|
||||
test -f admin-web/build/index.html
|
||||
test -f database-admin/build/index.html
|
||||
touch "$RELEASE_DIR/.complete"
|
||||
'
|
||||
REMOTE
|
||||
|
||||
@@ -60,10 +60,11 @@ if [ "$release_ready" = false ]; then
|
||||
-e "ssh ${SSH_OPTS[*]}" \
|
||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||
|
||||
# 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.
|
||||
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
|
||||
# `npm run build` → tsc then admin:build + database:build → admin-web/build and
|
||||
# database-admin/build for registerStaticSpa / registerDatabaseSpa.
|
||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm 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 ci --prefix database-admin && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||
fi
|
||||
|
||||
# 3. Ensure the service is installed (idempotent), then restart.
|
||||
|
||||
Generated
+112
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.36",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||
@@ -401,6 +401,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -423,6 +424,31 @@
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -1012,6 +1038,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
@@ -1550,7 +1577,8 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
@@ -1751,6 +1779,7 @@
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
@@ -1764,6 +1793,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -1773,6 +1803,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -1908,6 +1939,7 @@
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
@@ -1932,6 +1964,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1945,6 +1978,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2090,6 +2124,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2103,6 +2138,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2132,6 +2168,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
@@ -2141,6 +2178,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
@@ -2158,6 +2196,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@@ -2215,6 +2254,7 @@
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2275,7 +2315,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.21.0",
|
||||
@@ -2303,6 +2344,7 @@
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2405,7 +2447,8 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
@@ -2422,6 +2465,7 @@
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2431,6 +2475,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
@@ -2462,6 +2507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -2505,6 +2551,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
@@ -2523,6 +2570,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2532,6 +2580,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2541,6 +2590,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -2631,7 +2681,8 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
"license": "Unlicense",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
@@ -2730,6 +2781,7 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -2801,6 +2853,7 @@
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2810,6 +2863,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -2959,6 +3013,7 @@
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
@@ -3025,6 +3080,7 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -3040,7 +3096,8 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
@@ -3070,13 +3127,15 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
@@ -3093,6 +3152,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@@ -3127,6 +3187,7 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
@@ -3145,7 +3206,8 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/kitx": {
|
||||
"version": "2.2.0",
|
||||
@@ -3508,6 +3570,7 @@
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -3517,6 +3580,7 @@
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -3596,6 +3660,7 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -3637,6 +3702,7 @@
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3688,6 +3754,7 @@
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
@@ -3700,6 +3767,7 @@
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -3709,6 +3777,7 @@
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -3718,6 +3787,7 @@
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -3727,6 +3797,7 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
@@ -3759,7 +3830,6 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3809,6 +3879,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
@@ -3861,7 +3932,6 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
"@prisma/engines": "6.19.3"
|
||||
@@ -3925,6 +3995,7 @@
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
@@ -3938,6 +4009,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
@@ -3995,6 +4067,7 @@
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
},
|
||||
@@ -4008,6 +4081,7 @@
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
@@ -4125,6 +4199,7 @@
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
@@ -4171,7 +4246,8 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
@@ -4215,6 +4291,7 @@
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
@@ -4241,6 +4318,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -4250,6 +4328,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -4266,6 +4345,7 @@
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
@@ -4290,13 +4370,15 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
@@ -4309,6 +4391,7 @@
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4476,6 +4559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
@@ -4486,6 +4570,7 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -4573,6 +4658,7 @@
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
@@ -4581,7 +4667,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
@@ -4597,7 +4684,6 @@
|
||||
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
@@ -4616,6 +4702,7 @@
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
@@ -4634,6 +4721,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -4647,6 +4735,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -4656,6 +4745,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
@@ -4673,7 +4763,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4693,6 +4782,7 @@
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -4702,6 +4792,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -4712,7 +4803,6 @@
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -4880,6 +4970,7 @@
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
@@ -4911,7 +5002,8 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
@@ -4961,7 +5053,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -4971,6 +5062,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.35",
|
||||
"version": "0.0.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -30,10 +30,10 @@
|
||||
"axios": "1.18.1"
|
||||
}
|
||||
},
|
||||
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
|
||||
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
|
||||
"scripts": {
|
||||
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json && npm run admin:build",
|
||||
"build": "tsc -p tsconfig.json && npm run admin:build && npm run database:build",
|
||||
"start": "npm run prisma:migrate && node dist/server.js",
|
||||
"check": "tsc -p tsconfig.json --noEmit",
|
||||
"audit:production": "npm audit --omit=dev --audit-level=high",
|
||||
@@ -48,6 +48,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"admin:dev": "npm run dev --prefix admin-web",
|
||||
"admin:build": "npm run build --prefix admin-web"
|
||||
"admin:build": "npm run build --prefix admin-web",
|
||||
"database:dev": "npm run dev --prefix database-admin",
|
||||
"database:build": "npm run build --prefix database-admin"
|
||||
}
|
||||
}
|
||||
|
||||
+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 (see `Spec.System.PlatformAdministration`), intentionally not built
|
||||
-- audit surface, 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 RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3).
|
||||
-- Managed only by the platform super administrator; deliberately NOT
|
||||
-- org-scoped. Stores membership + nesting only, never permission data.
|
||||
-- Deletion is soft (archivedAt / revokedAt markers); a group delete
|
||||
-- cascade-soft-deletes its whole subtree as an application operation.
|
||||
-- The permission side (GROUP principal, FOLDER resource, grant inheritance)
|
||||
-- is intentionally deferred to a later migration.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MemberGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MemberGroupMembership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MemberGroupClosure" (
|
||||
"ancestorId" TEXT NOT NULL,
|
||||
"descendantId" TEXT NOT NULL,
|
||||
"depth" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+68
-36
@@ -1,6 +1,6 @@
|
||||
// Prisma schema for Curriculum Project Hub.
|
||||
//
|
||||
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
|
||||
// Aligns to 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 (spec RunState; enum
|
||||
// completeness OPEN — add states without a schema migration war).
|
||||
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is
|
||||
// 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 / Spec.System.PlatformAdministration),
|
||||
/// the platform administrator surface (ADR-0023),
|
||||
/// 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: Restrict)
|
||||
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
|
||||
skills OrganizationAgentSkill[]
|
||||
roles OrganizationAgentRole[]
|
||||
|
||||
@@index([organizationId, parentId])
|
||||
}
|
||||
|
||||
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
|
||||
/// create projects from unbound chats only when membersCanCreateProjects=true.
|
||||
model OrganizationProjectSettings {
|
||||
@@ -223,6 +193,7 @@ model User {
|
||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||
teamMemberships TeamMembership[]
|
||||
memberGroupMemberships MemberGroupMembership[]
|
||||
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||
@@ -423,6 +394,67 @@ model TeamExternalBinding {
|
||||
@@index([teamId, revokedAt])
|
||||
}
|
||||
|
||||
// --- Member groups (global, nestable authorization principal) ------------
|
||||
|
||||
/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023);
|
||||
/// Deletion is soft: `archivedAt` is a marker.
|
||||
/// Deleting a group cascade-soft-deletes its whole subtree — an application
|
||||
/// operation (walk the subtree, stamp archivedAt), not a DB constraint.
|
||||
model MemberGroup {
|
||||
id String @id @default(cuid())
|
||||
parentId String?
|
||||
name String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
archivedAt DateTime?
|
||||
|
||||
parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children MemberGroup[] @relation("groupTree")
|
||||
memberships MemberGroupMembership[]
|
||||
asAncestor MemberGroupClosure[] @relation("ancestor")
|
||||
asDescendant MemberGroupClosure[] @relation("descendant")
|
||||
|
||||
@@index([parentId, archivedAt])
|
||||
}
|
||||
|
||||
/// User↔group many-to-many; a user may belong to multiple groups. Soft delete
|
||||
/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed
|
||||
/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups.
|
||||
model MemberGroupMembership {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
|
||||
group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId, revokedAt])
|
||||
@@index([userId, revokedAt])
|
||||
@@index([groupId, revokedAt])
|
||||
}
|
||||
|
||||
/// Transitive closure of the MemberGroup tree. Every group has a depth=0
|
||||
/// self row. Turns ancestor/descendant resolution into one indexed join
|
||||
/// instead of a recursive CTE; maintained only on create / reparent (rare
|
||||
/// super-admin ops, so the write cost is amortized against hot reads). On soft
|
||||
/// delete the closure rows are retained; resolution filters by
|
||||
/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved
|
||||
/// subtree (cycle guard).
|
||||
model MemberGroupClosure {
|
||||
ancestorId String
|
||||
descendantId String
|
||||
depth Int
|
||||
|
||||
ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade)
|
||||
descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([ancestorId, descendantId])
|
||||
@@index([descendantId])
|
||||
}
|
||||
|
||||
/// Locally synchronized Feishu external principal membership.
|
||||
model ExternalDirectoryConnection {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -688,19 +472,6 @@ export class OrganizationAgentConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
async function requireFolder(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
folderId: string,
|
||||
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
|
||||
const folder = await tx.organizationAgentConfigFolder.findFirst({
|
||||
where: { id: folderId, organizationId },
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function archiveRoleSessions(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
@@ -759,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 };
|
||||
}>;
|
||||
@@ -779,6 +549,5 @@ function toRoleRow(role: {
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
folderId: role.folderId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 `AgentFileOp.Authorized`
|
||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||
* provider secrets from Bash. This upholds the workspace-bounded file-op
|
||||
* invariant (ADR-0018) without re-implementing the
|
||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
* is the mechanism, the ADR 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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
|
||||
* The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
|
||||
* ADR-0022 capacity dimensions.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# src/database/
|
||||
|
||||
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
|
||||
所以服务器启动时能正确识别这些路由。
|
||||
|
||||
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/`
|
||||
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个
|
||||
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。
|
||||
|
||||
后端路由:
|
||||
|
||||
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`,
|
||||
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
|
||||
- `GET /database/dev-login` —— 仅开发。见下。
|
||||
- `GET /database`、`GET /database/*` —— SPA shell / 客户端路由 fallback
|
||||
(`static.ts` 的 `registerDatabaseSpa`);资产在 `/database/_app/*`。
|
||||
|
||||
SPA 页面(`database-admin`,`paths.base='/database'`):
|
||||
|
||||
- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`(slug 来自
|
||||
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
|
||||
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**。
|
||||
|
||||
> **注册顺序要点**:concrete 路由(`/database/config`、`/database/dev-login`)必须在
|
||||
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证),
|
||||
> 否则通配会 shadow 它们。
|
||||
|
||||
## 开发模式:用环境变量开启一键登录
|
||||
|
||||
本地开发没有真实飞书 app 时,可以用环境变量开启一键登录,跳过飞书 OAuth,
|
||||
直接以现有 OWNER/ADMIN 身份登入后台。**仅限开发,不是生产登录路径。**
|
||||
|
||||
### 怎么开
|
||||
|
||||
在 `hub/.env` 里设:
|
||||
|
||||
```sh
|
||||
HUB_DEV_LOGIN_BYPASS="true"
|
||||
```
|
||||
|
||||
改完重启服务(`npm run dev`,或本地手动 `npx tsx src/server.ts`)。启动日志会
|
||||
打印一行 `DEV login bypass enabled: /database/dev-login ...` 作为确认。
|
||||
|
||||
开启后:
|
||||
|
||||
- `/database/config` 返回 `devLoginEnabled: true`,SPA 登录页据此显示
|
||||
「⚡ 一键登录管理员」按钮
|
||||
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
|
||||
回调完全一样的 session,然后跳到 `/database/dashboard`
|
||||
|
||||
### 怎么关
|
||||
|
||||
把值设成 `false`(或 `0` / `no` / `off`),或删掉这一行。关闭后按钮消失、
|
||||
`/database/dev-login` 返回 404 —— 按钮和端点同进同退。
|
||||
|
||||
### 双重门禁(重要)
|
||||
|
||||
真正的开关是两个条件的**与**(判断在 `plugin.ts`):
|
||||
|
||||
```
|
||||
allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
```
|
||||
|
||||
即:**只要 `NODE_ENV=production`,无论 `HUB_DEV_LOGIN_BYPASS` 设成什么,一键登录
|
||||
都强制关闭。** 生产始终只能走真实飞书 OAuth。
|
||||
|
||||
> 提醒:`HUB_DEV_LOGIN_BYPASS` 是敏感开关,别把开着它的 `.env` 带到任何联网 /
|
||||
> 共享环境。整个旁路逻辑自包含在本目录(`plugin.ts` + `routes/databaseRoutes.ts`),
|
||||
> `src/admin` 的登录路由未受影响。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()`;先挂 concrete 路由再挂 SPA |
|
||||
| `routes/databaseRoutes.ts` | 后端 JSON / redirect 路由(`/database/config`、`/database/dev-login`),**数据端点加在这里** |
|
||||
| `static.ts` | `registerDatabaseSpa`:托管 `database-admin/build` 的 SPA + `/database/*` fallback |
|
||||
|
||||
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`,
|
||||
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})`
|
||||
注册一次。**页面**则加在 `database-admin/src/routes/` 下(SvelteKit 路由)。
|
||||
|
||||
## SPA 构建与托管
|
||||
|
||||
- 前端在 `hub/database-admin/`,`npm run build`(或 hub 根的 `npm run database:build`)
|
||||
产出到 `database-admin/build/`。hub 的 `npm run build` 会把两个 SPA 一起带出来。
|
||||
- `static.ts` 默认从 `../../database-admin/build` 读产物;可用 `CPH_DATABASE_UI_DIR`
|
||||
覆盖。产物缺失时降级:只 warn,不挂 SPA,`/database/config` 与 `/database/dev-login` 仍可用。
|
||||
- 本地开发:hub 根 `npm run database:dev` 起 Vite,它把 `/api`、`/auth`、`/database`
|
||||
代理到 `127.0.0.1:8788`。
|
||||
|
||||
## 约定(与 admin 面一致)
|
||||
|
||||
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
|
||||
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
|
||||
`requireSession` / `requireOrgRole` / `requireProjectPermission`
|
||||
(都在 `../admin/auth/guards.js`)。
|
||||
3. **租户隔离**(ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`,
|
||||
不得跨 org。禁止无鉴权的数据路由。
|
||||
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
|
||||
不要在这里 `new PrismaClient()`。
|
||||
|
||||
## 为什么代码在 `src/` 下
|
||||
|
||||
`tsconfig.json` 固定 `rootDir: "src"` 且 `include: ["src/**/*.ts"]`。只有
|
||||
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch`(`npm run dev`)加载。放在
|
||||
`src/` 之外的目录不会被构建,外部识别不到。
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Registers the `/database/*` HTTP surface onto the Fastify app.
|
||||
*
|
||||
* Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls
|
||||
* registerDatabasePlugin() so the routes are recognized at startup.
|
||||
*
|
||||
* Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser
|
||||
* and the /auth/feishu/* login routes that the admin plugin adds.
|
||||
*
|
||||
* The dev login bypass is self-contained in THIS module: the flag is read here
|
||||
* (not threaded from hub.ts) and only ever enables the `/database/dev-login`
|
||||
* route below. Double-gated: NODE_ENV must not be production AND
|
||||
* HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu
|
||||
* OAuth.
|
||||
*/
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
|
||||
import { registerDatabaseSpa } from "./static.js";
|
||||
|
||||
export interface DatabasePluginConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
readonly siloOrganizationSlug: string;
|
||||
}
|
||||
|
||||
const FALSY = new Set(["", "0", "false", "no", "off"]);
|
||||
|
||||
export async function registerDatabasePlugin(
|
||||
app: FastifyInstance,
|
||||
config: DatabasePluginConfig,
|
||||
): Promise<void> {
|
||||
const allowDevLoginBypass =
|
||||
process.env["NODE_ENV"] !== "production" &&
|
||||
!FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase());
|
||||
if (allowDevLoginBypass) {
|
||||
app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth");
|
||||
}
|
||||
|
||||
await registerDatabaseRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||
allowDevLoginBypass,
|
||||
});
|
||||
|
||||
// SPA shell + fallback, registered after the concrete /database/* routes above
|
||||
// so the /database/* wildcard does not shadow them.
|
||||
await registerDatabaseSpa(app);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* `/database/*` route aggregator.
|
||||
*
|
||||
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
|
||||
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
||||
* literal string it serves.
|
||||
*
|
||||
* The login page and dashboard are now served by the `database-admin` SvelteKit
|
||||
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
|
||||
* concrete JSON/redirect routes the SPA depends on:
|
||||
*
|
||||
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
|
||||
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
||||
*
|
||||
* The dev bypass (/database/dev-login) is self-contained here and gated by
|
||||
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
|
||||
* NODE_ENV). Production requires real Feishu OAuth.
|
||||
*
|
||||
* NOTE: concrete routes here MUST be registered before the SPA fallback
|
||||
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
|
||||
* shadow them.
|
||||
*/
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
|
||||
export interface DatabaseRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
/** HMAC secret for the signed session cookie — reused from the admin plane. */
|
||||
readonly sessionSecret: string;
|
||||
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
|
||||
readonly siloOrganizationSlug: string;
|
||||
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
|
||||
readonly allowDevLoginBypass: boolean;
|
||||
}
|
||||
|
||||
export async function registerDatabaseRoutes(
|
||||
app: FastifyInstance,
|
||||
config: DatabaseRouteConfig,
|
||||
): Promise<void> {
|
||||
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
|
||||
// the page needs to build the Feishu login link and toggle the dev button —
|
||||
// no secrets, no user data.
|
||||
app.get("/database/config", async () => {
|
||||
return {
|
||||
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
};
|
||||
});
|
||||
|
||||
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
||||
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
|
||||
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
|
||||
// session guard behaves identically. Never registered in production.
|
||||
if (config.allowDevLoginBypass) {
|
||||
app.get("/database/dev-login", async (_request, reply) => {
|
||||
const membership = await config.prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { userId: true, organizationId: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (membership === null) {
|
||||
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
|
||||
}
|
||||
const identity = await config.prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
userId: membership.userId,
|
||||
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
|
||||
},
|
||||
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
|
||||
});
|
||||
if (identity === null) {
|
||||
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
|
||||
}
|
||||
const token = signSession(
|
||||
{
|
||||
userId: membership.userId,
|
||||
feishuIdentityId: identity.id,
|
||||
feishuConnectionId: identity.connectionId,
|
||||
feishuOrganizationId: identity.connection.organizationId,
|
||||
},
|
||||
config.sessionSecret,
|
||||
);
|
||||
// Local dev is http://127.0.0.1, so secure:false. This route only ever
|
||||
// runs outside production (double-gated in ./plugin.ts).
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
|
||||
return reply.redirect("/database/dashboard");
|
||||
});
|
||||
}
|
||||
|
||||
// Add more /database/* JSON routes here. Guard data routes with requireSession
|
||||
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
||||
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
|
||||
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
|
||||
* and the SPA index fallback for client-side routes under `/database/*`.
|
||||
*
|
||||
* The SvelteKit project lives in `hub/database-admin/` and is built with
|
||||
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
|
||||
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
|
||||
* colliding). On disk the files still live at `build/_app/*`; this handler maps
|
||||
* the `/database`-prefixed URLs back to those files.
|
||||
*
|
||||
* Run `npm run build` in database-admin/ to produce the static output. In
|
||||
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
|
||||
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
|
||||
*
|
||||
* Mirrors src/admin/static.ts.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, extname, join, resolve as resolvePath } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
function resolveUiDir(): string {
|
||||
const override = process.env["CPH_DATABASE_UI_DIR"];
|
||||
if (override && override.trim() !== "") return resolvePath(override);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolvePath(join(here, "..", "..", "database-admin", "build"));
|
||||
}
|
||||
|
||||
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
|
||||
const uiDir = resolveUiDir();
|
||||
if (!existsSync(join(uiDir, "index.html"))) {
|
||||
app.log.warn(
|
||||
{ uiDir },
|
||||
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
|
||||
|
||||
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
|
||||
// but on disk they're under build/_app/*.
|
||||
app.get("/database/_app/*", async (request, reply) => {
|
||||
const rel = (request.params as { "*": string })["*"];
|
||||
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "_app", safe));
|
||||
const mime = MIME[extname(safe)] ?? "application/octet-stream";
|
||||
return reply.type(mime).send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
|
||||
}
|
||||
});
|
||||
|
||||
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
|
||||
app.get("/database/favicon.svg", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "favicon.svg"));
|
||||
return reply.type("image/svg+xml").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
app.get("/database/robots.txt", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "robots.txt"));
|
||||
return reply.type("text/plain; charset=utf-8").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
|
||||
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
|
||||
// /database/dev-login, and the asset routes above) are more specific, so
|
||||
// Fastify's router matches them before this wildcard. Everything else under
|
||||
// /database serves index.html so SvelteKit's client router can resolve the view.
|
||||
app.get("/database", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
app.get("/database/*", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
}
|
||||
@@ -250,6 +250,7 @@ async function initializeSilo(
|
||||
data: {
|
||||
organizationId: input.organization.id,
|
||||
name: "Inbox",
|
||||
kind: "SYSTEM_INBOX",
|
||||
sortKey: "000000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
||||
*
|
||||
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
||||
* Static SPA assets and the org-admin HTML shell are exempt so a single page
|
||||
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||
* Static SPA assets and the admin HTML shells are exempt so a single page load
|
||||
* (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||
*/
|
||||
|
||||
/** Paths that must not consume the silo HTTP request-rate budget. */
|
||||
@@ -12,12 +12,21 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
|
||||
|
||||
if (path === "/api/healthz") return true;
|
||||
|
||||
// SvelteKit build output and top-level static files (see admin/static.ts).
|
||||
// admin-web SvelteKit build output at the root, and top-level static files
|
||||
// (see admin/static.ts).
|
||||
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
||||
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
||||
|
||||
// SPA index shell for client-side routes (not an API).
|
||||
// database-admin SvelteKit build output, served under /database (base path;
|
||||
// see database/static.ts).
|
||||
if (path === "/database/_app" || path.startsWith("/database/_app/")) return true;
|
||||
if (path === "/database/favicon.svg" || path === "/database/robots.txt") return true;
|
||||
|
||||
// SPA index shells for client-side routes (not APIs). The /database/* shell is
|
||||
// blanket-exempt like /admin/* since client routes are unknowable up front;
|
||||
// this also covers the once-per-load /database/config bootstrap.
|
||||
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
||||
if (path === "/database" || path.startsWith("/database/")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||
import type { CardContentSegment } from "../outboundImages.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -58,6 +59,7 @@ function toolIcon(toolName: string): string {
|
||||
export function buildAgentCard(params: {
|
||||
phase: CardPhase;
|
||||
text: string;
|
||||
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||
reasoningText: string | undefined;
|
||||
toolUseSteps: ToolUseTraceStep[];
|
||||
toolUseElapsedMs: number | undefined;
|
||||
@@ -65,19 +67,19 @@ export function buildAgentCard(params: {
|
||||
interrupted: boolean | undefined;
|
||||
runId: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||
const elements: unknown[] = [];
|
||||
|
||||
// Tool-use panel (always present if there are steps)
|
||||
if (toolUseSteps.length > 0) {
|
||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
|
||||
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||
elements.push(buildPendingToolUsePanel());
|
||||
}
|
||||
|
||||
// Reasoning panel
|
||||
if (reasoningText !== undefined && reasoningText !== "") {
|
||||
if (phase === "streaming" && text === "") {
|
||||
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) {
|
||||
// Still thinking: show reasoning inline
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -90,12 +92,11 @@ export function buildAgentCard(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Main text content
|
||||
if (text !== "") {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
});
|
||||
// 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);
|
||||
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
@@ -401,6 +402,43 @@ 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") {
|
||||
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;
|
||||
const slice = segment.content.length <= remaining
|
||||
? segment.content
|
||||
: truncateText(segment.content, remaining);
|
||||
remaining -= slice.length;
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
if (text === "") return [];
|
||||
return [{
|
||||
tag: "markdown",
|
||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||
}];
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
* 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 } from "../client.js";
|
||||
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
|
||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
||||
import {
|
||||
startToolUseTraceRun,
|
||||
@@ -31,6 +34,12 @@ import {
|
||||
getToolUseTraceSteps,
|
||||
} from "./trace-store.js";
|
||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||
import {
|
||||
type CardContentSegment,
|
||||
maskMarkdownImagesForStreaming,
|
||||
materializeAnswerSegments,
|
||||
sendImageMessage,
|
||||
} from "../outboundImages.js";
|
||||
|
||||
export interface StreamingCardSink {
|
||||
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
||||
@@ -44,6 +53,11 @@ 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;
|
||||
@@ -65,6 +79,9 @@ 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;
|
||||
@@ -73,6 +90,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -106,23 +126,43 @@ export class StreamingAgentCard {
|
||||
recordToolUseEnd({ runId: this.runId, ...params });
|
||||
this.scheduleFlush();
|
||||
}
|
||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): 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 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 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);
|
||||
if (answerText.length > 0 || segments.length > 0) {
|
||||
updated = await this.flushCard("complete", answerText, false, segments);
|
||||
} else if (this.currentMessageId !== null) {
|
||||
// Patch the existing card with the final text.
|
||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
||||
updated = await this.flushCard("complete", "", false, []);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||
updated = await this.deliverPlainFallback(segments, answerText);
|
||||
}
|
||||
if (!updated && this.interrupted) {
|
||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||
@@ -166,15 +206,30 @@ export class StreamingAgentCard {
|
||||
return this.flushCard(this.currentPhase(), this.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;
|
||||
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);
|
||||
|
||||
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: firstChunk,
|
||||
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
|
||||
contentSegments: contentSegments !== undefined && contentSegments.length > 0
|
||||
? contentSegments
|
||||
: undefined,
|
||||
reasoningText: this.reasoningText || undefined,
|
||||
toolUseSteps,
|
||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||
@@ -186,43 +241,89 @@ 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)
|
||||
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;
|
||||
}
|
||||
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;
|
||||
// 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,
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else if (answerText.trim() !== "") {
|
||||
textParts.push(maskMarkdownImagesForStreaming(answerText));
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -235,5 +336,5 @@ export class StreamingAgentCard {
|
||||
function appendFooter(text: string, footerText: string): string {
|
||||
if (footerText === "") return text;
|
||||
if (text === "") return footerText;
|
||||
return `${text.trimEnd()}\n\n${footerText}`;
|
||||
return `${text}\n\n${footerText}`;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,8 @@ export async function sendCard(
|
||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||
options,
|
||||
);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -334,7 +335,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
||||
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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 });
|
||||
}
|
||||
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 {
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
});
|
||||
// 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 fetchImpl(redirected, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "image/*,*/*;q=0.8" },
|
||||
});
|
||||
return readImageBody(second, maxBytes);
|
||||
}
|
||||
return readImageBody(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -496,6 +496,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
sendOptions,
|
||||
patchIntervalMs: undefined,
|
||||
maxMessageLength: undefined,
|
||||
workspaceRoot: projectWorkspaceRoot,
|
||||
workspaceDir: project.workspaceDir,
|
||||
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
|
||||
});
|
||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||
rt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify from "fastify";
|
||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||
import { registerDatabasePlugin } from "./database/plugin.js";
|
||||
import { prisma } from "./db.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||
@@ -143,6 +144,14 @@ export async function startHub(): Promise<void> {
|
||||
secretEnvelope,
|
||||
});
|
||||
|
||||
// `/database/*` surface. Registered after the admin plugin so the
|
||||
// @fastify/cookie parser and /auth/* login routes are already available.
|
||||
await registerDatabasePlugin(app, {
|
||||
prisma,
|
||||
sessionSecret,
|
||||
siloOrganizationSlug: siloOrganization.slug,
|
||||
});
|
||||
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
|
||||
* Org capacity policy service (ADR-0022).
|
||||
*
|
||||
* Stores per-Organization lower `organizationLimit` overrides per
|
||||
* `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be ≤ the
|
||||
* platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
|
||||
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be ≤ the
|
||||
* platform ceiling for that dimension. The effective limit (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 in Lean):
|
||||
* Role rules (product pin, not yet recorded in an ADR):
|
||||
* 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.
|
||||
|
||||
@@ -558,6 +558,26 @@ async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationI
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) return existing;
|
||||
|
||||
// Bootstrap once created a root "Inbox" without kind=SYSTEM_INBOX. Sibling-name
|
||||
// uniqueness then makes a second create fail. Recover by promoting that row.
|
||||
const legacyRootInbox = await prisma.folder.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
parentId: null,
|
||||
name: "Inbox",
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (legacyRootInbox !== null) {
|
||||
return prisma.folder.update({
|
||||
where: { id: legacyRootInbox.id },
|
||||
data: { kind: "SYSTEM_INBOX" },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.folder.create({
|
||||
data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
|
||||
select: { id: true },
|
||||
|
||||
@@ -118,105 +118,6 @@ describe("Organization Agent configuration management", () => {
|
||||
})).rejects.toThrow("must have exactly one active default Agent role");
|
||||
});
|
||||
|
||||
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
|
||||
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
|
||||
const lessonPrep = await configuration.createFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "备课",
|
||||
parentId: teaching.id,
|
||||
});
|
||||
expect(lessonPrep.parentId).toBe(teaching.id);
|
||||
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
|
||||
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
|
||||
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
|
||||
|
||||
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
|
||||
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
|
||||
|
||||
const renamed = await configuration.updateFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
folderId: teaching.id,
|
||||
name: "教研",
|
||||
});
|
||||
expect(renamed.name).toBe("教研");
|
||||
});
|
||||
|
||||
it("moves folders within the tree and rejects moves below a descendant", async () => {
|
||||
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
|
||||
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
|
||||
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
|
||||
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
|
||||
expect(moved.parentId).toBe(b.id);
|
||||
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
|
||||
.rejects.toThrow("folder cannot be moved below its descendant");
|
||||
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
|
||||
.rejects.toThrow("folder cannot be its own parent");
|
||||
});
|
||||
|
||||
it("deletes only empty folders", async () => {
|
||||
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
|
||||
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
|
||||
.rejects.toThrow("still has");
|
||||
|
||||
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
|
||||
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
|
||||
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
|
||||
.rejects.toThrow("child folder");
|
||||
|
||||
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
|
||||
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
|
||||
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
|
||||
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
|
||||
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
|
||||
expect(skill?.folderId).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects cross-Organization folder assignment", async () => {
|
||||
await seedTestOrganization("org_other", "other");
|
||||
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
|
||||
await expect(configuration.setSkillFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "typst",
|
||||
folderId: otherFolder.id,
|
||||
})).rejects.toThrow("folder not found in organization");
|
||||
await expect(configuration.setRoleFolder({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
folderId: otherFolder.id,
|
||||
})).rejects.toThrow("folder not found in organization");
|
||||
});
|
||||
|
||||
it("treats folder assignment as a label-class change (no session archival)", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
|
||||
});
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
id: "session-folder-assignment",
|
||||
projectId: "project-a",
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
|
||||
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
|
||||
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
|
||||
.resolves.toMatchObject({ archivedAt: null });
|
||||
});
|
||||
|
||||
async function makeSkill(parent: string, name: string): Promise<string> {
|
||||
const source = join(parent, "sources", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
|
||||
@@ -101,6 +101,43 @@ describe("ADR-0021 project onboarding", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("promotes a legacy root Inbox when Feishu chat creates a project", async () => {
|
||||
await seedUser("u-member", "ou_member", "MEMBER");
|
||||
// Production drift after bootstrap omitted kind=SYSTEM_INBOX. The protect
|
||||
// trigger refuses demotion, so the test plants the legacy shape directly.
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" DISABLE TRIGGER cph_protect_system_inbox`);
|
||||
try {
|
||||
await prisma.folder.updateMany({
|
||||
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX", archivedAt: null },
|
||||
data: { kind: "REGULAR" },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" ENABLE TRIGGER cph_protect_system_inbox`);
|
||||
}
|
||||
const legacy = await prisma.folder.findFirstOrThrow({
|
||||
where: { organizationId: DEFAULT_ORG_ID, parentId: null, name: "Inbox", archivedAt: null },
|
||||
select: { id: true, kind: true },
|
||||
});
|
||||
expect(legacy.kind).toBe("REGULAR");
|
||||
|
||||
const result = await createProjectFromFeishuChat(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorFeishuOpenId: "ou_member",
|
||||
chatId: "chat-legacy-inbox",
|
||||
name: "Recovered Inbox Project",
|
||||
workspaceRoot: await tempWorkspaceRoot(),
|
||||
});
|
||||
|
||||
expect(result.folderId).toBe(legacy.id);
|
||||
await expect(prisma.folder.findUniqueOrThrow({
|
||||
where: { id: legacy.id },
|
||||
select: { kind: true },
|
||||
})).resolves.toEqual({ kind: "SYSTEM_INBOX" });
|
||||
expect(await prisma.folder.count({
|
||||
where: { organizationId: DEFAULT_ORG_ID, name: "Inbox", archivedAt: null },
|
||||
})).toBe(1);
|
||||
});
|
||||
|
||||
it("blocks ordinary Feishu project creation when the org setting is off", async () => {
|
||||
await seedUser("u-member", "ou_member", "MEMBER");
|
||||
await setMembersCanCreateProjects(prisma, {
|
||||
|
||||
@@ -62,6 +62,11 @@ describe("Alpha Silo bootstrap", () => {
|
||||
{ roleId: "draft", label: "草稿", isDefault: true },
|
||||
{ roleId: "review", label: "审校", isDefault: false },
|
||||
]);
|
||||
await expect(prisma.folder.findMany({
|
||||
where: { organizationId: "org_alpha", archivedAt: null },
|
||||
select: { name: true, kind: true, parentId: true },
|
||||
})).resolves.toEqual([{ name: "Inbox", kind: "SYSTEM_INBOX", parentId: null }]);
|
||||
|
||||
|
||||
const persisted = JSON.stringify({
|
||||
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import {
|
||||
findMarkdownImagesOutsideCode,
|
||||
maskMarkdownImagesForStreaming,
|
||||
materializeAnswerSegments,
|
||||
uploadMessageImage,
|
||||
} from "../../src/feishu/outboundImages.js";
|
||||
import { buildAgentCard } from "../../src/feishu/card/builder.js";
|
||||
|
||||
const temps: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("outbound markdown image parsing", () => {
|
||||
it("finds image refs outside fenced code blocks", () => {
|
||||
const text = [
|
||||
"See diagram:",
|
||||
"",
|
||||
"",
|
||||
"```md",
|
||||
"",
|
||||
"```",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const refs = findMarkdownImagesOutsideCode(text);
|
||||
expect(refs.map((ref) => ref.src)).toEqual([
|
||||
"https://cdn.example.com/a.png",
|
||||
"assets/x.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("masks image urls for streaming cards", () => {
|
||||
expect(maskMarkdownImagesForStreaming("before  after")).toBe(
|
||||
"before alt text after",
|
||||
);
|
||||
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeAnswerSegments", () => {
|
||||
it("uploads remote and workspace images and builds card segments", async () => {
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-"));
|
||||
temps.push(workspaceRoot);
|
||||
const workspaceDir = join(workspaceRoot, "project");
|
||||
await mkdir(join(workspaceDir, "assets"), { recursive: true });
|
||||
await writeFile(
|
||||
join(workspaceDir, "assets", "local.png"),
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
);
|
||||
|
||||
const imageCreate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ image_key: "img_remote_1" })
|
||||
.mockResolvedValueOnce({ image_key: "img_local_1" });
|
||||
const rt = mockRuntime({ imageCreate });
|
||||
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(Buffer.from("remote-bytes"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
|
||||
const text = "Intro\n\n\n\nAnd local \nDone.";
|
||||
const { segments, unresolved } = await materializeAnswerSegments(text, {
|
||||
rt,
|
||||
workspaceRoot,
|
||||
workspaceDir,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(unresolved).toEqual([]);
|
||||
expect(imageCreate).toHaveBeenCalledTimes(2);
|
||||
expect(segments).toEqual([
|
||||
{ type: "markdown", content: "Intro\n\n" },
|
||||
{ type: "image", imgKey: "img_remote_1", alt: "remote" },
|
||||
{ type: "markdown", content: "\n\nAnd local " },
|
||||
{ type: "image", imgKey: "img_local_1", alt: "local" },
|
||||
{ type: "markdown", content: "\nDone." },
|
||||
]);
|
||||
|
||||
const card = buildAgentCard({
|
||||
phase: "complete",
|
||||
text: "",
|
||||
contentSegments: segments,
|
||||
reasoningText: undefined,
|
||||
toolUseSteps: [],
|
||||
toolUseElapsedMs: undefined,
|
||||
isError: false,
|
||||
interrupted: false,
|
||||
runId: undefined,
|
||||
});
|
||||
expect(card.elements).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ tag: "img", img_key: "img_remote_1" }),
|
||||
expect.objectContaining({ tag: "img", img_key: "img_local_1" }),
|
||||
expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("rejects private remote hosts", async () => {
|
||||
const imageCreate = vi.fn();
|
||||
const rt = mockRuntime({ imageCreate });
|
||||
const fetchImpl = vi.fn();
|
||||
const { segments, unresolved } = await materializeAnswerSegments(
|
||||
"",
|
||||
{ rt, fetchImpl: fetchImpl as unknown as typeof fetch },
|
||||
);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(imageCreate).not.toHaveBeenCalled();
|
||||
expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]);
|
||||
expect(segments).toEqual([{ type: "markdown", content: "x" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadMessageImage", () => {
|
||||
it("returns image_key from Feishu upload", async () => {
|
||||
const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } }));
|
||||
const rt = mockRuntime({ imageCreate });
|
||||
await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested");
|
||||
expect(imageCreate).toHaveBeenCalledWith({
|
||||
data: { image_type: "message", image: Buffer.from("png") },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly imageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
image: {
|
||||
create: options.imageCreate ?? vi.fn(),
|
||||
},
|
||||
message: {
|
||||
create: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
child: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
level: "info",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/.lake
|
||||
@@ -1,55 +0,0 @@
|
||||
# spec —— Lean 语义母本
|
||||
|
||||
这是本 monorepo 的**契约**:产品各部件语义的上游参照,用 Lean 编写。它的定位与约束见仓库根 `README.md` 的"宪法"5 条——本文件只讲**怎么往这份契约里写东西**。
|
||||
|
||||
## 现状
|
||||
|
||||
刚初始化的 Lean 工程(`lake init`),目前只有占位内容(`Spec/Basic.lean`)。实质领域内容(System 平台层、Courseware 产品层)将逐个概念加入,每个都遵循下面的规范。
|
||||
|
||||
## 构建
|
||||
|
||||
```sh
|
||||
cd spec
|
||||
lake build
|
||||
```
|
||||
|
||||
工具链锁定在 `lean-toolchain`(`leanprover/lean4:v4.31.0`)。无外部依赖——Mathlib / Batteries 等留待第一个真正需要它的定理出现时再引入(依赖碰到再加)。
|
||||
|
||||
## 写作规范
|
||||
|
||||
### 双半契约:prose + type
|
||||
|
||||
每个 top-level 声明**必须**带 `/-- … -/` doc 注释,用自然语言陈述其语义意图。两半缺一不可:
|
||||
|
||||
- **prose 半**给人读——说清"这在领域里是什么、为什么"。
|
||||
- **type 半**给机器读、给 type checker 把关——保证结构无洞。
|
||||
|
||||
agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是 agent 理解语义的唯一权威来源。
|
||||
|
||||
### 标签分类法
|
||||
|
||||
在 doc 注释里用以下标签标注每条语义的状态:
|
||||
|
||||
- **`PINNED`** —— 已解决的分歧点,契约在此处权威,双方据此对齐。
|
||||
- **`OPEN`** —— 故意未规定。双方均**不得假设**其解;实现遇到时必须 surface 出来讨论,而不是擅自决定。
|
||||
- **`ADR-NNNN`** —— 链接到根 `docs/adr/` 下的对应决策记录(如 `ADR-0002`),交代该语义的决策出处。
|
||||
|
||||
### 分歧点测试(写之前先过一遍)
|
||||
|
||||
新增任何概念前,先问:**"不写明,开发者与 agent 会不会各自做出不同假设?"**
|
||||
|
||||
- 会 → 它是分歧点,入契约。
|
||||
- 不会(显然的东西 / 纯 plumbing / 普通 CRUD 字段)→ 不入。
|
||||
|
||||
详见根 README 宪法第 5 条。
|
||||
|
||||
### 不用 `sorry`
|
||||
|
||||
无法陈述清楚的东西,用 `OPEN` 在 prose 里标注,而**不是**用 `sorry` 留一个假装成立的定理。`sorry` 会让 `lake build` 仍然变绿,却在契约里埋一个谎——这与"契约自包含、无洞"直接冲突。
|
||||
|
||||
### 命名
|
||||
|
||||
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Agent.Run`、`Spec.Courseware.Validity`。
|
||||
- **类型**:PascalCase。
|
||||
- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…`、`ValidTransition`、`Can…`。
|
||||
- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。
|
||||
@@ -1,5 +0,0 @@
|
||||
-- This module serves as the root of the `Spec` library.
|
||||
-- Import modules here that should be built as part of the library.
|
||||
import Spec.Prelude
|
||||
import Spec.System
|
||||
import Spec.Courseware
|
||||
@@ -1,21 +0,0 @@
|
||||
import Spec.Courseware.Model
|
||||
import Spec.Courseware.Export
|
||||
import Spec.Courseware.Check
|
||||
import Spec.Courseware.Open
|
||||
|
||||
/-!
|
||||
# Courseware —— 产品层契约(课程工程文件)
|
||||
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0016。按四组组织:
|
||||
|
||||
- **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子
|
||||
单位 `Element`、单节课 `Lesson`(element 的有序序列)。
|
||||
- **`Export`** —— export target = artifact + 有序 typed steps:产物 ADT `Artifact`
|
||||
(`singleFile` / `fileTree`),build 规格 `TargetSpec`(artifact + steps + 覆盖声明
|
||||
`covers`)与 `RenderConfig`。
|
||||
- **`Check`** —— checker 语义:`Severity` + 6 类诊断 + **合法 lesson = 无 error 级
|
||||
诊断**(模型外设施诊断以抽象谓词 + `Oracle` 表示);检查管线的 5 阶段、序、compile
|
||||
门控。
|
||||
- **`Open`** —— OPEN 骨架(核心关系 OPEN,已 surface):题库 `QuestionBank`、
|
||||
课程编排 `Course`。
|
||||
-/
|
||||
@@ -1,9 +0,0 @@
|
||||
import Spec.Courseware.Check.Diagnostic
|
||||
import Spec.Courseware.Check.Pipeline
|
||||
|
||||
/-!
|
||||
# Courseware.Check —— checker 语义
|
||||
|
||||
诊断分类与合法 lesson(`Diagnostic`)、检查管线的阶段与序(`Pipeline`)。决策出处
|
||||
ADR-0010。
|
||||
-/
|
||||
@@ -1,107 +0,0 @@
|
||||
import Spec.Courseware.Model.Lesson
|
||||
import Spec.Courseware.Export.Render
|
||||
|
||||
/-!
|
||||
# Diagnostic —— checker 诊断:分类、严重级别、合法 lesson
|
||||
|
||||
产品里"站在 Lean 位置"的 rule-based checker,语义在此沉淀(ADR-0010,经 ADR-0012
|
||||
修订)。它对 lesson 提诊断,每条有**分类**(`DiagKind`)与**严重级别**(`Severity`)。
|
||||
定级别类型(二分);定 7 类诊断各自的含义与级别,并把"**合法 lesson = 无
|
||||
error 级诊断**"建成判定(ADR-0005 deferred 的"完整合法判定"的回填);对**模型外设施**
|
||||
型诊断(typst 编过否、数据合 schema 否)用**抽象谓词 + `Oracle` 实现边界**表示——契约
|
||||
说"存在这条诊断、什么意思、什么级别",真值由实现提供,不在 Lean 内计算(不内嵌 typst
|
||||
编译器)。引用解析(`@ref`、相对 import)不另设诊断:它们都是 typst 编译期失败,归
|
||||
`typstCompile`(ADR-0012)。版本契约诊断 `cphVersionMismatch`(ADR-0016)是第 7 类。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 诊断严重级别(`PINNED` 二分, ADR-0005)。`error` 阻断(产物不合法),`warning` 不
|
||||
阻断(产物仍可导出,只是有损)。更细级别(info/hint)未决策,故只二分。 -/
|
||||
inductive Severity where
|
||||
| warning
|
||||
| error
|
||||
|
||||
/-- 诊断**分类**(`PINNED` 7 类, ADR-0010,经 ADR-0012 修订为 6 类,经 ADR-0016 增至 7
|
||||
类)。按"谁来判定"分三层:**结构型**(模型自身可判):`partPathMissing`/`unknownKind`/
|
||||
`cphVersionMismatch`;**schema/外部设施型**(靠实现 oracle):
|
||||
`missingContentFile`/`schemaViolation`/`typstCompile`;**语义型**:`renderIgnored`。 -/
|
||||
inductive DiagKind where
|
||||
/-- manifest 的 part 指向不存在的文件夹(或经 `..` 逃出根)。结构型。 -/
|
||||
| partPathMissing
|
||||
/-- part 声明了未知 kind(含 part 与 element.toml 的 kind 不一致)。结构型。 -/
|
||||
| unknownKind
|
||||
/-- kind schema 要求的某 `content` 字段缺对应 `<field>.typ`。结构/schema 型。 -/
|
||||
| missingContentFile
|
||||
/-- 实例数据不合其 kind 的 JSON Schema;亦作 manifest/element.toml 畸形的兜底。 -/
|
||||
| schemaViolation
|
||||
/-- 拼装出的 typst 源编译失败:语法错、未解析的交叉引用 `@ref`、越界或缺失的相对
|
||||
`import`/`include`(后两者即旧 `danglingReference` 的两种情形——typst 在编译期
|
||||
检出,故归此类,ADR-0012)。外部设施型。 -/
|
||||
| typstCompile
|
||||
/-- 某被用到的 kind 在某声明的 target 下无渲染规则,该 element 被忽略。语义型。 -/
|
||||
| renderIgnored
|
||||
/-- 工程文件的 `.cph-version` 与 CLI(cph)版本不相容(ADR-0016)。**结构型**(加载期
|
||||
判)。工程文件根的 `.cph-version` 声明它所面向的 cph 版本;CLI 加载时按**兼容性判
|
||||
定**比对自身版本(实现侧 `CARGO_PKG_VERSION`),不相容即产此类。**当前判定为版本
|
||||
完全相等才相容**(MVP;后续可放宽为 semver 区间,判定逻辑可逐步修改而不动本分类)。
|
||||
`error` 级——版本不相容的工程文件不应被该 CLI 处理。 -/
|
||||
| cphVersionMismatch
|
||||
|
||||
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
|
||||
`renderIgnored` 为 `warning`**——ADR-0005 种子规则"缺渲染 ⇒ warning,不阻断导出"。
|
||||
全函数使"哪类阻断"成为可引用、可对齐的事实(实现侧 `DiagCode` 级别据此对齐)。 -/
|
||||
def DiagKind.severity : DiagKind → Severity
|
||||
| .partPathMissing => .error
|
||||
| .unknownKind => .error
|
||||
| .missingContentFile => .error
|
||||
| .schemaViolation => .error
|
||||
| .typstCompile => .error
|
||||
| .renderIgnored => .warning
|
||||
| .cphVersionMismatch => .error
|
||||
|
||||
/-- 缺渲染诊断的级别 = **warning**(`PINNED`, ADR-0005/0010,**非 error**)。具名常量,
|
||||
使"它是 warning"可被实现侧 grep 对齐(实现里同名常量 `RENDER_IGNORED_SEVERITY` 引用
|
||||
本定义)。等价于 `DiagKind.renderIgnored.severity`。 -/
|
||||
def renderIgnoredSeverity : Severity := DiagKind.renderIgnored.severity
|
||||
|
||||
variable (P : Primitives)
|
||||
|
||||
/-- **缺渲染诊断**:lesson 在 target `t` 下存在无法渲染的 element(`PINNED`,
|
||||
ADR-0005/0009)。成立 ⟺ 存在某 element,其 kind 在 `t` 下 `covers` 为假。语义型诊断,
|
||||
级别 warning。 -/
|
||||
def renderIgnored (l : Lesson P) (c : RenderConfig P) (t : P.TargetId) : Prop :=
|
||||
∃ e ∈ l, ¬ c.covers e.kind t
|
||||
|
||||
/-!
|
||||
## 模型外设施型诊断:抽象谓词 + 实现边界(ADR-0010)
|
||||
|
||||
`typstCompile`/`schemaViolation`(的 schema-合规面)断言的是模型自身无法判定的事实——
|
||||
要跑 typst 编译器、schema 校验器。契约把它们建成**抽象谓词**,真值由实现提供的 oracle
|
||||
给出。下面用 `Oracle` 收口这些判定:它不是要在 Lean 里实现 checker,而是把"这些事实
|
||||
来自模型外"显式化、类型化。
|
||||
-/
|
||||
|
||||
/-- **实现侧判定 oracle**(`PINNED` 实现边界, ADR-0010)。每个字段是一个谓词,真值由
|
||||
实现(checker)提供。结构型诊断(part 路径、未知 kind)不入此 oracle——那些模型自身可判。 -/
|
||||
structure Oracle (l : Lesson P) (c : RenderConfig P) where
|
||||
/-- target `t` 下拼装源可编译(否 ⇒ `typstCompile`)。**含引用解析**:源能编译即蕴含
|
||||
其 `@ref`、相对 import 全部解析(ADR-0012 已把引用诊断并入 `typstCompile`)。 -/
|
||||
compiles : P.TargetId → Prop
|
||||
/-- 每个 element 数据合 schema(否 ⇒ `schemaViolation`)。 -/
|
||||
dataConforms : Prop
|
||||
/-- schema 要求的 content 文件齐备(否 ⇒ `missingContentFile`)。 -/
|
||||
contentFilesPresent : Prop
|
||||
|
||||
/-- **合法 lesson**(`PINNED`, ADR-0010;回填 ADR-0005 deferred 的"完整合法判定")。
|
||||
|
||||
合法 ⟺ 检查管线产出**零条 error 级诊断**。展开为:模型外设施判定(经 `Oracle`)全为真,
|
||||
**且**每个声明的 target 都编译通过。结构型诊断由 `cph-model` 在加载期判定;能走到这步
|
||||
谈合法性意味着已加载成功,故此处聚焦 schema/外部设施层。引用解析不单列——已被
|
||||
`compiles` 蕴含(ADR-0012)。`renderIgnored` 是 warning,**不**进合取——ADR-0005 种子
|
||||
规则的体现。`targets` 用全称式表达以不绑定 `TargetId` 的可枚举性。 -/
|
||||
def Legal (l : Lesson P) (c : RenderConfig P) (o : Oracle P l c) : Prop :=
|
||||
o.dataConforms ∧ o.contentFilesPresent ∧
|
||||
(∀ t : P.TargetId, (c.spec t).isSome → o.compiles t)
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,54 +0,0 @@
|
||||
import Spec.Courseware.Check.Diagnostic
|
||||
|
||||
/-!
|
||||
# Pipeline —— checker 检查管线的阶段与序(ADR-0010)
|
||||
|
||||
checker 的 `check` 按**固定顺序**跑五个阶段,逐阶段收集诊断;`compile` 阶段有**门控**。
|
||||
顺序与门控是契约——它决定用户看到哪些诊断(藏在缺文件背后的语法错,在文件补齐前不
|
||||
显示,这是有意的)。每阶段的**算法**不进 Lean(深度上限:只定**阶段、序、
|
||||
门控**)。阶段对应上游模块:`load`←`cph-model`;`structural`←part 路径/未知 kind;
|
||||
`schema`←`cph-schema`;`compile`←`cph-typst`(模型外设施);`coverage`←`renderIgnored`。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 检查管线的**阶段**(`PINNED` 5 阶段, ADR-0010)。
|
||||
|
||||
- `load` —— 解析 manifest + 各 element.toml。**含 `.cph-version` 兼容性判定**
|
||||
(ADR-0016:工程根 `.cph-version` 与 CLI 版本不相容 ⇒ `cphVersionMismatch` error)。
|
||||
硬失败(无法解析 lesson)则**停**整条管线。
|
||||
- `structural` —— part 路径存在、无 `..`、kind 已知且一致。此处判缺的 part 后续跳过。
|
||||
- `schema` —— 每个"存在且 kind 已知"的 part 按其 kind schema 校验。
|
||||
- `compile` —— 模型外设施阶段(typst 编译)。**门控:仅当前序零 error 才跑**。
|
||||
- `coverage` —— 语义型 warning(`renderIgnored`);**不**受门控,总跑。 -/
|
||||
inductive Phase where
|
||||
| load
|
||||
| structural
|
||||
| schema
|
||||
| compile
|
||||
| coverage
|
||||
deriving DecidableEq
|
||||
|
||||
/-- 管线阶段的**执行序**(`PINNED`, ADR-0010)。`order p` 越小越先跑。序是契约:
|
||||
`compile`(3)排在 `structural`(1)/`schema`(2)之后,正因前者门控于后者无 error。 -/
|
||||
def Phase.order : Phase → Nat
|
||||
| .load => 0
|
||||
| .structural => 1
|
||||
| .schema => 2
|
||||
| .compile => 3
|
||||
| .coverage => 4
|
||||
|
||||
/-- 某阶段是否**受"前序零 error"门控**(`PINNED`, ADR-0010)。唯 `compile` 受门控:藏在
|
||||
结构/schema 错背后的编译错,在前者修好前不显示——有意降噪。 -/
|
||||
def Phase.gated : Phase → Bool
|
||||
| .compile => true
|
||||
| _ => false
|
||||
|
||||
/-- 管线在 `load` 硬失败时**停**(`PINNED`, ADR-0010)。`load` 拿不到可解析 lesson 时,
|
||||
无 lesson 可喂下游,整条管线终止——唯一会截断后续所有阶段的情形(区别于 `compile`
|
||||
门控只跳过自己)。 -/
|
||||
def Phase.haltsPipelineOnFailure : Phase → Bool
|
||||
| .load => true
|
||||
| _ => false
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,8 +0,0 @@
|
||||
import Spec.Courseware.Export.Artifact
|
||||
import Spec.Courseware.Export.Render
|
||||
|
||||
/-!
|
||||
# Courseware.Export —— export target = artifact + 有序 typed steps
|
||||
|
||||
产物 ADT(`Artifact`)、build 规格与渲染覆盖(`Render`)。决策出处 ADR-0009 / 0011。
|
||||
-/
|
||||
@@ -1,23 +0,0 @@
|
||||
/-!
|
||||
# Artifact —— export target 的产物(ADR-0009 / 0011)
|
||||
|
||||
ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**。ADR-0011
|
||||
固定:产物是**带字段的 ADT**——"产物到底指什么"(单文件落在哪 / 一棵树产出哪些文件)
|
||||
是不好猜的领域语义,必须写进字段 + doc,而非抹成两个空构造子。路径/glob 用 `String`
|
||||
承载并由 doc 赋义(它们就是文本),不复刻文件系统类型。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- export 产物(`PINNED` 带字段 ADT, ADR-0011)。产物形状决定 `cph build` 吐文件还是
|
||||
目录、reduce 怎么折叠(`singleFile` 把有序片段拼成一份再编译——交叉引用 `@ref`、例题
|
||||
计数器能工作的前提;`fileTree` 每 part 落一文件)。后端/格式仍 OPEN(ADR-0009)。 -/
|
||||
inductive Artifact where
|
||||
/-- 单文件产物,落在 `filepath`(相对工程根)。讲义/教案 PDF 即此。 -/
|
||||
| singleFile (filepath : String)
|
||||
/-- 多文件产物:`root` 目录下匹配 `outputs` **glob** 的文件集(第三方平台 archive
|
||||
即此)。用 glob 而非显式清单:轻,又让消费方/checker 知道该产出哪些文件、可校验
|
||||
完整性。 -/
|
||||
| fileTree (root : String) (outputs : String)
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,93 +0,0 @@
|
||||
import Spec.Courseware.Model.Primitives
|
||||
import Spec.Courseware.Export.Artifact
|
||||
|
||||
/-!
|
||||
# Render —— export target = artifact + 有序 typed steps(ADR-0009 / 0011)
|
||||
|
||||
ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR-0011 固定 build
|
||||
的**形状**:一个 target 是 `artifact` + 一串**有序 typed step**。
|
||||
|
||||
- `typstCompile template` —— 把**模板文件**(如 `exports/student.typ`)编译成产物。它是
|
||||
*typed* 而非裸 shell,正因框架要把 **manifest 注入**模板(经 `--input manifest=…`),
|
||||
裸字符串表达不了这个 wiring。presentation(编号、样式)住模板里,不在 manifest。
|
||||
- `shell run` —— 逃生口,给难以声明的步骤(ADR-0005 的 (b) 类 medium-only)。
|
||||
- `assembleMarkdown field` —— 按 parts 顺序把每个 element 的 `field`(markdown content 叶子,
|
||||
ADR-0015)拼成**单文件 markdown** 产物。typed 而非 `cat`,因为按 manifest `[[parts]]` 顺序读取 +
|
||||
跳过缺失项 + 写到单文件产物路径这件事,框架 own 比裸 shell 稳。
|
||||
|
||||
**渲染覆盖**:ADR-0011 废止了 per-target `RenderRule` 载荷——渲染的"how"已移进模板。
|
||||
契约只保留覆盖声明 `covers`(该 target 渲染哪些 kind),供种子诊断用。
|
||||
|
||||
**shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把
|
||||
`run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配
|
||||
内容)。三条边界是真分歧点,故定为契约:
|
||||
1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生,
|
||||
绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。
|
||||
2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷;
|
||||
因此它**不**进 `Diagnostic` 的 6 类(那 6 类是 lesson 自身的诊断,见 `Check/Diagnostic.lean`),
|
||||
而由 build 执行层报告。诊断分类保持 6 类不变(ADR-0013 显式拒绝新增 `ShellStep` 诊断码)。
|
||||
3. **非-typst target 不过 typst 编译** —— 一个只含 `shell` step 的 target(教具包即此)由
|
||||
执行器跑命令,而非走 typst 引擎;`check` 的 compile 阶段跳过它。
|
||||
|
||||
**assembleMarkdown step 的执行语义(ADR-0015)。** 与 `shell` 同属"非-typst target":框架 own
|
||||
读取每个 element 的 `<field>.md`、按 `[[parts]]` 顺序拼接、写到单文件产物路径(产物是 markdown,
|
||||
不经 typst 引擎)。同 shell 的三条边界:① 只在显式 `build --target` 跑,`check` 不跑;② 装配失败
|
||||
(如写盘失败、引用图缺失)是 build-过程错误,不入 6 类诊断;③ 非-typst target,`check` 的 compile
|
||||
阶段跳过它。**单文件产物**(现):装配器注入课程级标题为文档 h1(课程元数据,非 element 内容),
|
||||
每份 `slides.md`/`transcript.md` 贡献其 `##` part 与 `###` 小节(presentation 在内容里, ADR-0011),
|
||||
按 `[[parts]]` 顺序拼接(分隔符空行)。**自包含**:装配器扫描产物里的 `` 图引用,把引用到的
|
||||
本地图从工程根按原相对路径复制进产物所在 build 根,使该 build 目录可独立交付;引用图在工程根缺失属
|
||||
build-过程错误(不入 6 类)。外部 URL(`http(s)://`/`data:`)不复制。结构化 FileTree 产物延后
|
||||
(待 parts 树形重组, ADR-0015 OPEN)。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
variable (P : Primitives)
|
||||
|
||||
/-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`;
|
||||
`steps` 是 list 因为 FileTree / 第三方 build 会需多步。不把模板内部、shell 命令的
|
||||
解析结构写进来(实现细节, ADR-0011 OPEN)。 -/
|
||||
inductive Step where
|
||||
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
|
||||
注入这件事裸 shell 写不出。 -/
|
||||
| typstCompile (template : String)
|
||||
/-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。**已实现**(ADR-0013):以工程根
|
||||
为 cwd 执行,opt-in(只在显式 build 该 target 时跑,`check` 不跑),失败属 build-过程错误
|
||||
而非 lesson 诊断。教具包(如 KenKen 交互 HTML 由外部 `kendoku` 生成)即走此 step。 -/
|
||||
| shell (run : String)
|
||||
/-- 装配 markdown:按 `[[parts]]` 顺序读取每个 element 的 `field`(markdown content 叶子,
|
||||
ADR-0015),注入课程 h1 后拼接成**单文件 markdown** 产物,并收集 `` 引用的本地图进
|
||||
build 根使产物自包含。typed 而非 `cat`:按序读取+跳过缺失+注入标题+写盘+收集图由框架 own。
|
||||
**已实现**(ADR-0015):非-typst target(`check` 不跑,失败属 build-过程错误不入 6 类诊断)。
|
||||
slides 大纲面 / 逐字稿口播面即走此 step(直接 markdown+KaTeX 撰写,绕开 typst→md 公式转换,
|
||||
ADR-0014 R2)。 -/
|
||||
| assembleMarkdown (field : String)
|
||||
|
||||
/-- 一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -/
|
||||
structure TargetSpec where
|
||||
/-- 产物(带字段, ADR-0011)。决定 build 折叠成单文件还是文件树。 -/
|
||||
artifact : Artifact
|
||||
/-- **有序** build steps。按序执行;MVP 仅一个 `typstCompile`。 -/
|
||||
steps : List Step
|
||||
/-- **覆盖声明**:`covers k` 表示此 target 渲染 kind `k`。ADR-0011 把旧
|
||||
`renders : KindId → Option RenderRule` 降级后的产物——契约只声明"渲染哪些 kind"
|
||||
(种子诊断 `renderIgnored` 用),"how"由 `steps` 的模板实现。 -/
|
||||
covers : P.KindId → Prop
|
||||
|
||||
/-- 渲染配置(`PINNED` target-中心, ADR-0009/0011)。`spec t = none` 表示 target `t`
|
||||
未声明(不导出);`some s` 给出其 build 规格。 -/
|
||||
structure RenderConfig where
|
||||
/-- target ↦ 该 target 的 build 规格(未声明则 `none`)。 -/
|
||||
spec : P.TargetId → Option (TargetSpec P)
|
||||
|
||||
/-- kind `k` 在 target `t` 下**被渲染**(`PINNED`, ADR-0009/0011;承接 ADR-0005)。成立
|
||||
⟺ `t` 已声明(`spec t = some s`)**且** `s.covers k`。为假即"此 kind 在此 target 下不
|
||||
被渲染"——checker 据此报 warning(见 `Diagnostic.renderIgnored`)。`P` 隐式以便点记法。 -/
|
||||
def RenderConfig.covers {P : Primitives} (c : RenderConfig P)
|
||||
(k : P.KindId) (t : P.TargetId) : Prop :=
|
||||
match c.spec t with
|
||||
| none => False
|
||||
| some s => s.covers k
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,13 +0,0 @@
|
||||
import Spec.Courseware.Model.Primitives
|
||||
import Spec.Courseware.Model.RichContent
|
||||
import Spec.Courseware.Model.Element
|
||||
import Spec.Courseware.Model.Lesson
|
||||
import Spec.Courseware.Model.Info
|
||||
|
||||
/-!
|
||||
# Courseware.Model —— 工程文件的内容模型
|
||||
|
||||
基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
|
||||
(`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 撰写态)。
|
||||
决策出处 ADR-0005 / 0006 / 0008。
|
||||
-/
|
||||
@@ -1,24 +0,0 @@
|
||||
import Spec.Courseware.Model.Primitives
|
||||
|
||||
/-!
|
||||
# Element —— 课程内容的原子单位
|
||||
|
||||
ADR-0005:element 实例 = 一个 kind 标签 + 符合该 kind schema 的数据。把它编码成
|
||||
依赖结构,使"数据必须匹配其 kind"成为类型层面的事实而非运行时校验。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
variable (P : Primitives)
|
||||
|
||||
/-- element 实例(`PINNED`, ADR-0005)。`data : P.ElementData kind` 由 `kind` 决定——
|
||||
无法构造数据与 kind 不符的 element,schema 合规由类型系统保证。ADR-0005 的 (a) 类
|
||||
字段(逐字稿、重点圈划)落在具体 kind 的 `ElementData` 内,不出现在此通用结构上;
|
||||
交互教具等"重型 kind"在此与例题、定理同构,仅 `kind` 不同,重实现在模型之外。 -/
|
||||
structure Element where
|
||||
/-- 该 element 的 kind。 -/
|
||||
kind : P.KindId
|
||||
/-- 符合 `kind` schema 的数据(类型随 `kind` 而变)。 -/
|
||||
data : P.ElementData kind
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,58 +0,0 @@
|
||||
/-!
|
||||
# Info —— 课时元信息:canonical 模型 vs 撰写态(authoring surface)
|
||||
|
||||
`[info]`(标题、作者)大多是 passthrough 元数据(ADR-0008),本不入契约。但**作者的
|
||||
基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个
|
||||
**有序列表**,不是单值或可选单值。
|
||||
|
||||
另一条模式:on-disk 的**撰写态**(用户实际填写的形态)是**语法糖**——单作者可写
|
||||
`author = "…"`,多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载
|
||||
边界**:`RawInfo` 经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是
|
||||
`List String`,raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo`
|
||||
(撰写态)两个结构存在的理由。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 作者的**撰写态形式**(`PINNED` 仅填写便利, ADR-0008)。on-disk 单作者可写裸
|
||||
字符串、多作者写数组——填写便利,非语义分歧。此 union **只活在加载边界**,经
|
||||
`RawAuthor.normalize` 折叠后不再出现。 -/
|
||||
inductive RawAuthor where
|
||||
/-- 单作者裸字符串 `author = "…"`。 -/
|
||||
| one (name : String)
|
||||
/-- 多作者数组 `author = ["…", "…"]`。 -/
|
||||
| many (names : List String)
|
||||
|
||||
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
|
||||
⇒ 原样。canonical 接收端始终是 `List String`。 -/
|
||||
def RawAuthor.normalize : RawAuthor → List String
|
||||
| .one n => [n]
|
||||
| .many ns => ns
|
||||
|
||||
/-- 课时元信息的 **canonical 模型**(`PINNED` author 为列表, ADR-0008)。`authors` 是
|
||||
**有序列表**:多人署名第一类,空列表 = 未署名。`title` 等其余字段是 passthrough 元数据,
|
||||
不在此承诺更多。这是系统其余部分唯一所见的形态——author 在此**已**是列表,不再是
|
||||
"字符串或数组"。 -/
|
||||
structure Info where
|
||||
/-- 标题(passthrough 元数据)。 -/
|
||||
title : String
|
||||
/-- 作者**有序列表**(空 = 未署名)。canonical 始终是列表。 -/
|
||||
authors : List String
|
||||
|
||||
/-- 撰写态的 `[info]`(`PINNED` 仅填写便利, ADR-0008)。`author` 用 `RawAuthor`
|
||||
(字符串或数组),`author` 缺省即未署名。此结构刻画"为便于填写而存在的 raw 形态",
|
||||
**不**是模型其余部分流通的形式——它经 `RawInfo.toInfo` 归一化为 canonical `Info`。 -/
|
||||
structure RawInfo where
|
||||
/-- 标题。 -/
|
||||
title : String
|
||||
/-- 作者 raw 形式(可选;缺省即未署名)。 -/
|
||||
author : Option RawAuthor
|
||||
|
||||
/-- raw `[info]` 归一化为 canonical `Info`(`PINNED` 加载边界归一化, ADR-0008)。缺省
|
||||
author ⇒ 空列表,否则按 `RawAuthor.normalize`。raw 的"字符串或数组"二态在此被消解,
|
||||
**不**泄漏进 `Info`——canonical 接收端恒为 `List String`。 -/
|
||||
def RawInfo.toInfo (r : RawInfo) : Info :=
|
||||
{ title := r.title
|
||||
authors := (r.author.map RawAuthor.normalize).getD [] }
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,16 +0,0 @@
|
||||
import Spec.Courseware.Model.Element
|
||||
|
||||
/-!
|
||||
# Lesson —— 单节课工程文件
|
||||
|
||||
ADR-0005:一个工程文件 = 一节课,是 element 实例的**有序序列**。课程/单元不是工程
|
||||
文件,而是 lesson 的编排(见 `Spec.Courseware.Course`,OPEN)。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 一节课(`PINNED`, ADR-0005)。用 `List` 因为 **element 次序承载教学语义**(先讲
|
||||
定义再举例 ≠ 反过来);**不建模时长**——ADR-0005 决定 lesson 是内容编排而非时间轴。 -/
|
||||
abbrev Lesson (P : Primitives) := List (Element P)
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,34 +0,0 @@
|
||||
/-!
|
||||
# Primitives —— Courseware 契约的基元
|
||||
|
||||
课程工程文件模型(ADR-0005)依赖一组基元:element kind 怎么标识、某 kind 的数据
|
||||
schema 是什么、export target 怎么标识。收口成载体 `Primitives`,让模型在其上参数化
|
||||
——契约谈得了 element / lesson / 渲染**之间的关系**,而把每个基元的**内部表示**留给
|
||||
实现。注意:某基元语义已 PINNED(如 schema 形态由 ADR-0006 固定)与其表示进 Lean
|
||||
是两回事——JSON Schema / typst 的内部结构属实现细节,不入 Lean,故基元在此仍以抽象
|
||||
类型承载。富内容的母本见 `Courseware.RichContent`。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/
|
||||
structure Primitives where
|
||||
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。用抽象
|
||||
类型而非 `inductive`:ADR-0005 决定 kind 是开放可扩展宇宙(stdlib + 第三方),
|
||||
封闭枚举会违背它——此处开放是**已决策的**(区别于 `RunState` 的"尚未封闭")。 -/
|
||||
KindId : Type
|
||||
/-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED`
|
||||
ADR-0006,表示仍抽象)。以 kind 为索引:`ElementData k` 即"符合 `k` schema 的
|
||||
数据"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源)由 ADR-0006
|
||||
固定,但属 JSON/typst 内部结构、实现细节,不进 Lean;契约只锚定"数据符合
|
||||
kind schema"这条关系,故此处仍是抽象类型。 -/
|
||||
ElementData : KindId → Type
|
||||
/-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次
|
||||
build,产出对 lesson 的一种投影(讲义/教案/PPT/平台 archive…),见 `Render`。 -/
|
||||
TargetId : Type
|
||||
|
||||
-- 注:原 `RenderRule : Type` 已随 ADR-0011 移除。渲染的"how"不再是契约层 per-target
|
||||
-- 载荷,而由 `Render.TargetSpec.steps` 里 `typstCompile` step 引用的**模板文件**承载;
|
||||
-- 契约只保留覆盖声明 `TargetSpec.covers`(该 target 渲染哪些 kind),供种子诊断用。
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,44 +0,0 @@
|
||||
/-!
|
||||
# RichContent —— 富内容(ADR-0006 的母本)
|
||||
|
||||
ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**源文本**,
|
||||
按其 **format** 决定语义(ADR-0015)。两种 format:
|
||||
- **typst** —— 一段 typst 源,语义取该源作为 module 求值后的 body content(讲义/教案面)。
|
||||
- **markdown** —— 一段**原样**的 markdown + KaTeX 源,**不经 typst 求值**(slides 大纲面 / 逐字稿口播面;
|
||||
ADR-0015)。直接以 markdown 撰写**绕开** typst→markdown 的公式转换难题(ADR-0014 R2):公式一开始就是
|
||||
KaTeX 源(`$…$`),没有"把 typst 公式转成 md"这一步。
|
||||
|
||||
关键约束(均 ADR-0006,源自 typst 源码事实):**typst** format 的富内容**不可无主**——typst 的源必须有
|
||||
`FileId`,否则 span 脱锚、相对 import 报"cannot access file system from here"。故每段 typst 富内容是 World 里
|
||||
的一等文件,坐落在一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。markdown format
|
||||
的富内容不参与 typst 求值,但同样由一个虚拟路径定位(供 markdown 装配 step 按序读取,见 `Export/Render`)。
|
||||
|
||||
只立锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON Schema 形状、format 的
|
||||
具体判别属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"+"叶子带 format"这两条关系。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
|
||||
/-- 富内容在工程文件路径结构中的**虚拟路径**(`OPEN` 表示, ADR-0006)。把一段富内容
|
||||
定位为 World 里的一等文件(span 可解析、相对 import 可锚定)。落盘后即真实相对路径
|
||||
(ADR-0007),不在本层承诺,故 opaque。 -/
|
||||
opaque VPath : Type
|
||||
|
||||
/-- 富内容的 **format**(`PINNED`, ADR-0015)。`content` 叶子带 format:typst 叶子被 typst 求值;
|
||||
markdown 叶子原样保留(markdown+KaTeX 源,不经求值)。 -/
|
||||
inductive ContentFormat where
|
||||
/-- typst 源:求值为 typst `Content`(讲义/教案面)。 -/
|
||||
| typst
|
||||
/-- markdown + KaTeX 源:原样保留,不经 typst 求值(slides 大纲面 / 逐字稿口播面;ADR-0015)。 -/
|
||||
| markdown
|
||||
|
||||
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
|
||||
**format**(`PINNED`, ADR-0015)。不建模源文本、不建模求值出的 `Content`(那是实现侧的事);
|
||||
"富内容经由一个 `VPath` 定位 + 带 format",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/
|
||||
structure RichContentRef where
|
||||
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
|
||||
vpath : VPath
|
||||
/-- 该富内容的 format(ADR-0015)。 -/
|
||||
format : ContentFormat
|
||||
|
||||
end Spec.Courseware
|
||||
@@ -1,9 +0,0 @@
|
||||
import Spec.Courseware.Open.QuestionBank
|
||||
import Spec.Courseware.Open.Course
|
||||
|
||||
/-!
|
||||
# Courseware.Open —— OPEN 骨架(核心关系)
|
||||
|
||||
题库与 element 的关系(`QuestionBank`)、课程编排规则(`Course`)。两者均为已 surface
|
||||
但未决策的 OPEN 分歧点,待专门 ADR 落定。
|
||||
-/
|
||||
@@ -1,10 +0,0 @@
|
||||
/-!
|
||||
# Course —— 课程编排(骨架,规则 OPEN)
|
||||
|
||||
ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程文件,而是 lesson 的
|
||||
**编排**。但"编排"的具体规则未决策:有序列表还是带层级(单元 → 课)的树?lesson 被
|
||||
引用还是被包含?跨 lesson 有无约束(目标覆盖、前后置)?这些都是 `OPEN`。
|
||||
|
||||
此处不替它选解——不建 `Course := List Lesson`(那会偷偷承诺
|
||||
"扁平有序、无层级")。只在此 surface:课程编排待专门 ADR。本文件当前不引入任何承诺性声明。
|
||||
-/
|
||||
@@ -1,10 +0,0 @@
|
||||
/-!
|
||||
# QuestionBank —— 题库(骨架,核心关系 OPEN)
|
||||
|
||||
题库是与工程文件并列的资产(类比 DAW 的 sample library):有自身结构的实体,又是最
|
||||
典型的可复用单元,lesson 会引用它。但**题库与 element 的关系尚未决策**,且用户明确
|
||||
指出"纯引用可能不够"——element 内联题目数据 / lesson 持指向题库条目的引用 / 两者并存?
|
||||
|
||||
这是一个 `OPEN` 分歧点。此处不替它选解——不建 `QuestionRef` 也不建
|
||||
内联结构,只在此 surface。待专门 ADR 落定后再填。本文件当前不引入任何承诺性声明。
|
||||
-/
|
||||
@@ -1,70 +0,0 @@
|
||||
/-!
|
||||
# Prelude —— System 层共享标识符
|
||||
|
||||
平台层引用一组标识符(项目、run、session、principal、chat、platform identity/audit)。
|
||||
其内部表示从未被决策(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque
|
||||
载体 `Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类
|
||||
关系,却不对标识符表示作承诺。
|
||||
-/
|
||||
|
||||
namespace Spec.System
|
||||
|
||||
/-- System 层标识符载体(关系结构 `PINNED`;各字段表示 `OPEN`)。 -/
|
||||
structure Identifiers where
|
||||
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
|
||||
ProjectId : Type
|
||||
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
|
||||
OrganizationId : Type
|
||||
/-- 租户层用户标识(`OPEN` 表示;独立实体,非飞书身份派生;见 `Hierarchy.User`)。 -/
|
||||
UserId : Type
|
||||
/-- 飞书用户 open_id(`OPEN` 表示;单应用作用域内唯一)。 -/
|
||||
FeishuOpenId : Type
|
||||
/-- 飞书 user_id(`OPEN` 表示;租户内唯一,换 app 不变)。 -/
|
||||
FeishuUserId : Type
|
||||
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
|
||||
FeishuAppId : Type
|
||||
/-- 飞书 app_secret 信封引用(`OPEN` 表示;ADR-0024)。 -/
|
||||
FeishuAppSecretRef : Type
|
||||
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
|
||||
TeamId : Type
|
||||
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
|
||||
FolderId : Type
|
||||
/-- Agent 配置 folder 标识(`OPEN` 表示;ADR-0028 透明组织节点,skill/role 共用一棵树;
|
||||
与 project explorer 的 `FolderId` 是不同实体,两棵树互不引用)。 -/
|
||||
AgentConfigFolderId : Type
|
||||
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
|
||||
ADR-0017;`@bot` 仅为触发品牌,不承诺 provider)。 -/
|
||||
RunId : Type
|
||||
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
|
||||
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
|
||||
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
|
||||
SessionId : Type
|
||||
/-- Organization-scoped Agent role 标识(`OPEN` 表示, ADR-0017);role 是动态运行配置,
|
||||
不是 slash command 枚举。 -/
|
||||
AgentRoleId : Type
|
||||
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/…
|
||||
子类型学未定且非分歧点,故只留 opaque 键)。 -/
|
||||
Principal : Type
|
||||
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
|
||||
ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子
|
||||
类型——principal 子类型学 OPEN,这里不预设"chat 是 principal 的哪种子型")。 -/
|
||||
ChatId : Type
|
||||
/-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/
|
||||
PlatformIdentityId : Type
|
||||
/-- 平台自有飞书应用标识(`OPEN` 表示;ADR-0023,与各 org 客户应用分离)。 -/
|
||||
PlatformFeishuApplicationId : Type
|
||||
/-- 平台应用所验证的外部飞书用户标识(`OPEN` 表示;ADR-0023,其具体 open_id/user_id
|
||||
表示与迁移方式不在本层固定)。 -/
|
||||
PlatformExternalUserId : Type
|
||||
/-- 可撤销 Platform Session 标识(`OPEN` 表示;ADR-0023)。 -/
|
||||
PlatformSessionId : Type
|
||||
/-- Platform Administrator invitation 标识(`OPEN` 表示;ADR-0023)。 -/
|
||||
PlatformInvitationId : Type
|
||||
/-- 一次平台控制面 mutation 标识(`OPEN` 表示;ADR-0023)。 -/
|
||||
PlatformMutationId : Type
|
||||
/-- 独立 Platform Audit Entry 标识(`OPEN` 表示;ADR-0023)。 -/
|
||||
PlatformAuditEntryId : Type
|
||||
/-- 平台离线恢复所声明的 incident 标识(`OPEN` 表示;ADR-0023)。 -/
|
||||
PlatformIncidentId : Type
|
||||
|
||||
end Spec.System
|
||||
@@ -1,57 +0,0 @@
|
||||
import Spec.System.Hierarchy
|
||||
import Spec.System.ProjectGroup
|
||||
import Spec.System.Organization
|
||||
import Spec.System.User
|
||||
import Spec.System.Connections
|
||||
import Spec.System.ProjectWorkspace
|
||||
import Spec.System.Capacity
|
||||
import Spec.System.PlatformAdministration
|
||||
import Spec.System.Agent.Run
|
||||
import Spec.System.Agent.AgentRole
|
||||
import Spec.System.Agent.Memory
|
||||
import Spec.System.Agent.AgentSurface
|
||||
import Spec.System.Agent.Usage
|
||||
import Spec.System.Agent.Capability
|
||||
import Spec.System.Lock
|
||||
import Spec.System.Permission
|
||||
import Spec.System.PermissionGrant
|
||||
import Spec.System.Audit
|
||||
/-!
|
||||
# System —— Hub 平台层契约
|
||||
|
||||
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。
|
||||
likec4 已画出结构;这里补语义:
|
||||
|
||||
- `Hierarchy` —— 三层主体:平台 → 组织 → 用户。
|
||||
- `User` —— 用户创建路径(管理员直接创建;飞书注册 `OPEN`)。
|
||||
- `Connections` —— 外部连接:提供商枚举(当前仅飞书) + 绑定/信息类型。
|
||||
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
||||
- `Organization` —— SaaS 租户(ADR-0020);project/team 单归属,TEAM grant 不跨 org;
|
||||
connection secret 信封与 fail-closed resolver(ADR-0024);
|
||||
owner/admin/member(`OrganizationRole`)及其管理规则(最后所有者保护)。
|
||||
- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界
|
||||
(ADR-0021)。
|
||||
- `Capacity` —— platform ceiling 与 org policy 的分层限制、持久 admission request 状态和
|
||||
平台紧急工作负载制动(ADR-0022)。
|
||||
- `PlatformAdministration` —— 独立平台身份/会话、单一管理员角色、绑定邀请、最后管理员
|
||||
保护、fail-closed 平台审计与离线 emergency grant(ADR-0023)。
|
||||
- `AgentRole` —— org-scoped agent 角色配置 + 技能(ADR-0017/0018)。
|
||||
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
||||
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
|
||||
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
|
||||
Lock 限定并发,Surface 限定波及面。机制 OPEN。
|
||||
- `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、
|
||||
外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none`
|
||||
表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。
|
||||
- `Capability` —— 外部能力(PDF→MD、音视频→文本等)注册与调用(ADR-0027);
|
||||
org-scoped 凭证连接复用 ADR-0024 信封但独立于 model-provider;调用是 Run 内副作用,
|
||||
产物落 workspace,消耗记 UsageFact。capability credential 不进 Agent 进程。
|
||||
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
|
||||
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
|
||||
(ADR-0004);组合规则 OPEN。
|
||||
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
|
||||
`PlatformAdministration` 独立承载。
|
||||
|
||||
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0027。
|
||||
-/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user