Compare commits

...

24 Commits

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

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

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:19:59 +08:00
hongjr03 ce18740870 feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 21:43:00 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
hongjr03 5e10419fc8 fix(hub): docmind client stream upload + correct API response parsing (#7)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:26:11 +08:00
hongjr03 64b3d1fc64 feat(hub): switch capability provider to Aliyun Doc Mind (ADR-0027) (#6)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 16:42:42 +08:00
hongjr03 b673dd1fe9 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) (#5)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 15:55:02 +08:00
hongjr03 aaa098bb8b feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026) (#4)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 14:46:41 +08:00
hongjr03 97f7972cc5 chore(hub): remove markdown_to_pdf tool (#3)
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.

Roles that still list markdown_to_pdf must be cleaned before startup.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 13:57:27 +08:00
hongjr03 cc42e6a7c6 chore: release v0.0.31 2026-07-18 13:38:07 +08:00
hongjr03 4e01c18cac feat(hub): add markdown_to_pdf tool and default web tools
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by
md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with
workspace-scoped basedir, front-matter stripped so untrusted markdown
cannot override dest/basedir/launch options, and MathJax for $/$ math.

Also include WebFetch and WebSearch in the unrestricted role tool
surface by default. Deploy skips Puppeteer's browser download and
expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
2026-07-18 13:38:07 +08:00
ChickenPige0n a12984d174 feat(admin-web): add folder and project creation functionality in FolderNode and FolderTree components 2026-07-18 12:32:45 +08:00
hongjr03 b93acd8e8c chore: release v0.0.30 2026-07-16 01:26:28 +08:00
hongjr03 35251986af feat(hub): derive admin model picker from org provider connection via OpenRouter API
The admin role model picker was hardcoded to the env-default model registry
(createDefaultModelRegistry), which only ever returned a single Sonnet model.
Roles could not select any other model regardless of what the org's provider
connection supported.

Replace the env-only model list with a ProviderModelCatalog that:
- Resolves the org's ACTIVE provider connection credential (BYOK or
  platform-managed, encrypted via ADR-0024 envelope)
- Calls OpenRouter GET /v1/models?supported_parameters=tools to list
  tool-capable models available to that org
- Caches results in-memory with a 5-minute TTL per organization
- Falls back to the env-default registry when no ACTIVE provider exists

The runtime modelRegistry no longer validates role.defaultModel against the
env model list — the admin already validated by selection from the provider
catalog. The env list remains as the fallback for roles with null defaultModel.

The admin roles page loads models independently (non-blocking) so roles
remain editable even if the provider API is slow or unreachable.
2026-07-16 01:24:00 +08:00
hongjr03 3ee6da7ceb chore: release v0.0.29
Web-based skill management: create, read, edit, disable skills from the
org admin surface. File tree editor with SKILL.md manifest support.
2026-07-16 01:12:51 +08:00
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

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

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

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

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
2026-07-16 01:11:49 +08:00
hongjr03 ae5f78f036 chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so
page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
2026-07-15 22:33:07 +08:00
hongjr03 0782e155f6 chore: release v0.0.27
Agent role/skill admin restore, OAuth login URL fixes, project create 404 fix.
2026-07-15 21:35:26 +08:00
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00
ChickenPige0n fb66614e38 fix(admin-web): break /admin/login returnTo redirect loop in dev
vite dev only proxied /api and /auth, so /admin/login hit the SPA root
layout, re-ran loadSession, got 401, and redirected back to /admin/login
with an ever-nesting returnTo. Proxy /admin/login to the backend (which
owns it before the SPA fallback) and guard redirectToLogin against
re-entering /admin/login.

Replace the only emoji-as-icon (the back-arrow on project detail) with
the Icon component (new arrow-left glyph).

Add scripts/dev-bootstrap.ts for seeding a local Silo (stub probes) so
npm run dev can start on a fresh dev DB.
2026-07-15 18:19:47 +08:00
hongjr03 11de9e81db fix(hub): redirect Feishu OAuth default login to org admin SPA
Default returnTo=/admin previously landed on the static complete page
meant for chat onboarding; send users to /admin/org/:slug instead.
2026-07-15 14:29:42 +08:00
hongjr03 46ce942aec fix(admin-web): use org-scoped Feishu OAuth login URL
Unscoped GET /auth/feishu is disabled by default. Derive org slug from
/admin/org/:slug, ?org=, or the Alpha Silo hostname and redirect to
/auth/feishu/:orgSlug so the login button works on tenant domains.
2026-07-15 00:52:58 +08:00
85 changed files with 8038 additions and 474 deletions
+4
View File
@@ -36,6 +36,10 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 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` 搜索派生文档、项目编号 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release - Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a assets. Skill content is imported into a content-addressed persistent store
content-addressed persistent store and records its digest in PostgreSQL. A through a shared ingestion pipeline (`importSkillDirectory` for host-console
role selects enabled Organization skills alongside its model, system prompt CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
and tool allowlist. Each run copies only those selected immutable versions safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
into a run-scoped plugin outside the project workspace; the sandbox exposes symlink rejection, SHA-256 content addressing. The web surface
that snapshot read-only and deletes it after the run. SDK-bundled skills and (`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
filesystem setting sources remain disabled, so project `.claude` content store as the host-console CLI (`installSkill`); both flow through
cannot register skills or widen tools. Requested skill versions are recorded `commitSkillContent` for deduplication and atomic rename into
on `run.created`; SDK initialization remains authoritative loading evidence. `versions/<digest>/`. Org-admin authentication gates the web surface; the
content-addressed store remains platform-controlled. A role selects enabled
Organization skills alongside its model, system prompt and tool allowlist.
Each run copies only those selected immutable versions into a run-scoped
plugin outside the project workspace; the sandbox exposes that snapshot
read-only and deletes it after the run. SDK-bundled skills and filesystem
setting sources remain disabled, so project `.claude` content cannot register
skills or widen tools. Requested skill versions are recorded on
`run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `bypassPermissions` is kept (headless server — no interactive prompts); the
+146
View File
@@ -0,0 +1,146 @@
# ADR 0026: Usage Fact Ledger
## Status
Accepted.
## Context
ADR-0022 pins the cost-attribution contract for the platform: token,
provider-reported cost, run count, and duration are attributed by Organization,
Project, Run, model, and Provider Connection; "missing provider cost remains
unknown rather than zero." ADR-0021 scopes usage accounting as operational
reporting, not payment collection (commercial billing stays deferred).
The implementation today stores this as **one scalar per `AgentRun`**:
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
adequate for a single provider-reported model loop, but it cannot represent:
- **External capability consumption** inside a run. PDF→Markdown bundle
conversion, audio/video transcription, OCR and similar media transforms are
not the main agent loop. They run as side effects of a run, may use a
different provider/model, may bill in non-token units (pages, seconds,
invocations), and may report cost through a different channel than the
OpenRouter gateway. Today there is no row to write that cost to — it would
either disappear or silently corrupt the run's single scalar.
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
tool, a gateway-side reroute). The scalar collapses them into one number.
- **Pricebook derivation** after the fact. With only a final USD figure and no
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
cost from a price table when the provider did not report it.
Treating each external call as a nested `AgentRun` was considered and
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
user-visible task boundary. External calls hold none of those. Making them
`AgentRun`s would pollute run counts, admission, lock semantics, and session
continuity, and would still not solve non-token metering.
## Decision
Introduce **`UsageFact`** as the single source of truth for billable
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
append-only `UsageFact` rows; each row records one billable consumption event:
- `kind``model_completion` (the main agent loop) | `external_capability`
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
silently folded into an existing one.
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
because non-token capabilities have none.
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
invocations), coexisting with tokens rather than replacing them.
- `costUsd?` + `costSource``provider_reported` | `pricebook_derived` |
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
provider reported a cost, `costSource = provider_reported` and that value
wins. When only tokens are known, a later pricebook pass may derive
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
`costSource = unknown` and `costUsd` stays null.
- `occurredAt` — when the consumption happened; the pricebook derivation
depends on this, not on `AgentRun.finishedAt`, because an external
capability may complete before the run finishes.
- `capabilityId?` — for `external_capability` facts, the registered capability
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
- `correlationId?` — external request id for reconciliation / idempotency;
not part of the aggregation key.
### Invariants
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
correction is a new row; the old row stays. `onDelete: Cascade` exists only
so a hard run delete (itself not a normal path) cleans up its facts.
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
ledger of one run, not a sub-run. External capability calls obey the same
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
today. A run with at least one `costUsd`-bearing fact contributes its sum.
### Rollup cache
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
as a **derived rollup cache**, not dropped:
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
tests asserting `run.costUsd`) continue to work without code changes for
historical runs.
- On run finish, the writer writes the `UsageFact` row first and then mirrors
it onto `AgentRun` as two separate statements, not one transaction. The
fact is the truth, so it is written first; the cache is derived, so it is
written second. A crash between the two leaves the cache stale, but the
usage service re-reads `UsageFact` directly, so staleness is recoverable
(and the reverse order would lose the truth, which is not). Keeping the
fact insert and the run update in separate statements also avoids holding
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
deadlocks against concurrent workspace teardown under the cascade path
`Organization → Project → AgentRun → UsageFact`.
- The org/project usage service and the slash `/usage` command read from
`UsageFact` directly — they are the canonical aggregation path.
### Migration
A new `UsageFact` table is added. For every existing `AgentRun` with a
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
inserts **one synthetic `model_completion` fact** carrying the run's
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
run id, so the row is identifiable as a backfill artefact. This keeps
historical reporting correct under the new aggregation path. Pre-migration
runs with no recorded cost remain `runsWithoutCost` by design, matching
ADR-0022's "missing cost ≠ zero" rule and the existing migration
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
truly unrecorded.
## Consequences
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
without per-capability schema changes: a new capability is one new
`capabilityId` value and one or more `external_capability` facts.
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
Readers that need the truth (cost breakdown, per-capability attribution)
must read `UsageFact`. The cache MUST be kept consistent on the write path.
- The capability registry, pricebook, and any commercial settlement remain
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
shape and invariants.
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
`(provider, model, occurredAt)` keeps the existing org/project/report
queries bounded.
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
rollup cache must be recomputed. The initial release writes facts once at
run finish and does not support post-hoc correction flows — that remains
`OPEN`.
## Deferred
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
with org-scoped enable/disable, input/output contracts, metering schemas,
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
the `capabilityId` field and the `external_capability` fact kind.
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
with time validity. Required to actually produce `pricebook_derived` costs;
until then, facts without provider-reported cost stay `unknown`.
- **Post-hoc cost correction flow** — append-only today; correction UI and
rollup recompute are `OPEN`.
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
@@ -0,0 +1,152 @@
# ADR 0027: External Capability Registry
## Status
Accepted.
## Context
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
fact and a `capabilityId` field, but deferred the capability registry itself.
Two concrete needs now force the issue:
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
must run as a side effect of an `AgentRun`, bill in non-token units
(pages, seconds), use a different provider than the model loop, and report
cost through a different channel. It is not a sub-run (ADR-0026 rejected
that) and not a model-provider call (it does not speak the Anthropic/
OpenRouter protocol).
- The Agent already has a Bash tool. Without a first-class capability seam,
the path of least resistance is for the agent to shell out to ad-hoc
scripts that embed API keys, write to arbitrary paths, and report nothing
to the ledger. That is exactly the unattributed, uncontained external
consumption ADR-0022/0026 exist to prevent.
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
is the wrong seam for these services:
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
different auth shapes (an API token, optionally a project id) and no
`/v1/models` endpoint.
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
is an OpenRouter-style model-routing id. A capability provider id
(`mineru`) names a *service*, not a model.
- Coupling capability credentials into the model-provider table would force
every capability's auth shape through `ProviderSecretPayloadV1` and every
readiness probe through `probeOpenRouterCredential`.
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
own connection table, its own payload schema, its own readiness probe, and
its own per-org uniqueness. Capability connections follow the same pattern.
## Decision
### External Capability
An **External Capability** is a platform-registered, org-enabled document or
media transform service invoked as a side effect of an `AgentRun`. It is
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
`audio_video_to_text`). A capability:
- Has an **input kind** (PDF, image, audio, video, …) and an **output
contract** (markdown bundle with extracted images, transcript text, …).
- Bills in **non-token units** (pages, audio-seconds) recorded on a
`UsageFact` with `kind = external_capability`, or in tokens when the
backing service reports them.
- Writes its output **into the invoking run's workspace** (ADR-0018
`AgentSurface` — no escapes).
- Is invoked through a **capability adapter** in Hub, never by the Agent
shelling out with embedded credentials.
### Capability Connection
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
structurally identical to the Feishu Application Connection:
- Belongs to exactly one Organization.
- Unique by `(organizationId, capabilityId)`.
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
valid active secret version.
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
machinery with `purpose = "capability"`.
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
the payload, not the connection table.
- A capability-specific **readiness probe** validates the credential before
activation (e.g. MinerU: a trivial authenticated GET). The probe is
injectable, matching the Feishu/provider pattern, so tests never hit the
network.
### Capability Adapter
The adapter is the seam between the Agent and the external service. It:
- Resolves the org's active capability connection (fail-closed, no
process-global fallback — ADR-0024).
- Accepts a workspace-relative input path and an output directory.
- Calls the backing service (MinerU, Whisper, …) via an injectable
`Client` interface so the real HTTP client is swappable and mockable.
- Writes the produced markdown + image assets into the run's workspace.
- Writes one `UsageFact` (or more, if the service reports per-stage
consumption) with `kind = external_capability`, `capabilityId`,
`provider` (the service id), `quantity + unit` (pages / seconds), and
`costUsd + costSource = provider_reported` when the service reports cost.
### Registry
The platform maintains a **registry** of known capabilities: their id,
input kind, output contract, metering unit, and adapter. This is
code-level registration (like `ToolRegistry`), not a database table — a
capability is available to an Organization only when (a) the platform
knows the adapter and (b) the Organization has an `ACTIVE` connection for
it. Both gates are required.
### What is NOT in this ADR
- The capability invocation is **not** a first-class persisted record
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
`capabilityId` + `correlationId` is the durable trace. If we later need
a richer invocation log (retries, partial output, multi-stage status),
that is a follow-up; for now the fact is enough.
- **Pricebook** remains deferred (ADR-0026). Capability facts use
`provider_reported` when the service returns cost; otherwise `unknown`.
- **Org-scoped enable/disable policy** beyond connection status is
deferred. An org with an `ACTIVE` connection has the capability; one
without does not. A finer "enabled but no credential" toggle is not
needed yet.
- **Agent-facing tool exposure** (how the Agent discovers and calls the
capability — MCP tool, Bash wrapper, or built-in) is an implementation
detail of the adapter wiring, not a contract concern. The contract pins
that the Agent never receives the capability credential.
## Consequences
- Adding a new external capability (e.g. `image_ocr`) is: register an
adapter, add a `capabilityId` constant, optionally extend the secret
payload — no schema change to `UsageFact` or `AgentRun`.
- The model-provider connection table stays focused on model routing;
capability credentials do not pollute its payload or readiness probe.
- Three connection types now share the ADR-0024 envelope: model-provider,
Feishu application, and capability. Each has its own table, payload
schema, and probe, but the same encryption, rotation, and resolver
boundary.
- The Agent's Bash tool remains available, but the intended path for
document/media transforms is the capability adapter. Whether to narrow
Bash for capability-shaped tasks is an operational policy decision,
not a contract one.
- Tests prove: workspace containment of capability output, fail-closed
credential resolution, `UsageFact` attribution with non-token metering,
and that the Agent process never receives the capability credential.
## Deferred
- `CapabilityInvocation` as a first-class durable record (status, retries,
partial output) — currently the `UsageFact` row is the only trace.
- Pricebook derivation for capability costs (ADR-0026 deferred).
- Org-scoped capability enable/disable policy finer than connection status.
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
+81
View File
@@ -0,0 +1,81 @@
# ADR 0028: Agent Configuration Folder Tree
## Status
Accepted.
## Context
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
configuration, managed without process restarts. The org admin surfaces for
them (`/admin/roles` and `/admin/skills`) render every
role/skill as one large editor card in a single flat list. As an Organization
accumulates roles and skills, the management pages degrade into an endless
scroll with no grouping affordance.
The project explorer already solves the analogous problem for projects with
transparent folders (ADR-0021): org-scoped navigation nodes that are not
permission resources. Roles and skills need the same affordance, but their
semantics differ from projects in one crucial way: skill names and role IDs
are referenced by role→skill bindings, run-time skill snapshot loading, and
Feishu slash commands. Any grouping mechanism must not leak into those
resolution paths.
## Decision
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
skills:
- One folder tree per Organization is shared by both roles and skills (e.g. a
"高三化学组" folder groups that team's roles and its skills together). It is
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
trees are managed independently and never reference each other.
- Folders are **transparent organization nodes**, following the ADR-0021
project-folder precedent: they exist for management-surface navigation and
grouping only, are not permission resources, and hold no grants.
- Folder membership is **not part of role/skill identity or resolution**:
- skill `name` and role `roleId` remain unique per Organization regardless
of folder membership;
- role→skill bindings, run admission's frozen role snapshot, run-scoped
skill loading, and Feishu slash commands never reference folders.
- Each role/skill sits in at most one folder; membership is optional
(unfiled items remain first-class). Folders nest arbitrarily.
- Folder assignment is a label-class change in the ADR-0017 sense: it never
archives Agent sessions, because the execution surface (model, prompt,
tools, skill content) is untouched.
- A folder can be deleted only when empty — no child folders, no roles, no
skills. Relocating items out of a folder is an explicit user action, so no
orphan-placement rule is needed yet.
- Admin web renders both pages as a left folder tree plus the item list of
the selected folder ("all" and "unfiled" included). The host-console CLI is
unchanged: folder management lives in the web surface, and CLI
`upsert-role`/`install-skill` never touch folder assignment.
## Consequences
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
`parentId`, delete restricted while referenced) plus nullable `folderId` on
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
delete, though the service refuses to delete non-empty folders).
- The spec pins the transparency and single-membership semantics in
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
not re-derive them differently (e.g. path-style names or per-folder
uniqueness).
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
that skip session archival by construction.
- Moving a role/skill between folders changes nothing about authorization,
run resolution, or audit-visible configuration lineage beyond the folder
assignment event itself.
## Open Questions / Deferred
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
per-item select for now.
- Folder-level usage aggregation for roles/skills is deferred (project
folders already aggregate usage under ADR-0021; agent configuration has no
usage dimension yet).
- CLI flags for folder assignment are deferred until a console workflow asks
for them.
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
for now: the Organization keeps exactly one active default role
(ADR-0017/0018 invariant) regardless of folder structure.
+4
View File
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Persistent root for the content-addressed agent skill store. Required at hub
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# This process is pinned to exactly one Organization. Feishu credentials are # This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection. # resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID="" HUB_SILO_ORGANIZATION_ID=""
+203 -4
View File
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
updatedAt: string; updatedAt: string;
} }
export interface CapabilityConnection {
id: string;
capabilityId: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals { export interface UsageTotals {
runCount: number; runCount: number;
runsWithCost: number; runsWithCost: number;
@@ -183,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals {
folderId: string | null; folderId: string | null;
} }
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
export interface UsageBreakdownRow {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
}
export interface UsageReport { export interface UsageReport {
from: string | null; from: string | null;
to: string | null; to: string | null;
projects: ProjectUsageRow[]; projects: ProjectUsageRow[];
totals: UsageTotals; totals: UsageTotals;
breakdown: UsageBreakdownRow[];
} }
export interface ProjectUsageReport extends ProjectUsageRow {
from: string | null;
to: string | null;
breakdown: UsageBreakdownRow[];
}
export interface UsageFactRow {
id: string;
occurredAt: string;
kind: string;
provider: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
quantity: number | null;
unit: string | null;
costUsd: number | null;
costSource: string;
capabilityId: string | null;
correlationId: string | null;
}
export interface SessionRunRow {
id: string;
status: string;
model: string;
provider: string;
inputTokens: number | null;
outputTokens: number | null;
costUsd: number | null;
costSource: string | null;
startedAt: string;
finishedAt: string | null;
error: string | null;
usageFacts: UsageFactRow[];
}
export interface SessionDetail {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
project: { id: string; name: string };
runs: SessionRunRow[];
}
export type CapacityDimension = export type CapacityDimension =
| 'requestRate' | 'requestRate'
| 'requestBodySize' | 'requestBodySize'
@@ -226,6 +304,59 @@ export interface CapacityPolicyView {
dimensions: CapacityDimensionRow[]; dimensions: CapacityDimensionRow[];
} }
export interface AgentRoleRow {
id: string;
roleId: string;
label: string;
defaultModel: string | null;
systemPrompt: string | null;
tools: readonly string[] | null;
sortOrder: number;
isDefault: boolean;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
folderId: string | null;
}
export interface AgentSkillRow {
id: string;
name: string;
version: string;
description: string | null;
contentDigest: string;
disabledAt: string | null;
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 {
path: string;
content: string;
}
export interface InstalledSkillResult {
id: string;
name: string;
contentDigest: string;
}
export interface AgentModelRow {
id: string;
label: string;
toolCapable: boolean;
}
// --- API --- // --- API ---
export const api = { export const api = {
@@ -261,8 +392,7 @@ export const api = {
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`), post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>, explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
myProjects: (slug: string) => myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) => createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{ post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string; id: string;
@@ -300,6 +430,8 @@ export const api = {
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[]; sessions: SessionSummary[];
}>, }>,
session: (slug: string, sessionId: string) =>
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => { usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.from) q.set('from', params.from); if (params?.from) q.set('from', params.from);
@@ -308,6 +440,16 @@ export const api = {
const qs = q.toString(); const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>; return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
}, },
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
const qs = q.toString();
return get(
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
) as Promise<ProjectUsageReport>;
},
providerConnections: (slug: string) => providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>, get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
@@ -338,8 +480,65 @@ export const api = {
disableFeishuApplication: (slug: string) => disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>, del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capacityPolicy: (slug: string) => capabilityConnections: (slug: string) =>
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>, get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
capabilityConnection: (slug: string, capabilityId: string) =>
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
connection: CapabilityConnection | null;
}>,
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) => setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>, put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
upsertAgentRole: (
slug: string,
roleId: string,
body: {
label: string;
defaultModel?: string | null;
systemPrompt?: string | null;
tools?: readonly string[] | null;
sortOrder?: number;
isDefault?: boolean;
},
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
skillNames: string[];
}>,
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
agentSkillFiles: (slug: string, name: string) =>
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
agentConfigFolders: (slug: string) =>
get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>,
createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) =>
post(`${orgBase(slug)}/agent-config-folders`, body) as Promise<AgentConfigFolderRow>,
patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise<AgentConfigFolderRow>,
deleteAgentConfigFolder: (slug: string, folderId: string) =>
del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>,
setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
setAgentSkillFolder: (slug: string, name: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
}; };
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
@@ -8,6 +8,8 @@
folders, folders,
projects, projects,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folder: ExplorerFolder; folder: ExplorerFolder;
folders: ExplorerFolder[]; folders: ExplorerFolder[];
@@ -19,30 +21,58 @@
binding: { chatId: string } | null; binding: { chatId: string } | null;
}[]; }[];
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let open = $state(true); let open = $state(true);
</script> </script>
<div> <div>
<button <div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
type="button" <button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100" <span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
onclick={() => (open = !open)} <span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
> <Icon name="folder" class="h-4 w-4" />
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span> </span>
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800"> <span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
<Icon name="folder" class="h-4 w-4" /> <span class="saas-badge-neutral">{folder.projectCount} 项目</span>
</span> {#if folder.childFolderCount > 0}
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span> <span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span> {/if}
{#if folder.childFolderCount > 0} </button>
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span> {#if onCreateFolder || onCreateProject}
<span
class="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
{#if onCreateFolder}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {folder.name} 内新建子文件夹"
onclick={() => onCreateFolder(folder.id)}
>
<Icon name="folder-plus" class="h-4 w-4" />
</button>
{/if}
{#if onCreateProject}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建项目"
aria-label="在 {folder.name} 内新建项目"
onclick={() => onCreateProject(folder.id)}
>
<Icon name="file-plus" class="h-4 w-4" />
</button>
{/if}
</span>
{/if} {/if}
</button> </div>
{#if open} {#if open}
<div class="ml-4 border-l border-surface-300 pl-2"> <div class="ml-4 border-l border-surface-300 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} /> <FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -9,6 +9,8 @@
projects, projects,
parentId, parentId,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folders: ExplorerFolder[]; folders: ExplorerFolder[];
projects: { projects: {
@@ -20,6 +22,8 @@
}[]; }[];
parentId: string | null; parentId: string | null;
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId)); let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
@@ -29,7 +33,7 @@
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each childProjects as p (p.id)} {#each childProjects as p (p.id)}
<a <a
href={`/admin/org/${slug}/projects/${p.id}`} href={`/admin/projects/${p.id}`}
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100" class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
> >
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700"> <span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
@@ -44,6 +48,6 @@
{/each} {/each}
{#each childFolders as f (f.id)} {#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} /> <FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
{/each} {/each}
</div> </div>
+33 -1
View File
@@ -15,9 +15,13 @@
| 'logout' | 'logout'
| 'org' | 'org'
| 'chevron' | 'chevron'
| 'arrow-left'
| 'folder' | 'folder'
| 'file' | 'file'
| 'check'; | 'folder-plus'
| 'file-plus'
| 'check'
| 'roles';
class?: string; class?: string;
} = $props(); } = $props();
</script> </script>
@@ -100,6 +104,10 @@
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
{:else if name === 'arrow-left'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
{:else if name === 'folder'} {:else if name === 'folder'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path <path
@@ -116,8 +124,32 @@
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/> />
</svg> </svg>
{:else if name === 'folder-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
{:else if name === 'file-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'} {:else if name === 'check'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
{:else if name === 'roles'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.847-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.847.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 6l1.035-.259a3.375 3.375 0 002.456-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
{/if} {/if}
@@ -0,0 +1,251 @@
<script lang="ts">
import { Checkbox, Label } from 'bits-ui';
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
r,
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 = {
label: r.label,
defaultModel: r.defaultModel ?? '',
systemPrompt: r.systemPrompt ?? '',
unrestricted: r.tools === null,
tools: r.tools ?? [],
sortOrder: String(r.sortOrder),
isDefault: r.isDefault,
skillNames: r.skillNames,
};
let label = $state(initial.label);
let defaultModel = $state(initial.defaultModel);
let systemPrompt = $state(initial.systemPrompt);
let unrestricted = $state(initial.unrestricted);
let selectedTools = $state<string[]>([...initial.tools]);
let sortOrder = $state(initial.sortOrder);
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) => {
(acc[t.group] ??= []).push(t);
return acc;
},
{} 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 skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
function skillsDirty(): boolean {
const a = [...selectedSkills].sort();
const b = [...r.skillNames].sort();
return a.length !== b.length || a.some((v, i) => v !== b[i]);
}
async function save() {
const trimmedLabel = label.trim();
if (trimmedLabel === '') {
toastError('显示名不能为空');
return;
}
const order = Number(sortOrder);
if (sortKeyDirty() && (!Number.isSafeInteger(order) || order < 0)) {
toastError('排序必须为非负整数');
return;
}
saving = true;
const tools = unrestricted ? null : selectedTools;
try {
const updated = await api.upsertAgentRole(slug, r.roleId, {
label: trimmedLabel,
defaultModel: defaultModel === '' ? null : defaultModel,
systemPrompt: systemPrompt === '' ? null : systemPrompt,
tools,
...(sortKeyDirty() ? { sortOrder: order } : {}),
isDefault,
});
onupdated(updated);
if (skillsDirty()) {
const res = await api.setAgentRoleSkills(slug, r.roleId, selectedSkills);
selectedSkills = [...res.skillNames];
onskillschanged(r.roleId, res.skillNames);
}
toastSuccess('角色已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
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">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">/{r.roleId}</span>
<span class="text-sm text-surface-700">{label || r.label}</span>
{#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">
<div>
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
</div>
<div>
<Label.Root for="role-sort-{r.id}" class="saas-label">排序</Label.Root>
<input id="role-sort-{r.id}" class="saas-input" type="number" min="0" bind:value={sortOrder} />
</div>
</div>
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div>
<div class="mt-4">
<span class="saas-label">工具白名单</span>
<label class="mb-3 flex cursor-pointer items-center gap-2 border border-surface-300 bg-surface-100 px-3 py-2">
<CheckboxControl bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
{#each Object.entries(groupedTools) as [group, tools]}
<div>
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-600">{group}</p>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each tools as t}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
{#snippet children({ checked })}
{#if checked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
<span>{t.label}</span>
</label>
{/each}
</div>
</div>
{/each}
</Checkbox.Group>
</div>
</div>
<div class="mt-4">
<span class="saas-label">技能绑定</span>
{#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
{:else}
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillItems as s}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.value)}
onchange={(checked) => {
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
}}
/>
<span class="font-mono text-xs">{s.label}</span>
</label>
{/each}
</div>
{/if}
</div>
<div class="mt-4">
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
bind:value={systemPrompt}></textarea>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<label class="flex cursor-pointer items-center gap-2">
<CheckboxControl bind:checked={isDefault} />
<span class="text-sm">设为组织默认角色</span>
</label>
<span class="text-xs text-surface-600">更新于 {fmtDate(r.updatedAt)}</span>
<div class="flex-1"></div>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
@@ -0,0 +1,337 @@
<script lang="ts">
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
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 };
let files = $state<FileNode[]>([]);
let selectedPath = $state<string | null>(null);
let version = $state(skill.version);
let description = $state(skill.description ?? '');
let loading = $state(false);
let saving = $state(false);
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'));
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
async function loadFiles() {
loading = true;
try {
const res = await api.agentSkillFiles(slug, skill.name);
files = res.files.map((f) => ({ path: f.path, content: f.content }));
if (files.length > 0 && selectedPath === null) {
selectedPath = files[0]!.path;
}
dirty = false;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loading = false;
}
}
function selectFile(path: string) {
selectedPath = path;
}
function updateContent(path: string, content: string) {
const file = files.find((f) => f.path === path);
if (file) {
file.content = content;
dirty = true;
if (path === 'SKILL.md') {
const desc = parseDescription(content);
if (desc !== null) description = desc;
}
}
}
function addFile() {
const path = newFilePath.trim();
if (path === '') {
toastError('文件路径不能为空');
return;
}
if (files.some((f) => f.path === path)) {
toastError(`文件已存在:${path}`);
return;
}
if (path.startsWith('/') || path.includes('..')) {
toastError('文件路径必须为相对路径');
return;
}
files.push({ path, content: '' });
selectedPath = path;
newFilePath = '';
showNewFile = false;
dirty = true;
}
function deleteFile(path: string) {
if (path === 'SKILL.md') {
toastError('SKILL.md 是必需的 manifest,不能删除');
return;
}
files = files.filter((f) => f.path !== path);
if (selectedPath === path) {
selectedPath = files.length > 0 ? files[0]!.path : null;
}
dirty = true;
}
function parseDescription(manifest: string): string | null {
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
return match ? match[1]!.trim() : null;
}
async function save() {
if (!hasManifest) {
toastError('缺少 SKILL.md manifest 文件');
return;
}
const trimmedVersion = version.trim();
if (trimmedVersion === '') {
toastError('版本号不能为空');
return;
}
saving = true;
try {
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
const result = await api.installAgentSkill(slug, skill.name, {
version: trimmedVersion,
files: fileEntries,
});
dirty = false;
toastSuccess('技能已保存');
oninstalled(result);
await loadFiles();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
saving = true;
try {
await api.patchAgentSkill(slug, skill.name, { disabled: true });
toastSuccess('技能已禁用');
ondisabled(skill.name);
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
function saveDescription() {
const manifest = files.find((f) => f.path === 'SKILL.md');
if (!manifest) return;
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
manifest.content = updated;
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)) {
return content.replace(regex, `${key}: ${value}`);
}
const lines = content.split('\n');
if (lines[0] === '---') {
lines.splice(1, 0, `${key}: ${value}`);
} else {
lines.unshift('---', `${key}: ${value}`, '---');
}
return lines.join('\n');
}
$effect(() => {
if (skill.name) loadFiles();
});
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">{skill.name}</span>
<span class="text-sm text-surface-700">v{skill.version}</span>
{#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">
<div>
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
</div>
<div>
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter</label>
<div class="flex gap-2">
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
</div>
</div>
</div>
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
<span>·</span>
<span>更新于 {fmtDate(skill.updatedAt)}</span>
{#if skill.boundRoleIds.length > 0}
<span>·</span>
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
{/if}
{#if dirty}
<span>·</span>
<span class="font-medium text-warning-700">未保存</span>
{/if}
</div>
{#if loading}
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
<div class="border border-surface-300 bg-surface-100">
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
<span class="text-xs font-medium text-surface-700">文件</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => (showNewFile = !showNewFile)}
>
{showNewFile ? '取消' : '+ 新增'}
</button>
</div>
{#if showNewFile}
<div class="border-b border-surface-300 px-3 py-2">
<input
class="saas-input text-xs"
placeholder="如 reference.md"
bind:value={newFilePath}
onkeydown={(e) => {
if (e.key === 'Enter') addFile();
}}
/>
</div>
{/if}
<div class="max-h-80 overflow-y-auto">
{#each sortedFiles as f (f.path)}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
onclick={() => selectFile(f.path)}
>
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="truncate font-mono text-xs">{f.path}</span>
{#if f.path === 'SKILL.md'}
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
{:else}
<span
class="ml-auto text-xs text-surface-500 hover:text-error-700"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
deleteFile(f.path);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
deleteFile(f.path);
}
}}
>
×
</span>
{/if}
</button>
{/each}
{#if files.length === 0}
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
{/if}
</div>
</div>
<div>
{#if selectedFile}
<div class="mb-2 flex items-center gap-2">
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
</div>
<textarea
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
bind:value={selectedFile.content}
oninput={() => (dirty = true)}
></textarea>
{:else}
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
选择一个文件或新建文件
</div>
{/if}
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
{saving ? '保存中…' : '保存'}
</button>
{#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用
</button>
{/if}
</div>
{/if}
</div>
+2
View File
@@ -10,6 +10,8 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'list_files', label: '列目录', group: '文件' }, { id: 'list_files', label: '列目录', group: '文件' },
{ id: 'search_files', label: '搜索', group: '文件' }, { id: 'search_files', label: '搜索', group: '文件' },
{ id: 'bash', label: 'Bash 命令', group: 'Shell' }, { id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', group: '网络' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' }, { id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' }, { id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, { id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
+46
View File
@@ -29,6 +29,52 @@ export function fmtNum(n: number): string {
return n.toLocaleString(); return n.toLocaleString();
} }
const USAGE_KIND_LABELS: Record<string, string> = {
model_completion: '模型完成',
external_capability: '外部能力',
tool_proxy: '工具代理',
};
const METER_UNIT_LABELS: Record<string, string> = {
pages: '页',
audio_seconds: '音频秒',
invocations: '次调用',
};
export function usageKindLabel(kind: string): string {
return USAGE_KIND_LABELS[kind] ?? kind;
}
export function meterUnitLabel(unit: string | null | undefined): string {
if (!unit) return '—';
return METER_UNIT_LABELS[unit] ?? unit;
}
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
if (quantity === null || quantity === undefined) return '—';
const u = meterUnitLabel(unit);
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
}
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
const hasIn = input !== null && input !== undefined;
const hasOut = output !== null && output !== undefined;
if (!hasIn && !hasOut) return '—';
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
}
export function runStatusLabel(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return '完成';
if (key === 'FAILED') return '失败';
if (key === 'CANCELED') return '取消';
if (key === 'TIMED_OUT') return '超时';
if (key === 'RUNNING') return '运行中';
if (key === 'QUEUED') return '排队';
return status;
}
export function orgRoleLabel(role: string): string { export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole; const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role; return ORG_ROLE_LABELS[key] ?? role;
+40
View File
@@ -0,0 +1,40 @@
import type { MeResponse, OrgMembership } from './api';
/** Alpha Silo host prefix: <slug>.educraft[.dev].… */
export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null {
const host = hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
return m?.[1] ?? null;
}
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
/**
* Resolve the tenancy for this browser session.
* Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership.
*/
export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null {
if (!me || me.organizations.length === 0) return null;
const host = hostOrgSlug();
if (host) {
const byHost = me.organizations.find((o) => o.slug === host);
if (byHost) return byHost;
}
const q = new URLSearchParams(search).get('org')?.trim();
if (q) {
const byQuery = me.organizations.find((o) => o.slug === q);
if (byQuery) return byQuery;
}
const admin = me.organizations.find((o) => isOrgAdmin(o));
return admin ?? me.organizations[0] ?? null;
}
/** SPA paths no longer embed org slug (subdomain carries tenancy). */
export function adminPath(rest: string = ''): string {
const cleaned = rest.replace(/^\/+/, '');
return cleaned === '' ? '/admin' : `/admin/${cleaned}`;
}
+33 -2
View File
@@ -32,9 +32,40 @@ export async function loadSession(): Promise<void> {
} }
} }
/**
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
*/
export function resolveLoginOrgSlug(): string | null {
const q = new URLSearchParams(window.location.search).get('org');
if (q && q.trim() !== '') return q.trim();
// Alpha Silo public host: <slug>.educraft.paradigm-edu.net (or educraft-dev)
const host = window.location.hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
if (m?.[1]) return m[1];
// Legacy path while old bookmarks still land briefly before redirect.
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
return null;
}
export function redirectToLogin(): void { export function redirectToLogin(): void {
const ret = encodeURIComponent(window.location.pathname + window.location.hash); if (window.location.pathname === '/admin/login') return;
window.location.href = `/auth/feishu?returnTo=${ret}`; const ret = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
const slug = resolveLoginOrgSlug();
if (slug === null) {
// Need an org slug for scoped OAuth — backend login helper can prompt.
window.location.href = `/admin/login?returnTo=${ret}`;
return;
}
window.location.href = `/auth/feishu/${encodeURIComponent(slug)}?returnTo=${ret}`;
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
+55 -80
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session'; import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api'; import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format'; import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte'; import ToastHost from '$lib/components/ToastHost.svelte';
@@ -20,74 +21,61 @@
const navItems = [ const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const }, { key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const }, { key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const }, { key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const }, { key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const }, { key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const }, { key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
]; ];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] { function memberships(): OrgMembership[] {
return $session.me?.organizations ?? []; return $session.me?.organizations ?? [];
} }
function adminOrgs(): OrgMembership[] { function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin); return memberships().filter((o) => isOrgAdmin(o));
} }
function currentOrg(): OrgMembership | null { function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath(); return resolveOrg($session.me, page.url.search);
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
} }
function pickHomeOrg(): OrgMembership | null { function isOnProjectRoute(): boolean {
const admin = adminOrgs()[0]; const parts = page.url.pathname.split('/').filter(Boolean);
if (admin) return admin; // /admin/projects or /admin/projects/:id
return memberships()[0] ?? null; return parts[0] === 'admin' && parts[1] === 'projects';
} }
function activeKey(): string { function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean); const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return ''; if (parts[0] !== 'admin') return '';
return parts[3] ?? 'overview'; // /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
} }
function navHref(key: string): string { function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug; if (key === 'overview') return adminPath();
if (!slug) return '/'; return adminPath(key);
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
} }
function pageTitle(): string { function pageTitle(): string {
const key = activeKey(); const key = activeKey();
if (key === 'overview' || key === '') return '概览'; if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台'; return navItems.find((i) => i.key === key)?.label ?? '管理后台';
} }
function switchOrg(nextSlug: string) { function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return; if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`); // Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
const url = new URL(page.url.href);
url.searchParams.set('org', nextSlug);
void goto(`${url.pathname}${url.search}`, { replaceState: true });
} }
function handleLogout(e: Event) { function handleLogout(e: Event) {
@@ -110,33 +98,23 @@
$effect(() => { $effect(() => {
if ($session.loading || !$session.me) return; if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath(); const path = page.url.pathname;
const matched = slug ? memberships().find((o) => o.slug === slug) : null; // Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
// Org admins: route to their first admin org if none matched as admin. const org = currentOrg();
const admins = adminOrgs(); const admins = adminOrgs();
if (matched && isAdmin(matched)) {
if (org && isOrgAdmin(org)) {
redirecting = false; redirecting = false;
return; return;
} }
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders; // Member only: allow project routes; bounce admin-only surfaces to projects.
// the org overview and other admin-only surfaces are not for them. if (org && !isOrgAdmin(org)) {
if (matched && !isAdmin(matched)) {
redirecting = false; redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean); if (!isOnProjectRoute()) {
const onOverview = parts.length === 3; // /admin/org/:slug const target = adminPath('projects');
if (onOverview) { if (path !== target) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
redirecting = true; redirecting = true;
void goto(target, { replaceState: true }); void goto(target, { replaceState: true });
} }
@@ -144,12 +122,11 @@
return; return;
} }
// No matched org and no admin orgs: route a member to their first org's // No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
// projects page so they can reach project MANAGE surfaces. if (!org && memberships().length > 0) {
if (!matched && memberships().length > 0) { const home = admins[0] ?? memberships()[0]!;
const home = memberships()[0]; const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
const target = `/admin/org/${home.slug}/projects`; if (path !== target && !path.startsWith(`${target}/`)) {
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true; redirecting = true;
void goto(target, { replaceState: true }); void goto(target, { replaceState: true });
} }
@@ -170,14 +147,14 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 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> ></path>
</svg> </svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p> <p class="text-sm">加载中…</p>
</div> </div>
</div> </div>
{:else if $session.error} {:else if $session.error}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<div <div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold" class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
> >
! !
</div> </div>
@@ -190,7 +167,7 @@
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<div <div
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold" class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
> >
CPH CPH
</div> </div>
@@ -199,7 +176,7 @@
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button> <button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div> </div>
</div> </div>
{:else if currentOrg() && isAdmin(currentOrg()!)} {:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!} {@const org = currentOrg()!}
{@const me = $session.me!} {@const me = $session.me!}
<div class="saas-shell"> <div class="saas-shell">
@@ -224,17 +201,19 @@
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div> <div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div> <div class="truncate text-xs text-surface-600">{org.name}</div>
</div> </div>
</div> </div>
<div class="px-3 pb-3"> {#if me.organizations.length > 1}
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700"> <div class="px-3 pb-3">
<Icon name="org" class="h-3.5 w-3.5" /> <div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
组织 <Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div> </div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} /> {/if}
</div>
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p> <p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
@@ -304,17 +283,13 @@
</main> </main>
</div> </div>
</div> </div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()} {:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!} {@const org = currentOrg()!}
{@const me = $session.me!} {@const me = $session.me!}
<div class="saas-shell"> <div class="saas-shell">
<div class="saas-main"> <div class="saas-main">
<header class="saas-topbar"> <header class="saas-topbar">
<a <a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
href={`/admin/org/${org.slug}/projects`}
class="saas-btn-ghost px-2!"
aria-label="返回项目列表"
>
<Icon name="menu" class="h-5 w-5" /> <Icon name="menu" class="h-5 w-5" />
</a> </a>
<div class="min-w-0"> <div class="min-w-0">
@@ -343,7 +318,7 @@
</main> </main>
</div> </div>
</div> </div>
{:else if currentOrg() && !isAdmin(currentOrg()!)} {:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!} {@const denied = currentOrg()!}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
@@ -352,7 +327,7 @@
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是 组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。 <span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p> </p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a> <a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1} {#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p> <p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4"> <div class="mb-4">
@@ -363,14 +338,14 @@
</div> </div>
</div> </div>
{:else if memberships().length > 0} {:else if memberships().length > 0}
{@const denied = pickHomeOrg()!} {@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2> <h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700"> <p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。 即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p> </p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a> <a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button> <button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@
return r === 'OWNER' || r === 'ADMIN'; return r === 'OWNER' || r === 'ADMIN';
}); });
const target = admin ?? me.organizations[0]; const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null; return target ? `/admin` : null;
} }
onMount(() => { onMount(() => {
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api'; import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format'; import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte'; import StatCard from '$lib/components/StatCard.svelte';
@@ -10,7 +11,8 @@
import SwitchControl from '$lib/components/SwitchControl.svelte'; import SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? ''); const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
let orgSlug = $derived(orgFromSession?.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined); let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null); let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
@@ -94,19 +96,56 @@
<div class="mb-4 flex items-end justify-between gap-3"> <div class="mb-4 flex items-end justify-between gap-3">
<div> <div>
<h2 class="saas-section-title">用量概览</h2> <h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p> <p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div> </div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</div> </div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} /> <StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} /> <StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} /> <StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} /> <StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} /> <StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} /> <StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div> </div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="saas-card overflow-hidden"> <div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3"> <div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3> <h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
@@ -129,7 +168,11 @@
<tbody> <tbody>
{#each usage.projects as p} {#each usage.projects as p}
<tr> <tr>
<td class="font-medium">{p.projectName}</td> <td class="font-medium">
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
{p.projectName}
</a>
</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td> <td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td> <td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td> <td class="tabular-nums">{fmtCost(p.costUsd)}</td>
@@ -0,0 +1,205 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string) {
editingCap = capId;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api'; import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.). // Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = { const DIMENSION_LABELS: Record<CapacityDimension, string> = {
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api'; import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui'; import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null); let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true); let loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api'; import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants'; import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -10,7 +12,8 @@
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] })); const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / '); const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
@@ -0,0 +1,23 @@
<script lang="ts">
/**
* Legacy bookmarks: /admin/org/:slug[/...] → /admin[/...]
* Tenancy lives on the silo host, not the path.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const raw = page.params.path ?? '';
const segments = raw.split('/').filter(Boolean);
// Drop the old org slug (first segment) when present.
const rest = segments.length > 0 ? segments.slice(1).join('/') : '';
const target = rest === '' ? '/admin' : `/admin/${rest}`;
const q = page.url.search;
void goto(`${target}${q}`, { replaceState: true });
});
</script>
<div class="saas-status-panel">
<p class="text-sm text-surface-600">正在重定向到新地址…</p>
</div>
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api'; import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte'; import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
@@ -13,10 +14,8 @@
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const org = $derived( const slug = $derived(org?.slug ?? '');
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
);
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN')); const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null); let data = $state<ExplorerData | null>(null);
@@ -32,6 +31,18 @@
let projectName = $state(''); let projectName = $state('');
let projectFolder = $state(''); let projectFolder = $state('');
function openFolderModal(parentId: string) {
folderName = '';
folderParent = parentId;
showFolderModal = true;
}
function openProjectModal(folderId: string) {
projectName = '';
projectFolder = folderId;
showProjectModal = true;
}
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
@@ -77,7 +88,7 @@
projectName = ''; projectName = '';
projectFolder = ''; projectFolder = '';
showProjectModal = false; showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`; window.location.href = `/admin/projects/${res.id}`;
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); toastError(err instanceof Error ? err.message : String(err));
} }
@@ -113,8 +124,8 @@
{:else if isAdmin && data} {:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。"> <PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()} {#snippet actions()}
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button> <button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button> <button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
{/snippet} {/snippet}
</PageHeader> </PageHeader>
@@ -122,7 +133,14 @@
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0} {#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" /> <EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else} {:else}
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} /> <FolderTree
folders={data.folders}
projects={data.projects}
parentId={null}
{slug}
onCreateFolder={openFolderModal}
onCreateProject={openProjectModal}
/>
{/if} {/if}
</div> </div>
@@ -190,7 +208,7 @@
</thead> </thead>
<tbody> <tbody>
{#each myProjects as p} {#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}> <tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td> <td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td> <td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td> <td class="text-surface-700">{fmtDate(p.createdAt)}</td>
@@ -7,17 +7,30 @@
type TeamRow, type TeamRow,
type SessionSummary, type SessionSummary,
type ExplorerData, type ExplorerData,
type ProjectUsageReport,
} from '$lib/api'; } from '$lib/api';
import { fmtDate, permissionRoleLabel } from '$lib/format'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
permissionRoleLabel,
usageKindLabel,
} from '$lib/format';
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants'; import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const projectId = $derived(page.params.projectId ?? ''); const projectId = $derived(page.params.projectId ?? '');
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] })); const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`; const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
@@ -25,6 +38,7 @@
let proj = $state<ProjectDetail | null>(null); let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]); let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]); let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]); let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null); let explorer = $state<ExplorerData | null>(null);
let loading = $state(true); let loading = $state(true);
@@ -48,14 +62,18 @@
// Team list is needed for grant UI whenever the actor has project MANAGE // Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only. // (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true; const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([ const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }), p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }), needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null), p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
p.actorIsOrgAdmin
? api.projectUsage(slug, projectId)
: Promise.resolve(null as ProjectUsageReport | null),
]); ]);
sessions = s.sessions; sessions = s.sessions;
teams = t.teams; teams = t.teams;
explorer = e; explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) { if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? ''; moveFolder = p.folderId ?? '';
} }
@@ -94,7 +112,7 @@
if (!confirm(`归档项目 ${proj?.name}?`)) return; if (!confirm(`归档项目 ${proj?.name}?`)) return;
try { try {
await api.archiveProject(slug, projectId); await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`; window.location.href = `/admin/projects`;
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); toastError(err instanceof Error ? err.message : String(err));
} }
@@ -155,10 +173,11 @@
{:else if proj} {:else if proj}
<div class="mb-2"> <div class="mb-2">
<a <a
href={`/admin/org/${slug}/projects`} href={`/admin/projects`}
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600" class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
> >
← 返回项目列表 <Icon name="arrow-left" class="h-4 w-4" />
返回项目列表
</a> </a>
</div> </div>
@@ -267,9 +286,67 @@
</div> </div>
{#if actorIsOrgAdmin} {#if actorIsOrgAdmin}
{#if projectUsage}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">项目用量分账</h3>
<p class="saas-muted mt-0.5 text-xs">
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
</div>
{#if projectUsage.breakdown.length === 0}
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each projectUsage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums text-xs">
{#if row.unit}
{fmtQuantity(row.quantity, row.unit)}
{:else}
{fmtTokens(row.inputTokens, row.outputTokens)}
{/if}
</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
<div class="saas-card overflow-hidden"> <div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3"> <div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3> <h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div> </div>
{#if sessions.length === 0} {#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" /> <EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
@@ -281,6 +358,7 @@
<th>模型</th> <th>模型</th>
<th>运行次数</th> <th>运行次数</th>
<th>更新</th> <th>更新</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -290,6 +368,14 @@
<td class="font-mono text-xs">{s.model}</td> <td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td> <td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td> <td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
<td class="text-right">
<a
class="text-sm text-primary-700 hover:underline"
href={`/admin/sessions/${s.id}`}
>
详情
</a>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api'; import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } from '$lib/format'; import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui'; import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]); let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -0,0 +1,327 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
roles = r.roles;
skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const r of roles) {
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
const visibleRoles = $derived(
selectedFolder === 'all'
? roles
: selectedFolder === 'unfiled'
? roles.filter((r) => !r.folderId)
: roles.filter((r) => r.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
let folderId: string | null = null;
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
function onRoleFolderChanged(roleId: string, folderId: string | null) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={roles.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleRoles as r (r.roleId)}
<RoleCard
{r}
{models}
{skills}
{slug}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -0,0 +1,238 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
runStatusLabel,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Icon from '$lib/components/Icon.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const sessionId = $derived(page.params.sessionId ?? '');
let detail = $state<SessionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let expandedRunId = $state<string | null>(null);
async function load() {
if (!slug || !sessionId) return;
loading = true;
error = null;
try {
detail = await api.session(slug, sessionId);
if (detail.runs.length > 0) {
expandedRunId = detail.runs[0]!.id;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function statusClass(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return 'saas-badge-success';
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
return 'saas-badge-primary';
}
function factMeter(f: UsageFactRow): string {
if (f.unit) return fmtQuantity(f.quantity, f.unit);
if (f.inputTokens !== null || f.outputTokens !== null) {
return fmtTokens(f.inputTokens, f.outputTokens);
}
return '—';
}
function factSource(f: UsageFactRow): string {
if (f.capabilityId) return f.capabilityId;
if (f.model) return f.model;
return '—';
}
function runCostHint(run: SessionRunRow): string {
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
if (f.costUsd === null) return acc;
return (acc ?? 0) + f.costUsd;
}, null);
const cache = run.costUsd;
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
}
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
return '成本未知';
}
function toggleRun(id: string) {
expandedRunId = expandedRunId === id ? null : id;
}
$effect(() => {
if (slug && sessionId) void load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if detail}
<div class="mb-2">
<a
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
href={`/admin/projects/${detail.project.id}`}
>
<Icon name="arrow-left" class="h-4 w-4" />
返回项目 {detail.project.name}
</a>
</div>
<PageHeader
title={detail.title?.trim() || '未命名会话'}
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
/>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-surface-600">会话 ID</dt>
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
</div>
<div>
<dt class="text-surface-600">项目</dt>
<dd class="mt-0.5">
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
{detail.project.name}
</a>
</dd>
</div>
<div>
<dt class="text-surface-600">创建 / 更新</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
</div>
{#if detail.archivedAt}
<div>
<dt class="text-surface-600">已归档</dt>
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
</div>
{/if}
<div>
<dt class="text-surface-600">运行数</dt>
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
</div>
</dl>
</div>
<div class="mb-3">
<h2 class="saas-section-title">运行与计费事实</h2>
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
</div>
{#if detail.runs.length === 0}
<div class="saas-card">
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
</div>
{:else}
<div class="space-y-3">
{#each detail.runs as run (run.id)}
{@const open = expandedRunId === run.id}
<div class="saas-card overflow-hidden">
<button
type="button"
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
onclick={() => toggleRun(run.id)}
>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
</div>
<div class="text-xs text-surface-700">
{fmtDate(run.startedAt)}
{#if run.finishedAt}
{fmtDate(run.finishedAt)}
{/if}
</div>
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
{#if run.error}
<div class="text-xs text-error-700">{run.error}</div>
{/if}
</div>
<div class="shrink-0 text-right text-sm">
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
</div>
</button>
{#if open}
<div class="border-t border-surface-200">
{#if run.usageFacts.length === 0}
<div class="px-5 py-4">
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>计量</th>
<th>成本</th>
<th>来源</th>
<th>关联 ID</th>
</tr>
</thead>
<tbody>
{#each run.usageFacts as fact}
<tr>
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
<td>
<span
class={fact.kind === 'external_capability'
? 'saas-badge-primary'
: 'saas-badge-success'}
>
{usageKindLabel(fact.kind)}
</span>
</td>
<td class="font-mono text-xs">{fact.provider}</td>
<td class="font-mono text-xs">{factSource(fact)}</td>
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
{fact.correlationId ?? '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,335 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
skills = s.skills;
folders = f.folders;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const s of skills) {
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
const visibleSkills = $derived(
selectedFolder === 'all'
? skills
: selectedFolder === 'unfiled'
? skills.filter((s) => !s.folderId)
: skills.filter((s) => s.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
toastError('技能名称不能为空');
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
return;
}
const version = newSkillVersion.trim();
if (version === '') {
toastError('版本号不能为空');
return;
}
creating = true;
try {
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
showNewSkill = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
creating = false;
}
}
function buildManifest(name: string, description: string): string {
const desc = description === '' ? name : description;
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
function onDisabled(_name: string) {
load();
}
function onSkillFolderChanged(name: string, folderId: string | null) {
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
</div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
</p>
{/if}
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleSkills as skill (skill.id)}
<SkillEditor
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -2,6 +2,8 @@
import { Collapsible } from 'bits-ui'; import { Collapsible } from 'bits-ui';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api'; import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,7 +11,8 @@
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let teams = $state<TeamRow[]>([]); let teams = $state<TeamRow[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -0,0 +1,241 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDateOnly,
fmtNum,
fmtQuantity,
fmtTokens,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let usage = $state<UsageReport | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let from = $state('');
let to = $state('');
function toIsoStart(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T00:00:00`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
function toIsoEnd(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T23:59:59.999`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
async function load() {
if (!slug) return;
loading = true;
error = null;
try {
usage = await api.usage(slug, {
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function clearRange() {
from = '';
to = '';
void load();
}
function sourceLabel(row: UsageBreakdownRow): string {
if (row.capabilityId) return row.capabilityId;
if (row.model) return row.model;
return '—';
}
function meterCell(row: UsageBreakdownRow): string {
if (row.unit) return fmtQuantity(row.quantity, row.unit);
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
return '—';
}
function meterHint(row: UsageBreakdownRow): string {
if (row.unit) return '非 token 计量';
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
return '无计量';
}
$effect(() => {
if (slug) void load();
});
</script>
{#if loading && !usage}
<LoadingState />
{:else if error && !usage}
<ErrorBanner message={error} onretry={load} />
{:else if usage}
<PageHeader
title="用量报告"
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
/>
<div class="saas-card-pad mb-6">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="saas-label" for="usage-from"></label>
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
</div>
<div>
<label class="saas-label" for="usage-to"></label>
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
</div>
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
{loading ? '加载中…' : '应用筛选'}
</button>
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
清除
</button>
{#if usage.from || usage.to}
<p class="saas-muted grow text-right text-xs">
窗口:
{usage.from ? fmtDateOnly(usage.from) : '—'}
{usage.to ? fmtDateOnly(usage.to) : '—'}
</p>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-error-700">{error}</p>
{/if}
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard
label="有成本 / 无成本"
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
hint="无成本 = 成本未知不是 $0"
/>
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
<StatCard
label="分账条目"
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
/>
</div>
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
<p class="saas-muted mt-0.5 text-xs">
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
</p>
</div>
{#if usage.breakdown.length === 0}
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>有成本 / 未知</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability'
? 'saas-badge-primary'
: row.kind === 'model_completion'
? 'saas-badge-success'
: 'saas-badge-primary'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{sourceLabel(row)}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">
<div>{meterCell(row)}</div>
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
</td>
<td class="tabular-nums text-surface-700">
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
</td>
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
</div>
{#if usage.projects.length === 0}
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>有成本 / 未知</th>
<th>in / out tokens</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-700">
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
</td>
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
<td class="text-right">
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
查看项目
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
+24 -6
View File
@@ -406,7 +406,8 @@
resize: vertical; resize: vertical;
} }
.saas-select-trigger { .saas-select-trigger,
.saas-combobox-input {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
@@ -425,14 +426,25 @@
text-align: left; 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:focus-visible,
.saas-select-trigger[data-state='open'] { .saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600); border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600); box-shadow: inset 0 0 0 1px var(--color-primary-600);
} }
.saas-select-trigger:disabled, .saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] { .saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
@@ -443,9 +455,15 @@
.saas-select-content { .saas-select-content {
z-index: 70; z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem)); max-height: min(
width: var(--bits-select-anchor-width); 18rem,
min-width: var(--bits-select-anchor-width); var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden; overflow: hidden;
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-surface-400); border: 1px solid var(--color-surface-400);
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://127.0.0.1:8788', '/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788', '/auth': 'http://127.0.0.1:8788',
// Backend owns /admin/login (registered before the SPA fallback in
// src/admin/static.ts). Proxy it in dev so the SPA doesn't re-render
// its layout on that path and 401-redirect into a returnTo loop.
'/admin/login': 'http://127.0.0.1:8788',
}, },
}, },
}); });
+1 -1
View File
@@ -95,7 +95,7 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0 exit 0
fi fi
cd "$HUB_DIR" cd "$HUB_DIR"
npm ci PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web npm ci --prefix admin-web
npm run audit:production npm run audit:production
npm run build npm run build
+1 -1
View File
@@ -63,7 +63,7 @@ if [ "$release_ready" = false ]; then
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete. # 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. # `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && 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 run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
+366 -110
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,12 +1,15 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.26", "version": "0.0.35",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
}, },
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.70.0", "@larksuiteoapi/node-sdk": "^1.70.0",
@@ -0,0 +1,64 @@
-- ADR-0026: append-only UsageFact ledger. AgentRun.costUsd/inputTokens/
-- outputTokens become a derived rollup cache; the truth is in UsageFact.
CREATE TABLE "UsageFact" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"kind" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"quantity" DECIMAL(18, 6),
"unit" TEXT,
"costUsd" DECIMAL(18, 8),
"costSource" TEXT NOT NULL,
"capabilityId" TEXT,
"correlationId" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UsageFact_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "UsageFact_runId_occurredAt_idx" ON "UsageFact"("runId", "occurredAt");
CREATE INDEX "UsageFact_runId_kind_idx" ON "UsageFact"("runId", "kind");
CREATE INDEX "UsageFact_provider_model_occurredAt_idx"
ON "UsageFact"("provider", "model", "occurredAt");
CREATE INDEX "UsageFact_capabilityId_occurredAt_idx"
ON "UsageFact"("capabilityId", "occurredAt");
ALTER TABLE "UsageFact"
ADD CONSTRAINT "UsageFact_runId_fkey"
FOREIGN KEY ("runId") REFERENCES "AgentRun"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: one synthetic model_completion fact per AgentRun that has any
-- recorded usage (cost or tokens). correlationId = run id marks these as
-- backfill artefacts (real facts use an external correlation id or null).
-- Runs with no recorded usage stay fact-less and remain runsWithoutCost,
-- matching ADR-0022's "missing cost ≠ zero" rule and the pre-existing
-- migration 20260709143000_agent_run_cost_tracking's "no backfill for the
-- truly unrecorded" stance.
INSERT INTO "UsageFact" (
"id", "runId", "occurredAt", "kind", "provider", "model",
"inputTokens", "outputTokens", "costUsd", "costSource",
"correlationId", "metadata"
)
SELECT
'usagefact_backfill_' || "AgentRun"."id",
"AgentRun"."id",
COALESCE("AgentRun"."finishedAt", "AgentRun"."startedAt", CURRENT_TIMESTAMP),
'model_completion',
"AgentRun"."provider",
"AgentRun"."model",
"AgentRun"."inputTokens",
"AgentRun"."outputTokens",
"AgentRun"."costUsd",
COALESCE("AgentRun"."costSource", 'unknown'),
"AgentRun"."id",
'{}'::jsonb
FROM "AgentRun"
WHERE "AgentRun"."costUsd" IS NOT NULL
OR "AgentRun"."inputTokens" IS NOT NULL
OR "AgentRun"."outputTokens" IS NOT NULL;
@@ -0,0 +1,69 @@
-- ADR-0027: org-scoped capability connections for external document/media
-- transforms (PDF→MD, audio/video→text, …). Mirrors the Feishu Application
-- Connection shape: reuses the ADR-0024 envelope machinery with its own
-- payload schema and readiness probe, distinct from the model-provider
-- connection.
CREATE TABLE "OrganizationCapabilityConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"capabilityId" TEXT NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationCapabilityConnection_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_key"
ON "OrganizationCapabilityConnection"("organizationId", "capabilityId");
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_activeSecretVersionId_key"
ON "OrganizationCapabilityConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationCapabilityConnection_organizationId_status_idx"
ON "OrganizationCapabilityConnection"("organizationId", "status");
CREATE INDEX "OrganizationCapabilityConnection_capabilityId_status_idx"
ON "OrganizationCapabilityConnection"("capabilityId", "status");
CREATE TABLE "CapabilityCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "CapabilityCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CapabilityCredentialVersion_connectionId_version_key"
ON "CapabilityCredentialVersion"("connectionId", "version");
CREATE INDEX "CapabilityCredentialVersion_connectionId_retiredAt_idx"
ON "CapabilityCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "CapabilityCredentialVersion_keyId_idx"
ON "CapabilityCredentialVersion"("keyId");
CREATE INDEX "CapabilityCredentialVersion_createdByUserId_idx"
ON "CapabilityCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "CapabilityCredentialVersion"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationCapabilityConnection"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- ADR-0028 agent configuration folder tree. One org-scoped transparent folder
-- tree shared by Agent roles and skills (management-surface grouping only);
-- skill name / roleId uniqueness, role→skill bindings, run-time skill loading
-- and Feishu slash commands never reference folders. A folder deletes only
-- when empty (service-enforced); item `folderId` references are SetNull as
-- backstop.
-- CreateTable
CREATE TABLE "OrganizationAgentConfigFolder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationAgentConfigFolder_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "OrganizationAgentSkill" ADD COLUMN "folderId" TEXT;
ALTER TABLE "OrganizationAgentRole" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "OrganizationAgentConfigFolder_organizationId_parentId_idx" ON "OrganizationAgentConfigFolder"("organizationId", "parentId");
CREATE INDEX "OrganizationAgentSkill_organizationId_folderId_idx" ON "OrganizationAgentSkill"("organizationId", "folderId");
CREATE INDEX "OrganizationAgentRole_organizationId_folderId_idx" ON "OrganizationAgentRole"("organizationId", "folderId");
-- AddForeignKey
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+140 -4
View File
@@ -44,8 +44,10 @@ model Organization {
externalDirectoryConnections ExternalDirectoryConnection[] externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[] providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection? feishuApplicationConnection OrganizationFeishuApplicationConnection?
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[] projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[] projectSearchDocuments ProjectSearchDocument[]
@@ -94,16 +96,19 @@ model OrganizationAgentSkill {
version String version String
description String? description String?
contentDigest String contentDigest String
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
roleBindings OrganizationAgentRoleSkill[] roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name]) @@unique([organizationId, name])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt]) @@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest]) @@index([contentDigest])
} }
@@ -120,17 +125,20 @@ model OrganizationAgentRole {
tools Json? tools Json?
sortOrder Int @default(0) sortOrder Int @default(0)
isDefault Boolean @default(false) isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
skillBindings OrganizationAgentRoleSkill[] skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId]) @@unique([organizationId, roleId])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder]) @@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
} }
/// Same-Organization join enforced by both composite foreign keys. `sortOrder` /// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -150,6 +158,29 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId]) @@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 /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true. /// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings { model OrganizationProjectSettings {
@@ -198,6 +229,7 @@ model User {
auditEntries AuditEntry[] @relation("auditActor") auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator") providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator") feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
capabilityCredentialVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[] feishuIdentities FeishuUserIdentity[]
} }
@@ -596,9 +628,13 @@ model AgentRun {
summary String? summary String?
inputTokens Int? inputTokens Int?
outputTokens Int? outputTokens Int?
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact. /// ADR-0026: derived rollup cache of UsageFact rows for this run. The truth
/// is in UsageFact; this column is convenience for existing readers. Null
/// means this run has no trusted cost fact (ADR-0022: missing cost ≠ zero).
costUsd Decimal? @db.Decimal(18, 8) costUsd Decimal? @db.Decimal(18, 8)
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback. /// ADR-0026: semantic source of the rolled-up costUsd (provider_reported |
/// pricebook_derived | unknown). Mirrors the dominant CostSource of the
/// run's facts; not a pricing-estimate fallback.
costSource String? costSource String?
metadata Json metadata Json
error String? error String?
@@ -612,6 +648,7 @@ model AgentRun {
projectLock ProjectAgentLock? projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages") messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges") fileChanges AgentFileChange[] @relation("runFileChanges")
usageFacts UsageFact[] @relation("runUsageFacts")
@@index([projectId, status]) @@index([projectId, status])
@@index([projectId, finishedAt]) @@index([projectId, finishedAt])
@@ -817,3 +854,102 @@ model AgentFileChange {
@@index([projectId]) @@index([projectId])
@@index([path]) @@index([path])
} }
// --- Usage fact ledger (ADR-0026) ----------------------------------------
/// ADR-0026: one billable consumption event inside an AgentRun. Append-only;
/// the run's AgentRun.costUsd/inputTokens/outputTokens are a derived rollup
/// of these rows, not the truth. kind=external_capability carries capabilityId
/// for media transforms (PDF→MD, audio/video→text, …). costUsd=null means
/// unknown, not zero (ADR-0022). The kind set is OPEN: new kinds must be
/// surfaced, not silently folded in.
model UsageFact {
id String @id @default(cuid())
runId String
/// When the consumption happened. Pricebook derivation uses this, not
/// AgentRun.finishedAt, because an external capability may finish before
/// the run ends.
occurredAt DateTime
/// model_completion | external_capability | tool_proxy. OPEN set.
kind String
/// e.g. openrouter, mineru, openai_whisper.
provider String
model String?
inputTokens Int?
outputTokens Int?
/// Non-token meter (pages, audio_seconds, invocations). Coexists with tokens.
quantity Decimal? @db.Decimal(18, 6)
unit String?
/// USD. Null = unknown, NOT zero (ADR-0022). When null, costSource=unknown.
costUsd Decimal? @db.Decimal(18, 8)
/// provider_reported | pricebook_derived | unknown. Required even when
/// costUsd is null, so a reader can distinguish "reported zero" from
/// "no report at all".
costSource String
/// For kind=external_capability, the registered capability id
/// (e.g. pdf_to_md_bundle, audio_video_to_text). Null for model_completion.
capabilityId String?
/// External request id for reconciliation / idempotency. Not an aggregation key.
correlationId String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runUsageFacts", fields: [runId], references: [id], onDelete: Cascade)
@@index([runId, occurredAt])
@@index([runId, kind])
@@index([provider, model, occurredAt])
@@index([capabilityId, occurredAt])
}
// --- External capability connections (ADR-0027) -------------------------
/// ADR-0027: org-scoped credential connection for an external capability
/// (PDF→MD, audio/video→text, …). Structurally mirrors the Feishu Application
/// Connection: reuses the ADR-0024 envelope (KEK→DEK→AES-256-GCM, AAD-bound)
/// but has its own payload schema and readiness probe, distinct from the
/// model-provider connection. Unique by (organizationId, capabilityId).
model OrganizationCapabilityConnection {
id String @id @default(cuid())
organizationId String
capabilityId String
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersions")
activeSecretVersion CapabilityCredentialVersion? @relation("activeCapabilityCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@unique([organizationId, capabilityId])
@@index([organizationId, status])
@@index([capabilityId, status])
}
/// ADR-0024/0027: one immutable authenticated envelope per capability secret
/// version. Same encryption machinery as Provider/Feishu credential versions;
/// the payload inside is CapabilitySecretPayloadV1 (baseUrl, apiToken,
/// optional projectId).
model CapabilityCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationCapabilityConnection @relation("capabilityCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationCapabilityConnection? @relation("activeCapabilityCredentialVersion")
createdBy User? @relation("capabilityCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
+52
View File
@@ -0,0 +1,52 @@
import { config } from "dotenv";
config();
import { PrismaClient } from "@prisma/client";
import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js";
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
async function main(): Promise<void> {
const keyringFile = process.env["HUB_SECRET_KEYRING_FILE"];
if (!keyringFile) throw new Error("HUB_SECRET_KEYRING_FILE is required");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring());
const prisma = new PrismaClient();
try {
const result = await bootstrapAlphaSilo(
prisma,
secrets,
{
organization: {
id: "org_default",
slug: "default",
name: "Default Organization",
},
owner: {
openId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-owner",
displayName: "Dev Owner",
},
feishu: {
appId: process.env["FEISHU_APP_ID"] ?? "dev-app",
appSecret: process.env["FEISHU_APP_SECRET"] ?? "dev-secret",
botOpenId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-bot",
},
provider: {
providerId: "openrouter",
baseUrl: process.env["ANTHROPIC_BASE_URL"] ?? "https://openrouter.ai/api",
authToken: process.env["ANTHROPIC_AUTH_TOKEN"] ?? "dev-token",
},
},
{
feishu: async () => undefined,
provider: async () => undefined,
},
);
console.log("bootstrap ok:", JSON.stringify(result, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((error: unknown) => {
console.error("[dev-bootstrap] failed:", error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Smoke test: real Aliyun docmind API call using createReadStream.
* Run: node --experimental-strip-types scripts/smoke-docmind.ts <pdf-path>
*/
import DocmindClient from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"];
const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"];
if (accessKeyId === undefined || accessKeySecret === undefined) {
console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars.");
process.exit(1);
}
const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf";
console.log(`Parsing: ${pdfPath}`);
const client = new DocmindClient.default({
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
accessKeyId,
accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as never);
const fileStream = createReadStream(pdfPath);
const runtime = new RuntimeOptions({});
console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance(
new DocmindClient.SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName: basename(pdfPath),
outputFormat: ["markdown"],
formulaEnhancement: true,
}),
runtime,
);
const jobId = submitResp.body?.data?.id;
console.log("Job ID:", jobId);
if (jobId === undefined) {
console.error("No job id:", JSON.stringify(submitResp.body));
process.exit(1);
}
console.log("Polling...");
const deadline = Date.now() + 5 * 60_000;
let status = "";
let markdownUrl = "";
let pageCount = 0;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 10_000));
const resp = await client.queryDocParserStatus(
new DocmindClient.QueryDocParserStatusRequest({ id: jobId }),
);
const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data;
status = data?.status ?? "";
console.log(` status: ${status}`);
if (status === "success") {
const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = md?.outputFileUrl ?? "";
pageCount = data?.pageCountEstimate ?? 0;
break;
}
if (status === "fail") {
console.error("Job failed!");
process.exit(1);
}
}
if (status !== "success" || markdownUrl === "") {
console.error("Failed or timed out:", status);
process.exit(1);
}
console.log("Downloading markdown from OSS...");
const mdResp = await fetch(markdownUrl);
const markdown = await mdResp.text();
console.log("--- Result ---");
console.log("Pages:", pageCount);
console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4));
console.log("Job ID:", jobId);
console.log("Markdown length:", markdown.length, "chars");
console.log("--- Markdown (first 1000 chars) ---");
console.log(markdown.slice(0, 1000));
+63
View File
@@ -0,0 +1,63 @@
---
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles PDFs from Feishu messages, local workspace files, and produces
high-quality Markdown with LaTeX formulas and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF to Markdown, extract text
from a PDF, or turn a PDF document into an editable format.
## How it works
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
Alibaba Cloud Document Mind to parse the PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files
## Workflow
### PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
### PDF already in the workspace
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send the generated markdown back to the
user if they requested it.
## Output
The tool returns a list of generated files:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
+367
View File
@@ -0,0 +1,367 @@
/**
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
*
* Surface for browsing and editing Organization-scoped Agent roles, the skills
* bound to them, and the model picker. The model list is **derived** from the
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
* no separate model table to maintain. When no ACTIVE provider exists, the
* route falls back to the env-default model registry so the admin surface
* remains usable.
*
* Skill management (create, read, edit, disable) is performed in-band through
* the deep module `OrganizationAgentConfiguration`, which writes to the same
* content-addressed persistent store used by the host-console CLI. All skill
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
* `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";
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
import { createDefaultModelRegistry } from "../../settings/runtime.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface AgentConfigRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly skillStoreRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
}
export async function registerAgentConfigRoutes(
app: FastifyInstance,
config: AgentConfigRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
return { roles };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-roles/:roleId", 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 {
label?: unknown;
defaultModel?: unknown;
systemPrompt?: unknown;
tools?: unknown;
sortOrder?: unknown;
isDefault?: unknown;
};
if (typeof body.label !== "string" || body.label.trim() === "") {
return reply.status(400).send({
error: { code: "bad_request", message: "label is required" },
});
}
const role = await agentConfig.upsertRole({
organizationId: auth.organization.id,
roleId,
label: body.label,
...(body.defaultModel === null || typeof body.defaultModel === "string"
? { defaultModel: body.defaultModel as string | null }
: {}),
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
? { systemPrompt: body.systemPrompt as string | null }
: {}),
...(body.tools === null || Array.isArray(body.tools)
? { tools: body.tools as readonly string[] | null }
: {}),
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
});
return role;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", 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 { skillNames?: unknown };
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
return reply.status(400).send({
error: { code: "bad_request", message: "skillNames must be a string array" },
});
}
await agentConfig.setRoleSkills({
organizationId: auth.organization.id,
roleId,
skillNames: body.skillNames as readonly string[],
});
return { skillNames: body.skillNames as string[] };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
return { skills };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-skills/:name/files", 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 skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
if (skill === undefined) {
return reply.status(404).send({
error: { code: "not_found", message: `skill not found: ${name}` },
});
}
const files = await agentConfig.readSkillFiles({
organizationId: auth.organization.id,
contentDigest: skill.contentDigest,
});
return { files };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-skills/:name", 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 { version?: unknown; files?: unknown };
if (typeof body.version !== "string" || body.version.trim() === "") {
return reply.status(400).send({
error: { code: "bad_request", message: "version is required" },
});
}
if (!Array.isArray(body.files) || body.files.length === 0) {
return reply.status(400).send({
error: { code: "bad_request", message: "files must be a non-empty array" },
});
}
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
for (const entry of body.files) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return reply.status(400).send({
error: { code: "bad_request", message: "each file must be an object" },
});
}
const e = entry as { path?: unknown; content?: unknown };
if (typeof e.path !== "string" || typeof e.content !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "each file needs string path and content" },
});
}
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
}
const result = await agentConfig.installSkillFromFiles({
organizationId: auth.organization.id,
files: skillFiles,
version: body.version,
});
// The manifest name is authoritative — reject if it doesn't match the
// URL param, so clients can't silently rename a skill under a different
// route key.
if (result.name !== name) {
return reply.status(400).send({
error: {
code: "bad_request",
message: `skill manifest name "${result.name}" does not match route "${name}"`,
},
});
}
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name", 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 { description?: unknown; disabled?: unknown };
if (body.disabled === true) {
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
return { disabled: true };
}
if (typeof body.description === "string") {
await agentConfig.updateSkillDescription({
organizationId: auth.organization.id,
name,
description: body.description,
});
return { updated: true };
}
return reply.status(400).send({
error: { code: "bad_request", message: "provide description or disabled: true" },
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const providerModels = await modelCatalog.listModels(auth.organization.id);
// Fall back to env-default registry when the org has no ACTIVE provider
// (or the provider API is unreachable on first load).
const models = providerModels.length > 0
? providerModels
: createDefaultModelRegistry(process.env).listModels();
return { models };
} catch (err) {
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);
}
});
}
+20 -7
View File
@@ -432,14 +432,16 @@ async function resolvePostLoginRedirect(
select: { organization: { select: { slug: true, name: true } } }, select: { organization: { select: { slug: true, name: true } } },
}); });
if (intended === null) return "/admin?error=not_an_active_org_member"; if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`; const orgRoot = "/admin";
// Default / missing returnTo sanitizes to "/admin". Always land in the org
// admin SPA (not the legacy static "close this tab" complete page).
if (returnTo === "/admin") { if (returnTo === "/admin") {
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`; return orgRoot;
} }
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot; return normalizeAdminReturnTo(returnTo) ?? orgRoot;
} }
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) { if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo; return normalizeAdminReturnTo(returnTo) ?? "/admin";
} }
const membership = await prisma.organizationMembership.findFirst({ const membership = await prisma.organizationMembership.findFirst({
where: { where: {
@@ -452,7 +454,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
if (membership !== null) { if (membership !== null) {
return `/admin/org/${membership.organization.slug}`; return "/admin";
} }
// Member-only or no org: still land on a shell page (SPA will explain). // Member-only or no org: still land on a shell page (SPA will explain).
const any = await prisma.organizationMembership.findFirst({ const any = await prisma.organizationMembership.findFirst({
@@ -461,7 +463,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
if (any !== null) { if (any !== null) {
return `/admin/org/${any.organization.slug}`; return "/admin/projects";
} }
return "/admin/login?error=no_organization"; return "/admin/login?error=no_organization";
} }
@@ -487,7 +489,18 @@ export function sanitizeReturnTo(raw: string): string {
if (!raw.startsWith("/admin")) { if (!raw.startsWith("/admin")) {
return "/admin"; return "/admin";
} }
return raw; return normalizeAdminReturnTo(raw) ?? "/admin";
}
/** Map legacy `/admin/org/:slug[...]` bookmarks onto slugless `/admin[...]` paths. */
export function normalizeAdminReturnTo(path: string): string | null {
if (!path.startsWith("/admin")) return null;
const legacy = path.match(/^\/admin\/org\/[^/]+(\/.*)?$/);
if (legacy) {
const rest = legacy[1] ?? "";
return rest === "" ? "/admin" : `/admin${rest}`;
}
return path;
} }
function trimTrailingSlash(url: string): string { function trimTrailingSlash(url: string): string {
@@ -0,0 +1,131 @@
/**
* ADR-0027: Admin routes for organization-scoped capability connections.
* GET /api/org/:orgSlug/capability-connections — list all
* GET /api/org/:orgSlug/capability-connections/:capId — read one
* PUT /api/org/:orgSlug/capability-connections/:capId — rotate/create
* DELETE /api/org/:orgSlug/capability-connections/:capId — disable
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface CapabilityConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly readinessProbe?: CapabilityReadinessProbe;
}
export async function registerCapabilityConnectionRoutes(
app: FastifyInstance,
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connections: await connections.list(auth.organization.id) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.list" }, "list failed");
return handleRouteError(reply, error);
}
});
app.get("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return { connection: await connections.read(auth.organization.id, capabilityId) };
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.read" }, "read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
secretVersion: result.activeVersion,
}, result.created ? "Capability Connection created" : "Capability Connection rotated");
const { created, ...metadata } = result;
return reply.status(created ? 201 : 200).send(metadata);
} catch (error) {
const facts = error instanceof CapabilityReadinessError
? {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
}
: { errorCode: "capability_connection_write_failed" };
request.log.error({ requestId: request.id, operation: "capability_connection.rotate", ...facts }, "rotate failed");
return handleRouteError(reply, error);
}
});
app.delete("/api/org/:orgSlug/capability-connections/:capabilityId", async (request, reply) => {
try {
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const result = await connections.disable({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
});
request.log.info({
organizationId: auth.organization.id,
capabilityId,
connectionId: result.id,
status: result.status,
}, "Capability Connection disabled");
return reply.send(result);
} catch (error) {
request.log.error({ requestId: request.id, operation: "capability_connection.disable" }, "disable failed");
return handleRouteError(reply, error);
}
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
}
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
};
}
+1 -1
View File
@@ -155,7 +155,7 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot, workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}), ...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
}); });
return reply.status(201).send(result); return reply.status(201).send({ id: result.projectId, name: body.name });
} catch (err) { } catch (err) {
return handleRouteError(reply, err); return handleRouteError(reply, err);
} }
+19
View File
@@ -16,10 +16,14 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import { registerCapacityRoutes } from "./capacityRoutes.js"; import { registerCapacityRoutes } from "./capacityRoutes.js";
import { readSkillStoreRoot } from "../../agent/skillStore.js";
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js"; import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js"; import { registerFeishuApplicationConnectionRoutes } from "./feishuApplicationConnectionRoutes.js";
import { registerCapabilityConnectionRoutes } from "./capabilityConnectionRoutes.js";
import type { CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
export interface OrgRouteConfig { export interface OrgRouteConfig {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
@@ -28,6 +32,7 @@ export interface OrgRouteConfig {
readonly secretEnvelope: LocalSecretEnvelope; readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe; readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe; readonly feishuConnectionReadinessProbe?: FeishuReadinessProbe;
readonly capabilityReadinessProbe?: CapabilityReadinessProbe;
} }
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> { export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -110,6 +115,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma, prisma: config.prisma,
sessionSecret: config.sessionSecret, sessionSecret: config.sessionSecret,
}); });
await registerAgentConfigRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
skillStoreRoot: readSkillStoreRoot(),
secretEnvelope: config.secretEnvelope,
});
await registerSessionsAndUsageRoutes(app, { await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma, prisma: config.prisma,
sessionSecret: config.sessionSecret, sessionSecret: config.sessionSecret,
@@ -130,4 +141,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
? { readinessProbe: config.feishuConnectionReadinessProbe } ? { readinessProbe: config.feishuConnectionReadinessProbe }
: {}), : {}),
}); });
await registerCapabilityConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.capabilityReadinessProbe !== undefined
? { readinessProbe: config.capabilityReadinessProbe }
: {}),
});
} }
+504 -17
View File
@@ -1,10 +1,49 @@
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "./roleTools.js"; import { assertSupportedRoleTools } from "./roleTools.js";
import { importSkillDirectory } from "./skillStore.js"; import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export interface AgentRoleRow {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: readonly string[] | null;
readonly sortOrder: number;
readonly isDefault: boolean;
readonly disabledAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
readonly folderId: string | null;
}
export interface AgentSkillRow {
readonly id: string;
readonly name: string;
readonly version: string;
readonly description: string | null;
readonly contentDigest: string;
readonly disabledAt: string | null;
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;
}
/** /**
* Deep module for controlled host-console Agent configuration. It owns the * Deep module for controlled host-console Agent configuration. It owns the
* filesystem/DB ordering, Organization checks and role-skill composition so * filesystem/DB ordering, Organization checks and role-skill composition so
@@ -13,43 +52,431 @@ const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export class OrganizationAgentConfiguration { export class OrganizationAgentConfiguration {
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly skillStoreRoot: string, private readonly skillStoreRoot: string | null,
) {} ) {}
async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
await this.requireActiveOrganization(input.organizationId);
const roles = await this.prisma.organizationAgentRole.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
});
return roles.map((role) => toRoleRow(role));
}
async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
await this.requireActiveOrganization(input.organizationId);
const skills = await this.prisma.organizationAgentSkill.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ name: "asc" }],
include: {
roleBindings: {
where: { role: { disabledAt: null } },
select: { role: { select: { roleId: true } } },
},
},
});
return skills.map((skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
description: skill.description,
contentDigest: skill.contentDigest,
disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
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: { async installSkill(input: {
readonly organizationId: string; readonly organizationId: string;
readonly sourceDir: string; readonly sourceDir: string;
readonly version: string; readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
const storeRoot = this.requireSkillStoreRoot();
await this.requireActiveOrganization(input.organizationId); await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version"); const version = nonEmpty(input.version, "skill version");
const imported = await importSkillDirectory({ const imported = await importSkillDirectory({
sourceDir: input.sourceDir, sourceDir: input.sourceDir,
storeRoot: this.skillStoreRoot, storeRoot,
}); });
return this.commitInstalledSkill({
organizationId: input.organizationId,
imported,
version,
action: "agent_skill.installed",
});
}
/**
* Install or replace a skill from an in-memory file list (web upload path).
* Same content-addressed storage, same session archival semantics as
* `installSkill`; only the ingestion source differs.
*/
async installSkillFromFiles(input: {
readonly organizationId: string;
readonly files: readonly SkillFileInput[];
readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
const storeRoot = this.requireSkillStoreRoot();
await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version");
const imported = await importSkillFromFiles({
files: input.files,
storeRoot,
});
return this.commitInstalledSkill({
organizationId: input.organizationId,
imported,
version,
action: "agent_skill.installed",
});
}
/**
* Read all files from the stored skill version identified by content digest.
* Returns UTF-8 content for each file; the web editor uses this to populate
* its file tree.
*/
async readSkillFiles(input: {
readonly organizationId: string;
readonly contentDigest: string;
}): Promise<readonly SkillFileOutput[]> {
const storeRoot = this.requireSkillStoreRoot();
await this.requireActiveOrganization(input.organizationId);
const skill = await this.prisma.organizationAgentSkill.findFirst({
where: {
organizationId: input.organizationId,
contentDigest: input.contentDigest,
},
select: { id: true, disabledAt: true },
});
if (skill === null) {
throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
}
return readSkillFiles({
storeRoot,
contentDigest: input.contentDigest,
});
}
/**
* Disable a skill (soft-delete). Sets `disabledAt` and archives active
* sessions of every role bound to this skill, since the execution surface
* changes when a bound skill disappears.
*/
async disableSkill(input: { readonly organizationId: string; readonly name: string }): 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, roleBindings: { select: { role: { select: { roleId: true } } } } },
});
if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
if (skill.disabledAt !== null) return;
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { disabledAt: new Date() },
});
await archiveRoleSessions(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.disabled",
metadata: { name: input.name },
},
});
});
}
/**
* Update only the `description` label of a skill. This is a label change,
* not an execution-surface change — no session archival (ADR-0017).
*/
async updateSkillDescription(input: {
readonly organizationId: string;
readonly name: string;
readonly description: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
const description = input.description.trim();
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}`);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { description: description === "" ? null : description },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.description_updated",
metadata: { name: input.name, description: description === "" ? null : description },
},
});
});
}
private requireSkillStoreRoot(): string {
if (this.skillStoreRoot === null) {
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
}
return this.skillStoreRoot;
}
/**
* Shared DB commit for an imported skill: upsert the skill record, archive
* affected sessions if the content digest changed, and write an audit entry.
*/
private async commitInstalledSkill(input: {
readonly organizationId: string;
readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
readonly version: string;
readonly action: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentSkill.findUnique({ const previous = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } }, where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
select: { contentDigest: true }, select: { contentDigest: true },
}); });
const skill = await tx.organizationAgentSkill.upsert({ const skill = await tx.organizationAgentSkill.upsert({
where: { where: {
organizationId_name: { organizationId_name: {
organizationId: input.organizationId, organizationId: input.organizationId,
name: imported.name, name: input.imported.name,
}, },
}, },
create: { create: {
organizationId: input.organizationId, organizationId: input.organizationId,
name: imported.name, name: input.imported.name,
version, version: input.version,
description: imported.description ?? null, description: input.imported.description ?? null,
contentDigest: imported.contentDigest, contentDigest: input.imported.contentDigest,
}, },
update: { update: {
version, version: input.version,
description: imported.description ?? null, description: input.imported.description ?? null,
contentDigest: imported.contentDigest, contentDigest: input.imported.contentDigest,
disabledAt: null, disabledAt: null,
}, },
select: { select: {
@@ -69,10 +496,10 @@ export class OrganizationAgentConfiguration {
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
action: "agent_skill.installed", action: input.action,
metadata: { metadata: {
name: skill.name, name: skill.name,
version, version: input.version,
contentDigest: skill.contentDigest, contentDigest: skill.contentDigest,
}, },
}, },
@@ -90,7 +517,7 @@ export class OrganizationAgentConfiguration {
readonly tools?: readonly string[] | null | undefined; readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined; readonly sortOrder?: number | undefined;
readonly isDefault?: boolean | undefined; readonly isDefault?: boolean | undefined;
}): Promise<{ readonly id: string; readonly roleId: string }> { }): Promise<AgentRoleRow> {
await this.requireActiveOrganization(input.organizationId); await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`); if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
const label = nonEmpty(input.label, "role label"); const label = nonEmpty(input.label, "role label");
@@ -150,7 +577,12 @@ export class OrganizationAgentConfiguration {
isDefault: effectiveIsDefault, isDefault: effectiveIsDefault,
disabledAt: null, disabledAt: null,
}, },
select: { id: true, roleId: true }, include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
}); });
const executionSurfaceChanged = previous !== null && ( const executionSurfaceChanged = previous !== null && (
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) || (input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
@@ -182,7 +614,7 @@ export class OrganizationAgentConfiguration {
}, },
}, },
}); });
return role; return toRoleRow(role);
}); });
} }
@@ -256,6 +688,19 @@ export class OrganizationAgentConfiguration {
} }
} }
async function requireFolder(
tx: Prisma.TransactionClient,
organizationId: string,
folderId: string,
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
const folder = await tx.organizationAgentConfigFolder.findFirst({
where: { id: folderId, organizationId },
select: { id: true, name: true, parentId: true },
});
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
return folder;
}
async function archiveRoleSessions( async function archiveRoleSessions(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
organizationId: string, organizationId: string,
@@ -295,3 +740,45 @@ function normalizeOptionalText(value: string | null | undefined): string | null
const normalized = value.trim(); const normalized = value.trim();
return normalized === "" ? null : normalized; return normalized === "" ? null : normalized;
} }
function parseTools(value: Prisma.JsonValue): readonly string[] | null {
if (value === null) return null;
if (!Array.isArray(value)) return null;
return value.filter((t): t is string => typeof t === "string");
}
function toRoleRow(role: {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: Prisma.JsonValue;
readonly sortOrder: number;
readonly isDefault: boolean;
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 };
}>;
}): AgentRoleRow {
return {
id: role.id,
roleId: role.roleId,
label: role.label,
defaultModel: role.defaultModel,
systemPrompt: role.systemPrompt,
tools: parseTools(role.tools),
sortOrder: role.sortOrder,
isDefault: role.isDefault,
disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
folderId: role.folderId,
};
}
+16 -1
View File
@@ -1,4 +1,12 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const; export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read",
"Write",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub"; export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
export const CPH_HUB_MCP_TOOL_IDS = [ export const CPH_HUB_MCP_TOOL_IDS = [
@@ -6,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context", "feishu_read_context",
"feishu_download_resource", "feishu_download_resource",
"request_approval", "request_approval",
"convert_pdf_to_md",
] as const; ] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -26,11 +35,15 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
// would need a separate command-policy layer. // would need a separate command-policy layer.
["cph_check", ["Bash"]], ["cph_check", ["Bash"]],
["cph_build", ["Bash"]], ["cph_build", ["Bash"]],
["web_fetch", ["WebFetch"]],
["web_search", ["WebSearch"]],
["Read", ["Read"]], ["Read", ["Read"]],
["Write", ["Write"]], ["Write", ["Write"]],
["Bash", ["Bash"]], ["Bash", ["Bash"]],
["Glob", ["Glob"]], ["Glob", ["Glob"]],
["Grep", ["Grep"]], ["Grep", ["Grep"]],
["WebFetch", ["WebFetch"]],
["WebSearch", ["WebSearch"]],
]); ]);
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
@@ -38,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["feishu_read_context", "feishu_read_context"], ["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"], ["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"], ["request_approval", "request_approval"],
["convert_pdf_to_md", "convert_pdf_to_md"],
["mcp__cph_hub__send_file", "send_file"], ["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"], ["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"], ["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"], ["mcp__cph_hub__request_approval", "request_approval"],
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
]); ]);
const SUPPORTED_ROLE_TOOLS = new Set([ const SUPPORTED_ROLE_TOOLS = new Set([
+102 -8
View File
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
readonly sourceDir: string; readonly sourceDir: string;
readonly storeRoot: string; readonly storeRoot: string;
}): Promise<ImportedSkillContent> { }): Promise<ImportedSkillContent> {
const source = await inspectSkillDirectory(input.sourceDir); return commitSkillContent({
storeRoot: input.storeRoot,
prepare: async (dir) => {
await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
return inspectSkillDirectory(dir);
},
});
}
/**
* Import a skill from an in-memory file list (web upload path). Each file
* path is relative to the skill root and must be a simple relative path
* (no absolute, no `..`). A `SKILL.md` manifest is required.
*/
export async function importSkillFromFiles(input: {
readonly files: readonly SkillFileInput[];
readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
const seen = new Set<string>();
for (const file of input.files) {
validateSkillFilePath(file.path);
if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
seen.add(file.path);
}
return commitSkillContent({
storeRoot: input.storeRoot,
prepare: async (dir) => {
for (const file of input.files) {
const dest = join(dir, file.path);
const parent = join(dest, "..");
await mkdir(parent, { recursive: true, mode: 0o750 });
await writeFile(dest, file.bytes, { mode: 0o640 });
}
return inspectSkillDirectory(dir);
},
});
}
/**
* Read all files from a stored skill version (by content digest). Returns
* relative paths and UTF-8 decoded content for the web editor.
*/
export async function readSkillFiles(input: {
readonly storeRoot: string;
readonly contentDigest: string;
}): Promise<readonly SkillFileOutput[]> {
if (!DIGEST_PATTERN.test(input.contentDigest)) {
throw new Error(`invalid content digest: ${input.contentDigest}`);
}
const dir = join(input.storeRoot, "versions", input.contentDigest);
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
await walk(resolve(dir), resolve(dir), files);
return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
path: f.path,
content: f.bytes.toString("utf8"),
}));
}
export interface SkillFileInput {
readonly path: string;
readonly bytes: Buffer;
}
export interface SkillFileOutput {
readonly path: string;
readonly content: string;
}
/**
* Shared commit logic: populate a temp dir, inspect it, deduplicate against
* an existing `versions/<digest>/` destination, and atomically rename.
*
* `prepare(dir)` writes the skill content into `dir` and returns the inspected
* result (name, description, contentDigest). The caller owns the write;
* commitSkillContent owns the move.
*/
async function commitSkillContent(input: {
readonly storeRoot: string;
readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
}): Promise<ImportedSkillContent> {
const versionsRoot = join(input.storeRoot, "versions"); const versionsRoot = join(input.storeRoot, "versions");
await mkdir(versionsRoot, { recursive: true, mode: 0o750 }); await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
let source: ImportedSkillContent;
try {
source = await input.prepare(temporary);
} catch (error) {
await rm(temporary, { recursive: true, force: true });
throw error;
}
const destination = join(versionsRoot, source.contentDigest); const destination = join(versionsRoot, source.contentDigest);
// If the destination already exists with identical content, reuse it.
try { try {
const existing = await inspectSkillDirectory(destination); const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) { if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
throw new Error(`stored skill content digest mismatch: ${source.name}`); throw new Error(`stored skill content digest mismatch: ${source.name}`);
} }
await rm(temporary, { recursive: true, force: true });
return source; return source;
} catch (error) { } catch (error) {
if (!isMissingPath(error)) throw error; if (!isMissingPath(error)) throw error;
} }
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
try { try {
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
const copied = await inspectSkillDirectory(temporary);
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
throw new Error(`skill changed while importing: ${source.name}`);
}
await rename(temporary, destination); await rename(temporary, destination);
} catch (error) { } catch (error) {
await rm(temporary, { recursive: true, force: true }); await rm(temporary, { recursive: true, force: true });
if (isDestinationExists(error)) { if (isDestinationExists(error)) {
const existing = await inspectSkillDirectory(destination); const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source; if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
return source;
}
} }
throw error; throw error;
} }
return source; return source;
} }
function validateSkillFilePath(path: string): void {
if (path === "") throw new Error("skill file path is empty");
if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
}
export async function prepareRunSkillPlugin(input: { export async function prepareRunSkillPlugin(input: {
readonly storeRoot: string; readonly storeRoot: string;
readonly runId: string; readonly runId: string;
@@ -0,0 +1,266 @@
/**
* ADR-0027: Organization-scoped capability connection service. Manages the
* lifecycle (rotate / read / disable) of capability credentials stored in
* ADR-0024 encrypted envelopes with purpose="capability".
*
* Mirrors FeishuApplicationConnectionService, but keyed by (organizationId,
* capabilityId) instead of 1:1 — an org may have multiple capabilities.
*/
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
export interface CapabilityCredentialInput {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export interface RotateCapabilityInput extends CapabilityCredentialInput {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}
export interface CapabilityConnectionMetadata {
readonly id: string;
readonly capabilityId: string;
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly activeVersion: number | null;
readonly keyId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CapabilityConnectionWriteResult extends CapabilityConnectionMetadata {
readonly created: boolean;
}
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
}
const payload = validateCredential(input);
await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.upsert({
where: {
organizationId_capabilityId: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
},
},
update: {},
create: {
id: randomUUID(),
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "DRAFT",
},
});
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: {
activeSecretVersion: true,
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
},
});
if (locked.organizationId !== input.organizationId) {
throw new Error("Capability Connection scope changed during rotation");
}
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
const secretVersionId = randomUUID();
const envelope = this.secrets.encryptJson(
{
purpose: "capability",
organizationId: input.organizationId,
connectionId: locked.id,
secretVersionId,
},
payload,
);
const now = new Date();
const secretVersion = await tx.capabilityCredentialVersion.create({
data: {
id: secretVersionId,
connectionId: locked.id,
version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: input.actorUserId,
},
});
if (locked.activeSecretVersion !== null) {
await tx.capabilityCredentialVersion.update({
where: { id: locked.activeSecretVersion.id },
data: { retiredAt: now },
});
}
const activated = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersion.id,
activatedAt: now,
disabledAt: null,
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: version === 1 ? "capability.created" : "capability.rotated",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
},
},
});
return {
...toMetadata(activated, { version, keyId: secretVersion.keyId }),
created: version === 1,
};
});
}
async list(organizationId: string): Promise<CapabilityConnectionMetadata[]> {
const connections = await this.prisma.organizationCapabilityConnection.findMany({
where: { organizationId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
orderBy: { capabilityId: "asc" },
});
return connections.map((c) => toMetadata(c, c.activeSecretVersion));
}
async read(organizationId: string, capabilityId: string): Promise<CapabilityConnectionMetadata | null> {
const connection = await this.prisma.organizationCapabilityConnection.findFirst({
where: { organizationId, capabilityId },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
return connection === null ? null : toMetadata(connection, connection.activeSecretVersion);
}
async disable(input: {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
}): Promise<CapabilityConnectionMetadata> {
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
const connection = await tx.organizationCapabilityConnection.findFirst({
where: { organizationId: input.organizationId, capabilityId: input.capabilityId },
select: { id: true },
});
if (connection === null) throw new Error("Capability Connection not found");
await tx.$queryRaw`SELECT "id" FROM "OrganizationCapabilityConnection" WHERE "id" = ${connection.id} FOR UPDATE`;
const locked = await tx.organizationCapabilityConnection.findUniqueOrThrow({
where: { id: connection.id },
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
});
if (locked.status === "DISABLED") return toMetadata(locked, locked.activeSecretVersion);
const disabled = await tx.organizationCapabilityConnection.update({
where: { id: locked.id },
data: { status: "DISABLED", disabledAt: new Date() },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: "capability.disabled",
metadata: {
connectionId: locked.id,
capabilityId: input.capabilityId,
previousStatus: locked.status,
status: "DISABLED",
},
},
});
return toMetadata(disabled, locked.activeSecretVersion);
});
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
}
function toMetadata(
connection: {
readonly id: string;
readonly capabilityId: string;
readonly status: string;
readonly createdAt: Date;
readonly updatedAt: Date;
},
secret: { readonly version: number; readonly keyId: string } | null,
): CapabilityConnectionMetadata {
return {
id: connection.id,
capabilityId: connection.capabilityId,
status: connection.status as CapabilityConnectionMetadata["status"],
activeVersion: secret?.version ?? null,
keyId: secret?.keyId ?? null,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
}
async function requireCapabilityAdmin(
tx: Prisma.TransactionClient,
input: { readonly organizationId: string; readonly actorUserId: string },
): Promise<void> {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.actorUserId,
role: { in: ["OWNER", "ADMIN"] },
revokedAt: null,
},
select: { id: true },
});
if (membership === null) {
throw new Error("only Organization OWNER or ADMIN may manage capability connections");
}
}
function nonEmpty(value: string, label: string): string {
const trimmed = value.trim();
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
@@ -0,0 +1,70 @@
/**
* ADR-0027: org-scoped capability credential resolver. Reuses the ADR-0024
* envelope decryption machinery (LocalSecretEnvelope) with
* purpose="capability", distinct from the model-provider and Feishu
* application connections. Fail-closed: no process-global fallback.
*
* Mirrors the Feishu application connection resolver shape, minus the
* readiness probe (capability probes are per-capability and injected by the
* adapter wiring, not this resolver).
*/
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import {
CapabilityConnectionUnavailable,
type CapabilitySecretPayload,
type CapabilityId,
} from "./types.js";
const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
readonly connectionId: string;
readonly organizationId: string;
readonly capabilityId: string;
}
/**
* Resolve the active capability credential for an organization. Throws
* CapabilityConnectionUnavailable when the org has no ACTIVE connection
* (fail-closed, ADR-0024). Decrypted plaintext exists only in the returned
* object for the duration of the capability call; it is never cached, logged,
* or passed to the Agent process.
*/
export async function resolveCapabilityCredential(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
input: { readonly organizationId: string; readonly capabilityId: CapabilityId },
): Promise<ResolvedCapabilityCredential> {
const connection = await prisma.organizationCapabilityConnection.findFirst({
where: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "ACTIVE",
},
include: { activeSecretVersion: true },
});
if (connection === null || connection.activeSecretVersion === null) {
throw new CapabilityConnectionUnavailable(input.capabilityId, input.organizationId);
}
const version = connection.activeSecretVersion;
const binding = {
purpose: CAPABILITY_PURPOSE,
organizationId: connection.organizationId,
connectionId: connection.id,
secretVersionId: version.id,
};
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
}
return {
connectionId: connection.id,
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
};
}
+69
View File
@@ -0,0 +1,69 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
readonly code: "capability_readiness_unsupported" | "capability_readiness_unreachable" | "capability_readiness_rejected",
message: string,
readonly category: NetworkFailureCategory | "configuration" | "http",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "CapabilityReadinessError";
}
}
/**
* Probe the Alibaba Cloud docmind credential. We call QueryDocParserStatus
* with a dummy id. The API will return:
* - 400 (InvalidParameter) → credential valid, just a bad id → probe passes
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable
*/
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: { authorization: authHeader, accept: "application/json" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"docmind credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
await response.body?.cancel();
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`docmind credential rejected: status ${response.status}`,
"http",
response.status,
);
}
};
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString("base64");
return `Basic ${credentials}`;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* ADR-0027: Alibaba Cloud Document Mind (docmind) client.
*
* Uses the official @alicloud/docmind-api20220711 SDK to call the document
* parsing (large model version) API. The API is asynchronous:
* 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id
* 2. QueryDocParserStatus — poll until data.status === "success";
* the markdown output URL is returned in outputFormatResult
* 3. Download markdown from OSS, extract and download referenced images
*
* Pricing (2025-07, aliyun docmind):
* - 图文文档基础链路: 0.02元/页
* - 图文文档增强链路 (含公式 LaTeX): 0.04元/页
* - 视频: 0.002元/秒
* - 音频: 0.00035元/秒
*/
import $DocmindClient, {
SubmitDocParserJobAdvanceRequest,
QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage {
readonly filename: string;
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface DocmindParseResult {
readonly markdown: string;
readonly images: readonly DocmindExtractedImage[];
readonly pageCount: number;
readonly costUsd: number | null;
readonly requestId: string | null;
}
export interface DocmindParseOptions {
readonly inputFilePath: string;
}
export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
}
export class DocmindClientError extends Error {
constructor(
message: string,
readonly code: "docmind_unreachable" | "docmind_rejected" | "docmind_invalid_response" | "docmind_no_output" | "docmind_timeout",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "DocmindClientError";
}
}
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */
const COST_PER_PAGE_USD = 0.0056;
const POLL_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 5 * 60_000;
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
accessKeySecret: credential.accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
});
const runtime = new RuntimeOptions({});
let submitResponse;
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
const jobId = submitResponse.body?.data?.id;
if (jobId === undefined || jobId === null || jobId === "") {
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
}
// 2. Poll QueryDocParserStatus until data.status === "success" or "fail".
const deadline = Date.now() + POLL_TIMEOUT_MS;
let status = "";
let markdownUrl: string | null = null;
let pageCount = 0;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const statusReq = new QueryDocParserStatusRequest({ id: jobId });
const statusResponse = await client.queryDocParserStatus(statusReq);
const body = statusResponse.body as {
data?: {
status?: string;
pageCountEstimate?: number;
outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>;
};
};
status = body.data?.status ?? "";
if (status === "success") {
const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown");
markdownUrl = mdResult?.outputFileUrl ?? null;
pageCount = body.data?.pageCountEstimate ?? 0;
break;
}
if (status === "fail") {
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
}
}
if (status !== "success") {
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
}
if (markdownUrl === null) {
throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output");
}
// 3. Download the markdown file from OSS.
let markdown: string;
try {
const mdResp = await fetch(markdownUrl);
if (!mdResp.ok) {
throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable");
}
markdown = await mdResp.text();
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
if (markdown === "") {
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Download images referenced in the markdown (OSS URLs).
// Markdown contains ![filename](http://...oss.../image.png?...) entries.
// We rewrite them to local relative paths and download the images.
const images: DocmindExtractedImage[] = [];
const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => {
const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length));
if (idx >= 0) {
const img = images[idx]!;
return `![${altText}](${img.filename})`;
}
return match;
});
// Collect all image URLs first, then download.
const imageUrls: Array<{ url: string; altText: string }> = [];
let match: RegExpExecArray | null;
const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
while ((match = collectRegex.exec(markdown)) !== null) {
imageUrls.push({ url: match[2]!, altText: match[1]! });
}
for (let i = 0; i < imageUrls.length; i++) {
const { url, altText } = imageUrls[i]!;
const filename = extractFilename(altText, url, i);
try {
const imgResp = await fetch(url);
if (!imgResp.ok) continue;
const data = new Uint8Array(await imgResp.arrayBuffer());
images.push({ filename, data });
} catch {
// Best-effort: skip images that fail to download.
}
}
// Rewrite markdown with local image paths.
let finalMarkdown = markdown;
let imageIdx = 0;
finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => {
if (imageIdx < images.length) {
const img = images[imageIdx]!;
imageIdx++;
return `![${altText}](${img.filename})`;
}
return match;
});
if (pageCount === 0) {
pageCount = Math.max(1, images.length);
}
const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD;
return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId };
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** Derive a clean filename from the alt text or URL. */
function extractFilename(altText: string, url: string, index: number): string {
// Try alt text first (docmind often puts the original filename).
if (altText !== "" && altText.length < 100) {
const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_");
if (cleaned.length > 0) return cleaned;
}
// Fall back to URL path.
const urlPath = new URL(url).pathname;
const base = basename(urlPath);
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* ADR-0027: pdf_to_md_bundle capability adapter.
*
* Converts a PDF in the run's workspace into a Markdown bundle (md + extracted
* images) by calling the MinerU document parsing service, writing outputs into
* the workspace, and recording consumption on a UsageFact (ADR-0026).
*
* Invariants (ADR-0027):
* 1. Credential isolation — the capability credential is resolved in Hub and
* never reaches the Agent process. The MineruClient receives it as a
* call argument, not from the environment.
* 2. Workspace containment — input and output paths are confined to the
* run's workspace dir (ADR-0018 AgentSurface). Escapes are rejected.
* 3. Mandatory fact — a successful invocation always writes ≥1 UsageFact
* with kind=external_capability, even when costUsd is null (ADR-0022:
* missing cost ≠ zero).
*/
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve, relative, isAbsolute } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import {
CAPABILITIES,
type CapabilityAdapter,
type CapabilityInvocationInput,
type CapabilityInvocationResult,
type CapabilityOutputArtifact,
} from "./types.js";
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
const PROVIDER_ID = "aliyun_docmind";
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
export class CapabilityPathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "CapabilityPathEscape";
}
}
/** Resolve a workspace-relative path, rejecting escapes (ADR-0018 AgentSurface). */
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
if (isAbsolute(requestedPath)) {
const rel = relative(workspaceDir, requestedPath);
if (rel.startsWith("..") || rel === "") {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return requestedPath;
}
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..")) {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return resolved;
}
export interface PdfToMdBundleDeps {
readonly secrets: LocalSecretEnvelope;
readonly client: CapabilityProviderClient;
readonly prisma: PrismaClient;
}
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
capabilityId: CAPABILITY_ID,
async invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult> {
const descriptor = CAPABILITIES[CAPABILITY_ID];
// 1. Resolve org-scoped credential (fail-closed, ADR-0024/0027).
const credential = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
organizationId: input.organizationId,
capabilityId: CAPABILITY_ID,
});
// 2. Confine input + output paths to the workspace (ADR-0018).
const absoluteInput = confineToWorkspace(input.inputPath, input.workspaceDir);
const absoluteOutputDir = confineToWorkspace(input.outputDir, input.workspaceDir);
await mkdir(absoluteOutputDir, { recursive: true });
// 3. Call the backing service.
let result;
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
if (result.markdown === "") {
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Write outputs into the workspace.
const artifacts: CapabilityOutputArtifact[] = [];
const mdPath = join(absoluteOutputDir, "document.md");
await writeFile(mdPath, result.markdown, "utf8");
artifacts.push({ path: relative(input.workspaceDir, mdPath), kind: "markdown" });
for (const image of result.images) {
const imagePath = join(absoluteOutputDir, image.filename);
const rel = relative(absoluteOutputDir, imagePath);
if (rel.startsWith("..")) {
// Defensive: image filename must not escape the output dir.
throw new CapabilityPathEscape(image.filename, absoluteOutputDir);
}
await writeFile(imagePath, image.data);
artifacts.push({ path: relative(input.workspaceDir, imagePath), kind: "image" });
}
// 5. Write the UsageFact (ADR-0026/0027). Always written on success;
// costUsd null means unknown, NOT zero (ADR-0022).
const occurredAt = new Date();
await deps.prisma.usageFact.create({
data: {
runId: input.runId,
occurredAt,
kind: "external_capability",
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
costSource: result.costUsd !== null ? "provider_reported" : "unknown",
capabilityId: CAPABILITY_ID,
correlationId: result.requestId,
metadata: {},
},
});
return {
artifacts,
consumption: {
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
correlationId: result.requestId,
},
};
},
};
}
+101
View File
@@ -0,0 +1,101 @@
/**
* ADR-0027: External capability types shared across the adapter layer.
*
* A capability is a platform-registered, org-enabled document/media transform
* invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
* and records consumption on a UsageFact (ADR-0026).
*/
import type { PrismaClient, Prisma } from "@prisma/client";
/** Stable capability identifiers registered with the platform (ADR-0027). */
export const CAPABILITY_IDS = [
"pdf_to_md_bundle",
"audio_video_to_text",
] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
/** Non-token metering unit for a capability (ADR-0026/0027). */
export interface CapabilityDescriptor {
readonly id: CapabilityId;
readonly meteringUnit: string;
}
/** Known capabilities and their metering units. Code-level registry. */
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
};
/** Input passed to a capability adapter invocation. */
export interface CapabilityInvocationInput {
readonly runId: string;
readonly organizationId: string;
readonly projectId: string;
/** Absolute workspace dir of the run's project (ADR-0018 surface root). */
readonly workspaceDir: string;
/** Workspace-relative path to the input file (PDF, audio, …). */
readonly inputPath: string;
/** Workspace-relative directory to write outputs into. Created if absent. */
readonly outputDir: string;
/** Prisma client for UsageFact writes. */
readonly prisma: PrismaClient;
}
/** A successfully produced output artifact (file written into workspace). */
export interface CapabilityOutputArtifact {
/** Workspace-relative path of the written artifact. */
readonly path: string;
readonly kind: "markdown" | "image" | "metadata" | "other";
}
/** Consumption recorded for one invocation (written to UsageFact). */
export interface CapabilityConsumption {
/** The backing service provider id (e.g. "mineru", "openai_whisper"). */
readonly provider: string;
/** Model id if the service reports one; null for non-model services. */
readonly model: string | null;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds). */
readonly quantity: number;
readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */
readonly costUsd: number | null;
/** External request id for reconciliation. */
readonly correlationId: string | null;
}
/** Result of a successful capability invocation. */
export interface CapabilityInvocationResult {
readonly artifacts: readonly CapabilityOutputArtifact[];
readonly consumption: CapabilityConsumption;
}
/** A capability adapter: resolves credentials, calls the service, writes output. */
export interface CapabilityAdapter {
readonly capabilityId: CapabilityId;
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
export interface CapabilitySecretPayload {
readonly schemaVersion: 1;
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) {
super(`no ACTIVE capability connection for ${capabilityId} in org ${organizationId}`);
this.name = "CapabilityConnectionUnavailable";
}
}
/** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient;
+144
View File
@@ -0,0 +1,144 @@
/**
* Provider model catalog: fetches the list of models available to an
* Organization from its ACTIVE provider connection (OpenRouter), with an
* in-memory TTL cache so the admin model picker stays responsive without
* hammering the upstream API on every page load.
*
* The model list is **derived** from the provider connection — there is no
* separate model table to maintain. When an org activates a provider, its
* models become selectable; when the provider is disabled, the list empties.
*/
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { decryptStoredProviderCredential } from "./providerConnections.js";
/** A model entry surfaced to the admin model picker. */
export interface ProviderModelEntry {
readonly id: string;
readonly label: string;
readonly toolCapable: boolean;
}
/** A model entry as returned by OpenRouter's /v1/models endpoint. */
interface OpenRouterModel {
readonly id: string;
readonly name: string;
readonly supported_parameters: readonly string[];
}
interface OpenRouterModelsResponse {
readonly data: readonly OpenRouterModel[];
}
/** Cache entry: models + expiry timestamp. */
interface CacheEntry {
readonly models: readonly ProviderModelEntry[];
readonly expiresAt: number;
}
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Per-organization model catalog cache. Keyed by organizationId so that
* multiple orgs in the same Silo don't cross-pollute. The cache is intentionally
* process-local: it survives for the lifetime of the Hub process and is
* invalidated by TTL, not by DB events. A cache miss re-fetches from the
* provider API.
*/
export class ProviderModelCatalog {
private readonly cache = new Map<string, CacheEntry>();
private readonly fetchImpl: typeof fetch;
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly ttlMs: number = DEFAULT_TTL_MS,
fetchImpl: typeof fetch = fetch,
) {
this.fetchImpl = fetchImpl;
}
/**
* List tool-capable models available to the organization's ACTIVE provider
* connection. Returns cached results when fresh; otherwise fetches from the
* provider API. Falls back to an empty list (not an error) when the org has
* no ACTIVE provider — the admin sees the env-default model from the
* separate env fallback in the route.
*/
async listModels(organizationId: string): Promise<readonly ProviderModelEntry[]> {
const cached = this.cache.get(organizationId);
if (cached !== undefined && cached.expiresAt > Date.now()) {
return cached.models;
}
const credential = await this.resolveActiveProviderCredential(organizationId);
if (credential === null) return [];
const models = await this.fetchModelsFromProvider(credential);
this.cache.set(organizationId, {
models,
expiresAt: Date.now() + this.ttlMs,
});
return models;
}
/** Force a cache invalidation (e.g. after provider connection changes). */
invalidate(organizationId: string): void {
this.cache.delete(organizationId);
}
private async resolveActiveProviderCredential(
organizationId: string,
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string } | null> {
const connection = await this.prisma.organizationProviderConnection.findFirst({
where: { organizationId, status: "ACTIVE" },
include: { activeSecretVersion: true },
});
const version = connection?.activeSecretVersion;
if (connection === null || version === null || version === undefined || version.retiredAt !== null) {
return null;
}
const payload = decryptStoredProviderCredential(this.secrets, {
organizationId,
connectionId: connection.id,
providerId: connection.providerId,
secretVersionId: version.id,
envelopeVersion: version.envelopeVersion,
keyId: version.keyId,
envelope: version.envelope,
});
return {
baseUrl: payload.baseUrl,
authToken: payload.authToken,
anthropicApiKey: payload.anthropicApiKey,
};
}
private async fetchModelsFromProvider(
credential: { readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string },
): Promise<readonly ProviderModelEntry[]> {
const base = credential.baseUrl.endsWith("/") ? credential.baseUrl : `${credential.baseUrl}/`;
// Filter to models that support tool calling — the agent runner requires it.
const url = new URL("v1/models?supported_parameters=tools", base);
const headers: Record<string, string> = {
authorization: `Bearer ${credential.authToken}`,
accept: "application/json",
};
if (credential.anthropicApiKey !== "") headers["x-api-key"] = credential.anthropicApiKey;
const response = await this.fetchImpl(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`provider models request failed: status ${response.status}`);
}
const body = (await response.json()) as OpenRouterModelsResponse;
return body.data.map((m) => ({
id: m.id,
label: m.name,
toolCapable: m.supported_parameters.includes("tools"),
}));
}
}
+24
View File
@@ -1,3 +1,27 @@
/**
* 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.
*/
/** Paths that must not consume the silo HTTP request-rate budget. */
export function isSiloHttpRateLimitExempt(url: string): boolean {
const path = (url.split("?", 1)[0] ?? url) || "/";
if (path === "/api/healthz") return true;
// SvelteKit build output 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).
if (path === "/admin" || path.startsWith("/admin/")) return true;
return false;
}
export class SiloFixedWindowRateLimiter { export class SiloFixedWindowRateLimiter {
private windowStartedAt: number; private windowStartedAt: number;
private used = 0; private used = 0;
+2
View File
@@ -42,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
request_approval: "thumb-up-filled", request_approval: "thumb-up-filled",
feishu_read_context: "search-filled", feishu_read_context: "search-filled",
feishu_download_resource: "download-filled", feishu_download_resource: "download-filled",
webfetch: "link-copy-filled",
websearch: "search-filled",
}; };
function toolIcon(toolName: string): string { function toolIcon(toolName: string): string {
+63 -4
View File
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js"; import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js"; import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js"; import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
export interface FileDeliveryToolOptions { export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
readonly chatId: string; readonly chatId: string;
readonly projectId: string; readonly projectId: string;
readonly organizationId: string;
readonly runId: string; readonly runId: string;
readonly workspaceRoot?: string | undefined; readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void; readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined; readonly tools?: readonly CphHubMcpToolId[] | undefined;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
} }
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance { export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
@@ -182,18 +189,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"request_approval", "request_approval",
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.", "Send an interactive Feishu approval/confirmation card in the current chat and wait for the user's button click.",
{ {
title: z.string().describe("Card title."), title: z.string().describe("Card title."),
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."), body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
options: z options: z
.array(z.object({ .array(z.object({
label: z.string().describe("Button label shown to the user."), label: z.string().describe("Button label shown to the user."),
value: z.string().describe("Action value returned to the agent when this button is clicked."), value: z.string().describe("Stable option value returned after the user clicks."),
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."), style: z.enum(["default", "primary", "danger"]).optional(),
})) }))
.min(1) .min(1)
.describe("Button choices for the approval card."), .describe("One or more response options."),
}, },
async (args) => { async (args) => {
const messageId = await sendApprovalCard( const messageId = await sendApprovalCard(
@@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
); );
} }
if (enabledTools.has("convert_pdf_to_md")) {
const adapter = createPdfToMdBundleAdapter({
secrets: options.secretEnvelope,
client: new AliyunDocmindClient(),
prisma: options.prisma,
});
tools.push(
tool(
"convert_pdf_to_md",
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
{
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
},
async (args) => {
try {
const result = await adapter.invoke({
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
inputPath: args.input_path,
outputDir: args.output_dir,
prisma: options.prisma,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
};
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools); const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({ return createSdkMcpServer({
name: "cph_hub", name: "cph_hub",
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) { if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing."); instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
} }
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
);
}
return instructions.join(" "); return instructions.join(" ");
} }
+54 -24
View File
@@ -92,7 +92,18 @@ export function createSlashCommandRegistry(
finishedAt: { not: null }, finishedAt: { not: null },
}, },
orderBy: { finishedAt: "asc" }, orderBy: { finishedAt: "asc" },
select: { model: true, provider: true, inputTokens: true, outputTokens: true, costUsd: true }, select: {
usageFacts: {
select: {
provider: true,
model: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
},
orderBy: { occurredAt: "asc" },
},
},
}); });
await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions); await sendText(rt, chatId, formatUsageReport(runs, scope), sendOptions);
}, },
@@ -118,18 +129,24 @@ async function currentRoleSessionIds(
return sessions.map((session) => session.id); return sessions.map((session) => session.id);
} }
interface UsageRun { interface UsageRunWithFacts {
readonly model: string; readonly usageFacts: readonly {
readonly provider: string; readonly provider: string;
readonly inputTokens: number | null; readonly model: string | null;
readonly outputTokens: number | null; readonly inputTokens: number | null;
readonly costUsd: unknown; readonly outputTokens: number | null;
readonly costUsd: unknown;
}[];
} }
function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "project"): string { function formatUsageReport(runs: readonly UsageRunWithFacts[], scope: "current" | "project"): string {
if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`; if (runs.length === 0) return `${scope === "current" ? "当前角色会话" : "当前项目"}还没有已结束的 Agent run。`;
// Bucket by (fact.provider, fact.model) — ADR-0026: external capabilities
// carry their own provider/model and contribute their own meter, so a run
// with a main loop + an external call lands in two buckets. A run with no
// cost-bearing fact is "unrecorded" (ADR-0022: missing cost ≠ zero).
const buckets = new Map<string, { const buckets = new Map<string, {
provider: string; model: string; runs: number; inputTokens: number; outputTokens: number; costUsd: number; provider: string; model: string; facts: number; inputTokens: number; outputTokens: number; costUsd: number;
}>(); }>();
let recordedRuns = 0; let recordedRuns = 0;
let unrecordedRuns = 0; let unrecordedRuns = 0;
@@ -137,21 +154,34 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
let totalOutputTokens = 0; let totalOutputTokens = 0;
let totalCostUsd = 0; let totalCostUsd = 0;
for (const run of runs) { for (const run of runs) {
const costUsd = decimalToNumberOrNull(run.costUsd); const facts = run.usageFacts;
if (costUsd === null) { unrecordedRuns++; continue; } if (facts.length === 0 || !facts.some((f) => f.costUsd !== null && f.costUsd !== undefined)) {
unrecordedRuns++;
// Tokens from unrecorded runs still count toward totals (matches pre-0026).
for (const f of facts) {
totalInputTokens += f.inputTokens ?? 0;
totalOutputTokens += f.outputTokens ?? 0;
}
continue;
}
recordedRuns++; recordedRuns++;
const key = `${run.provider}\u0000${run.model}`; for (const f of facts) {
const bucket = buckets.get(key) ?? { const costUsd = decimalToNumberOrNull(f.costUsd);
provider: run.provider, model: run.model, runs: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, if (costUsd === null) continue;
}; const model = f.model ?? "(unknown model)";
bucket.runs++; const key = `${f.provider}\u0000${model}`;
bucket.inputTokens += run.inputTokens ?? 0; const bucket = buckets.get(key) ?? {
bucket.outputTokens += run.outputTokens ?? 0; provider: f.provider, model, facts: 0, inputTokens: 0, outputTokens: 0, costUsd: 0,
bucket.costUsd += costUsd; };
buckets.set(key, bucket); bucket.facts += 1;
totalInputTokens += run.inputTokens ?? 0; bucket.inputTokens += f.inputTokens ?? 0;
totalOutputTokens += run.outputTokens ?? 0; bucket.outputTokens += f.outputTokens ?? 0;
totalCostUsd += costUsd; bucket.costUsd += costUsd;
buckets.set(key, bucket);
totalInputTokens += f.inputTokens ?? 0;
totalOutputTokens += f.outputTokens ?? 0;
totalCostUsd += costUsd;
}
} }
const lines = [ const lines = [
`${scope === "current" ? "当前角色会话" : "当前项目"}用量`, `${scope === "current" ? "当前角色会话" : "当前项目"}用量`,
@@ -160,7 +190,7 @@ function formatUsageReport(runs: readonly UsageRun[], scope: "current" | "projec
]; ];
if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`); if (unrecordedRuns > 0) lines.push(`另有 ${formatInteger(unrecordedRuns)} 个 run 未记录成本。`);
for (const bucket of buckets.values()) { for (const bucket of buckets.values()) {
lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.runs)} runs, ${formatUsd(bucket.costUsd)}`); lines.push(`- ${bucket.provider} / ${bucket.model}: ${formatInteger(bucket.facts)} , ${formatUsd(bucket.costUsd)}`);
} }
return lines.join("\n"); return lines.join("\n");
} }
+35 -3
View File
@@ -14,6 +14,7 @@ import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { import {
sendText, sendText,
sendTextMessage, sendTextMessage,
@@ -93,6 +94,7 @@ interface TriggerDeps {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
readonly settings: RuntimeSettings; readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
readonly secretEnvelope: LocalSecretEnvelope;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>; readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined; readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
@@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run // Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete). // lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text. // Shows tool-use trace panel + reasoning panel + answer text.
const deliveredFiles: string[] = [];
const card = new StreamingAgentCard({ const card = new StreamingAgentCard({
runId: run.id, runId: run.id,
rt, rt,
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
patchIntervalMs: undefined, patchIntervalMs: undefined,
maxMessageLength: undefined, maxMessageLength: undefined,
}); });
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({ const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt, rt,
chatId, chatId,
projectId, projectId,
organizationId: siloOrganizationId,
runId: run.id, runId: run.id,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions, sendOptions,
approvalManager, approvalManager,
tools: cphHubMcpToolsForRole(roleTools), tools: cphHubMcpToolsForRole(roleTools),
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
onDelivered: (path) => { onDelivered: (path) => {
deliveredFiles.push(path); deliveredFiles.push(path);
}, },
@@ -604,6 +609,32 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) }, data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
}); });
} }
// ADR-0026: the UsageFact ledger is the truth; AgentRun.costUsd /
// inputTokens / outputTokens are a derived rollup cache for existing
// readers. We write the fact first, then mirror it onto the run.
// They are separate statements (not one transaction) so the AgentRun
// row lock is held for the shortest possible window and concurrent
// workspace teardown cannot deadlock on the FK ShareLock. A crash
// between the two leaves the cache stale, but the cache is derived
// (the usage service re-reads facts), so staleness is recoverable;
// the reverse order would lose the truth and is unrecoverable.
const finishedAt = new Date();
const reportedCost = result.costUsd;
const costSource = reportedCost !== undefined ? "provider_reported" : "unknown";
await deps.prisma.usageFact.create({
data: {
runId: run.id,
occurredAt: finishedAt,
kind: "model_completion",
provider: providerId,
model,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
costUsd: reportedCost ?? null,
costSource,
metadata: {},
},
});
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
data: { data: {
@@ -618,9 +649,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: "FAILED", : "FAILED",
inputTokens: result.usage.inputTokens, inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens, outputTokens: result.usage.outputTokens,
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}), costUsd: reportedCost ?? null,
costSource,
error: result.error ?? null, error: result.error ?? null,
finishedAt: new Date(), finishedAt,
}, },
}); });
await writeAudit(deps.prisma, { await writeAudit(deps.prisma, {
+5 -2
View File
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js"; import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
import { readServerBinding } from "./settings/server.js"; import { readServerBinding } from "./settings/server.js";
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js"; import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js"; import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit }); const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000); const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/healthz") return; // Static SPA assets / admin HTML shell / healthz do not count toward the
// silo requestRate budget (a full admin load can pull dozens of chunks).
if (isSiloHttpRateLimitExempt(request.url)) return;
const decision = requestLimiter.consume(); const decision = requestLimiter.consume();
if (!decision.allowed) { if (!decision.allowed) {
await reply await reply
@@ -160,6 +162,7 @@ export async function startHub(): Promise<void> {
prisma, prisma,
settings: runtimeSettings, settings: runtimeSettings,
logger: app.log, logger: app.log,
secretEnvelope,
projectWorkspaceRoot, projectWorkspaceRoot,
publicBaseUrl, publicBaseUrl,
siloOrganizationId: siloOrganization.id, siloOrganizationId: siloOrganization.id,
+35
View File
@@ -73,9 +73,28 @@ export async function getSessionDetail(
inputTokens: true, inputTokens: true,
outputTokens: true, outputTokens: true,
costUsd: true, costUsd: true,
costSource: true,
startedAt: true, startedAt: true,
finishedAt: true, finishedAt: true,
error: true, error: true,
usageFacts: {
select: {
id: true,
occurredAt: true,
kind: true,
provider: true,
model: true,
inputTokens: true,
outputTokens: true,
quantity: true,
unit: true,
costUsd: true,
costSource: true,
capabilityId: true,
correlationId: true,
},
orderBy: { occurredAt: "asc" },
},
}, },
orderBy: { startedAt: "desc" }, orderBy: { startedAt: "desc" },
take: 100, take: 100,
@@ -106,9 +125,25 @@ export async function getSessionDetail(
inputTokens: r.inputTokens, inputTokens: r.inputTokens,
outputTokens: r.outputTokens, outputTokens: r.outputTokens,
costUsd: r.costUsd === null ? null : Number(r.costUsd), costUsd: r.costUsd === null ? null : Number(r.costUsd),
costSource: r.costSource,
startedAt: r.startedAt.toISOString(), startedAt: r.startedAt.toISOString(),
finishedAt: r.finishedAt?.toISOString() ?? null, finishedAt: r.finishedAt?.toISOString() ?? null,
error: r.error, error: r.error,
usageFacts: r.usageFacts.map((f) => ({
id: f.id,
occurredAt: f.occurredAt.toISOString(),
kind: f.kind,
provider: f.provider,
model: f.model,
inputTokens: f.inputTokens,
outputTokens: f.outputTokens,
quantity: f.quantity === null ? null : Number(f.quantity),
unit: f.unit,
costUsd: f.costUsd === null ? null : Number(f.costUsd),
costSource: f.costSource,
capabilityId: f.capabilityId,
correlationId: f.correlationId,
})),
})), })),
}; };
} }
+343 -112
View File
@@ -1,15 +1,22 @@
/** /**
* Org / project usage rollups from AgentRun cost facts (ADR-0021). * Org / project usage rollups from the UsageFact ledger (ADR-0021, ADR-0026).
* Operational usage accounting, not payment collection. Organizations may use * Operational usage accounting, not payment collection. Organizations may use
* either their own provider credentials or a distinct platform-managed * either their own provider credentials or a distinct platform-managed
* connection; commercial billing remains outside the pilot scope. * connection; commercial billing remains outside the pilot scope.
*
* The truth is in `UsageFact`; `AgentRun.costUsd / inputTokens / outputTokens`
* are a derived rollup cache. This service reads `UsageFact` directly so that
* external-capability consumption (PDFMD, ASR, ) is attributed correctly,
* not just the main model loop. A run with no cost-bearing fact is
* `runsWithoutCost` ADR-0022: missing cost zero.
*
* Breakdown buckets keep kind / provider / model / capability / unit, so the
* admin UI can separate model tokens from non-token external meters instead of
* collapsing everything into a single input/output token total.
*/ */
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
export interface ProjectUsageRow { export interface UsageTotals {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
readonly runCount: number; readonly runCount: number;
readonly runsWithCost: number; readonly runsWithCost: number;
readonly runsWithoutCost: number; readonly runsWithoutCost: number;
@@ -18,17 +25,264 @@ export interface ProjectUsageRow {
readonly costUsd: number | null; readonly costUsd: number | null;
} }
export interface ProjectUsageRow extends UsageTotals {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
}
/** One ledger slice: kind + provider + model + capability + unit. */
export interface UsageBreakdownRow {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly factCount: number;
readonly factsWithCost: number;
readonly factsWithoutCost: number;
readonly inputTokens: number;
readonly outputTokens: number;
/** Sum of quantity when unit is non-null; null when this bucket is token-only. */
readonly quantity: number | null;
readonly costUsd: number | null;
}
export interface UsageReport { export interface UsageReport {
readonly from: string | null; readonly from: string | null;
readonly to: string | null; readonly to: string | null;
readonly projects: readonly ProjectUsageRow[]; readonly projects: readonly ProjectUsageRow[];
readonly totals: { readonly totals: UsageTotals;
readonly runCount: number; readonly breakdown: readonly UsageBreakdownRow[];
readonly runsWithCost: number; }
readonly runsWithoutCost: number;
readonly inputTokens: number; export interface ProjectUsageReport extends ProjectUsageRow {
readonly outputTokens: number; readonly from: string | null;
readonly costUsd: number | null; readonly to: string | null;
readonly breakdown: readonly UsageBreakdownRow[];
}
type FactRow = {
readonly kind: string;
readonly provider: string;
readonly model: string | null;
readonly capabilityId: string | null;
readonly unit: string | null;
readonly quantity: unknown;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
readonly costUsd: unknown;
};
type RunWithFacts = {
readonly projectId: string;
readonly usageFacts: readonly FactRow[];
};
type MutableTotals = {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
type MutableBreakdown = {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
};
type MutableProjectRow = MutableTotals & {
readonly projectId: string;
readonly projectName: string;
readonly folderId: string | null;
};
const FACT_SELECT = {
kind: true,
provider: true,
model: true,
capabilityId: true,
unit: true,
quantity: true,
inputTokens: true,
outputTokens: true,
costUsd: true,
} as const;
function factCostUsdToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
function factQuantityToNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : null;
}
function breakdownKey(f: FactRow): string {
return [
f.kind,
f.provider,
f.model ?? "",
f.capabilityId ?? "",
f.unit ?? "",
].join("\u0000");
}
function emptyMutableTotals(): MutableTotals {
return {
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
};
}
function addCost(existing: number | null, add: number): number {
return (existing ?? 0) + add;
}
function rollupRunTokensAndCost(facts: readonly FactRow[]): {
readonly hasCost: boolean;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number | null;
} {
let inputTokens = 0;
let outputTokens = 0;
let costUsd: number | null = null;
let hasCost = false;
for (const f of facts) {
inputTokens += f.inputTokens ?? 0;
outputTokens += f.outputTokens ?? 0;
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
hasCost = true;
costUsd = addCost(costUsd, c);
}
}
return { hasCost, inputTokens, outputTokens, costUsd };
}
function accumulateBreakdown(
buckets: Map<string, MutableBreakdown>,
facts: readonly FactRow[],
): void {
for (const f of facts) {
const key = breakdownKey(f);
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = {
kind: f.kind,
provider: f.provider,
model: f.model,
capabilityId: f.capabilityId,
unit: f.unit,
factCount: 0,
factsWithCost: 0,
factsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
quantity: null,
costUsd: null,
};
buckets.set(key, bucket);
}
bucket.factCount += 1;
bucket.inputTokens += f.inputTokens ?? 0;
bucket.outputTokens += f.outputTokens ?? 0;
const qty = factQuantityToNumber(f.quantity);
if (qty !== null) {
bucket.quantity = (bucket.quantity ?? 0) + qty;
}
const c = factCostUsdToNumber(f.costUsd);
if (c !== null) {
bucket.factsWithCost += 1;
bucket.costUsd = addCost(bucket.costUsd, c);
} else {
bucket.factsWithoutCost += 1;
}
}
}
function freezeBreakdown(buckets: Map<string, MutableBreakdown>): UsageBreakdownRow[] {
return [...buckets.values()]
.map((b) => ({
kind: b.kind,
provider: b.provider,
model: b.model,
capabilityId: b.capabilityId,
unit: b.unit,
factCount: b.factCount,
factsWithCost: b.factsWithCost,
factsWithoutCost: b.factsWithoutCost,
inputTokens: b.inputTokens,
outputTokens: b.outputTokens,
quantity: b.quantity,
costUsd: b.costUsd,
}))
.sort((a, b) => {
const kindCmp = a.kind.localeCompare(b.kind);
if (kindCmp !== 0) return kindCmp;
const provCmp = a.provider.localeCompare(b.provider);
if (provCmp !== 0) return provCmp;
const capA = a.capabilityId ?? "";
const capB = b.capabilityId ?? "";
const capCmp = capA.localeCompare(capB);
if (capCmp !== 0) return capCmp;
const modelA = a.model ?? "";
const modelB = b.model ?? "";
return modelA.localeCompare(modelB);
});
}
function freezeTotals(t: MutableTotals): UsageTotals {
return {
runCount: t.runCount,
runsWithCost: t.runsWithCost,
runsWithoutCost: t.runsWithoutCost,
inputTokens: t.inputTokens,
outputTokens: t.outputTokens,
costUsd: t.costUsd,
};
}
function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void {
const roll = rollupRunTokensAndCost(facts);
totals.runCount += 1;
if (roll.hasCost) {
totals.runsWithCost += 1;
totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0);
} else {
totals.runsWithoutCost += 1;
}
totals.inputTokens += roll.inputTokens;
totals.outputTokens += roll.outputTokens;
}
function emptyReport(from?: Date, to?: Date): UsageReport {
return {
from: from?.toISOString() ?? null,
to: to?.toISOString() ?? null,
projects: [],
totals: freezeTotals(emptyMutableTotals()),
breakdown: [],
}; };
} }
@@ -54,85 +308,66 @@ export async function getOrgUsage(
return emptyReport(input.from, input.to); return emptyReport(input.from, input.to);
} }
const runWhere: Prisma.AgentRunWhereInput = { const projectIds = projects.map((p) => p.id);
projectId: { in: projects.map((p) => p.id) }, // Date range filters by run.startedAt, matching the pre-ADR-0026 semantics:
...(input.from !== undefined || input.to !== undefined // a run is in or out of the window based on when it started, and all of its
? { // facts come along. Filtering facts by occurredAt independently would let a
startedAt: { // run contribute partial cost to a window it doesn't belong to.
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
};
const runs = await prisma.agentRun.findMany({ const runs = await prisma.agentRun.findMany({
where: runWhere, where: {
projectId: { in: projectIds },
...(input.from !== undefined || input.to !== undefined
? {
startedAt: {
...(input.from !== undefined ? { gte: input.from } : {}),
...(input.to !== undefined ? { lte: input.to } : {}),
},
}
: {}),
},
select: { select: {
projectId: true, projectId: true,
inputTokens: true, usageFacts: {
outputTokens: true, select: FACT_SELECT,
costUsd: true, orderBy: { occurredAt: "asc" },
},
}, },
}); });
type MutableRow = { const byProject = new Map<string, MutableProjectRow>();
projectId: string;
projectName: string;
folderId: string | null;
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
};
const byProject = new Map<string, MutableRow>();
for (const p of projects) { for (const p of projects) {
byProject.set(p.id, { byProject.set(p.id, {
projectId: p.id, projectId: p.id,
projectName: p.name, projectName: p.name,
folderId: p.folderId, folderId: p.folderId,
runCount: 0, ...emptyMutableTotals(),
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
}); });
} }
for (const run of runs) { const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs as readonly RunWithFacts[]) {
const row = byProject.get(run.projectId); const row = byProject.get(run.projectId);
if (row === undefined) continue; if (row === undefined) continue;
row.runCount += 1; applyRunToTotals(row, run.usageFacts);
row.inputTokens += run.inputTokens ?? 0; accumulateBreakdown(breakdownBuckets, run.usageFacts);
row.outputTokens += run.outputTokens ?? 0;
if (run.costUsd === null) {
row.runsWithoutCost += 1;
} else {
row.runsWithCost += 1;
const cost = Number(run.costUsd);
row.costUsd = (row.costUsd ?? 0) + cost;
}
} }
const projectsOut: ProjectUsageRow[] = [...byProject.values()]; const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({
let runCount = 0; projectId: r.projectId,
let runsWithCost = 0; projectName: r.projectName,
let runsWithoutCost = 0; folderId: r.folderId,
let inputTokens = 0; ...freezeTotals(r),
let outputTokens = 0; }));
let costUsd: number | null = null;
const totals = emptyMutableTotals();
for (const row of projectsOut) { for (const row of projectsOut) {
runCount += row.runCount; totals.runCount += row.runCount;
runsWithCost += row.runsWithCost; totals.runsWithCost += row.runsWithCost;
runsWithoutCost += row.runsWithoutCost; totals.runsWithoutCost += row.runsWithoutCost;
inputTokens += row.inputTokens; totals.inputTokens += row.inputTokens;
outputTokens += row.outputTokens; totals.outputTokens += row.outputTokens;
if (row.costUsd !== null) { if (row.costUsd !== null) {
costUsd = (costUsd ?? 0) + row.costUsd; totals.costUsd = addCost(totals.costUsd, row.costUsd);
} }
} }
@@ -140,14 +375,8 @@ export async function getOrgUsage(
from: input.from?.toISOString() ?? null, from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null, to: input.to?.toISOString() ?? null,
projects: projectsOut, projects: projectsOut,
totals: { totals: freezeTotals(totals),
runCount, breakdown: freezeBreakdown(breakdownBuckets),
runsWithCost,
runsWithoutCost,
inputTokens,
outputTokens,
costUsd,
},
}; };
} }
@@ -159,7 +388,7 @@ export async function getProjectUsage(
readonly from?: Date | undefined; readonly from?: Date | undefined;
readonly to?: Date | undefined; readonly to?: Date | undefined;
}, },
): Promise<ProjectUsageRow> { ): Promise<ProjectUsageReport> {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: input.projectId, organizationId: input.organizationId }, where: { id: input.projectId, organizationId: input.organizationId },
select: { id: true, name: true, folderId: true }, select: { id: true, name: true, folderId: true },
@@ -167,39 +396,41 @@ export async function getProjectUsage(
if (project === null) { if (project === null) {
throw new Error(`project not found: ${input.projectId}`); throw new Error(`project not found: ${input.projectId}`);
} }
const report = await getOrgUsage(prisma, {
organizationId: input.organizationId,
from: input.from,
to: input.to,
});
const row = report.projects.find((p) => p.projectId === project.id);
return (
row ?? {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
runCount: 0,
runsWithCost: 0,
runsWithoutCost: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: null,
}
);
}
function emptyReport(from?: Date, to?: Date): UsageReport { const runs = await prisma.agentRun.findMany({
return { where: {
from: from?.toISOString() ?? null, projectId: project.id,
to: to?.toISOString() ?? null, ...(input.from !== undefined || input.to !== undefined
projects: [], ? {
totals: { startedAt: {
runCount: 0, ...(input.from !== undefined ? { gte: input.from } : {}),
runsWithCost: 0, ...(input.to !== undefined ? { lte: input.to } : {}),
runsWithoutCost: 0, },
inputTokens: 0, }
outputTokens: 0, : {}),
costUsd: null,
}, },
select: {
usageFacts: {
select: FACT_SELECT,
orderBy: { occurredAt: "asc" },
},
},
});
const totals = emptyMutableTotals();
const breakdownBuckets = new Map<string, MutableBreakdown>();
for (const run of runs) {
applyRunToTotals(totals, run.usageFacts as readonly FactRow[]);
accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]);
}
return {
projectId: project.id,
projectName: project.name,
folderId: project.folderId,
...freezeTotals(totals),
from: input.from?.toISOString() ?? null,
to: input.to?.toISOString() ?? null,
breakdown: freezeBreakdown(breakdownBuckets),
}; };
} }
+7 -12
View File
@@ -182,12 +182,16 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`); throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
} }
// The model list is the env-based fallback used when a role has no
// explicit defaultModel. Role defaultModels are chosen from the provider
// model catalog (admin surface) and may not be in the env list — we don't
// validate against it at runtime; the admin already validated by selection.
const defaults = await this.envSettings.modelRegistry(scope); const defaults = await this.envSettings.modelRegistry(scope);
const enabledModels = new Set(defaults.listModels().map((model) => model.id)); const fallbackModels = defaults.listModels();
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({ const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
id: role.roleId, id: role.roleId,
label: role.label, label: role.label,
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels), defaultModel: role.defaultModel ?? undefined,
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}), ...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}), ...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
skills: role.skillBindings.map((binding): RoleSkillEntry => { skills: role.skillBindings.map((binding): RoleSkillEntry => {
@@ -210,7 +214,7 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
}; };
}), }),
})); }));
return new InMemoryModelRegistry(defaults.listModels(), roles); return new InMemoryModelRegistry(fallbackModels, roles);
} }
runPolicy(input: RunPolicyInput): Promise<RunPolicy> { runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
@@ -225,15 +229,6 @@ function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly st
return value as string[]; return value as string[];
} }
function validateRoleModel(
roleId: string,
model: string | null,
enabledModels: ReadonlySet<string>,
): string | undefined {
if (model === null) return undefined;
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
return model;
}
async function loadActiveProviderSecret( async function loadActiveProviderSecret(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
+6 -13
View File
@@ -170,7 +170,7 @@ describe("admin auth + org API guards", () => {
try { try {
const res = await app.inject({ const res = await app.inject({
method: "GET", method: "GET",
url: "/auth/feishu?returnTo=/admin/org/test-default", url: "/auth/feishu?returnTo=/admin",
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
const location = res.headers.location; const location = res.headers.location;
@@ -233,7 +233,7 @@ describe("admin auth + org API guards", () => {
try { try {
const nonce = "nonce-test-1"; const nonce = "nonce-test-1";
const state = signOAuthState( const state = signOAuthState(
{ nonce, returnTo: "/admin/org/test-default" }, { nonce, returnTo: "/admin" },
SESSION_SECRET, SESSION_SECRET,
); );
const res = await app.inject({ const res = await app.inject({
@@ -242,7 +242,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` }, headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin/org/test-default"); expect(res.headers.location).toBe("/admin");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session="); expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } }); const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -293,7 +293,7 @@ describe("admin auth + org API guards", () => {
try { try {
const start = await app.inject({ const start = await app.inject({
method: "GET", method: "GET",
url: "/auth/feishu/test-default?returnTo=/admin/org/test-default/settings", url: "/auth/feishu/test-default?returnTo=/admin/settings",
}); });
expect(start.statusCode).toBe(302); expect(start.statusCode).toBe(302);
const authorize = new URL(String(start.headers.location)); const authorize = new URL(String(start.headers.location));
@@ -308,7 +308,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: nonceCookie }, headers: { cookie: nonceCookie },
}); });
expect(callback.statusCode).toBe(302); expect(callback.statusCode).toBe(302);
expect(callback.headers.location).toBe("/admin/org/test-default/settings"); expect(callback.headers.location).toBe("/admin/settings");
const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session"); const sessionCookie = cookiePair(callback.headers["set-cookie"], "cph_session");
const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); const me = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
expect(me.statusCode).toBe(200); expect(me.statusCode).toBe(200);
@@ -346,14 +346,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) }, headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
}); });
expect(defaultCallback.statusCode).toBe(302); expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe( expect(defaultCallback.headers.location).toBe("/admin");
"/auth/feishu/complete?org=Test%20Default%20Organization",
);
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
expect(complete.statusCode).toBe(200);
expect(complete.headers["content-type"]).toContain("text/html");
expect(complete.body).toContain("登录并加入组织成功");
expect(complete.body).toContain("Test Default Organization");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" }); await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
@@ -118,6 +118,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role"); })).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> { async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name); const source = join(parent, "sources", name);
await mkdir(source, { recursive: true }); await mkdir(source, { recursive: true });
@@ -0,0 +1,285 @@
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
import { DocmindClientError, type CapabilityProviderClient, type DocmindParseResult } from "../../src/capability/docmindClient.js";
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
const CAPABILITY_ID = "pdf_to_md_bundle";
const PROVIDER_ID = "aliyun_docmind";
/**
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
* - workspace containment (input + output confined to run workspace)
* - fail-closed credential resolution (no ACTIVE connection error)
* - UsageFact attribution with non-token meter (pages), costSource rules
* - outputs (md + images) written into workspace
* - docmind client errors propagate
* The client is mocked; the prisma + envelope + filesystem are real.
*/
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-cap-"));
runId = "run-cap-test";
// AgentRun is required for the UsageFact FK.
await prisma.project.create({
data: {
id: "proj-cap",
organizationId: DEFAULT_ORG_ID,
name: "Cap Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-cap",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "convert this pdf",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "cap-conn-1",
organizationId: DEFAULT_ORG_ID,
capabilityId: CAPABILITY_ID,
status: "ACTIVE",
activatedAt: new Date(),
},
});
const envelope = testSecretEnvelope.encryptJson(
{
purpose: "capability",
organizationId: DEFAULT_ORG_ID,
connectionId: connection.id,
secretVersionId: "cap-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "cap-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "cap-sv-1" },
});
}
function mockDocmindClient(result: Partial<DocmindParseResult> = {}): CapabilityProviderClient {
const full: DocmindParseResult = {
markdown: "# Parsed Document\n\nHello world.\n\n![fig](page_1.jpg)\n",
images: [
{ filename: "page_1.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
],
pageCount: 3,
costUsd: 0.0168,
requestId: "docmind-job-abc",
...result,
};
return {
parse: vi.fn(async (): Promise<DocmindParseResult> => full),
};
}
function makeAdapter(client: CapabilityProviderClient) {
return createPdfToMdBundleAdapter({
secrets: testSecretEnvelope,
client,
prisma,
});
}
async function seedInputPdf(name: string = "input.pdf"): Promise<string> {
const dir = join(workspaceRoot, "sources");
await mkdir(dir, { recursive: true });
const path = join(dir, name);
await writeFile(path, "%PDF-1.4 fake pdf bytes");
return join("sources", name);
}
it("writes md + images into the workspace and records a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockDocmindClient();
const adapter = makeAdapter(client);
const result = await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
// Outputs landed in workspace.
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
expect(md).toContain("# Parsed Document");
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1.jpg")).toBe(true);
// Client received the decrypted credential, not the env.
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
expect(call.accessKeyId).toBe("LTAI-test-key-id");
expect(call.endpoint).toBe("docmind-api.cn-hangzhou.aliyuncs.com");
// UsageFact written with non-token meter and provider_reported cost.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
const fact = facts[0]!;
expect(fact.kind).toBe("external_capability");
expect(fact.provider).toBe(PROVIDER_ID);
expect(fact.capabilityId).toBe(CAPABILITY_ID);
expect(Number(fact.quantity)).toBe(3);
expect(fact.unit).toBe("pages");
expect(Number(fact.costUsd!)).toBeCloseTo(0.0168, 8);
expect(fact.costSource).toBe("provider_reported");
expect(fact.correlationId).toBe("docmind-job-abc");
});
it("writes UsageFact with costSource=unknown when docmind reports no cost", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockDocmindClient({ costUsd: null, requestId: null });
const adapter = makeAdapter(client);
await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
const fact = (await prisma.usageFact.findFirstOrThrow({ where: { runId } }));
expect(fact.costUsd).toBeNull();
expect(fact.costSource).toBe("unknown");
// quantity still recorded — missing cost ≠ zero consumption (ADR-0022).
expect(Number(fact.quantity)).toBe(3);
});
it("fails closed when the org has no ACTIVE capability connection", async () => {
// No connection seeded.
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/no ACTIVE capability connection/);
// No UsageFact written for a failed resolution.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects an input path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath: "../../../etc/passwd",
outputDir: "output",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("rejects an output path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockDocmindClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "../../outside",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("propagates docmind client errors without writing a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client: CapabilityProviderClient = {
parse: vi.fn(async (): Promise<DocmindParseResult> => {
throw new DocmindClientError("upstream 502", "docmind_rejected", 502);
}),
};
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/upstream 502/);
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects empty markdown output as a parse failure", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockDocmindClient({ markdown: "" });
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/empty markdown/);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach } from "vitest";
import { prisma, resetDb, seedTestOrganization, DEFAULT_ORG_ID } from "./helpers.js";
import { getOrgUsage, getProjectUsage } from "../../src/org/usage.js";
/**
* ADR-0026: org/project usage rolls up from the UsageFact ledger, not from the
* AgentRun scalar cache. These tests pin the fact-based aggregation contract:
* - a run with a cost-bearing fact runsWithCost, costUsd summed
* - a run whose facts all have null costUsd runsWithoutCost ( zero)
* - non-token meter (quantity/unit) is stored but does not corrupt token sums
* - multiple facts per run (main loop + external capability) are summed together
* - the AgentRun rollup cache is NOT consulted by getOrgUsage
*/
describe("usage rollup from UsageFact (ADR-0026)", () => {
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
});
async function seedRun(opts: {
readonly projectId: string;
readonly projectName: string;
readonly folderId?: string;
readonly facts: ReadonlyArray<{
readonly kind?: string;
readonly provider?: string;
readonly model?: string;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly costUsd?: number | null;
readonly costSource?: string;
readonly capabilityId?: string;
readonly quantity?: number | null;
readonly unit?: string | null;
}>;
}): Promise<void> {
const project = await prisma.project.create({
data: {
id: opts.projectId,
organizationId: DEFAULT_ORG_ID,
name: opts.projectName,
workspaceDir: `/tmp/test-${opts.projectId}`,
...(opts.folderId !== undefined ? { folderId: opts.folderId } : {}),
},
});
const startedAt = new Date("2026-07-18T10:00:00Z");
const finishedAt = new Date("2026-07-18T10:05:00Z");
const run = await prisma.agentRun.create({
data: {
projectId: project.id,
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
metadata: {},
status: "COMPLETED",
prompt: "test",
startedAt,
finishedAt,
// Deliberately set the rollup cache to values that differ from the
// facts, to prove getOrgUsage reads facts, not the cache.
inputTokens: 999,
outputTokens: 999,
costUsd: 999,
costSource: "provider_reported",
},
});
for (const [i, f] of opts.facts.entries()) {
await prisma.usageFact.create({
data: {
runId: run.id,
occurredAt: new Date(startedAt.getTime() + i * 1000),
kind: f.kind ?? "model_completion",
provider: f.provider ?? "openrouter",
model: f.model !== undefined ? f.model : "mock-model",
inputTokens: f.inputTokens ?? null,
outputTokens: f.outputTokens ?? null,
costUsd: f.costUsd ?? null,
costSource: f.costSource ?? (f.costUsd !== undefined && f.costUsd !== null ? "provider_reported" : "unknown"),
capabilityId: f.capabilityId ?? null,
quantity: f.quantity ?? null,
unit: f.unit ?? null,
metadata: {},
},
});
}
}
it("sums cost and tokens from facts, ignoring the AgentRun rollup cache", async () => {
await seedRun({
projectId: "proj-a",
projectName: "A",
facts: [{ inputTokens: 10, outputTokens: 5, costUsd: 0.0023 }],
});
await seedRun({
projectId: "proj-b",
projectName: "B",
facts: [{ inputTokens: 20, outputTokens: 10, costUsd: 0.01 }],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(2);
expect(report.totals.runsWithCost).toBe(2);
expect(report.totals.runsWithoutCost).toBe(0);
expect(report.totals.inputTokens).toBe(30);
expect(report.totals.outputTokens).toBe(15);
expect(report.totals.costUsd).toBeCloseTo(0.0123, 8);
});
it("treats a run with only null-cost facts as runsWithoutCost, never zero", async () => {
await seedRun({
projectId: "proj-unknown",
projectName: "Unknown",
facts: [{ inputTokens: 7, outputTokens: 3, costUsd: null, costSource: "unknown" }],
});
await seedRun({
projectId: "proj-known",
projectName: "Known",
facts: [{ inputTokens: 4, outputTokens: 2, costUsd: 0.05 }],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(2);
expect(report.totals.runsWithCost).toBe(1);
expect(report.totals.runsWithoutCost).toBe(1);
// The unknown-cost run still contributes its tokens.
expect(report.totals.inputTokens).toBe(11);
expect(report.totals.outputTokens).toBe(5);
// And its cost is NOT counted as zero — only the known cost is summed.
expect(report.totals.costUsd).toBeCloseTo(0.05, 8);
});
it("sums multiple facts per run (main loop + external capability)", async () => {
await seedRun({
projectId: "proj-multi",
projectName: "Multi",
facts: [
{ kind: "model_completion", inputTokens: 100, outputTokens: 50, costUsd: 0.02 },
{
kind: "external_capability",
provider: "mineru",
model: null,
inputTokens: null,
outputTokens: null,
costUsd: 0.015,
costSource: "provider_reported",
capabilityId: "pdf_to_md_bundle",
quantity: 12,
unit: "pages",
},
],
});
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(1);
expect(report.totals.runsWithCost).toBe(1);
expect(report.totals.inputTokens).toBe(100);
expect(report.totals.outputTokens).toBe(50);
expect(report.totals.costUsd).toBeCloseTo(0.035, 8);
// ADR-0026 breakdown: model tokens vs external non-token meter must not collapse.
expect(report.breakdown).toHaveLength(2);
const modelBucket = report.breakdown.find((b) => b.kind === "model_completion");
const capBucket = report.breakdown.find((b) => b.kind === "external_capability");
expect(modelBucket).toMatchObject({
provider: "openrouter",
model: "mock-model",
capabilityId: null,
inputTokens: 100,
outputTokens: 50,
quantity: null,
costUsd: 0.02,
factCount: 1,
});
expect(capBucket).toMatchObject({
provider: "mineru",
model: null,
capabilityId: "pdf_to_md_bundle",
unit: "pages",
quantity: 12,
inputTokens: 0,
outputTokens: 0,
costUsd: 0.015,
factCount: 1,
});
});
it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => {
await seedRun({ projectId: "proj-empty", projectName: "Empty", facts: [] });
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.totals.runCount).toBe(1);
expect(report.totals.runsWithCost).toBe(0);
expect(report.totals.runsWithoutCost).toBe(1);
expect(report.totals.inputTokens).toBe(0);
expect(report.totals.outputTokens).toBe(0);
expect(report.totals.costUsd).toBeNull();
});
it("getProjectUsage returns just that project's row", async () => {
await seedRun({ projectId: "proj-x", projectName: "X", facts: [{ costUsd: 0.1, inputTokens: 1, outputTokens: 1 }] });
await seedRun({ projectId: "proj-y", projectName: "Y", facts: [{ costUsd: 0.2, inputTokens: 2, outputTokens: 2 }] });
const row = await getProjectUsage(prisma, { organizationId: DEFAULT_ORG_ID, projectId: "proj-x" });
expect(row.projectId).toBe("proj-x");
expect(row.runCount).toBe(1);
expect(row.costUsd).toBeCloseTo(0.1, 8);
expect(row.breakdown).toHaveLength(1);
expect(row.breakdown[0]).toMatchObject({ kind: "model_completion", costUsd: 0.1, inputTokens: 1 });
});
it("returns an empty report for an org with no projects", async () => {
const report = await getOrgUsage(prisma, { organizationId: DEFAULT_ORG_ID });
expect(report.projects).toHaveLength(0);
expect(report.totals.runCount).toBe(0);
expect(report.totals.costUsd).toBeNull();
expect(report.breakdown).toEqual([]);
});
});
+3
View File
@@ -290,6 +290,9 @@ function mockPrisma(): PrismaClient {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "run-1" })), update: vi.fn(async () => ({ id: "run-1" })),
}, },
usageFact: {
create: vi.fn(async () => ({ id: "fact-1" })),
},
projectAgentLock: { projectAgentLock: {
findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })), findUnique: vi.fn(async () => (lock === null ? null : { runId: lock.runId })),
create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => { create: vi.fn(async (args: { data: { projectId: string; runId: string } }) => {
@@ -0,0 +1,156 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ProviderModelCatalog, type ProviderModelEntry } from "../../src/connections/providerModelCatalog.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
const TEST_KEY = Buffer.alloc(32, "k");
const secrets = new LocalSecretEnvelope({
activeKeyId: "test-active",
keys: new Map([["test-active", TEST_KEY]]),
});
function makeEncryptedPayload(baseUrl: string, authToken: string, anthropicApiKey = "") {
const payload = { schemaVersion: 1, baseUrl, authToken, anthropicApiKey };
return secrets.encryptJson({ purpose: "provider-connection", organizationId: "org-1", connectionId: "conn-1", secretVersionId: "sv-1" }, payload);
}
function mockPrismaWithConnection(overrides: Partial<{
status: string;
retiredAt: Date | null;
providerId: string;
}> = {}) {
const envelope = makeEncryptedPayload("https://openrouter.ai/api", "sk-test-key", "sk-ant-test");
return {
organizationProviderConnection: {
findFirst: vi.fn().mockResolvedValue({
id: "conn-1",
organizationId: "org-1",
providerId: overrides.providerId ?? "openrouter",
status: overrides.status ?? "ACTIVE",
activeSecretVersion: {
id: "sv-1",
connectionId: "conn-1",
envelopeVersion: 1,
keyId: "test-active",
envelope,
retiredAt: overrides.retiredAt ?? null,
},
}),
},
};
}
function mockFetchResponse(models: Array<{ id: string; name: string; supported_parameters: string[] }>) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ data: models }),
});
}
describe("ProviderModelCatalog", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("fetches tool-capable models from the provider API", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = mockFetchResponse([
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools", "temperature"] },
{ id: "openai/gpt-4o", name: "GPT-4o", supported_parameters: ["tools", "temperature"] },
{ id: "meta/llama-3", name: "Llama 3", supported_parameters: ["temperature"] },
]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
const models = await catalog.listModels("org-1");
expect(models).toHaveLength(3);
expect(models.map((m: ProviderModelEntry) => m.id)).toEqual([
"anthropic/claude-sonnet-5",
"openai/gpt-4o",
"meta/llama-3",
]);
expect(models[0]).toMatchObject({ label: "Claude Sonnet 5", toolCapable: true });
expect(models[2]).toMatchObject({ label: "Llama 3", toolCapable: false });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const calledUrl = String(fetchImpl.mock.calls[0][0]);
expect(calledUrl).toContain("supported_parameters=tools");
});
it("returns cached results on subsequent calls within TTL", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = mockFetchResponse([
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
await catalog.listModels("org-1");
await catalog.listModels("org-1");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("re-fetches after TTL expires", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = mockFetchResponse([
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
await catalog.listModels("org-1");
vi.advanceTimersByTime(61_000);
await catalog.listModels("org-1");
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("returns empty list when no ACTIVE provider connection exists", async () => {
const prisma = {
organizationProviderConnection: {
findFirst: vi.fn().mockResolvedValue(null),
},
};
const fetchImpl = mockFetchResponse([]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
const models = await catalog.listModels("org-1");
expect(models).toEqual([]);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("invalidate forces a re-fetch on next call", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = mockFetchResponse([
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
await catalog.listModels("org-1");
catalog.invalidate("org-1");
await catalog.listModels("org-1");
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("sends auth token and anthropic API key in headers", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = mockFetchResponse([
{ id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", supported_parameters: ["tools"] },
]);
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
await catalog.listModels("org-1");
const init = fetchImpl.mock.calls[0][1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer sk-test-key");
expect(headers["x-api-key"]).toBe("sk-ant-test");
});
it("throws on non-200 response", async () => {
const prisma = mockPrismaWithConnection();
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 401 });
const catalog = new ProviderModelCatalog(prisma as never, secrets, 60_000, fetchImpl as never);
await expect(catalog.listModels("org-1")).rejects.toThrow("provider models request failed: status 401");
});
});
+9 -6
View File
@@ -68,14 +68,14 @@ describe("session cookie signing", () => {
describe("oauth state signing", () => { describe("oauth state signing", () => {
it("round-trips state with returnTo", () => { it("round-trips state with returnTo", () => {
const token = signOAuthState( const token = signOAuthState(
{ nonce: "abc", returnTo: "/admin/org/acme" }, { nonce: "abc", returnTo: "/admin" },
SECRET, SECRET,
600, 600,
1_700_000_000, 1_700_000_000,
); );
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({ expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "abc", nonce: "abc",
returnTo: "/admin/org/acme", returnTo: "/admin",
exp: 1_700_000_600, exp: 1_700_000_600,
}); });
}); });
@@ -83,13 +83,13 @@ describe("oauth state signing", () => {
it("binds state to an Organization and connection as one inseparable scope", () => { it("binds state to an Organization and connection as one inseparable scope", () => {
const token = signOAuthState({ const token = signOAuthState({
nonce: "scoped", nonce: "scoped",
returnTo: "/admin/org/acme", returnTo: "/admin",
organizationId: "org-acme", organizationId: "org-acme",
connectionId: "connection-acme", connectionId: "connection-acme",
}, SECRET, 600, 1_700_000_000); }, SECRET, 600, 1_700_000_000);
expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({ expect(verifyOAuthState(token, SECRET, 1_700_000_100)).toEqual({
nonce: "scoped", nonce: "scoped",
returnTo: "/admin/org/acme", returnTo: "/admin",
organizationId: "org-acme", organizationId: "org-acme",
connectionId: "connection-acme", connectionId: "connection-acme",
exp: 1_700_000_600, exp: 1_700_000_600,
@@ -98,8 +98,11 @@ describe("oauth state signing", () => {
}); });
describe("sanitizeReturnTo", () => { describe("sanitizeReturnTo", () => {
it("allows admin paths", () => { it("allows admin paths and rewrites legacy org slug prefixes", () => {
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin/org/acme"); expect(sanitizeReturnTo("/admin")).toBe("/admin");
expect(sanitizeReturnTo("/admin/usage")).toBe("/admin/usage");
expect(sanitizeReturnTo("/admin/org/acme")).toBe("/admin");
expect(sanitizeReturnTo("/admin/org/acme/usage")).toBe("/admin/usage");
}); });
it("blocks open redirects", () => { it("blocks open redirects", () => {
+26 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js"; import {
isSiloHttpRateLimitExempt,
SiloFixedWindowRateLimiter,
} from "../../src/deployment/siloRateLimit.js";
describe("Silo fixed-window rate limiter", () => { describe("Silo fixed-window rate limiter", () => {
it("returns an explicit retry interval and resets at the next window", () => { it("returns an explicit retry interval and resets at the next window", () => {
@@ -10,3 +13,25 @@ describe("Silo fixed-window rate limiter", () => {
expect(limiter.consume(61_000)).toEqual({ allowed: true }); expect(limiter.consume(61_000)).toEqual({ allowed: true });
}); });
}); });
describe("isSiloHttpRateLimitExempt", () => {
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/members")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
it("still rate-limits APIs and auth", () => {
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
});
});
+47 -1
View File
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js"; import {
importSkillDirectory,
importSkillFromFiles,
prepareRunSkillPlugin,
readSkillFiles,
} from "../../src/agent/skillStore.js";
describe("content-addressed Agent skill store", () => { describe("content-addressed Agent skill store", () => {
const roots: string[] = []; const roots: string[] = [];
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
})).rejects.toThrow(/content digest mismatch/); })).rejects.toThrow(/content digest mismatch/);
}); });
it("imports a skill from an in-memory file list and reads it back", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
];
const installed = await importSkillFromFiles({ files, storeRoot });
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
expect(readBack).toHaveLength(2);
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
expect(byPath["SKILL.md"]).toContain("name: web-skill");
expect(byPath["reference.md"]).toBe("ref\n");
});
it("rejects file paths that escape the skill root", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
];
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
});
it("rejects duplicate file paths in importSkillFromFiles", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
];
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
});
async function makeRoot(): Promise<string> { async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-")); const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
roots.push(root); roots.push(root);
+3
View File
@@ -29,6 +29,9 @@ structure Identifiers where
TeamId : Type TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/ /-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type FolderId : Type
/-- Agent 配置 folder 标识(`OPEN` 表示;ADR-0028 透明组织节点,skill/role 共用一棵树;
project explorer `FolderId` ,) -/
AgentConfigFolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@bot` , provider) -/ ADR-0017;`@bot` , provider) -/
RunId : Type RunId : Type
+9 -1
View File
@@ -10,6 +10,8 @@ import Spec.System.Agent.Run
import Spec.System.Agent.AgentRole import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface import Spec.System.Agent.AgentSurface
import Spec.System.Agent.Usage
import Spec.System.Agent.Capability
import Spec.System.Lock import Spec.System.Lock
import Spec.System.Permission import Spec.System.Permission
import Spec.System.PermissionGrant import Spec.System.PermissionGrant
@@ -39,11 +41,17 @@ likec4 已画出结构;这里补语义:
- `Memory` :(ADR-0003)+ MCP run/project - `Memory` :(ADR-0003)+ MCP run/project
- `AgentSurface` agent run (ADR-0018); Lock - `AgentSurface` agent run (ADR-0018); Lock
Lock ,Surface OPEN Lock ,Surface OPEN
- `Usage` Run append-only (ADR-0026); completion
fact run;`costUsd = none`
(ADR-0022)Run cost/token rollup cache
- `Capability` (PDFMD)(ADR-0027);
org-scoped ADR-0024 model-provider; Run ,
workspace, UsageFactcapability credential Agent
- `Permission` readeditmanage ;force-release - `Permission` readeditmanage ;force-release
- `PermissionGrant` grant(resource×principal×role) settings( policy ) - `PermissionGrant` grant(resource×principal×role) settings( policy )
(ADR-0004); OPEN (ADR-0004); OPEN
- `Audit` customer Project/Run ( OPEN);Platform Audit - `Audit` customer Project/Run ( OPEN);Platform Audit
`PlatformAdministration` `PlatformAdministration`
`Spec.Prelude`:ADR-0001..0004, 0018, 0020..0024 `Spec.Prelude`:ADR-0001..0004, 0018, 0020..0027
-/ -/
+34
View File
@@ -12,6 +12,10 @@ Role 的执行面(model/prompt/tools/skill 内容)变更时,该 role 的 active
Run role skill run-scoped , Run role skill run-scoped ,
run (ADR-0018)Skill org-scoped; `OPEN` run (ADR-0018)Skill org-scoped; `OPEN`
:skill role org folder (ADR-0028)folder
/,;name/roleId roleskill
run slash folder
-/ -/
namespace Spec.System namespace Spec.System
@@ -30,6 +34,9 @@ structure AgentRole where
tools : List String tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/ /-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String defaultModel : String
/-- 管理面分组 folder(`PINNED`, ADR-0028;`none` 表示未分类)。透明节点,见
`AgentConfigFolder` role ,roleId org -/
folder : Option I.AgentConfigFolderId
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/ /-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where structure AgentSkill where
@@ -43,6 +50,9 @@ structure AgentSkill where
contentDigest : String contentDigest : String
/-- 描述(`OPEN`)。 -/ /-- 描述(`OPEN`)。 -/
description : String description : String
/-- 管理面分组 folder(`PINNED`, ADR-0028;`none` 表示未分类)。透明节点,见
`AgentConfigFolder` skill ,name org -/
folder : Option I.AgentConfigFolderId
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/ /-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where structure AgentRoleSkillBinding where
@@ -57,4 +67,28 @@ structure AgentRoleSkillBinding where
/-- 排序(`PINNED`)。 -/ /-- 排序(`PINNED`)。 -/
sortOrder : Nat sortOrder : Nat
/-- Agent 配置 folder(`PINNED`, org-scoped, ADR-0028):skill 与 role **共用**的一棵
folder ,( ADR-0021
project Folder ): grants; skill name / roleId
name/roleId org folder ;roleskill run skill
slash folderfolder label
(ADR-0017),folder ( folder skill/role)
(`PINNED`), -/
structure AgentConfigFolder where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- folder 标识(`OPEN` 表示)。 -/
id : I.AgentConfigFolderId
/-- 父 folder;`none` 为根层(`PINNED`,可嵌套)。 -/
parent : Option I.AgentConfigFolderId
/-- Skill/role 的 folder 归属良构性(`PINNED`, ADR-0028):每个 skill/role 至多归属于
folder,** org** folder;`none`()
skill/role folder -/
def AgentConfigFolderPlacement.WellScoped
(organization : I.OrganizationId)
(placement : Option I.AgentConfigFolderId)
(folderOrg : I.AgentConfigFolderId Option I.OrganizationId) : Prop :=
folderId, placement = some folderId folderOrg folderId = some organization
end Spec.System end Spec.System
+104
View File
@@ -0,0 +1,104 @@
import Spec.Prelude
import Spec.System.Agent.Run
import Spec.System.Agent.Usage
/-!
# Capability (ADR-0027)
`AgentRun` ****:PDFMD bundleOCR
agent loop completion,, session (ADR-0026
nested Run) Run : workspace workspace
`UsageFact`
pin :
1. **ExternalCapability** org /,
`capabilityId`
2. **CapabilityConnection** org-scoped , ADR-0024
model-provider connection `active` connection resolver 使
3. **CapabilityInvocation ** / run workspace
(ADR-0018 `AgentSurface`), `UsageFact`
Agent :Agent capability adapter ,adapter Hub
org-scoped (ADR-0024 resolver boundary) model-provider
loopback proxy capability agent , Hub
(`PINNED`, ADR-0027):
- **** capability credential Agent ;adapter Hub
- **workspace ** run workspace (ADR-0018)
- ** fact** 1 `UsageFact`(`kind = externalCapability`),
使 `costUsd = none`(unknown ,ADR-0022/0026)
`CapabilityInvocation` (status/retries/partial output) pilot ;
`UsageFact` `capabilityId + correlationId` (ADR-0027 Deferred)
-/
namespace Spec.System.Agent
variable (I : Identifiers) (Path : Type)
/-- 外部能力的输入种类(`PINNED`, ADR-0027)。集合 `OPEN`——新增 kind 须 surface。 -/
inductive CapabilityInputKind where
/-- PDF 文档(`PINNED`)。 -/
| pdf
/-- 图片(`PINNED`)。 -/
| image
/-- 音频(`PINNED`)。 -/
| audio
/-- 视频(`PINNED`)。 -/
| video
/-- 外部能力(`PINNED`, ADR-0027)。平台注册的文档/媒体转换服务,由 org 启用。
capability `capabilityId` ( `pdf_to_md_bundle`),
org `CapabilityConnection` ;adapter -/
structure ExternalCapability where
/-- 稳定能力标识(`PINNED`;如 `pdf_to_md_bundle`、`audio_video_to_text`)。 -/
capabilityId : String
/-- 接受的输入种类(`PINNED`, ADR-0027)。 -/
inputKind : CapabilityInputKind
/-- 计量单位(`OPEN`;如 `"pages"`、`"audio_seconds"`)。与 UsageFact.unit 对齐。 -/
meteringUnit : String
/-- capability connection 的运行态(`PINNED`, ADR-0024/0027):与 model-provider /
Feishu connection , `active` resolver 使 -/
inductive CapabilityConnectionStatus where
| draft
| active
| disabled
/-- Organization 的外部能力凭证连接(`PINNED`, ADR-0027)。复用 ADR-0024 信封机制
model-provider connection:capability ( MinerUWhisper) auth
readiness probe, OpenRouter `/v1/models`
`(organization, capabilityId)` -/
structure OrganizationCapabilityConnection where
/-- connection 所属 organization(`PINNED`, ADR-0027)。 -/
organization : I.OrganizationId
/-- 该 connection 启用的 capability(`PINNED`, ADR-0027)。 -/
capability : ExternalCapability
/-- 运行态(`PINNED`, ADR-0024/0027)。 -/
status : CapabilityConnectionStatus
/-- 一次 capability 调用的良构约束(`PINNED`, ADR-0027)。输入路径与输出目录都必须落在
run workspace (ADR-0018 `AgentSurface`);`runWorkspace` `pathWithin`
( `OPEN`), -/
structure CapabilityInvocation where
/-- 发起调用的 run(`PINNED`;调用是 Run 内副作用,不跨 run,ADR-0026/0027)。 -/
run : I.RunId
/-- 被调用的 capability(`PINNED`)。 -/
capability : ExternalCapability
/-- 输入路径(workspace 内,`PINNED`, ADR-0018)。 -/
inputPath : Path
/-- 输出目录(workspace 内,`PINNED`, ADR-0018)。 -/
outputDir : Path
/-- capability 调用良构:输入与输出路径都落在 run 的 workspace 内(`PINNED`,
ADR-0018/0027) `AgentFileOp.Authorized` capability -/
def CapabilityInvocation.Authorized
(inv : CapabilityInvocation I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace inv.run = some w pathWithin inv.inputPath w pathWithin inv.outputDir w
end Spec.System.Agent
+92
View File
@@ -0,0 +1,92 @@
import Spec.Prelude
/-!
# Usage Run (ADR-0026)
`AgentRun` ****: completion
(PDFMD bundle)**** Run
session `AgentRun` Run
append-only ****(`UsageFact`)
`AgentRun` cost/token facts rollup cache,;
`UsageFact` "主模型""外部能力",
nested Run
(`PINNED`, ADR-0022 + ADR-0026):
- **append-only** fact ;`costUsd` fact,
- **`costUsd = none` "未知","零成本"** ADR-0022:missing provider cost
remains unknown rather than zero none 0
- **fact run** Run , `@bot`
: `capabilityId` + `correlationId`, `RunId`
- **** `costSource = pricebookDerived` ,`costUsd`
`occurredAt × (provider, model)` ;provider (`providerReported`)
`costSource = unknown`,`costUsd = none`
(capability)(pricebook) `OPEN`,pilot
(ADR-0021 defer commercial billing) pin ****
-/
namespace Spec.System.Agent
variable (I : Identifiers) (Time Cost Quantity : Type)
/-- 计量事实类型(`PINNED`, ADR-0026)。集合完整性 `OPEN`——新增 kind 须 surface,
kind -/
inductive UsageFactKind where
/-- 主 agent loop 的模型 completion(`PINNED`)。 -/
| modelCompletion
/-- 外部能力调用(`PINNED`;如 pdf_to_md_bundle、audio_video_to_text)。 -/
| externalCapability
/-- 代理网关侧旁路调用(`PINNED`;非主 loop 的模型调用,如嵌入工具内的子请求)。 -/
| toolProxy
/-- 成本来源(`PINNED`, ADR-0026)。优先级:provider 实报 > 价目派生 > unknown。 -/
inductive CostSource where
/-- provider/gateway 实报(`PINNED`)。 -/
| providerReported
/-- 用 `occurredAt × (provider, model)` 价目表派生(`PINNED`)。 -/
| pricebookDerived
/-- 缺失,而非零(`PINNED`;ADR-0022 missing cost ≠ zero)。 -/
| unknown
/-- 一次可计费消耗的计量事实(`PINNED`, ADR-0026)。append-only;一次 run ≥0 条。
:****(run/occurredAt/kind);
****(tokens/quantity/unit/costUsd/costSource)
token (//) `quantity + unit`, token -/
structure UsageFact where
/-- 所属 run(`PINNED`;fact 不跨 run,fact 不持锁,ADR-0026)。 -/
run : I.RunId
/-- 消耗发生时刻(`PINNED`);价目回算依赖此字段而非 run.finishedAt,
run -/
occurredAt : Time
/-- 事实类型(`PINNED`, ADR-0026)。 -/
kind : UsageFactKind
/-- provider 标识(`PINNED`;如 openrouter、mineru、openai_whisper)。 -/
provider : String
/-- 模型标识(`OPEN`;非模型计费可为 none)。 -/
model : Option String
/-- 输入 tokens(`OPEN`;非 token 计量可为 none)。 -/
inputTokens : Option Nat
/-- 输出 tokens(`OPEN`)。 -/
outputTokens : Option Nat
/-- 非 token 计量数值(`OPEN`;如页数、音频秒数)。与 tokens 并存。 -/
quantity : Option Quantity
/-- 计量单位(`OPEN`;如 "pages"、"audio_seconds"、"invocations")。 -/
unit : Option String
/-- 成本(`PINNED`;none = 未知,非零,ADR-0022)。 -/
costUsd : Option Cost
/-- 成本来源(`PINNED`;costUsd ≠ none 时必填,costUsd = none 时为 `unknown`)。 -/
costSource : CostSource
/-- 外部能力标识(`OPEN`;kind = externalCapability 时填,如 pdf_to_md_bundle)。 -/
capabilityId : Option String
/-- 外部请求关联 id(`OPEN`;对账/幂等用,不参与聚合)。 -/
correlationId : Option String
/-- Fact 的成本是否**已知**(`PINNED`, ADR-0026)。聚合 rollup 时,只对已知成本求和;
fact 0,( fact ) -/
def UsageFact.HasKnownCost (fact : UsageFact I Time Cost Quantity) : Prop :=
fact.costUsd none
end Spec.System.Agent