Compare commits

...

90 Commits

Author SHA1 Message Date
hongjr03 66fc9516d9 Merge pull request 'fix(hub): clear npm audit high gate (deploy-admin CI)' (#98) from fix/hub-audit-production-vulns into main
Reviewed-on: EduCraft/curriculum-project-hub#98
2026-08-05 15:44:12 +08:00
hongjr03 9c5021f8ed fix(hub): bump transitive deps to clear npm audit high gate
npm audit --omit=dev --audit-level=high (audit:production, enforced in the
deploy-admin fleet workflow) was failing on high-severity advisories in
fast-uri (3.1.4) and ip-address (10.2.0), plus moderate hono and
@hono/node-server. All fixes resolve within existing ^ ranges, so a plain
npm audit fix regenerated the lock only (package.json untouched):
fast-uri 3.1.4 -> 3.1.5, ip-address 10.2.0 -> 10.4.0, hono 4.12.28 -> 4.13.0.
audit:production now reports 0 vulnerabilities.
2026-08-05 15:43:50 +08:00
hongjr03 6290b39c63 Merge pull request 'fix(admin-web): sync package-lock so fleet deploy npm ci succeeds' (#97) from fix/admin-web-lockfile-emnapi-sync into main
Reviewed-on: EduCraft/curriculum-project-hub#97
2026-08-05 15:26:27 +08:00
hongjr03 79734b5435 fix(admin-web): sync package-lock so fleet deploy npm ci succeeds
The wasm32-wasi optional native bindings (tailwindcss oxide, rolldown)
declare @emnapi/* ranges that now resolve to 1.11.3 / 1.2.3 on the
registry, but the committed lock still pinned the older .1/.2 patch
versions. npm ci (strict sync) therefore failed with EUSAGE, breaking
the deploy-admin fleet workflow. Regenerated the lock with
npm install --package-lock-only.
2026-08-05 15:25:09 +08:00
hongjr03 5754005a97 Merge pull request 'fix(admin-web): 创建项目后跳转修正为使用 projectId' (#43) from fix/admin-projects-redirect-undefined into main
Reviewed-on: EduCraft/curriculum-project-hub#43
2026-08-04 16:55:04 +08:00
ChickenPige0n 97313aba0f fix(admin-web): redirect to created project using projectId not id 2026-08-01 22:54:34 +08:00
hongjr03 cfe733554a Merge pull request 'feat(admin-web): skill zip import and folder-grouped role picker' (#41) from feat/upload-skill-zip into main
Reviewed-on: EduCraft/curriculum-project-hub#41
2026-07-31 20:25:09 +08:00
ChickenPige0n ff990b4caf feat(admin-web): skill zip import and folder-grouped role picker
- parse skill package zips client-side, mirroring backend ingestion limits (ADR-0018)
- add zip upload flow on skills page and zip replace in SkillEditor
- group role skill bindings by the shared management folder tree (ADR-0028)
- add fflate dependency
2026-07-31 15:41:42 +08:00
hongjr03 6ea8e33148 Merge pull request 'fix(hub): enable SDK auto-compact for resumed sessions' (#40) from fix/sdk-auto-compact into main
Reviewed-on: EduCraft/curriculum-project-hub#40
2026-07-30 17:13:15 +08:00
hongjr03 295f07d111 fix(hub): enable SDK auto-compact for resumed sessions
Sessions resume across runs (ADR-0017). Without auto-compact the SDK jsonl
grows unboundedly — a 7-day / 26-run session hit 31 MB / 1995 lines, making
every API call resend the full history and inflating a trivial "change a
title" task to 22 minutes. Enable autoCompactEnabled in the SDK settings so
the SDK compacts automatically when the context window fills.
2026-07-30 17:12:31 +08:00
hongjr03 e1da845bfc Merge pull request 'feat(hub): org-scoped agent role/skill folder tree (ADR-0028)' (#37) from feat/agent-config-folder-tree into main
Reviewed-on: EduCraft/curriculum-project-hub#37
2026-07-30 16:18:51 +08:00
hongjr03 ace724c609 fix(hub): always send interrupt notice when card finalize fails
If StreamingAgentCard.finish cannot patch the live card, plain-text fallback
can still succeed with partial answer text. Interrupt is terminal — always
emit the explicit 已中断 notice when the card path failed so teachers see the
abort. Harden the integration assertion with waitFor.
2026-07-30 15:02:08 +08:00
hongjr03 ee928b5832 fix(hub): restore project create payload and stabilize integration DB seed
Explorer POST /projects was dropping projectId/folderId/workspaceDir after a
narrowed response shape, breaking admin-explorer. Make seedTestOrganization
idempotent under shared-DB isolation, force single-worker vitest, and align the
OAuth no-membership redirect expectation with authRoutes.
2026-07-30 14:44:11 +08:00
hongjr03 0f4377f16c fix(hub): assert DB wipe and single-worker integration tests
Fail fast if TRUNCATE left Organization rows, drop the extra deleteMany
before seed create, and force vitest maxWorkers=1 so forks cannot race
the shared Postgres.
2026-07-30 14:24:05 +08:00
hongjr03 751d0c4100 fix(hub): harden test org seed and run hub-check only on push
seedTestOrganization now wipes+creates instead of fragile upsert.
hub-check drops pull_request triggers so push/PR pairs no longer double
migrate against the runner; branch push status remains the gate.
2026-07-30 14:05:22 +08:00
hongjr03 0ebfeb927a fix(ci): cancel concurrent hub-check runs on the same ref 2026-07-30 13:37:08 +08:00
hongjr03 4eafbf20a9 fix(hub): reset integration DB with TRUNCATE CASCADE
deleteMany could not reliably clear nested agent-config folders and left
ghost Organization rows that broke the next upsert. Truncate every public
table except _prisma_migrations before seeding the default org.
2026-07-30 13:28:42 +08:00
hongjr03 49e1e2f19e fix(hub): cascade agent-config folder parent deletes
parentId RESTRICT prevented Organization.deleteMany from clearing nested
folder trees during test resetDb, leaving half-wiped rows and breaking
subsequent upserts. Service still refuses non-empty folder deletes;
DB cascade only unblocks org teardown / full wipe.
2026-07-30 13:20:24 +08:00
hongjr03 9c1f9de9c1 fix(hub): give integration tests skill-store root and portable DB URL
Admin routes always construct OrganizationAgentConfiguration via
readSkillStoreRoot(); CI and local runs without HUB_SKILL_STORE_ROOT
failed open. Seed a tmp root when unset. Also make preflight CLI tests
honor DATABASE_URL and set the skill-store env in hub-check.
2026-07-30 13:12:08 +08:00
hongjr03 ac53d42a0a fix(hub): honor DATABASE_URL in integration test helpers
CI hub-check reaches Postgres as the service hostname `postgres`, but
helpers hard-coded 127.0.0.1:5432, so migrate ran against the service
while vitest connected to the wrong place. Prefer env when set.
2026-07-30 13:00:58 +08:00
hongjr03 e0e25ca4c5 fix(hub): expect always-on todo_write in pbank tool mapping 2026-07-30 12:51:48 +08:00
hongjr03 6ddc0b5bd1 fix(ci): skip live bwrap sandbox proof without unprivileged userns
Act/docker runners commonly block non-privileged user namespaces, so
setpriv+CapEff=0 cannot run bwrap. Gate the proof on `unshare --user`
and keep unit + remaining integration tests as the default CI net.
2026-07-30 12:44:41 +08:00
hongjr03 b91938071d fix(ci): enable unprivileged userns for hub-check bwrap 2026-07-30 12:38:11 +08:00
hongjr03 6f736abe50 fix(hub): assert sandbox skills by deny-list, not exact set
Claude SDK may report an extra host/doctor skill id even with
disableBundledSkills. Keep the ADR-0018 guarantee: managed outline
loads and workspace-local untrusted skills do not.
2026-07-30 12:31:33 +08:00
hongjr03 fdd83999df fix(ci): clear caps as root when switching sandbox uid 2026-07-30 12:24:12 +08:00
hongjr03 d4cd1ad74f fix(ci): drop caps after runuser without setgroups 2026-07-30 12:17:49 +08:00
hongjr03 315e4bd018 fix(ci): keep node/npx on PATH for unprivileged sandbox proof 2026-07-30 12:09:20 +08:00
hongjr03 3a46ebc54d fix(ci): run hub-check sandbox proof as unprivileged user
agent-sandbox-linux requires uid>0, CapEff=0, and NoNewPrivs=1. Act runners
often execute as root with residual caps; create cphci and drop privileges
via setpriv before vitest.
2026-07-30 12:09:02 +08:00
hongjr03 213f00eb07 fix(ci): install Rust in hub-check for shipping cph
hub-check builds cph via cargo install; the runner image has no rustup.
Mirror checker-check's dtolnay/rust-toolchain + cargo cache.
2026-07-30 12:02:49 +08:00
hongjr03 127a8c3418 fix(ci): install admin-web deps in hub-check before build
hub build runs admin:build; fleet deploy already npm ci --prefix admin-web
but hub-check only installed hub/. Without that, vite fails on @sveltejs/kit.
2026-07-30 11:58:02 +08:00
hongjr03 61512454cc fix(ci): use service DNS for hub-check Postgres (no host port)
Concurrent hub-check jobs raced on host-published 5432/15432. Drop the
host port mapping and talk to the service container as postgres:5432.
2026-07-30 11:47:44 +08:00
hongjr03 03142c75ea fix(ci): reach hub-check Postgres by service DNS
Concurrent hub-check jobs on the shared runner fought over published
host ports (5432 then 15432). Drop host port mapping and connect to the
service container as postgres:5432 on the job network.
2026-07-30 11:47:21 +08:00
hongjr03 46d6722254 fix(ci): restore hub-check postgres wait deadline 2026-07-30 11:46:18 +08:00
hongjr03 3a4b9e052a fix(ci): bind hub-check Postgres on host 15432
Runner host 5432 is already allocated (leftover containers), causing
hub-check service Postgres to fail start. Map service DB to 15432 and
point wait/integration DATABASE_URL at that port.
2026-07-30 11:45:36 +08:00
hongjr03 faafece1a4 ci: re-run hub-check after postgres flake 2026-07-30 11:42:18 +08:00
hongjr03 d7bbffb9c6 fix(hub): rebase agent config folders onto main and sync lockfile
Rebased feat/agent-config-folder-tree onto current main, keeping cursor
invalidation (not session archive) and requireFolder helpers. Regenerated
package-lock so npm ci finds @emnapi/*; tighten session-cursor test assert
for missing claudeSessionId key.
2026-07-30 11:37:34 +08:00
ChickenPige0n 6c8d2da897 feat(hub): add SearchableSelectField component and update RoleCard to use it 2026-07-30 11:36:28 +08:00
ChickenPige0n c9adf83e5c 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-30 11:36:27 +08:00
hongjr03 db7b7ec094 Merge pull request 'fix(hub): download Feishu resources via bot-owned lark-cli' (#39) from fix/feishu-bot-cli-download into main 2026-07-30 11:28:09 +08:00
hongjr03 88386fb943 fix(hub): download Feishu resources via bot-owned lark-cli
Agent tool downloads and trigger attachment staging both used the SDK
messageResource path, which fails closed for multi-MB teacher files and
did not share the bot-identity transport contract. Route every download
through Hub-owned createFeishuBotCli (secret via stdin, disposable HOME,
HUB_FEISHU_CLI_BIN), keep workspace containment on write, and inject the
adapter in trigger tests.
2026-07-30 11:27:53 +08:00
hongjr03 97c7054529 Merge pull request 'fix(hub): raise Docmind OSS upload timeout past httpx 3s default' (#38) from fix/docmind-oss-upload-timeout into main 2026-07-30 11:17:25 +08:00
hongjr03 97a99cd381 fix(hub): raise Docmind OSS upload timeout past httpx 3s default
SubmitDocParserJobAdvance uploads PDFs to Aliyun OSS via tea/httpx, which
defaults readTimeout to 3000ms when RuntimeOptions is empty. Multi-MB
teacher PDFs on para silo failed with ReadTimeout(3000) before the job
could start. Set connectTimeout=15s and readTimeout=5m.
2026-07-30 11:16:56 +08:00
hongjr03 3367d3340b fix(hub): do not crash Hub on missing DocMind input files
Merge missing-file DocMind stream crash fix
2026-07-27 12:25:15 +08:00
hongjr03 34d5d5e88e fix(hub): do not crash Hub on missing DocMind input files
createReadStream emits async ENOENT without a listener, which became an
unhandled 'error' event and exited the silo process. Teachers then saw the
startup "process restart" notice. Wait for stream open and convert missing
files into DocmindClientError instead.
2026-07-27 12:25:04 +08:00
hongjr03 bdd722c463 fix(hub): include Edit in default single-agent tools
Merge pull request #34 from fix/hub-include-edit-tool
2026-07-25 14:19:04 +08:00
hongjr03 2699ff3679 fix(hub): include Edit in default single-agent tools
Removing the claude_code preset dropped Edit. Unrestricted roles need it for
in-place file edits; write_file also grants Edit for restricted roles.
2026-07-25 14:18:57 +08:00
hongjr03 a3af63c1c6 fix(hub): stop unrestricted roles from loading multi-agent tools
Merge pull request #33 from fix/hub-no-background-agent-tools
2026-07-25 13:45:01 +08:00
hongjr03 755704e2ae fix(hub): stop unrestricted roles from loading multi-agent tools
The claude_code preset exposed Agent/SendMessage/Task. Background agents
abort with reason "background", which the SDK maps to Bash
toolDenialKind "cancelled" ("user doesn't want this action") and freezes
command execution mid-run.
2026-07-25 13:40:27 +08:00
hongjr03 dcda4cb7a3 Merge pull request 'fix(hub): unblock production deployment audit' (#32) from fix/deploy-router-audit into main 2026-07-24 16:46:17 +08:00
hongjr03 9e26585e1f fix(hub): unblock production deployment audit 2026-07-24 16:43:11 +08:00
hongjr03 8bfbecf60f Merge pull request 'fix(hub): clarify BYOK rotation state' (#31) from fix/provider-rotation-ux into main 2026-07-24 16:34:20 +08:00
hongjr03 4087f09994 fix(hub): clarify BYOK rotation state 2026-07-24 16:32:19 +08:00
hongjr03 3708162fa9 Merge pull request 'fix(hub): lock provider id during BYOK rotate + scroll/focus feedback' (#29) 2026-07-24 14:46:53 +08:00
hongjr03 4de290a73c fix(hub): lock provider id during BYOK rotate + scroll/focus feedback
Clicking 轮换 on a BYOK provider row prefilled the 供应方 ID below but gave
no signal — no scroll, no focus, no toast — so it looked dead ("点不进去").
The 供应方 ID input stayed editable too: changing it silently created a new
connection (version 1) instead of rotating the one clicked.

- startRotate now locks the id (readonly), scrolls the form into view,
  focuses 接口地址, and toasts "已填入供应方 X…".
- Heading switches between 轮换凭据 · <id> and 新建 / 轮换 BYOK 凭据;
  an inline hint explains how to create a different provider (清空).
- save() distinguishes create vs rotate via the returned activeVersion
  (version 1 → 已创建 BYOK 连接, else 凭据已轮换).
- Add toastInfo to $lib/toast for the info-level affordance.

Backend unchanged: list/rotate/reject-member/reject-platform-overwrite tests
already pass; this is a UI feedback + id-lock fix.
2026-07-24 14:39:26 +08:00
hongjr03 744c707a39 Merge pull request 'fix(hub): render Feishu checklist from TaskCreate/TaskUpdate' (#28) from fix/task-checklist-card-panel into main 2026-07-23 23:21:50 +08:00
hongjr03 74f5c4a02e fix(hub): render Feishu checklist from TaskCreate/TaskUpdate
Headless Claud agents expose TaskCreate/TaskUpdate rather than TodoWrite.
Fold those tool events into the live card checklist, pass real tool names
and inputs through tool-result, and hide Task* noise once the panel is up.
2026-07-23 23:21:34 +08:00
hongjr03 326224b778 Merge pull request 'fix(hub): shipible checklist via cph_hub todo_write MCP tool' (#27) from fix/hub-mcp-todo-write into main 2026-07-23 23:08:59 +08:00
hongjr03 e3b463d390 fix(hub): ship checklist via cph_hub todo_write MCP tool
Native Claude TodoWrite is not registered in headless agent mode even with
--tools default. Add mcp__cph_hub__todo_write (always enabled), mirror the
TodoWrite schema, instruct multi-step runs to use it, and keep the Feishu
progress panel parsing both native and hub tool names.
2026-07-23 23:08:42 +08:00
hongjr03 ceaf64c4f9 Merge pull request 'fix(hub): expose TodoWrite via SDK default toolset' (#26) from fix/todowrite-default-toolset into main 2026-07-23 22:56:28 +08:00
hongjr03 bb426dfaf5 fix(hub): expose TodoWrite via SDK default toolset
Unrestricted roles were still passed an explicit --tools name list. The
native Claude binary only reliably registers bundled tools like TodoWrite
on --tools default. Treat role tools JSON null as unrestricted, use the
claude_code preset (→ default) in that case, and keep TodoWrite on allowedTools.
2026-07-23 22:56:26 +08:00
hongjr03 b4fe734e80 Merge pull request 'feat(hub): live TodoWrite checklist on Feishu agent cards' (#25) from feat/agent-todo-progress-card into main 2026-07-23 22:42:54 +08:00
hongjr03 2f79b7743f feat(hub): live TodoWrite checklist on Feishu agent cards
Always expose Claude Agent SDK TodoWrite (todoFeatureEnabled) so multi-step
runs can plan in the open. Parse TodoWrite payloads into a progress panel on
the streaming Feishu card (completed/in_progress/pending) and filter raw
TodoWrite noise out of the tool-use list.
2026-07-23 22:42:02 +08:00
hongjr03 4a03bc1f4a Merge pull request 'fix(hub): extract PBank zips in-process without host unzip' (#24) from fix/pbank-pure-node-unzip into main 2026-07-23 21:05:26 +08:00
hongjr03 8a81c60ea5 fix(hub): extract PBank zips in-process without host unzip
pbank materialize previously shelled out to `unzip` and soft-failed when
the binary was missing, so agents only saw titles. Read zip entries with
Node zlib (store/deflate) and write under workspace .pbank-sources.
2026-07-23 21:05:15 +08:00
hongjr03 00f7a8db39 Merge pull request 'feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)' (#23) from feat/hub-pbank-capability into main 2026-07-23 20:13:19 +08:00
hongjr03 54837717fd feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)
Register pbank as an ADR-0027 external capability with org-scoped
username/password envelopes, readiness via /login, and in-process
cph_hub MCP tools (search/get/get_many) that materialize sources under
the run workspace. Extend the capability secret payload for docmind vs
pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the
pbank-problem-report skill. Credentials never reach the Agent process.
2026-07-23 20:13:00 +08:00
hongjr03 2285ae871e Merge pull request 'fix(hub): enable tenant Typst package resolution' (#22) from fix/tenant-typst-package-resolution into main
Reviewed-on: EduCraft/curriculum-project-hub#22
2026-07-22 17:50:55 +08:00
hongjr03 36660f72d6 fix(hub): enable tenant Typst package resolution 2026-07-22 17:47:56 +08:00
hongjr03 6462e42823 fix(hub): stamp CheckMark/CrossMark when agent run finishes (v0.0.41) (#21) 2026-07-21 14:36:21 +08:00
hongjr03 6f7497bce8 fix(hub): stamp CheckMark/CrossMark when agent run finishes (v0.0.41)
After removing the Typing reaction, add CheckMark on success or CrossMark
on failure so teachers can see completion on the source message without
opening the card.
2026-07-21 06:36:09 +00:00
hongjr03 e634418a02 feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40) (#20) 2026-07-21 13:56:10 +08:00
hongjr03 db49a0d23d feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40)
Teachers convert many PDFs in one tool call with bounded Docmind concurrency.
Each item keeps its own output_dir/document.md and UsageFact; failures are
per-file. Mirror role skills to .cph/runtime-skills and CPH_RUNTIME_SKILLS_DIR
so agents can Read SKILL.md instead of dead .claude/sandbox stubs.
2026-07-21 05:55:55 +00:00
hongjr03 54b9fee22c fix(hub): agent 会话记忆在配置变更/发版后丢失 + Feishu thread 400 (#19)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-20 22:45:42 +08:00
hongjr03 5f668d71a2 fix(hub): forward host HTTP_PROXY into agent sandbox (v0.0.39) (#18) 2026-07-20 21:46:09 +08:00
hongjr03 3fbc4b81c2 fix(hub): forward host HTTP(S)_PROXY into agent sandbox (v0.0.39)
Host egress requires the local forward proxy; sandbox env previously
omitted PROXY vars so Bash/curl timed out on public image URLs. Pass
HTTP(S)/ALL/NO_PROXY (+ lowercase) and NODE_USE_ENV_PROXY from the
trusted service environment into the agent subprocess.
2026-07-20 13:46:07 +00:00
hongjr03 e016564cc5 feat(hub): raise agent limits + teacher failure notices (v0.0.38) (#17) 2026-07-20 20:07:47 +08:00
hongjr03 6cefb2a938 feat(hub): raise agent turns/time limits and notify teachers on failure
Defaults and silo env go to 150 turns / 1800s wall clock. Run completion
appends a clear Feishu notice for max-turns, timeout, and other failures
(partial answer kept). Startup process-restart kills notify the bound chat.
Release v0.0.38.
2026-07-20 12:07:45 +00:00
hongjr03 34c4908237 chore(hub): default HUB_MAX_FILES_PER_MESSAGE to 20 (#16) 2026-07-20 19:50:47 +08:00
hongjr03 46687dd5f6 chore(hub): default HUB_MAX_FILES_PER_MESSAGE to 20
Match production silos (raised for multi-image Feishu posts).
2026-07-20 11:50:45 +00:00
hongjr03 e9b578153e fix(hub): strip card markdown images + prefer inline ![] over send_file (v0.0.37) (#15)
fix card inline images v0.0.37
2026-07-20 19:39:08 +08:00
hongjr03 93f3f2424c fix(hub): strip card markdown images + prefer inline ![] over send_file
Feishu interactive markdown rejects ![](http...) without image_key
(error 230099 empty/missing imagekey). Always mask residual image md in
card builders; skip img tags with empty keys; skip inline-code examples;
fetch remote images with a browser UA and without env HTTP_PROXY.
Steer the agent: use ![alt](workspace-path) for 图文, send_file only for
downloadable attachments.

Release v0.0.37.
2026-07-20 11:38:55 +00:00
hongjr03 73cb0e5b47 feat(hub): embed agent images via Feishu upload + release v0.0.36 (#14)
feat(hub): embed agent images via Feishu upload + release v0.0.36

Merge pull request #14
2026-07-20 18:42:38 +08:00
hongjr03 e21096c642 feat(hub): embed agent images via Feishu upload + release v0.0.36
Materialize markdown image refs on agent finish: fetch/read bytes, upload
im.v1.image, and render native card img elements so remote image URLs no
longer trip Feishu content-security. Stream masks image URLs mid-run;
card failure falls back to plain text plus standalone image messages.

Docs: clarify im:resource covers outbound Agent image send.
2026-07-20 10:40:03 +00:00
hongjr03 dc2d1c2f9e Merge pull request 'chore: remove Lean spec; ADRs are the single source of truth' (#13) from chore/remove-lean-spec into main
Reviewed-on: EduCraft/curriculum-project-hub#13
2026-07-20 17:21:14 +08:00
hongjr03 3f9b60f692 chore: remove Lean spec; ADRs are the single source of truth
The spec/ Lean semantic master had no conformance gate, no codegen, and
no CI tie to implementations — alignment was carried entirely by human
review, the same mechanism that carries the ADRs. In practice the ADRs
plus greppable code comments were already the load-bearing artifacts,
so spec/ was the most expensive kind of stale documentation.

- delete spec/ and the spec-check CI workflow
- README: constitution rewritten around ADRs as decision truth
- AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR,
  never rewrite ADR history; supersede instead)
- code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers
  (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma)
- leave ADR bodies and .scratch audit snapshots untouched (history);
  fix live references in open readiness tickets
2026-07-20 09:07:26 +00:00
hongjr03 4234ba4c96 Merge pull request 'fix(hub): mark bootstrap Inbox as SYSTEM_INBOX' (#12) from fix/hub-bootstrap-system-inbox into main 2026-07-19 20:16:09 +08:00
hongjr03 15f9443d3d fix(hub): mark bootstrap Inbox as SYSTEM_INBOX
Alpha silo bootstrap created the root Inbox without kind=SYSTEM_INBOX, so
Feishu card project creation tried to insert a second Inbox and hit the
sibling-name unique index. Tag the bootstrap folder correctly and promote
any legacy root Inbox on ensure.
2026-07-19 20:08:44 +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
175 changed files with 9318 additions and 3557 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based
# checker that "stands in Lean's position" at product runtime).
# lesson checker).
#
# Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# spec-to-implementation conformance gate — implementations align to the Lean
# contract by human review, not by CI. See the repo README.
# This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# decision-to-implementation conformance gate — implementations align to the
# ADRs by human review, not by CI. See the repo README.
on:
push:
+62 -15
View File
@@ -1,15 +1,18 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# The Hub is the Feishu-group collaboration + agent runtime half.
# This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: hub-check-${{ github.ref }}
cancel-in-progress: true
jobs:
hub-check:
runs-on: ubuntu-latest
@@ -20,8 +23,9 @@ jobs:
POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test
ports:
- 5432:5432
# Avoid host-port binds: concurrent hub-check jobs on the shared
# runner raced on published 5432/15432 ("port is already allocated").
# Reach the service by Docker DNS name from the job container instead.
options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s
@@ -33,15 +37,33 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain (for cph binary)
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-hub-check-${{ runner.os }}-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: hub/package-lock.json
cache-dependency-path: |
hub/package-lock.json
hub/admin-web/package-lock.json
- name: Install dependencies
run: npm ci
run: |
npm ci
npm ci --prefix admin-web
- name: Audit production Node dependencies
run: npm run audit:production
@@ -54,8 +76,10 @@ jobs:
node <<'NODE'
const net = require("node:net");
const deadline = Date.now() + 60000;
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
const socket = net.createConnection({ host, port });
socket.once("connect", () => {
socket.end();
process.exit(0);
@@ -63,7 +87,7 @@ jobs:
socket.once("error", () => {
socket.destroy();
if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432");
console.error(`Postgres did not become reachable at ${host}:${port}`);
process.exit(1);
}
setTimeout(tryConnect, 1000);
@@ -90,19 +114,41 @@ jobs:
run: |
cd ..
cargo install --path crates/cph-cli --locked
# Make cph available to the unprivileged sandbox user below.
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
- name: Prove real Claude SDK Bash sandbox boundary
run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts
set -euo pipefail
# Nested act/docker runners often disallow unprivileged user
# namespaces, which bwrap requires once CapEff is cleared. Skip the
# live proof there; unit + non-sandbox integration still gate.
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
if ! unshare --user true 2>/dev/null; then
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
exit 0
fi
if ! id cphci >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash cphci
fi
install -d -o cphci -g cphci -m 0700 /w/t
REPO_ROOT="$(cd .. && pwd)"
NODE_BIN_DIR="$(dirname "$(command -v node)")"
NPX_BIN="$(command -v npx)"
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
/usr/bin/setpriv \
--reuid=cphci --regid=cphci --init-groups \
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
--no-new-privs \
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
- name: Run unit tests
run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below.
# PostgreSQL is the job service container reachable as `postgres`.
- name: Run integration tests (mock provider, real prisma + cph)
run: |
npx prisma migrate deploy --schema prisma/schema.prisma
@@ -110,7 +156,8 @@ jobs:
--exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts
env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
-20
View File
@@ -1,20 +0,0 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+4 -4
View File
@@ -1,7 +1,3 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.pdf
@@ -19,3 +15,7 @@ node_modules/
# OS / editor
.DS_Store
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
with the durable decision in ADR-0022. Numerical
ceilings remain open until production-like calibration.
The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question
After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test/spec health, deploy a clean
repeatable release procedure prove build/test health, deploy a clean
production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
the pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,9 +40,7 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant.
The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
+16 -16
View File
@@ -1,29 +1,25 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
不接收 org provider credential(见 ADR-0024)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
双因子的离线恢复流程(见 ADR-0023)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -36,6 +32,10 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
@@ -44,12 +44,12 @@
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+8 -10
View File
@@ -1,25 +1,23 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+16 -23
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
## 安装 `cph` 命令行
@@ -33,47 +33,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/
(exporter/ …) ← 将来的其他部件,平级于 crates/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
## 宪法
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
4 条是本仓库的协作约定,是一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
1. **角色 —— ADR 是决策真相**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
2. **对齐机制 —— Lean 只做上游参照**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
2. **对齐机制 —— 人肉承载,无机器兜底**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
3. **资产性 —— 由 review 纪律承载,无机器兜底**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
3. **形态 —— 自包含**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
4. **形态 —— 它是人机共识的契约**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
4. **深度判据 —— 只收录分歧点**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+3 -2
View File
@@ -1,7 +1,8 @@
# crates/
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
These crates implement the rule-based lesson checker whose semantics are
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+9 -11
View File
@@ -19,13 +19,11 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// **PINNED to `warning` by ADR-0005:** when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
/// severity as a const makes "it is a warning, not an error" a greppable
/// fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
@@ -57,12 +55,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// gate enforcing this alignment (repo constitution); it is kept greppable
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// kept greppable here so a reviewer can tie the orchestrator's gate to
/// the ADR.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
+10 -15
View File
@@ -2,7 +2,7 @@
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
@@ -17,32 +17,27 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
///
/// ```text
/// inductive Severity where
/// | warning
/// | error
/// warning | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// constitution); it is maintained by review, which is why the decision is
/// documented here rather than only in the ADR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`.
/// element. ADR-0010 `warning`.
Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
/// Blocking: the artifact is invalid. ADR-0010 `error`.
Error,
}
+25 -38
View File
@@ -3,9 +3,7 @@
//! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
//!
//! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -23,7 +21,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file.
///
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
/// `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -86,9 +84,8 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// renders. Realizes ADR-0011's "render
/// coverage is a declaration, not a payload": the declaration keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps.
///
@@ -102,13 +99,10 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011).
///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT with fields:
///
/// ```text
/// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// ```
///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -120,20 +114,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here.
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
/// implementation alignment (see the repo constitution) — it is maintained by
/// review, which is why the decision is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
/// root). ADR-0011 `singleFile`. The default artifact shape.
SingleFile {
/// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`.
filepath: PathBuf,
},
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
/// A set of files under `root` matching the `outputs` glob. ADR-0011
/// `fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
@@ -156,14 +150,10 @@ impl Artifact {
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT:
///
/// ```text
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -183,20 +173,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
/// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
/// artifact; the framework injects the manifest. ADR-0011
/// `typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
/// Run a shell command — the escape hatch. ADR-0011 `shell`.
Shell {
/// The command line to run.
run: String,
},
/// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself.
AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`,
@@ -226,12 +216,11 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim).
///
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// The *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
/// at the load boundary. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
/// Lesson title.
@@ -240,7 +229,6 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no
/// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>,
}
@@ -290,8 +278,7 @@ struct RawProject {
name: String,
}
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// The authoring-surface `[info]`: the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)]
@@ -301,7 +288,7 @@ struct RawInfo {
}
/// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
/// (`author = ["…", "…"]`). A fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)]
@@ -312,7 +299,7 @@ enum RawAuthor {
}
impl RawAuthor {
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// Flatten to the ordered author list: a single
/// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> {
match self {
+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 -1
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,6 +66,9 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调
进入“事件与回调”。
+20 -3
View File
@@ -20,12 +20,14 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# Alpha Silo safety limits. max turns may use its default; every other value is
# mandatory in production and should be calibrated on the target host.
# HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_TURNS="150"
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_AGENT_MAX_RUN_SECONDS="1800"
HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILES_PER_MESSAGE="20"
HUB_MAX_FILE_BYTES="26214400"
# Max concurrent Alibaba Docmind jobs for one convert_pdf_to_md batch (1-8).
HUB_PDF_TO_MD_MAX_CONCURRENT="3"
HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120"
@@ -38,9 +40,24 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# Optional tenant-local Typst package roots. When configured, the agent
# sandbox forwards these exact paths to Typst. The preinstalled package path is
# read-only; the cache path is the only additional write location.
# Keep the preinstalled package path outside the release tree and provision it
# with the namespace layout expected by Typst, for example:
# <root>/paradigm/paradigm-templates/0.2.20/
# Use a separate service-writable cache path when runtime dependencies may be
# downloaded; do not make the immutable preinstalled directory the cache.
# TYPST_PACKAGE_PATH="/srv/curriculum-project-hub/typst-packages/org-a"
# TYPST_PACKAGE_CACHE_PATH="/var/cache/cph-hub/org-a/typst"
# This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
# Absolute path to the bot-only lark-cli binary used by Agent Feishu tools.
# The CLI is invoked by Hub with a disposable HOME and the ACTIVE Feishu
# Application Connection; the App Secret is never put in Agent argv/env.
HUB_FEISHU_CLI_BIN="/usr/local/bin/lark-cli"
HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
+96 -7
View File
@@ -7,6 +7,9 @@
"": {
"name": "admin-web",
"version": "0.0.1",
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
@@ -26,24 +29,38 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -881,6 +898,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -1768,6 +1851,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+3
View File
@@ -29,5 +29,8 @@
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"fflate": "^0.8.3"
}
}
+119 -2
View File
@@ -193,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals {
folderId: string | null;
}
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
export interface UsageBreakdownRow {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
breakdown: UsageBreakdownRow[];
}
export interface ProjectUsageReport extends ProjectUsageRow {
from: string | null;
to: string | null;
breakdown: UsageBreakdownRow[];
}
export interface UsageFactRow {
id: string;
occurredAt: string;
kind: string;
provider: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
quantity: number | null;
unit: string | null;
costUsd: number | null;
costSource: string;
capabilityId: string | null;
correlationId: string | null;
}
export interface SessionRunRow {
id: string;
status: string;
model: string;
provider: string;
inputTokens: number | null;
outputTokens: number | null;
costUsd: number | null;
costSource: string | null;
startedAt: string;
finishedAt: string | null;
error: string | null;
usageFacts: UsageFactRow[];
}
export interface SessionDetail {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
project: { id: string; name: string };
runs: SessionRunRow[];
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
@@ -249,6 +317,7 @@ export interface AgentRoleRow {
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
folderId: string | null;
}
export interface AgentSkillRow {
@@ -261,6 +330,14 @@ export interface AgentSkillRow {
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
folderId: string | null;
}
/** ADR-0028 transparent folder node shared by agent roles and skills. */
export interface AgentConfigFolderRow {
id: string;
name: string;
parentId: string | null;
}
export interface SkillFileEntry {
@@ -333,7 +410,7 @@ export const api = {
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
post(`${orgBase(slug)}/projects`, body) as Promise<{ projectId: string; folderId: string | null; workspaceDir: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
@@ -353,6 +430,8 @@ export const api = {
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[];
}>,
session: (slug: string, sessionId: string) =>
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
@@ -361,6 +440,16 @@ export const api = {
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
const qs = q.toString();
return get(
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
) as Promise<ProjectUsageReport>;
},
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
@@ -400,7 +489,18 @@ export const api = {
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
body:
| { kind?: 'docmind'; accessKeyId: string; accessKeySecret: string; endpoint: string }
| {
kind: 'pbank';
baseUrl: string;
username: string;
password: string;
rightsStatus?: string;
rightsHolder?: string;
rightsScope?: string;
rightsNote?: string;
},
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
@@ -435,4 +535,21 @@ export const api = {
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
agentConfigFolders: (slug: string) =>
get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>,
createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) =>
post(`${orgBase(slug)}/agent-config-folders`, body) as Promise<AgentConfigFolderRow>,
patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise<AgentConfigFolderRow>,
deleteAgentConfigFolder: (slug: string, folderId: string) =>
del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>,
setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
setAgentSkillFolder: (slug: string, name: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
};
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
@@ -33,7 +33,7 @@
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<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"
>
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
+138 -19
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { Checkbox, Label } from 'bits-ui';
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
@@ -14,15 +15,23 @@
models,
skills,
slug,
folders,
folderItems,
onupdated,
onskillschanged,
onfolderchanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
/** ADR-0028 shared management tree — skill picker groups only; not binding identity */
folders: AgentConfigFolderRow[];
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props();
const initial = {
@@ -44,6 +53,8 @@
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
@@ -53,12 +64,69 @@
{} as Record<string, typeof TOOL_OPTIONS>,
);
const modelItems = $derived([
{ value: '', label: '(使用平台默认模型)' },
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
]);
const modelItems = $derived.by(() => {
const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` }));
const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog];
// Keep a previously saved model selectable even if it left the live catalog.
if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) {
items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` });
}
return items;
});
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
function folderPathLabel(folderId: string): string {
const folder = folders.find((f) => f.id === folderId);
if (!folder) return '(未知文件夹)';
const parts: string[] = [folder.name];
let cur: AgentConfigFolderRow | undefined = folder;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Group picker by management folder; bindings remain skill name (ADR-0028). */
const skillGroups = $derived.by(() => {
type Group = { key: string; label: string; skills: AgentSkillRow[] };
const byFolder = new Map<string | null, AgentSkillRow[]>();
for (const s of skills) {
const key = s.folderId;
const list = byFolder.get(key) ?? [];
list.push(s);
byFolder.set(key, list);
}
for (const list of byFolder.values()) {
list.sort((a, b) => a.name.localeCompare(b.name));
}
const filed = [...byFolder.entries()]
.filter((e): e is [string, AgentSkillRow[]] => e[0] !== null)
.map(([id, list]) => ({ key: id, label: folderPathLabel(id), skills: list }))
.sort((a, b) => a.label.localeCompare(b.label));
const unfiled = byFolder.get(null);
const groups: Group[] = [...filed];
if (unfiled && unfiled.length > 0) {
groups.push({ key: 'unfiled', label: '未分类', skills: unfiled });
}
return groups;
});
function groupSelectedCount(groupSkills: AgentSkillRow[]): number {
return groupSkills.filter((s) => selectedSkills.includes(s.name)).length;
}
function toggleGroup(groupSkills: AgentSkillRow[], checked: boolean) {
const names = new Set(groupSkills.map((s) => s.name));
if (checked) {
const next = new Set(selectedSkills);
for (const n of names) next.add(n);
selectedSkills = [...next];
} else {
selectedSkills = selectedSkills.filter((n) => !names.has(n));
}
}
function skillsDirty(): boolean {
const a = [...selectedSkills].sort();
@@ -105,6 +173,24 @@
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the configuration save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === r.folderId) return;
savingFolder = true;
try {
await api.setAgentRoleFolder(slug, r.roleId, folderId);
onfolderchanged(r.roleId, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = r.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
</script>
<div class="saas-card-pad">
@@ -114,6 +200,12 @@
{#if r.isDefault}
<span class="saas-badge-success">默认</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -129,7 +221,13 @@
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} />
<SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div>
<div class="mt-4">
@@ -166,19 +264,40 @@
<div class="mt-4">
<span class="saas-label">技能绑定</span>
{#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装ADR-0018)。</p>
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板ADR-0018)。</p>
{:else}
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillItems as s}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.value)}
onchange={(checked) => {
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
}}
/>
<span class="font-mono text-xs">{s.label}</span>
</label>
<div class="space-y-3">
{#each skillGroups as group (group.key)}
<div class="border border-surface-300">
<label class="flex cursor-pointer items-center gap-2 border-b border-surface-200 bg-surface-100 px-2 py-1.5 text-sm">
<CheckboxControl
checked={groupSelectedCount(group.skills) === group.skills.length}
onchange={(checked) => toggleGroup(group.skills, checked)}
/>
<span class="text-xs font-semibold text-surface-700">{group.label}</span>
<span class="text-[10px] text-surface-500">
{groupSelectedCount(group.skills)}/{group.skills.length}
</span>
</label>
<div class="grid grid-cols-1 gap-0.5 p-1 sm:grid-cols-2">
{#each group.skills as s (s.name)}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.name)}
onchange={(checked) => {
selectedSkills = checked
? [...selectedSkills, s.name]
: selectedSkills.filter((x) => x !== s.name);
}}
/>
<span class="font-mono text-xs">{s.name}</span>
{#if s.version}
<span class="text-[10px] text-surface-500">v{s.version}</span>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
@@ -2,19 +2,26 @@
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { parseSkillZip } from '$lib/skillZip';
import Icon from '$lib/components/Icon.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
slug,
skill,
folderItems,
oninstalled,
ondisabled,
onfolderchanged,
}: {
slug: string;
skill: AgentSkillRow;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void;
onfolderchanged: (name: string, folderId: string | null) => void;
} = $props();
type FileNode = { path: string; content: string };
@@ -28,6 +35,10 @@
let dirty = $state(false);
let newFilePath = $state('');
let showNewFile = $state(false);
let folderValue = $state(skill.folderId ?? '');
let savingFolder = $state(false);
let zipInputEl = $state<HTMLInputElement | null>(null);
let zipImporting = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
@@ -152,6 +163,53 @@
dirty = true;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the content save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === skill.folderId) return;
savingFolder = true;
try {
await api.setAgentSkillFolder(slug, skill.name, folderId);
onfolderchanged(skill.name, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = skill.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function importZip(fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const trimmedVersion = version.trim();
if (trimmedVersion === '') {
toastError('请先填写版本号再导入 zip');
if (zipInputEl) zipInputEl.value = '';
return;
}
zipImporting = true;
try {
const buf = new Uint8Array(await file.arrayBuffer());
const parsed = parseSkillZip(buf);
if (parsed.name !== skill.name) {
throw new Error(`zip 内技能名 "${parsed.name}" 与当前技能 "${skill.name}" 不一致`);
}
files = parsed.files.map((f) => ({ path: f.path, content: f.content }));
selectedPath = files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null;
if (parsed.description !== null) description = parsed.description;
dirty = true;
toastSuccess(`已载入 zip${files.length} 个文件),请保存以写入`);
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
zipImporting = false;
if (zipInputEl) zipInputEl.value = '';
}
}
function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) {
@@ -178,6 +236,12 @@
{#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -292,9 +356,20 @@
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty || zipImporting}>
{saving ? '保存中…' : '保存'}
</button>
<label class="saas-btn-ghost cursor-pointer {zipImporting ? 'pointer-events-none opacity-50' : ''}">
{zipImporting ? '导入中…' : '从 zip 替换'}
<input
bind:this={zipInputEl}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
class="hidden"
disabled={zipImporting || saving}
onchange={(e) => importZip(e.currentTarget.files)}
/>
</label>
{#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用
+3
View File
@@ -12,12 +12,15 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', group: '网络' },
{ id: 'todo', label: '任务清单 (todo_write)', group: '规划' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' },
{ id: 'convert_pdf_to_md', label: 'PDF→Markdown', group: '能力' },
{ id: 'pbank', label: '题库 (PBank)', group: '能力' },
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
+46
View File
@@ -29,6 +29,52 @@ export function fmtNum(n: number): string {
return n.toLocaleString();
}
const USAGE_KIND_LABELS: Record<string, string> = {
model_completion: '模型完成',
external_capability: '外部能力',
tool_proxy: '工具代理',
};
const METER_UNIT_LABELS: Record<string, string> = {
pages: '页',
audio_seconds: '音频秒',
invocations: '次调用',
};
export function usageKindLabel(kind: string): string {
return USAGE_KIND_LABELS[kind] ?? kind;
}
export function meterUnitLabel(unit: string | null | undefined): string {
if (!unit) return '—';
return METER_UNIT_LABELS[unit] ?? unit;
}
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
if (quantity === null || quantity === undefined) return '—';
const u = meterUnitLabel(unit);
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
}
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
const hasIn = input !== null && input !== undefined;
const hasOut = output !== null && output !== undefined;
if (!hasIn && !hasOut) return '—';
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
}
export function runStatusLabel(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return '完成';
if (key === 'FAILED') return '失败';
if (key === 'CANCELED') return '取消';
if (key === 'TIMED_OUT') return '超时';
if (key === 'RUNNING') return '运行中';
if (key === 'QUEUED') return '排队';
return status;
}
export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
+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}`;
}
+7 -4
View File
@@ -35,12 +35,9 @@ export async function loadSession(): Promise<void> {
/**
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
*/
export function resolveLoginOrgSlug(): string | null {
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
const q = new URLSearchParams(window.location.search).get('org');
if (q && q.trim() !== '') return q.trim();
@@ -48,6 +45,12 @@ export function resolveLoginOrgSlug(): string | null {
const host = window.location.hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
if (m?.[1]) return m[1];
// Legacy path while old bookmarks still land briefly before redirect.
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
return null;
}
+97
View File
@@ -0,0 +1,97 @@
import { unzipSync } from 'fflate';
import type { SkillFileEntry } from '$lib/api';
/** Mirror hub/src/agent/skillStore.ts limits (ADR-0018). */
const MAX_SKILL_FILES = 512;
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
export interface ParsedSkillZip {
readonly name: string;
readonly description: string | null;
readonly files: readonly SkillFileEntry[];
}
/**
* Parse a skill package zip. Root of the archive IS the skill directory
* (must contain SKILL.md directly — no wrapping folder).
*/
export function parseSkillZip(data: Uint8Array): ParsedSkillZip {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(data, {
filter: (file) => !file.name.endsWith('/'),
});
} catch {
throw new Error('无法解析 zip 文件');
}
const files: SkillFileEntry[] = [];
let totalBytes = 0;
for (const [rawPath, bytes] of Object.entries(entries)) {
const path = normalizeZipPath(rawPath);
if (path === null) continue;
totalBytes += bytes.byteLength;
if (totalBytes > MAX_SKILL_BYTES) {
throw new Error(`技能总大小超过 ${MAX_SKILL_BYTES / (1024 * 1024)} MiB 上限`);
}
if (files.length >= MAX_SKILL_FILES) {
throw new Error(`技能文件数超过 ${MAX_SKILL_FILES} 上限`);
}
// Text-only alpha path — API encodes content as UTF-8 strings.
try {
files.push({ path, content: decodeUtf8Strict(bytes) });
} catch {
throw new Error(`文件不是合法 UTF-8 文本:${path}`);
}
}
if (files.length === 0) {
throw new Error('zip 为空或不含可用文件');
}
const manifest = files.find((f) => f.path === 'SKILL.md');
if (!manifest) {
throw new Error('zip 根目录必须包含 SKILL.md(根即 skill 目录,不要多包一层文件夹)');
}
const name = parseFrontmatterField(manifest.content, 'name');
if (name === null || name === '') {
throw new Error('SKILL.md frontmatter 缺少 name');
}
if (!SKILL_NAME_PATTERN.test(name)) {
throw new Error('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
}
const description = parseFrontmatterField(manifest.content, 'description');
files.sort((a, b) => a.path.localeCompare(b.path));
return { name, description, files };
}
function normalizeZipPath(raw: string): string | null {
let path = raw.replace(/\\/g, '/');
// Drop zip noise and absolute/parent escapes before other checks.
if (path.startsWith('__MACOSX/') || path.includes('/__MACOSX/')) return null;
const base = path.split('/').pop() ?? '';
if (base === '.DS_Store' || base.startsWith('._')) return null;
if (path.startsWith('/') || path.includes('\0')) {
throw new Error(`非法文件路径:${raw}`);
}
// Strip a single leading "./"
if (path.startsWith('./')) path = path.slice(2);
const parts = path.split('/').filter((p) => p !== '' && p !== '.');
if (parts.length === 0 || parts.some((p) => p === '..')) {
throw new Error(`非法文件路径:${raw}`);
}
return parts.join('/');
}
function parseFrontmatterField(manifest: string, key: string): string | null {
const match = new RegExp(`^${key}:\\s*['"]?([^'"\\r\\n]+)['"]?\\s*$`, 'm').exec(manifest);
return match ? match[1]!.trim() : null;
}
function decodeUtf8Strict(bytes: Uint8Array): string {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
+3
View File
@@ -32,3 +32,6 @@ export function toastSuccess(message: string): void {
export function toastError(message: string): void {
pushToast(message, 'error', 5000);
}
export function toastInfo(message: string): void {
pushToast(message, 'info');
}
+52 -76
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
@@ -20,6 +21,7 @@
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
@@ -31,66 +33,49 @@
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
return memberships().filter((o) => isOrgAdmin(o));
}
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
return resolveOrg($session.me, page.url.search);
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
// /admin/projects or /admin/projects/:id
return parts[0] === 'admin' && parts[1] === 'projects';
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
if (parts[0] !== 'admin') return '';
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
if (key === 'overview') return adminPath();
return adminPath(key);
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
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) {
@@ -113,33 +98,23 @@
$effect(() => {
if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
const path = page.url.pathname;
// 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();
if (matched && isAdmin(matched)) {
if (org && isOrgAdmin(org)) {
redirecting = false;
return;
}
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders;
// the org overview and other admin-only surfaces are not for them.
if (matched && !isAdmin(matched)) {
// Member only: allow project routes; bounce admin-only surfaces to projects.
if (org && !isOrgAdmin(org)) {
redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean);
const onOverview = parts.length === 3; // /admin/org/:slug
if (onOverview) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
if (!isOnProjectRoute()) {
const target = adminPath('projects');
if (path !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -147,12 +122,11 @@
return;
}
// No matched org and no admin orgs: route a member to their first org's
// projects page so they can reach project MANAGE surfaces.
if (!matched && memberships().length > 0) {
const home = memberships()[0];
const target = `/admin/org/${home.slug}/projects`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
if (!org && memberships().length > 0) {
const home = admins[0] ?? memberships()[0]!;
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
if (path !== target && !path.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
@@ -173,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"
></path>
</svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-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>
@@ -193,7 +167,7 @@
<div class="saas-status-panel">
<div class="saas-status-card">
<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
</div>
@@ -202,7 +176,7 @@
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isAdmin(currentOrg()!)}
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
@@ -227,17 +201,19 @@
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div>
<div class="truncate text-xs text-surface-600">{org.name}</div>
</div>
</div>
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
{#if me.organizations.length > 1}
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
{/if}
<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>
@@ -307,13 +283,13 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
@@ -342,7 +318,7 @@
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!)}
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
@@ -351,7 +327,7 @@
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
@@ -362,14 +338,14 @@
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={`/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>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null;
return target ? `/admin` : null;
}
onMount(() => {
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
@@ -10,7 +11,8 @@
import SwitchControl from '$lib/components/SwitchControl.svelte';
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 settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
@@ -94,19 +96,56 @@
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p>
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
@@ -129,7 +168,11 @@
<tbody>
{#each usage.projects as p}
<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 text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
@@ -0,0 +1,311 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
type CapKind = 'docmind' | 'pbank';
const KNOWN_CAPABILITIES = [
{
id: 'pdf_to_md_bundle',
kind: 'docmind' as const,
label: 'PDF → Markdown',
description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)'
},
{
id: 'audio_video_to_text',
kind: 'docmind' as const,
label: '音视频 → 文本',
description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)'
},
{
id: 'pbank',
kind: 'pbank' as const,
label: '题库 (PBank)',
description: '搜索/拉取 Paradigm 题库题目与源工程;Agent 通过 pbank_* 工具访问,凭据不下发到 Agent 进程'
}
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let editingKind = $state<CapKind>('docmind');
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let baseUrl = $state('https://pbank.paradigm-edu.net/api');
let username = $state('');
let password = $state('');
let rightsStatus = $state('owned');
let rightsHolder = $state('Paradigm Education');
let rightsScope = $state('internal teaching-material production');
let rightsNote = $state('');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string, kind: CapKind) {
editingCap = capId;
editingKind = kind;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
baseUrl = 'https://pbank.paradigm-edu.net/api';
username = '';
password = '';
rightsStatus = 'owned';
rightsHolder = 'Paradigm Education';
rightsScope = 'internal teaching-material production';
rightsNote = '';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
saving = true;
try {
let result: CapabilityConnection;
if (editingKind === 'docmind') {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'docmind',
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim()
});
} else {
if (baseUrl.trim() === '' || username.trim() === '' || password.trim() === '') {
toastError('Base URL、用户名、密码均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'pbank',
baseUrl: baseUrl.trim(),
username: username.trim(),
password: password.trim(),
...(rightsStatus.trim() !== '' ? { rightsStatus: rightsStatus.trim() } : {}),
...(rightsHolder.trim() !== '' ? { rightsHolder: rightsHolder.trim() } : {}),
...(rightsScope.trim() !== '' ? { rightsScope: rightsScope.trim() } : {}),
...(rightsNote.trim() !== '' ? { rightsNote: rightsNote.trim() } : {})
});
}
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id, cap.kind)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
{#if cap.kind === 'docmind'}
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input
id="ak-secret-{cap.id}"
class="saas-input"
type="password"
bind:value={accessKeySecret}
/>
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
{:else}
<p class="saas-muted mb-3 text-sm">
Paradigm 题库登录凭据。激活前会探测 /login;凭据仅写入信封新版本。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="pbank-base-{cap.id}">API Base URL</Label.Root>
<input id="pbank-base-{cap.id}" class="saas-input font-mono text-sm" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-user-{cap.id}">用户名</Label.Root>
<input id="pbank-user-{cap.id}" class="saas-input font-mono text-sm" bind:value={username} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-pass-{cap.id}">密码</Label.Root>
<input id="pbank-pass-{cap.id}" class="saas-input" type="password" bind:value={password} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-status-{cap.id}">权利状态</Label.Root>
<input
id="pbank-rights-status-{cap.id}"
class="saas-input font-mono text-sm"
bind:value={rightsStatus}
placeholder="owned | exclusive_license | licensed_adapt | unknown"
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-holder-{cap.id}">权利主体</Label.Root>
<input
id="pbank-rights-holder-{cap.id}"
class="saas-input text-sm"
bind:value={rightsHolder}
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-scope-{cap.id}">使用范围</Label.Root>
<input id="pbank-rights-scope-{cap.id}" class="saas-input text-sm" bind:value={rightsScope} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-note-{cap.id}">权利说明(可选)</Label.Root>
<textarea
id="pbank-rights-note-{cap.id}"
class="saas-input min-h-20 text-sm"
bind:value={rightsNote}
></textarea>
</div>
</div>
{/if}
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,13 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const 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.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
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 loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -10,7 +12,8 @@
import SelectField from '$lib/components/SelectField.svelte';
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 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>
@@ -1,202 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
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 slug = $derived(page.params.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,169 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.providerConnections(slug);
connections = res.connections;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startRotate(row: ProviderConnectionRow) {
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function resetForm() {
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
async function save() {
const id = providerId.trim();
if (id === '') {
toastError('请填写供应方 ID');
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填');
return;
}
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token,
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
try {
await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
resetForm();
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型供应方"
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
</div>
{#if connections.length === 0}
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>供应方</th>
<th>凭据模式</th>
<th>状态</th>
<th>版本</th>
<th>更新于</th>
<th></th>
</tr>
</thead>
<tbody>
{#each connections as row}
<tr>
<td class="font-mono text-sm">{row.providerId}</td>
<td>{providerModeLabel(row.mode)}</td>
<td>{row.status}</td>
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
</div>
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
@@ -1,129 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
roles = r.roles;
skills = s.skills;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
roles = [...roles, created];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.roleId)}
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
{/each}
</div>
{/if}
{/if}
@@ -1,141 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.agentSkills(slug);
skills = res.skills;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
toastError('技能名称不能为空');
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
return;
}
const version = newSkillVersion.trim();
if (version === '') {
toastError('版本号不能为空');
return;
}
creating = true;
try {
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
showNewSkill = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
creating = false;
}
}
function buildManifest(name: string, description: string): string {
const desc = description === '' ? name : description;
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
function onDisabled(_name: string) {
load();
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
</div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
</p>
{/if}
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else}
<div class="space-y-4">
{#each skills as skill (skill.id)}
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
{/each}
</div>
{/if}
{/if}
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -13,8 +14,8 @@
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
@@ -87,7 +88,7 @@
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
window.location.href = `/admin/projects/${res.projectId}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -207,7 +208,7 @@
</thead>
<tbody>
{#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-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
@@ -7,8 +7,19 @@
type TeamRow,
type SessionSummary,
type ExplorerData,
type ProjectUsageReport,
} 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 PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -18,7 +29,8 @@
import Icon from '$lib/components/Icon.svelte';
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 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}`;
@@ -26,6 +38,7 @@
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
@@ -49,14 +62,18 @@
// Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([
const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
p.actorIsOrgAdmin
? api.projectUsage(slug, projectId)
: Promise.resolve(null as ProjectUsageReport | null),
]);
sessions = s.sessions;
teams = t.teams;
explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? '';
}
@@ -95,7 +112,7 @@
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`;
window.location.href = `/admin/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -156,7 +173,7 @@
{:else if proj}
<div class="mb-2">
<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"
>
<Icon name="arrow-left" class="h-4 w-4" />
@@ -269,9 +286,67 @@
</div>
{#if actorIsOrgAdmin}
{#if projectUsage}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">项目用量分账</h3>
<p class="saas-muted mt-0.5 text-xs">
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
</div>
{#if projectUsage.breakdown.length === 0}
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each projectUsage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums text-xs">
{#if row.unit}
{fmtQuantity(row.quantity, row.unit)}
{:else}
{fmtTokens(row.inputTokens, row.outputTokens)}
{/if}
</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
@@ -283,6 +358,7 @@
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -292,6 +368,14 @@
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
<td class="text-right">
<a
class="text-sm text-primary-700 hover:underline"
href={`/admin/sessions/${s.id}`}
>
详情
</a>
</td>
</tr>
{/each}
</tbody>
@@ -0,0 +1,271 @@
<script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastInfo, toastSuccess } from '$lib/toast';
type FormState =
| { kind: 'new'; error?: string }
| { kind: 'rotate'; providerId: string; error?: string }
| { kind: 'saving'; intent: 'new' | 'rotate'; providerId?: string };
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let formState = $state<FormState>({ kind: 'new' });
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
const rotationId = $derived(
formState.kind === 'rotate'
? formState.providerId
: formState.kind === 'saving' && formState.intent === 'rotate'
? (formState.providerId ?? null)
: null
);
const saving = $derived(formState.kind === 'saving');
const formError = $derived(
formState.kind === 'new' || formState.kind === 'rotate' ? (formState.error ?? null) : null
);
async function load() {
loading = true;
error = null;
try {
const res = await api.providerConnections(slug);
connections = res.connections;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function startRotate(row: ProviderConnectionRow) {
formState = { kind: 'rotate', providerId: row.providerId };
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
await tick();
const el = document.getElementById('base-url');
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.focus();
toastInfo(`已开始轮换 ${row.providerId},请填写新接口地址与访问令牌`);
}
function resetForm() {
formState = { kind: 'new' };
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function cancelOrClear() {
const wasRotating = rotationId !== null;
resetForm();
if (wasRotating) toastInfo('已取消轮换');
}
function showFormError(message: string, targetProviderId: string | null) {
formState =
targetProviderId === null
? { kind: 'new', error: message }
: { kind: 'rotate', providerId: targetProviderId, error: message };
toastError(message);
}
async function save() {
const targetProviderId = formState.kind === 'rotate' ? formState.providerId : null;
const intent = targetProviderId === null ? 'new' : 'rotate';
const id = providerId.trim();
if (id === '') {
showFormError('请填写供应方 ID', targetProviderId);
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
showFormError('接口地址与访问令牌均为必填', targetProviderId);
return;
}
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token,
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
formState =
intent === 'rotate'
? { kind: 'saving', intent, providerId: id }
: { kind: 'saving', intent };
try {
const saved = await api.rotateProviderConnection(slug, id, body);
resetForm();
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
formState =
intent === 'rotate'
? { kind: 'rotate', providerId: id, error: message }
: { kind: 'new', error: message };
toastError(message);
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型供应方"
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
</div>
{#if connections.length === 0}
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>供应方</th>
<th>凭据模式</th>
<th>状态</th>
<th>版本</th>
<th>更新于</th>
<th></th>
</tr>
</thead>
<tbody>
{#each connections as row}
<tr>
<td class="font-mono text-sm">{row.providerId}</td>
<td>{providerModeLabel(row.mode)}</td>
<td>{row.status}</td>
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button
class="saas-btn-ghost px-2! py-1! text-xs"
onclick={() => startRotate(row)}
disabled={saving}
aria-label={`开始轮换供应方 ${row.providerId}`}
>
开始轮换
</button>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">
{rotationId ? `轮换凭据 · ${rotationId}` : '新建 BYOK 凭据'}
</h3>
<p class="saas-muted mb-4" role="status">
{#if saving}
正在验证新凭据;验证通过后才会切换版本,请勿重复提交。
{:else if rotationId}
已选择供应方 {rotationId}。点击“开始轮换”只打开此表单;填写新接口地址和访问令牌后,点击“验证并保存”才会生效。
{:else}
填写供应方 ID、接口地址和访问令牌,保存前会先验证凭据。平台托管连接不在此处管理。
{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input
id="provider-id"
class="saas-input font-mono text-sm"
bind:value={providerId}
placeholder="openrouter"
readonly={rotationId !== null}
disabled={saving}
/>
{#if rotationId}
<p class="mt-1 text-xs text-surface-500">
轮换目标已锁定为 {rotationId}。如需新建其他供应方,请先取消轮换。
</p>
{/if}
</div>
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
disabled={saving}
/>
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input
id="auth-token"
class="saas-input"
type="password"
bind:value={authToken}
disabled={saving}
/>
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input
id="anthropic-key"
class="saas-input"
type="password"
bind:value={anthropicApiKey}
disabled={saving}
/>
</div>
</div>
{#if formError}
<div role="alert" class="border border-error-200 bg-error-50 px-4 py-3 text-sm text-error-700">
{formError}
</div>
{/if}
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={cancelOrClear} disabled={saving}>
{rotationId ? '取消轮换' : '清空'}
</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'}
</button>
</div>
</div>
{/if}
@@ -0,0 +1,328 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
roles = r.roles;
skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const r of roles) {
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
const visibleRoles = $derived(
selectedFolder === 'all'
? roles
: selectedFolder === 'unfiled'
? roles.filter((r) => !r.folderId)
: roles.filter((r) => r.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
let folderId: string | null = null;
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
function onRoleFolderChanged(roleId: string, folderId: string | null) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={roles.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleRoles as r (r.roleId)}
<RoleCard
{r}
{models}
{skills}
{slug}
{folders}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -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,408 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { parseSkillZip } from '$lib/skillZip';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
let showZipUpload = $state(false);
let zipVersion = $state('0.1.0');
let zipUploading = $state(false);
let zipInputEl = $state<HTMLInputElement | null>(null);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
skills = s.skills;
folders = f.folders;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const s of skills) {
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
const visibleSkills = $derived(
selectedFolder === 'all'
? skills
: selectedFolder === 'unfiled'
? skills.filter((s) => !s.folderId)
: skills.filter((s) => s.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
toastError('技能名称不能为空');
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
return;
}
const version = newSkillVersion.trim();
if (version === '') {
toastError('版本号不能为空');
return;
}
creating = true;
try {
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
showNewSkill = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
creating = false;
}
}
function buildManifest(name: string, description: string): string {
const desc = description === '' ? name : description;
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
async function uploadZip(fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const version = zipVersion.trim();
if (version === '') {
toastError('版本号不能为空');
if (zipInputEl) zipInputEl.value = '';
return;
}
zipUploading = true;
try {
const buf = new Uint8Array(await file.arrayBuffer());
const parsed = parseSkillZip(buf);
const result = await api.installAgentSkill(slug, parsed.name, {
version,
files: parsed.files,
});
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已从 zip 安装(${parsed.files.length} 个文件)`);
showZipUpload = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
zipUploading = false;
if (zipInputEl) zipInputEl.value = '';
}
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
function onDisabled(_name: string) {
load();
}
function onSkillFolderChanged(name: string, folderId: string | null) {
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<div>
<div class="saas-card-pad mb-6 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="saas-section-title">新建技能</h2>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="text-sm text-primary-700 hover:text-primary-900"
onclick={() => {
showZipUpload = !showZipUpload;
if (showZipUpload) showNewSkill = false;
}}
>
{showZipUpload ? '取消上传' : '上传 zip'}
</button>
<button
type="button"
class="text-sm text-primary-700 hover:text-primary-900"
onclick={() => {
showNewSkill = !showNewSkill;
if (showNewSkill) showZipUpload = false;
}}
>
{showNewSkill ? '取消' : '+ 空白模板'}
</button>
</div>
</div>
{#if showZipUpload}
<div class="grid gap-3 sm:grid-cols-[8rem_1fr_auto]">
<input class="saas-input text-sm" placeholder="版本号" bind:value={zipVersion} disabled={zipUploading} />
<input
bind:this={zipInputEl}
class="saas-input text-sm file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
disabled={zipUploading}
onchange={(e) => uploadZip(e.currentTarget.files)}
/>
<span class="self-center text-xs text-surface-600">{zipUploading ? '安装中…' : '选择 .zip'}</span>
</div>
<p class="text-xs text-surface-600">
zip 根目录即为 skill(须直接含 SKILL.md)。名称取自 manifest;仅 UTF-8 文本;当前选中管理文件夹时会自动归入。覆盖同 name 会更新内容(可能使绑定角色会话不可安全恢复)。
</p>
{/if}
{#if showNewSkill}
<div class="grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
</p>
{/if}
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleSkills as skill (skill.id)}
<SkillEditor
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
</Modal>
{/if}
@@ -2,6 +2,8 @@
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,7 +11,8 @@
import EmptyState from '$lib/components/EmptyState.svelte';
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 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;
}
.saas-select-trigger {
.saas-select-trigger,
.saas-combobox-input {
display: inline-flex;
width: 100%;
align-items: center;
@@ -425,14 +426,25 @@
text-align: left;
}
.saas-combobox-input {
cursor: text;
padding-right: 2.25rem;
}
.saas-combobox-input::placeholder {
color: var(--color-surface-500);
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
.saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
.saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@@ -443,9 +455,15 @@
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
max-height: min(
18rem,
var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden;
border-radius: 0;
border: 1px solid var(--color-surface-400);
+5 -3
View File
@@ -85,7 +85,7 @@ REMOTE
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -95,8 +95,10 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0
fi
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev
npm ci --include=dev --prefix admin-web
test -x node_modules/.bin/tsc
test -x admin-web/node_modules/.bin/vite
npm run audit:production
npm run build
test -f admin-web/build/index.html
+4 -2
View File
@@ -60,10 +60,12 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# 2. Install deps (including build-time dev deps) for hub + admin-web,
# audit hub prod, build tsc + SPA, mark complete. NODE_ENV=production may be
# inherited by the remote shell, so --include=dev is intentional here.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && 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 --include=dev && npm ci --include=dev --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+3 -1
View File
@@ -164,17 +164,19 @@ DATABASE_URL=
HUB_SILO_ORGANIZATION_ID=
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
CPH_BIN=$CPH_BIN_DEFAULT
HUB_FEISHU_CLI_BIN=/usr/local/bin/lark-cli
HOST=$HOST
PORT=$PORT
HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_TURNS=150
HUB_AGENT_MAX_CONCURRENT_RUNS=
HUB_AGENT_MAX_RUN_SECONDS=
HUB_HTTP_BODY_LIMIT_BYTES=
HUB_MAX_FILES_PER_MESSAGE=
HUB_MAX_FILE_BYTES=
HUB_PDF_TO_MD_MAX_CONCURRENT=3
HUB_HTTP_REQUESTS_PER_MINUTE=
HUB_FEISHU_EVENTS_PER_MINUTE=
HUB_FEISHU_LISTENER_ENABLED=true
+3 -3
View File
@@ -367,11 +367,11 @@ seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25"
seed_default MAX_TURNS "150"
seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900"
seed_default MAX_RUN_SECONDS "1800"
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILES_PER_MESSAGE "20"
seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120"
+95 -71
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.31",
"version": "0.0.42",
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
@@ -247,22 +247,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
"integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz",
"integrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -271,9 +271,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
"integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz",
"integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==",
"cpu": [
"arm64"
],
@@ -284,9 +284,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
"integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz",
"integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==",
"cpu": [
"x64"
],
@@ -297,12 +297,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
"integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz",
"integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -310,12 +313,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz",
"integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.217.tgz",
"integrity": "sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -323,12 +329,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
"integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz",
"integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -336,12 +345,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz",
"integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.217.tgz",
"integrity": "sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -349,9 +361,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
"integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz",
"integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==",
"cpu": [
"arm64"
],
@@ -362,9 +374,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
"integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz",
"integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==",
"cpu": [
"x64"
],
@@ -425,22 +437,34 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1034,13 +1058,13 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.14.1"
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
@@ -1069,13 +1093,13 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -2685,9 +2709,9 @@
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -2799,9 +2823,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -2999,9 +3023,9 @@
}
},
"node_modules/hono": {
"version": "4.12.28",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -3106,9 +3130,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -3637,9 +3661,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -3897,9 +3921,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -3917,7 +3941,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.32",
"version": "0.0.42",
"private": true,
"type": "module",
"engines": {
@@ -30,7 +30,7 @@
"axios": "1.18.1"
}
},
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build",
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/
-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- audit surface, intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
@@ -0,0 +1,33 @@
-- ADR-0028 agent configuration folder tree. One org-scoped transparent folder
-- tree shared by Agent roles and skills (management-surface grouping only);
-- skill name / roleId uniqueness, role→skill bindings, run-time skill loading
-- and Feishu slash commands never reference folders. A folder deletes only
-- when empty (service-enforced); item `folderId` references are SetNull as
-- backstop.
-- CreateTable
CREATE TABLE "OrganizationAgentConfigFolder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationAgentConfigFolder_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "OrganizationAgentSkill" ADD COLUMN "folderId" TEXT;
ALTER TABLE "OrganizationAgentRole" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "OrganizationAgentConfigFolder_organizationId_parentId_idx" ON "OrganizationAgentConfigFolder"("organizationId", "parentId");
CREATE INDEX "OrganizationAgentSkill_organizationId_folderId_idx" ON "OrganizationAgentSkill"("organizationId", "folderId");
CREATE INDEX "OrganizationAgentRole_organizationId_folderId_idx" ON "OrganizationAgentRole"("organizationId", "folderId");
-- AddForeignKey
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Allow Organization wipe/cascade to clear nested agent-config folders.
-- Service layer still refuses non-empty folder deletes (ADR-0028); this only
-- unblocks parent-row removal during org teardown and test resetDb.
ALTER TABLE "OrganizationAgentConfigFolder" DROP CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey";
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+36 -6
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub.
//
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// Aligns to ADR-0001..0004, 0017. Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - AgentSession is provider/model-bound. Provider runtime cursors such as
@@ -11,8 +11,8 @@
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is
// open — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
@@ -47,6 +47,7 @@ model Organization {
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@ -61,7 +62,7 @@ enum OrganizationStatus {
}
/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// the platform administrator surface (ADR-0023),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
id String @id @default(cuid())
@@ -95,16 +96,19 @@ model OrganizationAgentSkill {
version String
description String?
contentDigest String
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest])
}
@@ -121,17 +125,20 @@ model OrganizationAgentRole {
tools Json?
sortOrder Int @default(0)
isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -151,6 +158,29 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId])
}
/// ADR-0028: org-scoped transparent folder tree shared by agent roles and
/// skills. Management-surface navigation/grouping only — not a permission
/// resource, and never referenced by role→skill bindings, run-time skill
/// loading, or Feishu slash commands. Skill name and roleId stay unique per
/// organization regardless of folder membership. A folder is deleted only
/// when empty (service-enforced); item references are SetNull as backstop.
model OrganizationAgentConfigFolder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent OrganizationAgentConfigFolder? @relation("agentConfigFolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
skills OrganizationAgentSkill[]
roles OrganizationAgentRole[]
@@index([organizationId, parentId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
+5 -1
View File
@@ -26,7 +26,11 @@ const client = new DocmindClient.default({
} as never);
const fileStream = createReadStream(pdfPath);
const runtime = new RuntimeOptions({});
const runtime = new RuntimeOptions({
connectTimeout: 15_000,
// httpx defaults to 3000ms; OSS upload of multi-MB PDFs needs far more.
readTimeout: 5 * 60_000,
});
console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance(
+60
View File
@@ -0,0 +1,60 @@
---
name: pbank-problem-report
description: >
Use when a teacher wants Paradigm PBank (题库) problems as lesson examples,
asks to search PBank by outline, map candidates to outline parts, inspect
downloaded PBank source zips including images, or produce a Markdown
selection/adaptation report.
---
# PBank Problem Report
Use the `cph_hub` MCP tools `pbank_search_problems`, `pbank_get_problem`, and
`pbank_get_many_problems` to support problem selection from Paradigm PBank.
The normal output is a Markdown report for the teacher to review before any
lesson source is changed.
## Workflow
1. Understand the current lesson before searching.
- Read the lesson outline, plan, or main source files in the current workspace.
- Identify the teaching parts, target concepts, required difficulty, and what kind of examples are needed.
2. Search PBank with narrow queries.
- Use `pbank_search_problems` with Chinese keywords from the outline.
- Prefer several focused searches over one broad search.
- Keep the initial result set small, then fetch only plausible candidates.
3. Fetch source projects for candidates.
- Use `pbank_get_problem` or `pbank_get_many_problems`.
- Keep `includeProjects: true`, `materializeProjects: true`, and `includeAssetImages: true` unless the user explicitly asks otherwise.
- Read the returned `rights` object first. Treat it as the authoritative rights signal for all content returned by these tools.
- Read returned `files`, `assets`, `zipPath`, and `extractDir`. Images usually live under `fig/` in the extracted source tree.
- When image or PDF assets matter, cite their workspace-relative paths under `.pbank-sources/...`.
4. Produce a Markdown report instead of editing the lesson by default.
- Save it in the current project workspace as `pbank-candidate-report.md` unless the user gives another path.
- Do not copy problems into the lesson, rewrite the lesson, or write back to PBank unless the user explicitly asks.
5. When the teacher explicitly asks for adaptation, follow the rights signal.
- If `rights.derivativeUseAllowed` is `true`, you may quote, rewrite, adapt, and reorganize the returned PBank content into the current teaching-material project.
- Do not refuse on generic copyright grounds when the tools say derivative use is allowed.
- If `rights.derivativeUseAllowed` is `false`, stay read-only and ask the teacher/admin to confirm rights first.
## Report Shape
Use concise Chinese. Include:
- Lesson outline parts and the example need for each part.
- A candidate table with PBank title/id/link, matched outline part, fit score, directly usable material, required modifications, assets, and recommendation.
- Per-problem notes explaining the mathematical/physical idea, why it matches, and what needs to be changed for this lesson.
- A short final recommendation: which problems to use directly, which to adapt, and which to discard.
## Rules
- Do not invent problem statements, diagrams, or answers. Base every claim on PBank metadata or extracted source files.
- Treat `.pbank-sources/` as a local read cache. It is safe to read from and reference in reports.
- Keep PBank itself read-only. The teacher's workflow is selection and adaptation inside the current lesson project, not modifying the bank.
- Treat rights metadata as authoritative. Do not override `rights.derivativeUseAllowed: true` with a generic copyright refusal.
- If tools fail because no ACTIVE `pbank` capability connection exists, tell the user an org admin must configure 题库 credentials under 外部能力.
- If no candidate fits, say that directly and record the searches tried.
+96
View File
@@ -0,0 +1,96 @@
---
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles single or multiple PDFs (concurrent batch), Feishu attachments, and
local workspace files. Produces high-quality Markdown with LaTeX formulas
and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF (or several PDFs) to
Markdown, extract text from a PDF, or turn PDF documents into an editable
format.
## How it works
The `convert_pdf_to_md` tool (provided by the in-process `cph_hub` MCP server)
calls Alibaba Cloud Document Mind to parse each PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files **per** `output_dir`
There is **no** workspace `.mcp.json` source file. MCP tools are injected by
Hub at run start. Do not look for MCP or skill source under workspace
`.claude/` — those paths are sandbox stubs (often character devices) and are
not readable constitution.
## Where this skill text lives
Prefer the Skill tool when the runtime offers it. If you need to re-read these
instructions with Read:
- Workspace copy (always under cwd): `.cph/runtime-skills/pdf-to-md/SKILL.md`
- Absolute path env: `$CPH_RUNTIME_SKILLS_DIR/pdf-to-md/SKILL.md`
## Workflow
### One PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with `input_path` + `output_dir`.
### PDF already in the workspace
1. Use `convert_pdf_to_md` with `input_path` and `output_dir`.
### Multiple PDFs (concurrent)
1. Download or locate every PDF in the workspace first.
2. Call **`convert_pdf_to_md` once** with:
```json
{
"items": [
{ "input_path": "sources/a.pdf", "output_dir": "md/a" },
{ "input_path": "sources/b.pdf", "output_dir": "md/b" }
]
}
```
3. Hub submits Docmind jobs with bounded concurrency (default 3, max 8;
optional `concurrency` argument). Prefer this over N sequential tool calls.
4. **Each item must use a distinct `output_dir`** — the tool always writes
`document.md` inside that directory; shared dirs overwrite each other.
5. Partial failure returns per-file OK/FAIL lines; re-run only failed items.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send generated markdown (or a zip you
assemble) back to the user if they requested delivery.
## Output
Per `output_dir`:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
Billed per page (0.04 CNY/page ≈ $0.0056/page for enhanced formula mode).
Each successful file records its own usage fact on the run ledger.
+121
View File
@@ -15,6 +15,10 @@
* `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection).
*
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
* roles and skills for management-surface grouping. Folder endpoints never
* touch session state assignment is a label-class change (ADR-0017).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err);
}
});
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
return { folders };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await agentConfig.createFolder({
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
});
return reply.status(201).send(folder);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
const folder = await agentConfig.updateFolder({
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return folder;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await agentConfig.deleteFolder({
organizationId: auth.organization.id,
folderId,
});
return { deleted: true };
} catch (err) {
return handleRouteError(reply, err);
}
});
// Folder assignment is a label-class change (ADR-0017): these endpoints
// never archive Agent sessions (ADR-0028).
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setRoleFolder({
organizationId: auth.organization.id,
roleId,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setSkillFolder({
organizationId: auth.organization.id,
name,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+17 -6
View File
@@ -432,16 +432,16 @@ async function resolvePostLoginRedirect(
select: { organization: { select: { slug: true, name: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/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") {
return orgRoot;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
return normalizeAdminReturnTo(returnTo) ?? orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
return returnTo;
return normalizeAdminReturnTo(returnTo) ?? "/admin";
}
const membership = await prisma.organizationMembership.findFirst({
where: {
@@ -454,7 +454,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
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).
const any = await prisma.organizationMembership.findFirst({
@@ -463,7 +463,7 @@ async function resolvePostLoginRedirect(
orderBy: { createdAt: "asc" },
});
if (any !== null) {
return `/admin/org/${any.organization.slug}`;
return "/admin/projects";
}
return "/admin/login?error=no_organization";
}
@@ -489,7 +489,18 @@ export function sanitizeReturnTo(raw: string): string {
if (!raw.startsWith("/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 {
@@ -7,8 +7,12 @@
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import {
CapabilityConnectionService,
type CapabilityCredentialInput,
} from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import { secretKindForCapability } from "../../capability/types.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
const connections =
config.readinessProbe === undefined
? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const credential = parseCredentialBody(capabilityId, request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
credential,
});
request.log.info({
organizationId: auth.organization.id,
@@ -113,19 +116,57 @@ export async function registerCapabilityConnectionRoutes(
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
const expectedKind = secretKindForCapability(capabilityId);
const kind =
body.kind === "docmind" || body.kind === "pbank"
? body.kind
: expectedKind;
if (kind !== expectedKind) {
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`);
}
if (kind === "docmind") {
return {
kind: "docmind",
accessKeyId: requireStringField(body, "accessKeyId"),
accessKeySecret: requireStringField(body, "accessKeySecret"),
endpoint: requireStringField(body, "endpoint"),
};
}
const rightsStatus = optionalStringField(body, "rightsStatus");
const rightsHolder = optionalStringField(body, "rightsHolder");
const rightsScope = optionalStringField(body, "rightsScope");
const rightsNote = optionalStringField(body, "rightsNote");
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
kind: "pbank",
baseUrl: requireStringField(body, "baseUrl"),
username: requireStringField(body, "username"),
password: requireStringField(body, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
function requireStringField(body: Record<string, unknown>, name: string): string {
const value = body[name];
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${name} is required`);
}
return value;
}
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined {
const value = body[name];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+6 -1
View File
@@ -155,7 +155,12 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send({ id: result.projectId, name: body.name });
return reply.status(201).send({
projectId: result.projectId,
folderId: result.folderId,
workspaceDir: result.workspaceDir,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}
+256 -11
View File
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
readonly folderId: string | null;
}
export interface AgentSkillRow {
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly boundRoleIds: readonly string[];
readonly folderId: string | null;
}
/**
* ADR-0028 transparent folder node of the org's shared agent-config folder
* tree. Grouping only: never part of skill/role identity or run resolution.
*/
export interface AgentConfigFolderRow {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
}
/**
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
folderId: skill.folderId,
}));
}
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
await this.requireActiveOrganization(input.organizationId);
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
where: { organizationId: input.organizationId },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: { id: true, name: true, parentId: true },
});
return folders;
}
async createFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
const name = nonEmpty(input.name, "folder name");
return this.prisma.$transaction(async (tx) => {
if (input.parentId !== undefined) {
await requireFolder(tx, input.organizationId, input.parentId);
}
const folder = await tx.organizationAgentConfigFolder.create({
data: {
organizationId: input.organizationId,
name,
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.created",
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
},
});
return folder;
});
}
/**
* Rename and/or move a folder inside the same Organization tree. Moving is
* rejected when the target parent is the folder itself or one of its
* descendants (would create a cycle).
*/
async updateFolder(input: {
readonly organizationId: string;
readonly folderId: string;
readonly name?: string | undefined;
readonly parentId?: string | null | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
return this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent");
}
await requireFolder(tx, input.organizationId, input.parentId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
UNION ALL
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
}
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
const updated = await tx.organizationAgentConfigFolder.update({
where: { id: folder.id },
data: {
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.updated",
metadata: {
folderId: folder.id,
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
},
});
return updated;
});
}
/**
* Delete a folder. Refused while the folder still has child folders, roles
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
* rule is needed).
*/
async deleteFolder(input: {
readonly organizationId: string;
readonly folderId: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
const childFolders = await tx.organizationAgentConfigFolder.count({
where: { parentId: folder.id },
});
if (childFolders > 0) {
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
}
const skills = await tx.organizationAgentSkill.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
const roles = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
if (skills > 0 || roles > 0) {
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
}
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.deleted",
metadata: { folderId: folder.id, name: folder.name },
},
});
});
}
/**
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
* a label-class change in the ADR-0017 sense the execution surface is
* untouched, so no session archival (ADR-0028).
*/
async setSkillFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const skill = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
select: { id: true, disabledAt: true },
});
if (skill === null || skill.disabledAt !== null) {
throw new Error(`active skill not found in organization: ${input.name}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.folder_set",
metadata: { name: input.name, folderId: input.folderId },
},
});
});
}
/**
* Assign a role to a folder (or unfile it with `folderId: null`). Same
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
*/
async setRoleFolder(input: {
readonly organizationId: string;
readonly roleId: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const role = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { id: true, disabledAt: true },
});
if (role === null || role.disabledAt !== null) {
throw new Error(`active role not found in organization: ${input.roleId}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentRole.update({
where: { id: role.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.folder_set",
metadata: { roleId: input.roleId, folderId: input.folderId },
},
});
});
}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
@@ -173,7 +389,7 @@ export class OrganizationAgentConfiguration {
where: { id: skill.id },
data: { disabledAt: new Date() },
});
await archiveRoleSessions(
await invalidateRoleSessionClaudeIds(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
@@ -271,7 +487,7 @@ export class OrganizationAgentConfiguration {
},
});
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
await archiveRoleSessions(
await invalidateRoleSessionClaudeIds(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
@@ -379,7 +595,7 @@ export class OrganizationAgentConfiguration {
if (activeDefaultCount !== 1) {
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
}
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
@@ -449,7 +665,7 @@ export class OrganizationAgentConfiguration {
})),
});
}
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
@@ -472,7 +688,22 @@ export class OrganizationAgentConfiguration {
}
}
async function archiveRoleSessions(
/**
* Invalidate the provider session cursor (e.g. `claudeSessionId`) for every
* active session of the given roles, WITHOUT archiving the session.
*
* Execution-surface changes (role model/systemPrompt/tools, skill content or
* binding changes) make a stale provider session cursor unsafe to resume: the
* prior turns were produced under a different config. But the conversation
* history itself (AgentMessage rows) is still valuable and the logical Hub
* session should stay continuous the next run re-seeds context from the
* transcript instead of resuming the old provider session. So we drop only
* the cursor, not the session.
*
* `userResumable` is cleared because the session is no longer backed by a
* live provider cursor the user can drop back into.
*/
async function invalidateRoleSessionClaudeIds(
tx: Prisma.TransactionClient,
organizationId: string,
roleIds: readonly string[],
@@ -482,24 +713,36 @@ async function archiveRoleSessions(
where: {
roleId: { in: [...new Set(roleIds)] },
project: { organizationId },
archivedAt: null,
},
select: { id: true, archivedAt: true, metadata: true },
select: { id: true, metadata: true },
});
const archivedAt = new Date();
for (const session of sessions) {
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
? session.metadata as Prisma.JsonObject
: {};
const { claudeSessionId: _drop, ...rest } = metadata;
await tx.agentSession.update({
where: { id: session.id },
data: {
...(session.archivedAt === null ? { archivedAt } : {}),
metadata: { ...metadata, userResumable: false },
},
data: { metadata: { ...rest, userResumable: false } },
});
}
}
async function requireFolder(
tx: Prisma.TransactionClient,
organizationId: string,
folderId: string,
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
const folder = await tx.organizationAgentConfigFolder.findFirst({
where: { id: folderId, organizationId },
select: { id: true, name: true, parentId: true },
});
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
return folder;
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
@@ -530,6 +773,7 @@ function toRoleRow(role: {
readonly disabledAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly folderId: string | null;
readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>;
@@ -549,5 +793,6 @@ function toRoleRow(role: {
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
folderId: role.folderId,
};
}
+81 -43
View File
@@ -1,11 +1,13 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
@@ -14,6 +16,11 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
"todo_write",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -23,46 +30,65 @@ export interface ClaudeSdkToolConfig {
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
["read_file", ["Read"]],
["write_file", ["Write"]],
["list_files", ["Glob"]],
["search_files", ["Grep"]],
["bash", ["Bash"]],
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
read_file: ["Read"],
write_file: ["Write", "Edit"],
list_files: ["Glob"],
search_files: ["Grep"],
bash: ["Bash"],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
["cph_check", ["Bash"]],
["cph_build", ["Bash"]],
["web_fetch", ["WebFetch"]],
["web_search", ["WebSearch"]],
["Read", ["Read"]],
["Write", ["Write"]],
["Bash", ["Bash"]],
["Glob", ["Glob"]],
["Grep", ["Grep"]],
["WebFetch", ["WebFetch"]],
["WebSearch", ["WebSearch"]],
]);
cph_check: ["Bash"],
cph_build: ["Bash"],
web_fetch: ["WebFetch"],
web_search: ["WebSearch"],
todo: ["TodoWrite"],
TodoWrite: ["TodoWrite"],
Read: ["Read"],
Write: ["Write"],
Edit: ["Edit"],
Bash: ["Bash"],
Glob: ["Glob"],
Grep: ["Grep"],
WebFetch: ["WebFetch"],
WebSearch: ["WebSearch"],
};
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["send_file", "send_file"],
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"],
]);
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
send_file: ["send_file"],
feishu_read_context: ["feishu_read_context"],
feishu_download_resource: ["feishu_download_resource"],
request_approval: ["request_approval"],
convert_pdf_to_md: ["convert_pdf_to_md"],
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"],
todo: ["todo_write"],
TodoWrite: ["todo_write"],
todo_write: ["todo_write"],
"mcp__cph_hub__send_file": ["send_file"],
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
"mcp__cph_hub__request_approval": ["request_approval"],
"mcp__cph_hub__convert_pdf_to_md": ["convert_pdf_to_md"],
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
"mcp__cph_hub__todo_write": ["todo_write"],
};
const SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
export function claudeSdkToolConfigForRole(
roleTools: readonly string[] | null | undefined,
): ClaudeSdkToolConfig {
// DB/runtime "unrestricted" is JSON null; treat the same as undefined.
if (roleTools === undefined || roleTools === null) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
@@ -74,13 +100,12 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
pushUnique(builtIns, tool);
pushUnique(allowedTools, tool);
}
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) {
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
@@ -88,24 +113,37 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
export function cphHubMcpToolsForRole(
roleTools: readonly string[] | null | undefined,
): readonly CphHubMcpToolId[] {
// Always expose hub-side todo_write so progress cards work even when the
// native Claude TodoWrite tool is not registered in headless agent mode.
if (roleTools === undefined || roleTools === null) {
return [...CPH_HUB_MCP_TOOL_IDS];
}
const tools: CphHubMcpToolId[] = [];
const tools: CphHubMcpToolId[] = ["todo_write"];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(tools, mcpTool);
}
}
return tools;
}
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
if (roleTools === undefined) return true;
export function roleToolsAllow(
roleTools: readonly string[] | null | undefined,
roleTool: string,
): boolean {
if (roleTools === undefined || roleTools === null) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
if (configured === roleTool) return true;
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
}
return false;
}
+113 -14
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* provider secrets from Bash. This upholds the workspace-bounded file-op
* invariant (ADR-0018) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper the OS sandbox
* is the mechanism, the contract pins the invariant.
* is the mechanism, the ADR pins the invariant.
*/
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
@@ -47,7 +47,7 @@ export type StreamEvent =
| { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly input?: unknown; readonly durationMs?: number }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
@@ -140,7 +140,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let cleanupSecurity = async (): Promise<void> => {};
try {
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
// Role tools JSON null means the default single-agent tool set, not "deny all".
const roleToolIds = req.tools === null ? undefined : req.tools;
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
const workspaceRoot = req.project.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
throw new Error("Agent run requires the configured workspace root");
@@ -154,14 +156,42 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
});
cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0;
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
// Always use an explicit tool list — never the claude_code preset.
// The preset registers Agent/SendMessage/Task multi-agent machinery.
// Concurrent background agents abort with reason "background", and the
// Claude Agent SDK maps that to toolDenialKind "cancelled" with:
// "The user doesn't want to take this action right now..."
// which freezes Bash mid-run while Read/Glob continue to work.
// Hub "unrestricted" means the default single-agent built-ins + MCP, not
// the full interactive Claude product surface.
const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const);
const toolsOption: QueryOptions["tools"] = uniqueTools([
...toolConfig.tools,
"TodoWrite",
...skillExtras,
]);
const allowedToolsOption = uniqueTools([
...toolConfig.allowedTools,
"TodoWrite",
"mcp__cph_hub__todo_write",
...skillExtras,
]);
// Hard deny multi-agent orchestration even if a future preset/skills path
// reintroduces them — bypassPermissions would otherwise auto-allow them.
const disallowedToolsOption = [
"Agent",
"SendMessage",
"TeamCreate",
"Task",
"ScheduleWakeup",
] as const;
const options: QueryOptions = {
cwd: security.cwd,
// `skills` controls discovery/allowlisting, but an explicit `tools`
// list still has to expose the Skill dispatcher itself.
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
allowedTools: [...toolConfig.allowedTools],
tools: toolsOption,
allowedTools: allowedToolsOption,
disallowedTools: [...disallowedToolsOption],
maxTurns: cap,
includePartialMessages: true,
// ADR-0018: bypass interactive prompts (headless server); the sandbox
@@ -178,7 +208,16 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
settings: { disableBundledSkills: true },
settings: {
disableBundledSkills: true,
todoFeatureEnabled: true,
// Sessions are resumed across runs (ADR-0017). Without auto-compact
// the SDK jsonl grows unboundedly — a long-lived project session hit
// 31 MB / 1995 lines, making every API call resend the entire history
// and inflating a "change a title" task to 22 minutes. Let the SDK
// compact automatically when the context window fills.
autoCompactEnabled: true,
},
...(hasSkills && security.skillPluginRoot !== undefined
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
: {}),
@@ -198,13 +237,25 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (req.abortController !== undefined) options.abortController = req.abortController;
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
// When there is no provider session cursor to resume (first run, or after a
// role/skill/model config change invalidated claudeSessionId), re-seed the
// conversation from this Hub session's prior AgentMessage rows. This keeps
// the logical session continuous across config changes and restarts — the
// agent still "remembers" the earlier turns even though the SDK starts a
// fresh provider session. The resume path above is preferred when available
// (it carries tool calls/results natively and avoids re-sending tokens).
const promptForAgent = req.resumeSessionId === undefined
? await withSessionHistory(req, req.prompt)
: req.prompt;
const conversation = query({
prompt: req.prompt,
prompt: promptForAgent,
options,
});
// Track tool start timestamps for duration calculation
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
const toolStartTimestamps = new Map<string, number>();
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
for await (const message of conversation) {
switch (message.type) {
@@ -225,6 +276,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now());
toolMetaByUseId.set(toolUseId, {
name: evt.content_block.name,
input: undefined,
});
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
}
if (evt.type === "content_block_stop") {
@@ -246,6 +301,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined;
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
onStream?.({
type: "tool-end",
toolName: block.name,
@@ -279,12 +335,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const isError = block.is_error === true;
const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId);
const meta = toolMetaByUseId.get(toolUseId);
onStream?.({
type: "tool-result",
toolUseId,
toolName: toolUseId,
toolName: meta?.name ?? toolUseId,
result: resultText,
isError,
...(meta?.input !== undefined ? { input: meta.input } : {}),
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
});
}
@@ -333,6 +391,39 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
}
}
/**
* Re-seed a run's prompt with this Hub session's prior conversation when the
* SDK cannot resume a provider session (no `resumeSessionId`). Pulls prior
* `AgentMessage` rows for this session excluding the current run's own user
* message, which was already persisted before `runAgent` called this and
* frames them as `<session_history>` so the model treats them as prior turns,
* not new instructions. The current run's prompt follows as the live request.
*
* Best-effort: if the history query fails, the run proceeds with the bare
* prompt rather than aborting. A cap (`MAX_HISTORY_TURNS`) bounds token cost;
* older turns beyond the cap are dropped, preserving the most recent context.
*/
async function withSessionHistory(req: RunRequest, prompt: string): Promise<string> {
const MAX_HISTORY_TURNS = 40;
try {
const messages = await req.prisma.agentMessage.findMany({
where: { sessionId: req.sessionId, runId: { not: req.runId } },
orderBy: { createdAt: "asc" },
select: { role: true, content: true },
take: MAX_HISTORY_TURNS * 2, // user+assistant per turn
});
if (messages.length === 0) return prompt;
const turns: string[] = [];
for (const message of messages) {
const label = message.role === "assistant" ? "Assistant" : "User";
turns.push(`${label}: ${message.content}`);
}
return `<session_history>\nThis is the prior conversation in this session, replayed because the provider session could not be resumed. Treat these as earlier turns you produced or received.\n\n${turns.join("\n\n")}\n</session_history>\n\n${prompt}`;
} catch {
return prompt;
}
}
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
if (content === "") return;
try {
@@ -361,3 +452,11 @@ function extractToolResultText(content: unknown): string {
}
return parts.join("\n");
}
function uniqueTools(tools: readonly string[]): string[] {
const out: string[] = [];
for (const tool of tools) {
if (!out.includes(tool)) out.push(tool);
}
return out;
}
+57 -2
View File
@@ -1,4 +1,4 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
@@ -21,6 +21,20 @@ const SAFE_HOST_ENV_KEYS = [
"LOGNAME",
"SHELL",
"CPH_BIN",
// Host egress is often only reachable via a local forward proxy. Without
// these, sandboxed Bash/curl times out on public HTTPS (ADR-0018: network
// open ≠ direct routing). Values come from the trusted service environment.
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"NODE_USE_ENV_PROXY",
"TYPST_PACKAGE_PATH",
"TYPST_PACKAGE_CACHE_PATH",
] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
@@ -122,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0
? null
@@ -130,6 +145,25 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
runId: input.runId,
skills: selectedSkills,
});
// Mirror selected skills under the workspace so the agent can Read SKILL.md
// without guessing the opaque host plugin UUID path. Workspace `.claude/` and
// `.mcp.json` are Claude sandbox stubs — not skill/MCP source of truth.
const runtimeSkillsRel = join(".cph", "runtime-skills");
const runtimeSkillsAbs = join(workspaceDir, runtimeSkillsRel);
await rm(runtimeSkillsAbs, { recursive: true, force: true });
await mkdir(runtimeSkillsAbs, { recursive: true, mode: 0o700 });
if (skillPlugin !== null) {
const pluginSkillsRoot = join(skillPlugin.root, "skills");
const skillNames = await readdir(pluginSkillsRoot);
for (const skillName of skillNames) {
await cp(join(pluginSkillsRoot, skillName), join(runtimeSkillsAbs, skillName), {
recursive: true,
force: true,
});
}
env.CPH_RUNTIME_SKILLS_DIR = runtimeSkillsAbs;
env.CPH_RUNTIME_SKILLS_REL = runtimeSkillsRel;
}
return {
cwd: workspaceDir,
workspaceRoot,
@@ -143,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
// Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write.
@@ -213,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
platformPaths.push(resolve(cphBin));
}
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
return [...new Set(platformPaths.map((path) => resolve(path)))];
}
function hostTypstCacheWritePaths(env: Readonly<Record<string, string | undefined>>): string[] {
return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]);
}
function configuredTypstPackagePaths(
env: Readonly<Record<string, string | undefined>>,
names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[],
): string[] {
const paths: string[] = [];
for (const name of names) {
const packagePath = env[name]?.trim();
if (packagePath === undefined || packagePath === "") continue;
if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`);
const canonical = resolve(packagePath);
if (canonical === "/") throw new Error(`${name} must not be the filesystem root`);
paths.push(canonical);
}
return paths;
}
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const home = homedir();
const paths = [
+266
View File
@@ -0,0 +1,266 @@
/**
* Parse agent checklist tools into a stable model for Feishu card progress.
*
* Supports:
* - Claude TodoWrite (and hub mcp todo_write): full-list replace
* - Claude TaskCreate / TaskUpdate tools used in headless agent mode
*/
export type AgentTodoStatus = "pending" | "in_progress" | "completed";
export interface AgentTodoItem {
/** Present for Task* tools; optional for whole-list TodoWrite payloads. */
readonly id: string | undefined;
readonly content: string;
readonly status: AgentTodoStatus;
/** Present-tense label while the item is active, when the model supplies it. */
readonly activeForm: string | undefined;
}
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
export function isTodoWriteTool(toolName: string): boolean {
const lower = toolName.toLowerCase();
return (
toolName === "TodoWrite" ||
toolName.endsWith("__TodoWrite") ||
lower === "todo_write" ||
lower.endsWith("__todo_write")
);
}
export function isTaskChecklistTool(toolName: string): boolean {
const base = stripToolSuffix(toolName);
return (
base === "TaskCreate" ||
base === "TaskUpdate" ||
base === "TaskList" ||
base === "TaskGet" ||
base === "TaskStop" ||
base === "TaskOutput"
);
}
export function isChecklistProgressTool(toolName: string): boolean {
return isTodoWriteTool(toolName) || isTaskChecklistTool(toolName);
}
/**
* Extract the full todo list from a TodoWrite / todo_write tool_use input.
* Returns null when the payload is not a usable body.
*/
export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
if (!("todos" in input)) return null;
const rawTodos = (input as { todos?: unknown }).todos;
if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null;
const todos: AgentTodoItem[] = [];
for (const raw of rawTodos) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const record = raw as Record<string, unknown>;
const content = typeof record.content === "string" ? record.content.trim() : "";
if (content === "") continue;
const statusRaw = typeof record.status === "string" ? record.status : "pending";
const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus)
? (statusRaw as AgentTodoStatus)
: "pending";
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
const id =
typeof record.id === "string" && record.id.trim() !== "" ? record.id.trim() : undefined;
todos.push({ id, content, status, activeForm });
}
return todos.length === 0 ? null : todos;
}
/**
* Fold a Task* / TodoWrite tool event into the running checklist.
* Returns null when the event does not change checklist state.
*/
export function applyChecklistToolEvent(
current: readonly AgentTodoItem[],
params: {
readonly toolName: string;
readonly input: unknown;
readonly result: unknown;
readonly toolUseId?: string | undefined;
},
): readonly AgentTodoItem[] | null {
if (isTodoWriteTool(params.toolName)) {
return parseTodoWriteInput(params.input);
}
const baseName = stripToolSuffix(params.toolName);
if (baseName === "TaskCreate") {
return applyTaskCreate(current, params.input, params.result, params.toolUseId);
}
if (baseName === "TaskUpdate") {
return applyTaskUpdate(current, params.input);
}
return null;
}
export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
readonly completed: number;
readonly total: number;
readonly inProgress: number;
} {
let completed = 0;
let inProgress = 0;
for (const todo of todos) {
if (todo.status === "completed") completed += 1;
else if (todo.status === "in_progress") inProgress += 1;
}
return { completed, total: todos.length, inProgress };
}
function stripToolSuffix(toolName: string): string {
// SDK sometimes emits TaskCreate_0 sequential copies in the card title path.
const bare = toolName.includes("__") ? (toolName.split("__").pop() ?? toolName) : toolName;
return bare.replace(/_\d+$/, "");
}
function applyTaskCreate(
current: readonly AgentTodoItem[],
input: unknown,
result: unknown,
toolUseId: string | undefined,
): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
const subject =
typeof record.subject === "string"
? record.subject.trim()
: typeof record.description === "string"
? record.description.trim()
: "";
if (subject === "") return null;
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
const idFromResult = extractTaskIdFromResult(result);
const provisionalId = toolUseId ?? `task-${current.length + 1}`;
const id = idFromResult ?? provisionalId;
// Replace any provisional row for this tool use or same pending subject.
const without = current.filter(
(t) =>
t.id !== provisionalId &&
t.id !== toolUseId &&
!(t.content === subject && t.status === "pending" && t.id !== id),
);
const existing = without.find((t) => taskIdsMatch(t.id, id));
if (existing !== undefined) {
return without.map((t) =>
taskIdsMatch(t.id, id)
? {
id,
content: subject,
status: existing.status,
activeForm: activeForm ?? existing.activeForm,
}
: t,
);
}
return [
...without,
{
id,
content: subject,
status: "pending",
activeForm,
},
];
}
function applyTaskUpdate(
current: readonly AgentTodoItem[],
input: unknown,
): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
const taskId =
typeof record.taskId === "string"
? record.taskId.trim()
: typeof record.id === "string"
? record.id.trim()
: "";
if (taskId === "") return null;
const statusRaw = typeof record.status === "string" ? record.status : undefined;
if (statusRaw === "deleted") {
const next = current.filter((t) => !taskIdsMatch(t.id, taskId));
return next.length === current.length ? null : next;
}
const status: AgentTodoStatus | undefined =
statusRaw !== undefined && STATUSES.has(statusRaw as AgentTodoStatus)
? (statusRaw as AgentTodoStatus)
: undefined;
const subject =
typeof record.subject === "string" && record.subject.trim() !== ""
? record.subject.trim()
: undefined;
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
if (status === undefined && subject === undefined && activeForm === undefined) return null;
let found = false;
const next = current.map((todo) => {
if (!taskIdsMatch(todo.id, taskId)) return todo;
found = true;
return {
id: todo.id ?? taskId,
content: subject ?? todo.content,
status: status ?? todo.status,
activeForm: activeForm ?? todo.activeForm,
};
});
if (found) return next;
// Update arrived before create (or id mismatch): synthesize a row so the
// teacher still sees lifecycle updates.
if (subject === undefined && status === undefined) return null;
return [
...current,
{
id: taskId,
content: subject ?? `任务 ${taskId}`,
status: status ?? "pending",
activeForm,
},
];
}
function extractTaskIdFromResult(result: unknown): string | undefined {
const text =
typeof result === "string"
? result
: result !== null && typeof result === "object" && "content" in result
? String((result as { content: unknown }).content)
: "";
if (text === "") return undefined;
const hash = text.match(/Task\s*#\s*([0-9A-Za-z_-]+)/i);
if (hash?.[1]) return hash[1];
const bare = text.match(/\bid\s*[:=]\s*["']?([0-9A-Za-z_-]+)/i);
if (bare?.[1]) return bare[1];
return undefined;
}
function taskIdsMatch(a: string | undefined, b: string): boolean {
if (a === undefined) return false;
return normalizeTaskId(a) === normalizeTaskId(b);
}
function normalizeTaskId(id: string | undefined): string {
if (id === undefined) return "";
return id.trim().replace(/^#/, "");
}
+1 -1
View File
@@ -107,7 +107,7 @@ export function feishuContextTool(
inputSchema: z.object({
chat_id: z.string().describe("The Feishu chat id to read from."),
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
id: z.string().describe("The anchor id (message id or run id)."),
id: z.string().describe("The anchor id: a message_id for trigger_message/status_card/reply, or a thread_id for thread."),
}),
execute: async (args): Promise<string> => {
if (args.chat_id !== ctx.boundChatId) {
@@ -10,22 +10,41 @@ import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import {
CAPABILITY_IDS,
type CapabilitySecretPayload,
type DocmindCapabilitySecretPayload,
type PbankCapabilitySecretPayload,
secretKindForCapability,
} from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
const KNOWN_CAPABILITY_IDS = new Set<string>(CAPABILITY_IDS);
export interface CapabilityCredentialInput {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export type CapabilityCredentialInput =
| {
readonly kind: "docmind";
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
| {
readonly kind: "pbank";
readonly baseUrl: string;
readonly username: string;
readonly password: string;
readonly rightsStatus?: string;
readonly rightsHolder?: string;
readonly rightsScope?: string;
readonly rightsNote?: string;
};
export interface RotateCapabilityInput extends CapabilityCredentialInput {
export interface RotateCapabilityInput {
readonly organizationId: string;
readonly capabilityId: string;
readonly actorUserId: string;
readonly credential: CapabilityCredentialInput;
}
export interface CapabilityConnectionMetadata {
@@ -45,25 +64,24 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
private readonly readinessProbe: CapabilityReadinessProbe;
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential,
) {
this.readinessProbe = readinessProbe;
}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`);
}
const payload = validateCredential(input);
const payload = validateCredential(input.capabilityId, input.credential);
await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
await this.readinessProbe(payload);
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
@@ -142,6 +160,7 @@ export class CapabilityConnectionService {
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
secretKind: payload.kind,
},
},
});
@@ -209,14 +228,47 @@ export class CapabilityConnectionService {
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
function validateCredential(
capabilityId: string,
input: CapabilityCredentialInput,
): CapabilitySecretPayload {
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
throw new Error(`unsupported capabilityId: ${capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
const expectedKind = secretKindForCapability(capabilityId);
if (input.kind !== expectedKind) {
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`);
}
if (input.kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: nonEmpty(input.accessKeyId, "accessKeyId"),
accessKeySecret: nonEmpty(input.accessKeySecret, "accessKeySecret"),
endpoint: nonEmpty(input.endpoint, "endpoint"),
};
}
const baseUrl = normalizeBaseUrl(nonEmpty(input.baseUrl, "baseUrl"));
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://")) {
throw new Error("baseUrl must be an absolute http(s) URL");
}
const rightsStatus = optional(input.rightsStatus);
const rightsHolder = optional(input.rightsHolder);
const rightsScope = optional(input.rightsScope);
const rightsNote = optional(input.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl,
username: nonEmpty(input.username, "username"),
password: nonEmpty(input.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
function toMetadata(
@@ -264,3 +316,13 @@ function nonEmpty(value: string, label: string): string {
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
function optional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
+13 -9
View File
@@ -12,17 +12,19 @@ import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import {
CapabilityConnectionUnavailable,
normalizeCapabilitySecretPayload,
secretKindForCapability,
type CapabilitySecretPayload,
type CapabilityId,
} from "./types.js";
const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
export type ResolvedCapabilityCredential = CapabilitySecretPayload & {
readonly connectionId: string;
readonly organizationId: string;
readonly capabilityId: string;
}
};
/**
* Resolve the active capability credential for an organization. Throws
@@ -54,17 +56,19 @@ export async function resolveCapabilityCredential(
connectionId: connection.id,
secretVersionId: version.id,
};
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
const payload = normalizeCapabilitySecretPayload(
secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1),
);
const expectedKind = secretKindForCapability(input.capabilityId);
if (payload.kind !== expectedKind) {
throw new Error(
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
);
}
return {
connectionId: connection.id,
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
...payload,
};
}
+74 -12
View File
@@ -1,18 +1,11 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
* ADR-0027: Capability readiness probes. Validate credentials before
* activation. Docmind uses QueryDocParserStatus; PBank uses /login.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
@@ -33,7 +26,7 @@ export class CapabilityReadinessError extends Error {
* - 401/403 (InvalidAccessKey/Forbidden) credential invalid probe fails
* - network error unreachable
*/
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecretPayload): Promise<void> {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
@@ -61,6 +54,75 @@ export const probeDocmindCredential: CapabilityReadinessProbe = async (input) =>
response.status,
);
}
}
/** Probe PBank by logging in and ensuring a token is returned. */
export async function probePbankCredentialPayload(input: PbankCapabilitySecretPayload): Promise<void> {
const baseUrl = input.baseUrl.replace(/\/+$/, "");
let response: Response;
try {
response = await fetch(`${baseUrl}/login`, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ username: input.username, password: input.password }),
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"PBank credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
const data: unknown = await response.json().catch(() => null);
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank credential rejected: status ${response.status}`,
"http",
response.status,
);
}
if (!response.ok) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank login failed: status ${response.status}`,
"http",
response.status,
);
}
if (
typeof data !== "object" ||
data === null ||
!("token" in data) ||
typeof data.token !== "string" ||
data.token.trim() === ""
) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
"PBank login did not return a token",
"http",
response.status,
);
}
}
/** Default readiness probe: dispatch by secret kind. */
export const probeCapabilityCredential: CapabilityReadinessProbe = async (payload) => {
if (payload.kind === "docmind") {
await probeDocmindCredentialPayload(payload);
return;
}
if (payload.kind === "pbank") {
await probePbankCredentialPayload(payload);
return;
}
throw new CapabilityReadinessError(
"capability_readiness_unsupported",
"unsupported capability secret kind",
"configuration",
);
};
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
+57 -10
View File
@@ -19,9 +19,10 @@ import $DocmindClient, {
QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { createReadStream, type ReadStream } from "node:fs";
import { once } from "node:events";
import { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js";
import type { DocmindCapabilitySecretPayload } from "./types.js";
/** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage {
@@ -43,7 +44,7 @@ export interface DocmindParseOptions {
}
export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
}
export class DocmindClientError extends Error {
@@ -61,11 +62,33 @@ export class DocmindClientError extends Error {
const COST_PER_PAGE_USD = 0.0056;
const POLL_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 5 * 60_000;
/**
* httpx (tea transport) defaults read/connect timeout to 3000ms when unset.
* SubmitDocParserJobAdvance uploads the PDF to OSS; multi-MB files routinely
* exceed 3s on the silo host (production: ReadTimeout(3000) on
* docmind-api-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com).
*/
export const DOCMIND_CONNECT_TIMEOUT_MS = 15_000;
/** Allow slow / large PDF OSS uploads up to the same bound as job polling. */
export const DOCMIND_READ_TIMEOUT_MS = POLL_TIMEOUT_MS;
/** RuntimeOptions for Docmind SDK calls that may upload or wait on the wire. */
export function createDocmindRuntimeOptions(): RuntimeOptions {
return new RuntimeOptions({
connectTimeout: DOCMIND_CONNECT_TIMEOUT_MS,
readTimeout: DOCMIND_READ_TIMEOUT_MS,
});
}
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
// Open first so missing local inputs fail closed before touching the SDK.
// Unhandled createReadStream('error') previously crashed the Hub process.
const fileName = basename(options.inputFilePath);
const fileStream = await openLocalFileStream(options.inputFilePath);
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
@@ -75,22 +98,21 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
// Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
});
const runtime = new RuntimeOptions({});
const runtime = createDocmindRuntimeOptions();
let submitResponse;
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
fileStream.destroy();
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
@@ -229,3 +251,28 @@ function extractFilename(altText: string, url: string, index: number): string {
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
/**
* Open a local file as a ReadStream only after the fd is successfully open.
* createReadStream() emits asynchronous 'error' for missing paths; without a
* listener that becomes an unhandled EventEmitter error and exits Node.
*/
async function openLocalFileStream(path: string): Promise<ReadStream> {
const stream = createReadStream(path);
try {
await once(stream, "open");
} catch (error) {
stream.destroy();
const err = error instanceof Error ? error : new Error(String(error));
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new DocmindClientError(`input file not found: ${path}`, "docmind_rejected");
}
throw new DocmindClientError(err.message, "docmind_unreachable");
}
// After open, residual stream errors must not become unhandled and crash Hub.
stream.on("error", () => {
// The Aliyun SDK / destroy path owns consumption failures after open.
});
return stream;
}
+664
View File
@@ -0,0 +1,664 @@
/**
* ADR-0027: pbank capability Paradigm search/fetch tools for Agent runs.
*
* Invariants:
* 1. Credential isolation org ACTIVE connection is resolved in Hub; credentials
* never reach the Agent process (ADR-0024/0027).
* 2. Workspace containment materialize path is always under the run workspace
* (ADR-0018 AgentSurface).
* 3. Mandatory fact each successful tool call writes 1 UsageFact with
* kind=external_capability and unit=requests (cost unknown unless reported).
*/
import { inflateRawSync } from "node:zlib";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import {
extractProblemId,
HttpPbankClient,
pbankRightsFromCredential,
type PbankClient,
} from "./pbankClient.js";
import {
CAPABILITIES,
asPbankSecret,
type PbankCapabilitySecretPayload,
} from "./types.js";
export const PBANK_CAPABILITY_ID = "pbank" as const;
const PROVIDER_ID = "paradigm_pbank";
const MAX_BATCH_SIZE = 20;
const MAX_PROJECT_BYTES = 80 * 1024 * 1024;
const MAX_PROJECT_TEXT_BYTES = 120 * 1024;
const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024;
const MAX_INLINE_ASSET_BYTES = 1024 * 1024;
const MAX_INLINE_ASSETS = 4;
const CACHE_DIR_NAME = ".pbank-sources";
export class CapabilityPathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "CapabilityPathEscape";
}
}
export interface PbankServiceDeps {
readonly prisma: PrismaClient;
readonly secrets: LocalSecretEnvelope;
readonly client?: PbankClient;
}
export interface PbankToolContext {
readonly organizationId: string;
readonly runId: string;
readonly workspaceDir: string;
}
export interface PbankSearchArgs {
readonly q?: string | undefined;
readonly keywords?: readonly string[] | undefined;
readonly pageNum?: number | undefined;
readonly pageSize?: number | undefined;
}
export interface PbankGetProblemArgs {
readonly urlOrId: string;
readonly includeProjects?: boolean | undefined;
readonly materializeProjects?: boolean | undefined;
readonly includeAssetImages?: boolean | undefined;
readonly includeOccurrences?: boolean | undefined;
}
export interface PbankGetManyArgs {
readonly urlsOrIds: readonly string[];
readonly includeProjects?: boolean | undefined;
readonly materializeProjects?: boolean | undefined;
readonly includeAssetImages?: boolean | undefined;
readonly includeOccurrences?: boolean | undefined;
}
export interface PbankToolResult {
readonly data: unknown;
readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[];
}
interface TokenCacheEntry {
readonly token: string;
readonly expiresAt: number;
readonly password: string;
readonly username: string;
readonly baseUrl: string;
}
export interface PbankService {
searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult>;
getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult>;
getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult>;
}
export function createPbankService(deps: PbankServiceDeps): PbankService {
const client = deps.client ?? new HttpPbankClient();
const tokenCache = new Map<string, TokenCacheEntry>();
return {
async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult> {
const credential = await resolvePbankCredential(deps, ctx.organizationId);
const token = await loginCached(client, tokenCache, credential);
const pageNum = clampInt(args.pageNum ?? 1, 1, 10_000);
const pageSize = clampInt(args.pageSize ?? 10, 1, 50);
const result = await client.searchProblems(credential, token, {
q: args.q,
keywords: args.keywords,
pageNum,
pageSize,
});
const data = {
rights: pbankRightsFromCredential(credential),
...(typeof result === "object" && result !== null ? result : { result }),
};
await writeUsageFact(deps.prisma, ctx.runId, "search", 1);
return { data, inlineImages: [] };
},
async getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult> {
const credential = await resolvePbankCredential(deps, ctx.organizationId);
const bundle = await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, args);
await writeUsageFact(deps.prisma, ctx.runId, extractProblemId(args.urlOrId), 1);
return toToolResult(bundle);
},
async getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult> {
if (args.urlsOrIds.length === 0) {
throw new Error("urlsOrIds must not be empty");
}
if (args.urlsOrIds.length > MAX_BATCH_SIZE) {
throw new Error(`urlsOrIds exceeds max batch size ${MAX_BATCH_SIZE}`);
}
const credential = await resolvePbankCredential(deps, ctx.organizationId);
const problems = [];
for (const urlOrId of args.urlsOrIds) {
problems.push(
await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, {
urlOrId,
includeProjects: args.includeProjects,
materializeProjects: args.materializeProjects,
includeAssetImages: args.includeAssetImages,
includeOccurrences: args.includeOccurrences,
}),
);
}
await writeUsageFact(deps.prisma, ctx.runId, "batch", problems.length);
return toToolResult({
count: problems.length,
rights: pbankRightsFromCredential(credential),
problems,
});
},
};
}
async function resolvePbankCredential(
deps: PbankServiceDeps,
organizationId: string,
): Promise<PbankCapabilitySecretPayload & { connectionId: string }> {
const resolved = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
organizationId,
capabilityId: PBANK_CAPABILITY_ID,
});
const secret = asPbankSecret(resolved);
return { ...secret, connectionId: resolved.connectionId };
}
async function loginCached(
client: PbankClient,
cache: Map<string, TokenCacheEntry>,
credential: PbankCapabilitySecretPayload & { connectionId: string },
): Promise<string> {
const now = Date.now();
const cached = cache.get(credential.connectionId);
if (
cached !== undefined &&
cached.expiresAt - 60_000 > now &&
cached.username === credential.username &&
cached.password === credential.password &&
cached.baseUrl === credential.baseUrl
) {
return cached.token;
}
const login = await client.login(credential);
cache.set(credential.connectionId, {
token: login.token,
expiresAt: login.expiresAt,
username: credential.username,
password: credential.password,
baseUrl: credential.baseUrl,
});
return login.token;
}
async function getProblemBundle(
client: PbankClient,
cache: Map<string, TokenCacheEntry>,
credential: PbankCapabilitySecretPayload & { connectionId: string },
workspaceDir: string,
args: PbankGetProblemArgs,
): Promise<Record<string, unknown>> {
const id = extractProblemId(args.urlOrId);
const token = await loginCached(client, cache, credential);
const problem = await client.getProblem(credential, token, id);
const result: Record<string, unknown> = {
id,
source: `${credential.baseUrl.replace(/\/+$/, "")}/problem/${id}`,
rights: pbankRightsFromCredential(credential),
problem,
};
const includeProjects = args.includeProjects !== false;
const materializeProjects = args.materializeProjects !== false;
const includeAssetImages = args.includeAssetImages !== false;
if (includeProjects) {
const projects: Record<string, unknown> = {};
for (const target of ["problem", "answer"] as const) {
try {
projects[target] = await downloadAndMaterializeProject({
client,
credential,
token,
id,
target,
workspaceDir,
materialize: materializeProjects,
includeAssetImages,
});
} catch (error) {
projects[target] = {
target,
status: "error",
message: error instanceof Error ? error.message : String(error),
};
}
}
result.projects = projects;
}
if (args.includeOccurrences === true) {
try {
result.occurrences = await client.getOccurrences(credential, token, id);
} catch (error) {
result.occurrences = {
status: "error",
message: error instanceof Error ? error.message : String(error),
};
}
}
return result;
}
async function downloadAndMaterializeProject(input: {
readonly client: PbankClient;
readonly credential: PbankCapabilitySecretPayload;
readonly token: string;
readonly id: string;
readonly target: "problem" | "answer";
readonly workspaceDir: string;
readonly materialize: boolean;
readonly includeAssetImages: boolean;
}): Promise<Record<string, unknown>> {
const downloaded = await input.client.downloadProject(
input.credential,
input.token,
input.id,
input.target,
);
if (downloaded.buffer.byteLength > MAX_PROJECT_BYTES) {
throw new Error(`project archive exceeds ${MAX_PROJECT_BYTES} bytes`);
}
if (!isZipBuffer(downloaded.buffer, downloaded.contentType)) {
const text = decodeUtf8IfText(downloaded.buffer);
if (text !== null) {
return {
target: input.target,
status: "text",
bytes: downloaded.buffer.byteLength,
content: text.slice(0, MAX_PROJECT_TEXT_BYTES),
omitted: text.length > MAX_PROJECT_TEXT_BYTES ? [{ reason: "text truncated" }] : [],
};
}
return {
target: input.target,
status: "binary",
bytes: downloaded.buffer.byteLength,
contentType: downloaded.contentType,
};
}
return readProjectZip(downloaded.buffer, {
id: input.id,
target: input.target,
workspaceDir: input.workspaceDir,
materialize: input.materialize,
includeAssetImages: input.includeAssetImages,
});
}
async function readProjectZip(
buffer: Buffer,
options: {
readonly id: string;
readonly target: string;
readonly workspaceDir: string;
readonly materialize: boolean;
readonly includeAssetImages: boolean;
},
): Promise<Record<string, unknown>> {
// Pure Node unzip (store/deflate). Do not shell out to host `unzip` —
// silo service PATH/tooling must not gate 题库 materialize.
const entries = listZipEntries(buffer);
const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir);
const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target));
const extractDir = options.materialize ? join(cacheDir, "source") : null;
const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null;
if (options.materialize) {
await mkdir(cacheDir, { recursive: true });
if (extractDir !== null) {
await rm(extractDir, { recursive: true, force: true });
await mkdir(extractDir, { recursive: true });
}
if (zipPath !== null) await writeFile(zipPath, buffer);
}
const files: Array<Record<string, unknown>> = [];
const assets: Array<{
path: string;
localPath: string | null;
bytes: number;
mimeType: string;
inlineData?: string;
}> = [];
const extractedFiles: Array<Record<string, unknown>> = [];
const omitted: Array<Record<string, unknown>> = [];
let usedTextBytes = 0;
let usedExtractedBytes = 0;
let inlineAssetCount = 0;
for (const entry of entries) {
if (!safeZipPath(entry.name)) {
omitted.push({ path: entry.name, reason: "unsafe path" });
continue;
}
if (entry.name.endsWith("/") || entry.isDirectory) continue;
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
omitted.push({ path: entry.name, reason: "extracted byte limit reached" });
continue;
}
const maxEntryBytes = Math.max(
1,
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
);
try {
const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes);
usedExtractedBytes += entryBuffer.length;
let localPath: string | null = null;
if (extractDir !== null) {
localPath = join(extractDir, ...entry.name.split(/[\\/]+/));
await mkdir(dirname(localPath), { recursive: true });
await writeFile(localPath, entryBuffer);
extractedFiles.push({
path: entry.name,
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
bytes: entryBuffer.length,
});
}
const mimeType = assetMimeType(entry.name);
if (mimeType !== null) {
const asset: {
path: string;
localPath: string | null;
bytes: number;
mimeType: string;
inlineData?: string;
} = {
path: entry.name,
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
bytes: entryBuffer.length,
mimeType,
};
if (
options.includeAssetImages &&
isInlineImageMime(mimeType) &&
entryBuffer.length <= MAX_INLINE_ASSET_BYTES &&
inlineAssetCount < MAX_INLINE_ASSETS
) {
asset.inlineData = entryBuffer.toString("base64");
inlineAssetCount += 1;
}
assets.push(asset);
}
if (isTextLikePath(entry.name)) {
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
omitted.push({ path: entry.name, reason: "text byte limit reached" });
continue;
}
const content = decodeUtf8IfText(entryBuffer);
if (content === null) {
omitted.push({ path: entry.name, reason: "text decode failed" });
continue;
}
usedTextBytes += Buffer.byteLength(content, "utf8");
files.push({
path: entry.name,
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
content,
});
}
} catch (error) {
omitted.push({
path: entry.name,
reason: error instanceof Error ? error.message : String(error),
});
}
}
return {
target: options.target,
status: "downloaded",
bytes: buffer.length,
zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath),
extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir),
extractedFiles,
files,
assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset),
inlineAssets: assets
.filter((asset) => asset.inlineData !== undefined)
.map((asset) => ({
path: asset.path,
localPath: asset.localPath,
bytes: asset.bytes,
mimeType: asset.mimeType,
data: asset.inlineData,
})),
omitted,
};
}
interface ZipEntryMeta {
readonly name: string;
readonly method: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly localHeaderOffset: number;
readonly isDirectory: boolean;
}
/** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */
function listZipEntries(buffer: Buffer): ZipEntryMeta[] {
let eocd = -1;
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
if (buffer.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("invalid zip: missing end of central directory");
const totalEntries = buffer.readUInt16LE(eocd + 10);
const centralSize = buffer.readUInt32LE(eocd + 12);
const centralOffset = buffer.readUInt32LE(eocd + 16);
if (centralOffset + centralSize > buffer.length) {
throw new Error("invalid zip: central directory out of range");
}
const entries: ZipEntryMeta[] = [];
let offset = centralOffset;
for (let i = 0; i < totalEntries; i += 1) {
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error("invalid zip: bad central directory entry");
}
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
entries.push({
name,
method,
compressedSize,
uncompressedSize,
localHeaderOffset,
isDirectory: name.endsWith("/"),
});
offset = nameStart + nameLen + extraLen + commentLen;
}
return entries;
}
function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer {
if (entry.uncompressedSize > maxBytes) {
throw new Error(`zip entry exceeds ${maxBytes} bytes`);
}
const local = entry.localHeaderOffset;
if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) {
throw new Error("invalid zip: bad local header");
}
const nameLen = buffer.readUInt16LE(local + 26);
const extraLen = buffer.readUInt16LE(local + 28);
const dataStart = local + 30 + nameLen + extraLen;
const dataEnd = dataStart + entry.compressedSize;
if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range");
const compressed = buffer.subarray(dataStart, dataEnd);
if (entry.method === 0) {
if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`);
return Buffer.from(compressed);
}
if (entry.method === 8) {
return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes }));
}
throw new Error(`unsupported zip compression method ${entry.method}`);
}
async function writeUsageFact(
prisma: PrismaClient,
runId: string,
correlationId: string,
quantity: number,
): Promise<void> {
const descriptor = CAPABILITIES[PBANK_CAPABILITY_ID];
await prisma.usageFact.create({
data: {
runId,
occurredAt: new Date(),
kind: "external_capability",
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity,
unit: descriptor.meteringUnit,
costUsd: null,
costSource: "unknown",
capabilityId: PBANK_CAPABILITY_ID,
correlationId,
metadata: {},
},
});
}
function toToolResult(data: unknown): PbankToolResult {
const inlineImages: Array<{ data: string; mimeType: string }> = [];
collectInlineAssets(data, inlineImages);
return { data, inlineImages };
}
function collectInlineAssets(
value: unknown,
out: Array<{ data: string; mimeType: string }>,
): void {
if (typeof value !== "object" || value === null) return;
if (Array.isArray(value)) {
for (const item of value) collectInlineAssets(item, out);
return;
}
const record = value as Record<string, unknown>;
if (Array.isArray(record.inlineAssets)) {
for (const asset of record.inlineAssets) {
if (typeof asset !== "object" || asset === null) continue;
const item = asset as Record<string, unknown>;
if (typeof item.data === "string" && typeof item.mimeType === "string") {
out.push({ data: item.data, mimeType: item.mimeType });
}
}
delete record.inlineAssets;
}
if (record.projects !== undefined) collectInlineAssets(record.projects, out);
if (Array.isArray(record.problems)) {
for (const problem of record.problems) collectInlineAssets(problem, out);
}
}
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..") || rel === "") {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return resolved;
}
function toWorkspaceRelative(workspaceDir: string, absolutePath: string): string {
const rel = relative(workspaceDir, absolutePath);
if (rel.startsWith("..")) {
throw new CapabilityPathEscape(absolutePath, workspaceDir);
}
return rel;
}
function decodeUtf8IfText(buffer: Buffer): string | null {
const text = buffer.toString("utf8");
const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1);
if (replacementRatio > 0.02) return null;
return text;
}
function isZipBuffer(buffer: Buffer, contentType: string): boolean {
if (contentType.includes("zip")) return true;
return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b;
}
function isTextLikePath(filePath: string): boolean {
const lower = filePath.toLowerCase();
return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) =>
lower.endsWith(ext),
);
}
function assetMimeType(filePath: string): string | null {
const lower = filePath.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".pdf")) return "application/pdf";
return null;
}
function isInlineImageMime(mimeType: string): boolean {
return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
}
function safeZipPath(entry: string): boolean {
if (entry.includes("\0")) return false;
const normalized = entry.replace(/\\/g, "/");
if (normalized.startsWith("/") || normalized.includes("://")) return false;
for (const part of normalized.split("/")) {
if (part === ".." || part === "") return false;
}
return true;
}
function safePathSegment(value: string): string {
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
if (cleaned === "" || cleaned === "." || cleaned === "..") return "item";
return cleaned.slice(0, 80);
}
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
const n = Math.trunc(value);
if (n < min) return min;
if (n > max) return max;
return n;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Paradigm PBank () HTTP client.
*
* Credentials are injected per call from the org capability connection
* (ADR-0024/0027) never read from process env and never passed to the Agent.
*/
import type { PbankCapabilitySecretPayload } from "./types.js";
const DEFAULT_BASE_URL = "https://pbank.paradigm-edu.net/api";
const POSITIVE_RIGHTS_STATUSES = new Set(["owned", "exclusive_license", "licensed_adapt"]);
export interface PbankRights {
readonly status: string;
readonly holder: string;
readonly scope: string;
readonly note: string;
readonly derivativeUseAllowed: boolean;
readonly source: "operator_confirmed";
}
export interface PbankLoginResult {
readonly token: string;
readonly expiresAt: number;
}
export interface PbankSearchInput {
readonly q?: string | undefined;
readonly keywords?: readonly string[] | undefined;
readonly pageNum: number;
readonly pageSize: number;
}
export interface PbankProjectDownload {
readonly buffer: Buffer;
readonly contentType: string;
}
export class PbankClientError extends Error {
constructor(
message: string,
readonly code: "pbank_unreachable" | "pbank_rejected" | "pbank_invalid_response",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "PbankClientError";
}
}
export interface PbankClient {
login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult>;
searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown>;
getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload>;
}
export function pbankRightsFromCredential(credential: PbankCapabilitySecretPayload): PbankRights {
const status = normalizeRightsStatus(credential.rightsStatus ?? "unknown");
const holder = (credential.rightsHolder ?? "").trim() || "Paradigm Education";
const scope = (credential.rightsScope ?? "").trim() || "internal teaching-material production";
const note =
(credential.rightsNote ?? "").trim() ||
"All content returned by this capability is operator-confirmed as owned by Paradigm Education or sufficiently licensed for excerpting, rewriting, and adaptation within current teaching-material projects.";
return {
status,
holder,
scope,
note,
derivativeUseAllowed: POSITIVE_RIGHTS_STATUSES.has(status),
source: "operator_confirmed",
};
}
export function normalizePbankBaseUrl(value: string | undefined): string {
const raw = (value ?? "").trim();
if (raw === "") return DEFAULT_BASE_URL;
return raw.replace(/\/+$/, "");
}
export function extractProblemId(value: string): string {
const input = value.trim();
if (input === "") throw new PbankClientError("urlOrId is required", "pbank_invalid_response");
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
const direct = input.match(uuidPattern);
if (direct !== null) return direct[0]!;
try {
const url = new URL(input);
for (const key of ["id", "problemId", "problem_id"] as const) {
const fromQuery = url.searchParams.get(key);
const match = fromQuery?.match(uuidPattern);
if (match !== null && match !== undefined) return match[0]!;
}
} catch {
// Not a URL.
}
throw new PbankClientError(`Could not find a problem UUID in: ${input}`, "pbank_invalid_response");
}
export class HttpPbankClient implements PbankClient {
async login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult> {
const data = await this.requestJson(credential, "/login", {
method: "POST",
body: { username: credential.username, password: credential.password },
timeoutMs: 30_000,
});
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new PbankClientError("PBank login returned non-object", "pbank_invalid_response");
}
const record = data as Record<string, unknown>;
if (typeof record.token !== "string" || record.token.trim() === "") {
throw new PbankClientError("PBank login did not return a token", "pbank_invalid_response");
}
const expiresAt =
typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
? record.expiresAt
: Date.now() + 30 * 60_000;
return { token: record.token, expiresAt };
}
async searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown> {
return this.requestJson(credential, "/problem/query", {
token,
query: {
q: input.q,
keywords: input.keywords !== undefined && input.keywords.length > 0 ? input.keywords.join(",") : undefined,
pageNum: input.pageNum,
pageSize: input.pageSize,
},
});
}
async getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}`, { token });
}
async getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}/occurrence`, {
token,
query: { pageNum: 1, pageSize: 20 },
});
}
async downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload> {
const url = buildUrl(credential.baseUrl, `/problem/${encodeURIComponent(id)}/project/${target}`);
let response: Response;
try {
response = await fetch(url, {
headers: { authorization: `Bearer ${token}`, accept: "*/*" },
signal: AbortSignal.timeout(120_000),
});
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
if (!response.ok) {
throw new PbankClientError(
`PBank project download failed: ${response.status}`,
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
const contentType = response.headers.get("content-type") ?? "";
const arrayBuffer = await response.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), contentType };
}
private async requestJson(
credential: PbankCapabilitySecretPayload,
route: string,
options: {
readonly method?: string;
readonly token?: string;
readonly body?: unknown;
readonly query?: Record<string, string | number | undefined>;
readonly timeoutMs?: number;
},
): Promise<unknown> {
const url = buildUrl(credential.baseUrl, route, options.query);
const headers: Record<string, string> = { accept: "application/json" };
if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
if (options.body !== undefined) headers["content-type"] = "application/json";
const init: RequestInit = {
method: options.method ?? "GET",
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
};
if (options.body !== undefined) init.body = JSON.stringify(options.body);
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new PbankClientError(
extractErrorMessage(data, `PBank API request failed: ${response.status}`),
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
return data;
}
}
function buildUrl(
baseUrl: string,
route: string,
query?: Record<string, string | number | undefined>,
): string {
const url = new URL(route.replace(/^\/+/, ""), `${normalizePbankBaseUrl(baseUrl)}/`);
if (query !== undefined) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === "") continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function extractErrorMessage(data: unknown, fallback: string): string {
if (typeof data === "object" && data !== null) {
const record = data as Record<string, unknown>;
if (typeof record.message === "string" && record.message !== "") return record.message;
if (typeof record.error === "string" && record.error !== "") return record.error;
}
if (typeof data === "string" && data !== "") return data;
return fallback;
}
function normalizeRightsStatus(value: string): string {
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (normalized === "") return "unknown";
return normalized;
}
+131 -1
View File
@@ -23,6 +23,7 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import {
CAPABILITIES,
asDocmindSecret,
type CapabilityAdapter,
type CapabilityInvocationInput,
type CapabilityInvocationResult,
@@ -63,6 +64,135 @@ export interface PdfToMdBundleDeps {
readonly prisma: PrismaClient;
}
/** Default max concurrent Docmind jobs for one convert_pdf_to_md batch call. */
export const DEFAULT_PDF_TO_MD_CONCURRENCY = 3;
/** Hard ceiling for agent-requested concurrency (also clamps env). */
export const MAX_PDF_TO_MD_CONCURRENCY = 8;
/** Max PDFs accepted in one batch tool call. */
export const MAX_PDF_TO_MD_BATCH_ITEMS = 32;
export function clampPdfToMdConcurrency(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_PDF_TO_MD_CONCURRENCY;
const n = Math.trunc(value);
if (n < 1) return 1;
if (n > MAX_PDF_TO_MD_CONCURRENCY) return MAX_PDF_TO_MD_CONCURRENCY;
return n;
}
/** Read HUB_PDF_TO_MD_MAX_CONCURRENT (default 3, max 8). */
export function readPdfToMdConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env["HUB_PDF_TO_MD_MAX_CONCURRENT"]?.trim();
if (raw === undefined || raw === "") return DEFAULT_PDF_TO_MD_CONCURRENCY;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new Error(`HUB_PDF_TO_MD_MAX_CONCURRENT must be a positive integer, got ${raw}`);
}
return clampPdfToMdConcurrency(parsed);
}
export interface PdfToMdBatchItem {
readonly inputPath: string;
readonly outputDir: string;
}
export type PdfToMdBatchItemResult =
| {
readonly ok: true;
readonly inputPath: string;
readonly outputDir: string;
readonly result: CapabilityInvocationResult;
}
| {
readonly ok: false;
readonly inputPath: string;
readonly outputDir: string;
readonly error: string;
};
/**
* Run worker over items with bounded parallelism. Order of results matches
* input order. Rejects in worker are not swallowed caller should catch.
*/
export async function mapPool<T, R>(
items: readonly T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
const results = new Array<R>(items.length);
let next = 0;
async function runWorker(): Promise<void> {
for (;;) {
const index = next;
next += 1;
if (index >= items.length) return;
results[index] = await worker(items[index]!, index);
}
}
await Promise.all(Array.from({ length: limit }, () => runWorker()));
return results;
}
/**
* Convert multiple PDFs with bounded concurrency. Each item is attribute-
* independent (own paths + own UsageFact). Failures are per-item and do not
* cancel siblings; order matches `items`.
*/
export async function invokePdfToMdBatch(
adapter: CapabilityAdapter,
base: Omit<CapabilityInvocationInput, "inputPath" | "outputDir">,
items: readonly PdfToMdBatchItem[],
concurrency: number = DEFAULT_PDF_TO_MD_CONCURRENCY,
): Promise<PdfToMdBatchItemResult[]> {
if (items.length === 0) {
throw new Error("pdf_to_md batch requires at least one item");
}
if (items.length > MAX_PDF_TO_MD_BATCH_ITEMS) {
throw new Error(
`pdf_to_md batch supports at most ${MAX_PDF_TO_MD_BATCH_ITEMS} items per call (got ${items.length})`,
);
}
const seenOutputDirs = new Set<string>();
for (const item of items) {
if (item.inputPath.trim() === "" || item.outputDir.trim() === "") {
throw new Error("pdf_to_md batch items require non-empty inputPath and outputDir");
}
const key = item.outputDir.replace(/\\/g, "/").replace(/\/+$/, "");
if (seenOutputDirs.has(key)) {
throw new Error(
`pdf_to_md batch items must use distinct output_dir values; duplicate: ${item.outputDir}`,
);
}
seenOutputDirs.add(key);
}
const limit = clampPdfToMdConcurrency(concurrency);
return mapPool(items, limit, async (item) => {
try {
const result = await adapter.invoke({
...base,
inputPath: item.inputPath,
outputDir: item.outputDir,
});
return {
ok: true as const,
inputPath: item.inputPath,
outputDir: item.outputDir,
result,
};
} catch (error) {
return {
ok: false as const,
inputPath: item.inputPath,
outputDir: item.outputDir,
error: error instanceof Error ? error.message : String(error),
};
}
});
}
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
@@ -83,7 +213,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
// 3. Call the backing service.
let result;
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
+123 -7
View File
@@ -1,11 +1,11 @@
/**
* ADR-0027: External capability types shared across the adapter layer.
*
* A capability is a platform-registered, org-enabled document/media transform
* A capability is a platform-registered, org-enabled external service
* invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
* and records consumption on a UsageFact (ADR-0026).
* client, writes output into the run's workspace when applicable (AgentSurface,
* ADR-0018), and records consumption on a UsageFact (ADR-0026).
*/
import type { PrismaClient, Prisma } from "@prisma/client";
@@ -13,6 +13,7 @@ import type { PrismaClient, Prisma } from "@prisma/client";
export const CAPABILITY_IDS = [
"pdf_to_md_bundle",
"audio_video_to_text",
"pbank",
] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
@@ -27,6 +28,7 @@ export interface CapabilityDescriptor {
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
pbank: { id: "pbank", meteringUnit: "requests" },
};
/** Input passed to a capability adapter invocation. */
@@ -59,7 +61,7 @@ export interface CapabilityConsumption {
readonly model: string | null;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds). */
/** Non-token meter (page count, audio seconds, request count). */
readonly quantity: number;
readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */
@@ -80,15 +82,39 @@ export interface CapabilityAdapter {
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
export interface CapabilitySecretPayload {
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
export interface DocmindCapabilitySecretPayload {
readonly schemaVersion: 1;
readonly kind: "docmind";
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
/**
* Paradigm PBank () login credentials.
* Rights fields are operator-confirmed license signals echoed to the agent;
* the credential secret itself never reaches the agent process (ADR-0024/0027).
*/
export interface PbankCapabilitySecretPayload {
readonly schemaVersion: 1;
readonly kind: "pbank";
readonly baseUrl: string;
readonly username: string;
readonly password: string;
readonly rightsStatus?: string;
readonly rightsHolder?: string;
readonly rightsScope?: string;
readonly rightsNote?: string;
}
/**
* Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Discriminated by `kind`. Legacy envelopes without `kind` are normalized to
* `docmind` when accessKey fields are present.
*/
export type CapabilitySecretPayload = DocmindCapabilitySecretPayload | PbankCapabilitySecretPayload;
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) {
@@ -99,3 +125,93 @@ export class CapabilityConnectionUnavailable extends Error {
/** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient;
/** Map capability id → expected secret kind. */
export function secretKindForCapability(capabilityId: string): "docmind" | "pbank" {
switch (capabilityId) {
case "pdf_to_md_bundle":
case "audio_video_to_text":
return "docmind";
case "pbank":
return "pbank";
default:
throw new Error(`unsupported capabilityId: ${capabilityId}`);
}
}
/**
* Normalize a decrypted envelope payload into a full CapabilitySecretPayload.
* Accepts legacy docmind payloads that omit `kind`.
*/
export function normalizeCapabilitySecretPayload(raw: unknown): CapabilitySecretPayload {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("invalid capability secret payload");
}
const payload = raw as Record<string, unknown>;
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${String(payload.schemaVersion)}`);
}
const kind =
payload.kind === "pbank" || payload.kind === "docmind"
? payload.kind
: typeof payload.accessKeyId === "string"
? "docmind"
: typeof payload.username === "string"
? "pbank"
: null;
if (kind === null) throw new Error("capability secret payload missing kind");
if (kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: requireString(payload.accessKeyId, "accessKeyId"),
accessKeySecret: requireString(payload.accessKeySecret, "accessKeySecret"),
endpoint: requireString(payload.endpoint, "endpoint"),
};
}
const rightsStatus = optionalString(payload.rightsStatus);
const rightsHolder = optionalString(payload.rightsHolder);
const rightsScope = optionalString(payload.rightsScope);
const rightsNote = optionalString(payload.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl: requireString(payload.baseUrl, "baseUrl"),
username: requireString(payload.username, "username"),
password: requireString(payload.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
export function asDocmindSecret(payload: CapabilitySecretPayload): DocmindCapabilitySecretPayload {
if (payload.kind !== "docmind") {
throw new Error(`expected docmind capability secret, got ${payload.kind}`);
}
return payload;
}
export function asPbankSecret(payload: CapabilitySecretPayload): PbankCapabilitySecretPayload {
if (payload.kind !== "pbank") {
throw new Error(`expected pbank capability secret, got ${payload.kind}`);
}
return payload;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${label} must not be empty`);
}
return value.trim();
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
* ADR-0022 capacity dimensions.
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by
* capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service.
*/
+1
View File
@@ -250,6 +250,7 @@ async function initializeSilo(
data: {
organizationId: input.organization.id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
+227
View File
@@ -0,0 +1,227 @@
import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import type { PrismaClient } from "@prisma/client";
import {
resolveActiveFeishuApplication,
type ResolvedFeishuApplication,
} from "../connections/feishuApplicationConnections.js";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
WorkspaceFileBoundaryError,
writeNewWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
const DEFAULT_TIMEOUT_MS = 180_000;
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
const DEFAULT_CLI_PATH = "/usr/local/bin:/usr/bin:/bin";
const SAFE_CLI_ENV_KEYS = [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"NODE_USE_ENV_PROXY",
] as const;
export interface FeishuBotCliDownloadRequest {
readonly messageId: string;
readonly fileKey: string;
readonly resourceType: "image" | "file";
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly workspaceRelativePath: string;
readonly maxBytes?: number | undefined;
}
export interface FeishuBotCli {
downloadResource(request: FeishuBotCliDownloadRequest): Promise<string>;
}
export interface FeishuBotCliOptions {
readonly organizationId: string;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
readonly binary?: string | undefined;
readonly timeoutMs?: number | undefined;
readonly resolveCredential?: (() => Promise<ResolvedFeishuApplication>) | undefined;
}
interface CommandResult {
readonly stdout: string;
readonly stderr: string;
}
/**
* Runs the real lark-cli as a Hub-owned bot operation.
*
* The CLI receives the App Secret over stdin and gets a disposable HOME. No
* Feishu credential is placed in Agent environment, project files, argv, or
* the process-global CLI configuration. The caller still owns project/chat
* authorization; this adapter only performs bot-identity transport.
*/
export function createFeishuBotCli(options: FeishuBotCliOptions): FeishuBotCli {
const resolveCredential = options.resolveCredential ?? (() => resolveActiveFeishuApplication(
options.prisma,
options.secretEnvelope,
{ organizationId: options.organizationId },
));
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return {
async downloadResource(request): Promise<string> {
const credential = await resolveCredential();
const root = await mkdtemp(join(tmpdir(), "cph-feishu-bot-cli-"), { encoding: "utf8" });
const home = join(root, "home");
await mkdirPrivate(home);
const cliEnv = buildCliEnv(home);
const cliBinary = options.binary ?? process.env["HUB_FEISHU_CLI_BIN"] ?? "lark-cli";
const temporaryName = "resource.bin";
const temporaryPath = join(home, temporaryName);
try {
await runCli(
cliBinary,
["config", "init", "--app-id", credential.appId, "--app-secret-stdin", "--brand", "feishu"],
{ cwd: root, env: cliEnv, stdin: `${credential.appSecret}\n`, timeoutMs, label: "config init" },
);
await runCli(
cliBinary,
[
"im",
"+messages-resources-download",
"--as",
"bot",
"--message-id",
request.messageId,
"--file-key",
request.fileKey,
"--type",
request.resourceType,
"--output",
temporaryName,
],
{ cwd: home, env: cliEnv, timeoutMs, label: "resource download" },
);
const metadata = await stat(temporaryPath);
if (!metadata.isFile()) {
throw new Error("lark-cli resource download did not produce a regular file");
}
if (request.maxBytes !== undefined && metadata.size > request.maxBytes) {
throw new WorkspaceFileBoundaryError(
`Feishu resource exceeds ${request.maxBytes} bytes: ${request.fileKey}`,
request.workspaceRelativePath,
"limit",
);
}
return await writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
createReadStream(temporaryPath),
request.maxBytes,
);
} finally {
await rm(root, { recursive: true, force: true });
}
},
};
}
async function mkdirPrivate(path: string): Promise<void> {
await mkdir(path, { recursive: true, mode: 0o700 });
await chmod(path, 0o700);
}
function buildCliEnv(home: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of SAFE_CLI_ENV_KEYS) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
if (env.PATH === undefined || env.PATH.trim() === "") env.PATH = DEFAULT_CLI_PATH;
env.HOME = home;
env.XDG_CONFIG_HOME = join(home, ".config");
env.XDG_CACHE_HOME = join(home, ".cache");
env.XDG_STATE_HOME = join(home, ".state");
return env;
}
async function runCli(
binary: string,
args: readonly string[],
input: {
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly stdin?: string | undefined;
readonly timeoutMs: number;
readonly label: string;
},
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve, reject) => {
const child = spawn(binary, args, {
cwd: input.cwd,
env: input.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let outputBytes = 0;
let settled = false;
const timer = setTimeout(() => {
child.kill("SIGTERM");
finish(new Error(`lark-cli ${input.label} timed out after ${input.timeoutMs}ms`));
}, input.timeoutMs);
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer | string): void => {
if (outputBytes >= MAX_COMMAND_OUTPUT_BYTES) return;
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
const remaining = MAX_COMMAND_OUTPUT_BYTES - outputBytes;
const bounded = text.slice(0, remaining);
outputBytes += Buffer.byteLength(bounded);
if (target === "stdout") stdout += bounded;
else stderr += bounded;
};
const finish = (error?: Error, result?: CommandResult): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error !== undefined) reject(error);
else resolve(result!);
};
child.stdout.on("data", (chunk: Buffer | string) => appendOutput("stdout", chunk));
child.stderr.on("data", (chunk: Buffer | string) => appendOutput("stderr", chunk));
child.once("error", (error) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
child.once("close", (code, signal) => {
if (code !== 0) {
const detail = (stderr.trim() || stdout.trim()).slice(0, 500);
const status = code === null ? signal ?? "signal" : `exit ${code}`;
finish(new Error(
detail === ""
? `lark-cli ${input.label} failed (${status})`
: `lark-cli ${input.label} failed (${status}): ${detail}`,
));
return;
}
finish(undefined, { stdout, stderr });
});
child.stdin.end(input.stdin);
});
}
+151 -17
View File
@@ -2,9 +2,10 @@
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
* 2. A collapsible tool-use panel (tool steps with status, params, results)
* 3. A collapsible reasoning panel (thinking text)
* 4. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
@@ -12,6 +13,9 @@
*/
import type { ToolUseTraceStep } from "./trace-store.js";
import type { AgentTodoItem } from "../../agent/todoList.js";
import { todoProgressSummary } from "../../agent/todoList.js";
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
// ---------------------------------------------------------------------------
// Types
@@ -38,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
todowrite: "todo-filled",
todo_write: "todo-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
@@ -47,10 +53,26 @@ const TOOL_ICONS: Record<string, string> = {
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
function isTodoToolName(toolName: string): boolean {
const lower = toolName.toLowerCase();
const bare = lower.includes("__") ? (lower.split("__").pop() ?? lower) : lower;
const stripped = bare.replace(/_\d+$/, "");
return (
stripped === "todowrite" ||
stripped === "todo_write" ||
stripped === "taskcreate" ||
stripped === "taskupdate" ||
stripped === "tasklist" ||
stripped === "taskget" ||
stripped === "taskstop" ||
stripped === "taskoutput"
);
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
@@ -58,26 +80,39 @@ function toolIcon(toolName: string): string {
export function buildAgentCard(params: {
phase: CardPhase;
text: string;
contentSegments?: readonly CardContentSegment[] | undefined;
reasoningText: string | undefined;
todos: readonly AgentTodoItem[] | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
elements.push(buildPendingToolUsePanel());
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
if (todos !== undefined && todos.length > 0) {
elements.push(buildTodoPanel(todos, phase !== "complete"));
}
// Tool-use panel (exclude todo tools — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
if (visibleToolSteps.length > 0) {
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
} else if (
todos === undefined ||
todos.length === 0
) {
if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
elements.push(buildPendingToolUsePanel());
}
}
// Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "") {
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) {
// Still thinking: show reasoning inline
elements.push({
tag: "markdown",
@@ -90,12 +125,11 @@ export function buildAgentCard(params: {
}
}
// Main text content
if (text !== "") {
elements.push({
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
});
// Main answer: either materialized segments (markdown + Feishu-hosted images)
// or a single markdown block.
const answerElements = buildAnswerElements(text, contentSegments);
if (answerElements.length > 0) {
elements.push(...answerElements);
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({
tag: "markdown",
@@ -160,6 +194,62 @@ function buildInterruptAction(runId: string): unknown {
};
}
// ---------------------------------------------------------------------------
// Todo checklist panel
// ---------------------------------------------------------------------------
function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown {
const { completed, total, inProgress } = todoProgressSummary(todos);
const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`];
if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`);
const lines = todos.map((todo) => formatTodoLine(todo));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: titleParts.join(" "),
text_color: completed === total && total > 0 ? "green" : "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: lines.join("\n"),
text_size: "notation",
},
],
};
}
function formatTodoLine(todo: AgentTodoItem): string {
const label =
todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== ""
? todo.activeForm
: todo.content;
if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`;
if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`;
return `- [ ] ${escapeMd(todo.content)}`;
}
function escapeMd(text: string): string {
return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1");
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
@@ -401,6 +491,50 @@ function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
}
function buildAnswerElements(
text: string,
contentSegments: readonly CardContentSegment[] | undefined,
): unknown[] {
if (contentSegments !== undefined && contentSegments.length > 0) {
const elements: unknown[] = [];
let remaining = MAX_TEXT_LENGTH;
for (const segment of contentSegments) {
if (segment.type === "image") {
if (segment.imgKey.trim() === "") continue;
elements.push({
tag: "img",
img_key: segment.imgKey,
alt: { tag: "plain_text", content: segment.alt },
mode: "fit_horizontal",
preview: true,
});
continue;
}
if (segment.content === "" || remaining <= 0) continue;
// Feishu card markdown rejects ![](url) without a Feishu image_key
// ("card contains images but no imagekey" / empty image key).
const safe = maskMarkdownImagesForStreaming(segment.content);
if (safe === "" || remaining <= 0) continue;
const slice = safe.length <= remaining
? safe
: truncateText(safe, remaining);
remaining -= slice.length;
elements.push({
tag: "markdown",
content: slice,
});
}
return elements;
}
if (text === "") return [];
const safe = maskMarkdownImagesForStreaming(text);
if (safe === "") return [];
return [{
tag: "markdown",
content: truncateText(safe, MAX_TEXT_LENGTH),
}];
}
function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
+181 -55
View File
@@ -18,10 +18,13 @@
* 4. onToolEnd(name, id, input, result?, error?) complete a tool step
* 5. finish(finalText) flush + transition to complete card
* 6. fail(errorText) flush + transition to error card
*
* On finish, markdown image references (`![](url|path)`) are downloaded /
* read, uploaded to Feishu as message images, and embedded as native card
* `img` elements so external URLs never hit Feishu content-security checks.
*/
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText } from "../client.js";
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import {
startToolUseTraceRun,
@@ -31,6 +34,17 @@ import {
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
import {
applyChecklistToolEvent,
isChecklistProgressTool,
type AgentTodoItem,
} from "../../agent/todoList.js";
import {
type CardContentSegment,
maskMarkdownImagesForStreaming,
materializeAnswerSegments,
sendImageMessage,
} from "../outboundImages.js";
export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
@@ -44,6 +58,11 @@ export interface StreamingCardOptions {
readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined;
/** Project workspace root; required to resolve local image paths. */
readonly workspaceRoot?: string | undefined;
/** Project workspace directory; required to resolve local image paths. */
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
@@ -52,6 +71,7 @@ export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private todos: readonly AgentTodoItem[] = [];
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
@@ -65,6 +85,9 @@ export class StreamingAgentCard {
private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
private readonly workspaceRoot: string | undefined;
private readonly workspaceDir: string | undefined;
private readonly maxImageBytes: number | undefined;
constructor(options: StreamingCardOptions) {
this.runId = options.runId;
@@ -73,6 +96,9 @@ export class StreamingAgentCard {
this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
this.workspaceRoot = options.workspaceRoot;
this.workspaceDir = options.workspaceDir;
this.maxImageBytes = options.maxImageBytes;
startToolUseTraceRun(this.runId);
}
@@ -103,28 +129,64 @@ export class StreamingAgentCard {
error: string | undefined;
durationMs: number | undefined;
}): void {
if (isChecklistProgressTool(params.toolName)) {
const next = applyChecklistToolEvent(this.todos, {
toolName: params.toolName,
input: params.input,
result: params.result,
toolUseId: params.toolUseId,
});
if (next !== null) this.todos = next;
}
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
async finish(
fallbackText: string,
options: {
readonly interrupted?: boolean;
readonly footerText?: string | undefined;
readonly isError?: boolean;
} = {},
): Promise<void> {
await this.flushChain;
this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? "";
const isError = options.isError === true;
const fallbackWithFooter = appendFooter(fallbackText, footerText);
try {
let updated = true;
if (this.text.length > 0) {
this.text = appendFooter(this.text, footerText);
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
// No streaming text was sent. If we never created a card, send one now.
this.text = fallbackWithFooter;
updated = await this.flushCard("complete", this.text);
} else if (this.currentMessageId !== null) {
// Patch the existing card with the final text.
updated = await this.flushCard("complete", fallbackWithFooter);
let answerText =
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
this.text = answerText;
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
rt: this.rt,
workspaceRoot: this.workspaceRoot,
workspaceDir: this.workspaceDir,
maxImageBytes: this.maxImageBytes,
});
if (unresolved.length > 0) {
this.rt.logger.warn(
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
"some answer images could not be uploaded to Feishu",
);
}
if (!updated && this.interrupted) {
let cardUpdated = true;
if (answerText.length > 0 || segments.length > 0) {
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
} else if (this.currentMessageId !== null) {
cardUpdated = await this.flushCard("complete", "", isError, []);
}
if (!cardUpdated) {
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
await this.deliverPlainFallback(segments, answerText);
}
// Interrupt is terminal; if the live card could not be finalized, always
// send an explicit notice so the teacher sees the abort even when plain
// text partial delivery succeeded.
if (!cardUpdated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
}
} finally {
@@ -166,16 +228,32 @@ export class StreamingAgentCard {
return this.flushCard(this.currentPhase(), this.text);
}
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
const chunks = splitAtBoundary(text, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return true;
private async flushCard(
phase: CardPhase,
text: string,
isError = false,
contentSegments?: readonly CardContentSegment[],
): Promise<boolean> {
// During live streaming, strip image URLs so Feishu never fetches remote
// ranks mid-run. Materialized segments are only used on the complete pass.
const displayText =
phase === "complete" && contentSegments !== undefined
? text
: maskMarkdownImagesForStreaming(text);
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
const firstChunk = chunks[0] ?? "";
// When we have segments (complete+images), keep first-card complete content
// on segments only; overflow text (rare) falls back to plain chunked cards.
const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({
phase,
text: firstChunk,
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
contentSegments: contentSegments !== undefined && contentSegments.length > 0
? contentSegments
: undefined,
reasoningText: this.reasoningText || undefined,
todos: this.todos.length > 0 ? this.todos : undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
@@ -186,43 +264,91 @@ export class StreamingAgentCard {
if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
let updated = this.currentMessageId !== null;
// Send overflow chunks as new messages (rare for agent output)
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
return updated;
} else {
let updated = await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
// Send overflow chunks as new messages (rare for agent output). Segments
// already include the whole answer; only plain text overflows.
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
}
return updated;
}
let updated = await patchCard(this.rt, this.currentMessageId, card);
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
}
return updated;
}
private async deliverPlainFallback(
segments: readonly CardContentSegment[],
answerText: string,
): Promise<boolean> {
const textParts: string[] = [];
const imageKeys: string[] = [];
if (segments.length > 0) {
for (const segment of segments) {
if (segment.type === "markdown") {
if (segment.content.trim() !== "") textParts.push(segment.content);
} else {
imageKeys.push(segment.imgKey);
}
}
} else if (answerText.trim() !== "") {
textParts.push(maskMarkdownImagesForStreaming(answerText));
}
let any = false;
if (textParts.length > 0) {
const messageId = await sendLongText(
this.rt,
this.chatId,
textParts.join("\n\n"),
this.sendOptions,
);
any = messageId !== null;
}
for (const imageKey of imageKeys) {
try {
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
any = any || messageId !== null;
} catch (error) {
this.rt.logger.warn(
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
"standalone image fallback failed",
);
}
}
return any;
}
private currentPhase(): CardPhase {
@@ -235,5 +361,5 @@ export class StreamingAgentCard {
function appendFooter(text: string, footerText: string): string {
if (footerText === "") return text;
if (text === "") return footerText;
return `${text.trimEnd()}\n\n${footerText}`;
return `${text}\n\n${footerText}`;
}
+3 -2
View File
@@ -316,7 +316,8 @@ export async function sendCard(
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
} catch (e) {
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
return null;
}
}
@@ -334,7 +335,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
});
return true;
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed");
return false;
}
}
+32 -12
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { ToolContext } from "../agent/tools.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
import type { FeishuBotCli } from "./botCli.js";
import type { FeishuRuntime } from "./client.js";
export interface FeishuMessageResourceArgs {
readonly messageId: string;
@@ -13,7 +14,11 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
readonly path: string;
}
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
readonly workspaceRoot: string;
readonly botCli: FeishuBotCli;
readonly maxFileBytes?: number | undefined;
};
interface MessageLookupResult {
readonly data?: {
@@ -51,14 +56,29 @@ export async function downloadFeishuMessageResource(
"inbox",
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
);
const savePath = await downloadMessageFile(
rt,
args.messageId,
args.fileKey,
context.workspaceRoot,
context.workspaceDir,
workspaceRelativePath,
args.resourceType,
);
return { ...args, path: savePath };
try {
const savePath = await context.botCli.downloadResource({
messageId: args.messageId,
fileKey: args.fileKey,
resourceType: args.resourceType,
workspaceRoot: context.workspaceRoot,
workspaceDir: context.workspaceDir,
workspaceRelativePath,
maxBytes: context.maxFileBytes,
});
return { ...args, path: savePath };
} catch (error) {
rt.logger.error(
{
err: error,
messageId: args.messageId,
fileKey: args.fileKey,
resourceType: args.resourceType,
boundChatId: context.boundChatId,
workspaceDir: context.workspaceDir,
},
"Feishu bot CLI resource download failed",
);
throw error;
}
}
+310 -2
View File
@@ -1,17 +1,31 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { createFeishuBotCli } from "./botCli.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
createPdfToMdBundleAdapter,
invokePdfToMdBatch,
readPdfToMdConcurrency,
MAX_PDF_TO_MD_BATCH_ITEMS,
type PdfToMdBatchItemResult,
} from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
import { CapabilityConnectionUnavailable } from "../capability/types.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly projectId: string;
readonly organizationId: string;
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
@@ -20,9 +34,16 @@ export interface FileDeliveryToolOptions {
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
readonly tools?: readonly CphHubMcpToolId[] | undefined;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
const botCli = createFeishuBotCli({
organizationId: options.organizationId,
prisma: options.prisma,
secretEnvelope: options.secretEnvelope,
});
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
const tools: Array<SdkMcpToolDefinition<any>> = [];
@@ -140,7 +161,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"feishu_download_resource",
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.",
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace using the Organization bot identity. Use message_id and file_key returned by feishu_read_context.",
{
message_id: z.string().describe("The Feishu message id containing the resource."),
file_key: z.string().describe("The image_key or file_key from that message's content."),
@@ -168,6 +189,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
boundChatId: options.chatId,
workspaceRoot,
workspaceDir: options.workspaceDir,
botCli,
maxFileBytes: options.maxFileBytes,
},
options.rt,
);
@@ -230,6 +253,205 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
if (enabledTools.has("convert_pdf_to_md")) {
const adapter = createPdfToMdBundleAdapter({
secrets: options.secretEnvelope,
client: new AliyunDocmindClient(),
prisma: options.prisma,
});
tools.push(
tool(
"convert_pdf_to_md",
"Convert one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become LaTeX.",
{
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."),
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."),
items: z.array(z.object({
input_path: z.string().describe("Workspace-relative path to one input PDF."),
output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."),
})).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`),
concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."),
},
async (args) => {
const base = {
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
prisma: options.prisma,
};
try {
if (args.items !== undefined && args.items.length > 0) {
const batchResults = await invokePdfToMdBatch(
adapter,
base,
args.items.map((item) => ({
inputPath: item.input_path,
outputDir: item.output_dir,
})),
args.concurrency ?? readPdfToMdConcurrency(),
);
return {
content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }],
...(batchResults.every((item) => item.ok) ? {} : { isError: true }),
};
}
if (args.input_path === undefined || args.input_path.trim() === ""
|| args.output_dir === undefined || args.output_dir.trim() === "") {
return {
isError: true,
content: [{
type: "text",
text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.",
}],
};
}
const result = await adapter.invoke({
...base,
inputPath: args.input_path,
outputDir: args.output_dir,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
};
} catch (e) {
return {
isError: true,
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
};
}
},
{ alwaysLoad: true },
),
);
}
const pbankEnabled =
enabledTools.has("pbank_search_problems") ||
enabledTools.has("pbank_get_problem") ||
enabledTools.has("pbank_get_many_problems");
if (pbankEnabled) {
const pbank = createPbankService({
prisma: options.prisma,
secrets: options.secretEnvelope,
});
const pbankCtx = {
organizationId: options.organizationId,
runId: options.runId,
workspaceDir: options.workspaceDir,
};
if (enabledTools.has("pbank_search_problems")) {
tools.push(
tool(
"pbank_search_problems",
"Search Paradigm PBank (题库) by title/keyword. Returns page metadata, operator-confirmed rights guidance, and matching problem summaries. Requires an ACTIVE org capability connection for `pbank`.",
{
q: z.string().optional().describe("Search text."),
keywords: z.array(z.string()).optional().describe("Exact keywords to filter by."),
pageNum: z.number().int().min(1).optional().describe("Page number (default 1)."),
pageSize: z.number().int().min(1).max(50).optional().describe("Page size (default 10, max 50)."),
},
async (args) =>
runPbankTool(() =>
pbank.searchProblems(pbankCtx, {
q: args.q,
keywords: args.keywords,
pageNum: args.pageNum,
pageSize: args.pageSize,
}),
),
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("pbank_get_problem")) {
tools.push(
tool(
"pbank_get_problem",
"Fetch one PBank problem by URL or UUID. Returns metadata, rights guidance, text-like source files, local zip/extract paths under .pbank-sources/, and optional image assets. Requires ACTIVE org capability `pbank`.",
{
urlOrId: z.string().min(1).describe("PBank problem URL or UUID."),
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
},
async (args) => runPbankTool(() => pbank.getProblem(pbankCtx, args)),
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("pbank_get_many_problems")) {
tools.push(
tool(
"pbank_get_many_problems",
"Fetch several PBank problems by URL or UUID. Use when the teacher pastes multiple example links. Returns rights guidance together with each problem. Requires ACTIVE org capability `pbank`.",
{
urlsOrIds: z.array(z.string().min(1)).min(1).max(20).describe("PBank problem URLs or UUIDs."),
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
},
async (args) => runPbankTool(() => pbank.getManyProblems(pbankCtx, args)),
{ alwaysLoad: true },
),
);
}
}
if (enabledTools.has("todo_write")) {
tools.push(
tool(
"todo_write",
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
{
todos: z
.array(
z.object({
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("pending | in_progress | completed"),
activeForm: z
.string()
.optional()
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
}),
)
.min(1)
.max(32)
.describe("Full replacement list for the checklist (not a patch)."),
},
async (args) => {
const completed = args.todos.filter((t) => t.status === "completed").length;
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
const lines = args.todos.map((t, i) => {
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
return `${i + 1}. [${mark}] ${label}`;
});
return {
content: [
{
type: "text",
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
},
],
};
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
@@ -240,11 +462,68 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
});
}
function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string {
const ok = results.filter((item) => item.ok);
const failed = results.filter((item) => !item.ok);
const lines = [
`Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`,
];
for (const item of results) {
if (item.ok) {
const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path;
lines.push(
`OK ${item.inputPath}${item.outputDir}`
+ (md !== undefined ? ` (${md})` : "")
+ `; pages=${item.result.consumption.quantity}`
+ `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`,
);
for (const artifact of item.result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
} else {
lines.push(`FAIL ${item.inputPath}${item.outputDir}: ${item.error}`);
}
}
return lines.join("\n");
}
async function runPbankTool(
invoke: () => Promise<PbankToolResult>,
): Promise<{
content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
>;
isError?: boolean;
}> {
try {
const result = await invoke();
const content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
> = [{ type: "text", text: JSON.stringify(result.data, null, 2) }];
for (const image of result.inlineImages) {
content.push({ type: "image", data: image.data, mimeType: image.mimeType });
}
return { content };
} catch (error) {
const message =
error instanceof CapabilityConnectionUnavailable
? `${error.message}. Ask an org admin to configure the pbank capability connection.`
: error instanceof Error
? error.message
: String(error);
return { isError: true, content: [{ type: "text", text: message }] };
}
}
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = [];
if (enabledTools.has("send_file")) {
instructions.push(
"Use send_file when the user asks to receive, resend, download, or attach a file.",
"Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).",
"For inline 图文 answers, put ![alt](workspace-relative-path) in the final assistant text instead of send_file; the hub embeds those images in the reply card.",
"Do not claim a file was sent unless send_file returns success.",
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
);
@@ -260,5 +539,34 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.",
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.",
"For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.",
);
}
if (
enabledTools.has("pbank_search_problems") ||
enabledTools.has("pbank_get_problem") ||
enabledTools.has("pbank_get_many_problems")
) {
instructions.push(
"Use pbank_search_problems / pbank_get_problem / pbank_get_many_problems for Paradigm PBank (题库) selection.",
"Treat the returned rights object as authoritative for derivative use.",
"Materialized sources land under workspace-relative .pbank-sources/ — read them; do not invent problem content.",
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
);
}
if (enabledTools.has("todo_write")) {
instructions.push(
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
);
}
instructions.push(
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
);
return instructions.join(" ");
}
+429
View File
@@ -0,0 +1,429 @@
/**
* Resolve markdown image references in agent answers into Feishu-hosted
* image_keys so cards can embed them without remote URLs (which trip Feishu
* content-security controls).
*/
import { isIP } from "node:net";
import type { FeishuRuntime } from "./client.js";
import { withRetry } from "./client.js";
import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]*)\)/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;
const INLINE_CODE_RE = /`[^`\n]+`/g;
const IMAGE_FETCH_HEADERS: Record<string, string> = {
accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
// Some CDNs (incl. Wikimedia) reject bare programmatic clients with 400 HTML.
"user-agent":
"Mozilla/5.0 (compatible; EducraftHub/1.0; +https://educraft.paradigm-edu.net) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
export type CardContentSegment =
| { readonly type: "markdown"; readonly content: string }
| { readonly type: "image"; readonly imgKey: string; readonly alt: string };
export interface MarkdownImageRef {
readonly fullMatch: string;
readonly alt: string;
readonly src: string;
readonly index: number;
readonly length: number;
}
export interface OutboundImageContext {
readonly rt: FeishuRuntime;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
readonly maxImages?: number | undefined;
readonly fetchImpl?: typeof fetch | undefined;
}
type ImageCreateResponse = {
image_key?: string;
data?: { image_key?: string };
} | null;
type MessageCreateResponse = {
message_id?: string;
data?: { message_id?: string };
} | null;
/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
export function maskMarkdownImagesForStreaming(text: string): string {
return rewriteMarkdownImagesOutsideCode(text, (ref) => {
const alt = ref.alt.trim();
return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
});
}
export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
const blocked = blockedRanges(text);
const refs: MarkdownImageRef[] = [];
IMAGE_MARKDOWN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
const index = match.index;
if (blocked.some((range) => index >= range.start && index < range.end)) continue;
const fullMatch = match[0];
const alt = match[1] ?? "";
const rawSrc = (match[2] ?? "").trim();
const src = stripUrlTitle(rawSrc);
if (src === "") continue;
refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
}
return refs;
}
/**
* Upload reachable markdown images and split the answer into card segments
* (markdown + Feishu img elements). Unresolved images become visible alt text.
*/
export async function materializeAnswerSegments(
text: string,
ctx: OutboundImageContext,
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) {
return {
segments: text === "" ? [] : [{ type: "markdown", content: text }],
unresolved: [],
};
}
const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
const keyBySrc = new Map<string, string>();
const unresolved: string[] = [];
const uniqueSrcs: string[] = [];
for (const ref of refs) {
if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
}
for (const src of uniqueSrcs.slice(0, maxImages)) {
try {
const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
if (bytes === null) {
unresolved.push(src);
continue;
}
const imageKey = await uploadMessageImage(ctx.rt, bytes);
keyBySrc.set(src, imageKey);
} catch (error) {
ctx.rt.logger.warn(
{ src, err: error instanceof Error ? error.message : String(error) },
"outbound image materialize failed",
);
unresolved.push(src);
}
}
for (const src of uniqueSrcs.slice(maxImages)) {
unresolved.push(src);
}
const segments: CardContentSegment[] = [];
let cursor = 0;
for (const ref of refs) {
if (ref.index > cursor) {
pushMarkdown(segments, text.slice(cursor, ref.index));
}
const imageKey = keyBySrc.get(ref.src);
if (imageKey !== undefined) {
segments.push({
type: "image",
imgKey: imageKey,
alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
});
} else {
const alt = ref.alt.trim();
pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
}
cursor = ref.index + ref.length;
}
if (cursor < text.length) {
pushMarkdown(segments, text.slice(cursor));
}
return { segments, unresolved };
}
export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
if (image.byteLength === 0) {
throw new Error("image is empty");
}
if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
}
const client = rt.client as unknown as {
im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
};
const res = await withRetry(async () =>
client.im.v1.image.create({
data: { image_type: "message", image },
}),
);
const imageKey = res?.image_key ?? res?.data?.image_key;
if (imageKey === undefined || imageKey === "") {
throw new Error("Feishu image upload response is missing image_key");
}
return imageKey;
}
/** Send a standalone image message (fallback if card embed is unavailable). */
export async function sendImageMessage(
rt: FeishuRuntime,
chatId: string,
imageKey: string,
options?: { readonly replyToMessageId?: string | undefined },
): Promise<string | null> {
const client = rt.client as unknown as {
im: {
v1: {
message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
};
};
};
};
const replyTo = options?.replyToMessageId;
if (replyTo !== undefined && replyTo !== "") {
const res = await client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "image",
content: JSON.stringify({ image_key: imageKey }),
},
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
export async function resolveOutboundImageBytes(
src: string,
ctx: OutboundImageContext,
maxBytes: number,
): Promise<Buffer | null> {
if (isRemoteUrl(src)) {
return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
}
const root = ctx.workspaceRoot?.trim();
const dir = ctx.workspaceDir?.trim();
if (root === undefined || root === "" || dir === undefined || dir === "") {
return null;
}
try {
const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
return file.data;
} catch (error) {
if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
return null;
}
throw error;
}
}
function rewriteMarkdownImagesOutsideCode(
text: string,
replace: (ref: MarkdownImageRef) => string,
): string {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) return text;
let out = "";
let cursor = 0;
for (const ref of refs) {
out += text.slice(cursor, ref.index);
out += replace(ref);
cursor = ref.index + ref.length;
}
out += text.slice(cursor);
return out;
}
function pushMarkdown(segments: CardContentSegment[], content: string): void {
if (content === "") return;
const last = segments[segments.length - 1];
if (last !== undefined && last.type === "markdown") {
segments[segments.length - 1] = { type: "markdown", content: last.content + content };
return;
}
segments.push({ type: "markdown", content });
}
function blockedRanges(text: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
FENCED_CODE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
INLINE_CODE_RE.lastIndex = 0;
while ((match = INLINE_CODE_RE.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Skip inline spans fully inside a fence already recorded above.
if (ranges.some((range) => start >= range.start && end <= range.end)) continue;
ranges.push({ start, end });
}
return ranges;
}
function stripUrlTitle(raw: string): string {
const trimmed = raw.trim();
// Markdown optional title: url "title" or url 'title'
const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
return (titled?.[1] ?? trimmed).trim();
}
function isRemoteUrl(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
async function fetchRemoteImage(
src: string,
fetchImpl: typeof fetch,
maxBytes: number,
): Promise<Buffer | null> {
let url: URL;
try {
url = new URL(src);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!isPublicHttpHost(url.hostname)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
// Direct fetch: host env may enable NODE_USE_ENV_PROXY; several image CDNs
// reject or rewrite traffic through shared egress proxies.
const response = await fetchWithoutEnvProxy(fetchImpl, url, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: IMAGE_FETCH_HEADERS,
});
// One safe redirect hop to another public http(s) host.
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (location === null || location === "") return null;
let redirected: URL;
try {
redirected = new URL(location, url);
} catch {
return null;
}
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
if (!isPublicHttpHost(redirected.hostname)) return null;
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: IMAGE_FETCH_HEADERS,
});
return readImageBody(second, maxBytes);
}
return readImageBody(response, maxBytes);
} finally {
clearTimeout(timer);
}
}
/**
* Fetch without inheriting HTTP(S)_PROXY from the process env for one call.
* Restores env immediately so unrelated concurrent work keeps proxy settings.
*/
async function fetchWithoutEnvProxy(
fetchImpl: typeof fetch,
url: URL,
init: RequestInit,
): Promise<Response> {
const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
const saved: Array<[string, string | undefined]> = proxyKeys.map((key) => [key, process.env[key]]);
try {
for (const key of proxyKeys) delete process.env[key];
return await fetchImpl(url, init);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
if (!response.ok) return null;
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
if (
contentType !== "" &&
!contentType.startsWith("image/") &&
!contentType.includes("octet-stream") &&
(contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
) {
return null;
}
const contentLength = Number(response.headers.get("content-length") ?? "NaN");
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
const buf = Buffer.from(await response.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
return buf;
}
function isPublicHttpHost(hostname: string): boolean {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
return false;
}
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
return false;
}
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
const ipVersion = isIP(unbracketed);
if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
return true;
}
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const a = parts[0]!;
const b = parts[1]!;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast / reserved
return false;
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
if (normalized === "::1") return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
if (normalized.startsWith("fe80:")) return true; // link-local
const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
return false;
}
+10 -5
View File
@@ -7,8 +7,10 @@
*
* - `trigger_message` / `reply`: `message.get` by message_id.
* - `status_card`: the run's status card message same `message.get` by id.
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
* `parent_message_id` filter; we map "thread" to that.
* - `thread`: lark's thread replies. `im.v1.message.list` with
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a
* message_id, which Feishu rejects with 230001). The caller supplies the
* thread_id via `args.id`; the trigger context exposes it as `thread_id`.
*
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
* we cast through a known request/response shape and return a compact JSON for
@@ -69,11 +71,14 @@ export async function readFeishuContext(
return JSON.stringify(compact(msg));
}
case "thread": {
// Thread = replies to a parent message. `container_id` is the parent's
// message_id; container_id_type=message_id scopes the list to that thread.
// Thread = replies to a topic. Feishu's `im.v1.message.list` scopes
// thread replies when `container_id_type="thread"` and `container_id`
// is the thread_id (NOT a message_id — that is rejected with 230001
// "invalid container_id_type"). The caller supplies the thread_id via
// `args.id`; the trigger context exposes it as `thread_id`.
const res = await api.list({
params: {
container_id_type: "message_id",
container_id_type: "thread",
container_id: args.id,
page_size: 50,
},
+12 -10
View File
@@ -6,7 +6,7 @@ import {
removeWorkspaceFileIfUnchangedNoFollow,
type WorkspaceFileWriteResult,
} from "../security/workspaceFiles.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
import type { FeishuBotCli } from "./botCli.js";
export interface MessageResourceStageRequest {
readonly fileKey: string;
@@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult {
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
export async function stageMessageResources(
rt: FeishuRuntime,
botCli: FeishuBotCli,
messageId: string,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
@@ -49,16 +49,18 @@ export async function stageMessageResources(
try {
await mkdir(stagingRoot, { mode: 0o700 });
for (const [index, request] of requests.entries()) {
const stagedPath = await downloadMessageFile(
rt,
// Bot-identity transport only (ADR-0024): org App Secret stays in Hub,
// never crosses into the Agent surface. Staging still lands under the
// private .cph-staging tree before publish link into the tenant workspace.
const stagedPath = await botCli.downloadResource({
messageId,
request.fileKey,
fileKey: request.fileKey,
resourceType: request.resourceType,
workspaceRoot,
stagingRoot,
`resource-${index}`,
request.resourceType,
limits?.maxBytesPerFile,
);
workspaceDir: stagingRoot,
workspaceRelativePath: `resource-${index}`,
maxBytes: limits?.maxBytesPerFile,
});
resources.push({
resourceType: request.resourceType,
workspaceRelativePath: request.workspaceRelativePath,
+106
View File
@@ -0,0 +1,106 @@
/**
* User-visible run termination copy for Feishu teachers.
* Keep messages short, actionable, and free of stack traces.
*/
export interface RunOutcomeNoticeInput {
readonly wallTimeExceeded: boolean;
readonly interrupted: boolean;
readonly resultStatus: string;
readonly resultError: string | undefined;
readonly maxTurns: number;
readonly maxRunSeconds: number;
readonly hasPartialText: boolean;
}
export interface RunOutcomeNotice {
/** Mark the streaming card as failed (red footer). */
readonly isError: boolean;
/**
* Teacher-facing explanation. Appended after any partial answer text so the
* cause of a stop is never silent.
*/
readonly notice: string | undefined;
}
export function teacherFacingRunOutcome(input: RunOutcomeNoticeInput): RunOutcomeNotice {
if (input.wallTimeExceeded) {
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u23F1 \u4EFB\u52A1\u8D85\u65F6\uFF1A\u5DF2\u8FBE\u5230\u5355\u6B21\u8FD0\u884C\u65F6\u95F4\u4E0A\u9650\uFF08${input.maxRunSeconds} \u79D2\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u91CD\u8BD5\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u8FD0\u884C\u65F6\u957F\u4E0A\u9650\u3002`,
),
};
}
if (input.interrupted) {
return { isError: false, notice: undefined };
}
if (input.resultStatus === "completed") {
return { isError: false, notice: undefined };
}
const err = input.resultError ?? "";
if (isMaxTurnsError(err) || input.resultStatus === "length") {
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u26A0\uFE0F \u4EFB\u52A1\u4E2D\u65AD\uFF1A\u5DF2\u8FBE\u5230\u6700\u5927\u6B65\u9AA4\u6570\uFF08${input.maxTurns} \u8F6E\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u7EE7\u7EED\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u6B65\u9AA4\u4E0A\u9650\u3002`,
),
};
}
if (err.trim() !== "") {
const brief = sanitizeErrorBrief(err);
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${brief}\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002`,
),
};
}
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
"\u274C \u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002",
),
};
}
export function appendTeacherNotice(body: string, notice: string | undefined): string {
if (notice === undefined || notice === "") return body;
if (body.trim() === "") return notice;
return `${body.trimEnd()}\n\n${notice}`;
}
export function isMaxTurnsError(error: string): boolean {
const lower = error.toLowerCase();
return (
lower.includes("maximum number of turns") ||
lower.includes("max_turns") ||
lower.includes("error_max_turns") ||
lower.includes("result_error_max_turns") ||
(lower.includes("result_error_during_execution") && lower.includes("turn"))
);
}
function noticeLine(hasPartialText: boolean, message: string): string {
if (!hasPartialText) return message;
return `${message}\n\uFF08\u4E0A\u65B9\u4E3A\u5DF2\u751F\u6210\u7684\u90E8\u5206\u7ED3\u679C\u3002\uFF09`;
}
function sanitizeErrorBrief(error: string): string {
const oneLine = error.replace(/\s+/g, " ").trim();
// Drop common SDK prefixes for readability.
const stripped = oneLine
.replace(/^Claude Code returned an error result:\s*/i, "")
.replace(/^Error:\s*/i, "");
if (stripped.length <= 160) return stripped;
return `${stripped.slice(0, 157)}...`;
}
+65 -13
View File
@@ -1,7 +1,8 @@
/**
* Sends a "processing" reaction immediately, then streams a single
* interactive card through the full agent run lifecycle: thinking tool
* calls (with trace panel) streaming answer text final card. The card
* calls (with trace panel) streaming answer text final card. On finish,
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
* shows a collapsible tool-use panel, a collapsible reasoning panel, and
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid
* spamming the Feishu API.
@@ -14,6 +15,7 @@ import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
sendText,
sendTextMessage,
@@ -39,6 +41,7 @@ import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { appendTeacherNotice, teacherFacingRunOutcome } from "./runOutcomeNotice.js";
import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
@@ -51,6 +54,7 @@ import {
type MessageResourceStageRequest,
type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
@@ -93,6 +97,7 @@ interface TriggerDeps {
readonly prisma: PrismaClient;
readonly settings: RuntimeSettings;
readonly logger: FastifyBaseLogger;
readonly secretEnvelope: LocalSecretEnvelope;
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
@@ -111,6 +116,8 @@ interface TriggerDeps {
readonly allowLegacyFeishuIdentity?: boolean | undefined;
/** Alpha Silo aggregate ingress ceiling across message and card events. */
readonly maxFeishuEventsPerMinute?: number | undefined;
/** Test/injection seam for bot-identity Feishu resource downloads. */
readonly feishuBotCli?: FeishuBotCli | undefined;
}
interface TriggerActor {
@@ -299,8 +306,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
senderOpenId,
});
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const botCli = deps.feishuBotCli ?? createFeishuBotCli({
organizationId: deps.siloOrganizationId,
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
});
const stagedResources = await stageTriggerMessageResources(
rt,
botCli,
msg,
projectWorkspaceRoot,
deps.resourceLimits,
@@ -486,6 +498,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text.
const deliveredFiles: string[] = [];
const card = new StreamingAgentCard({
runId: run.id,
rt,
@@ -493,12 +506,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions,
patchIntervalMs: undefined,
maxMessageLength: undefined,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
projectId,
organizationId: siloOrganizationId,
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
@@ -506,6 +522,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions,
approvalManager,
tools: cphHubMcpToolsForRole(roleTools),
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
onDelivered: (path) => {
deliveredFiles.push(path);
},
@@ -571,7 +589,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: undefined,
input: event.input,
result: event.result,
error: event.isError ? event.result : undefined,
durationMs: event.durationMs,
@@ -590,13 +608,24 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
agentExecution
.then(async (result) => {
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const finalText =
const hasPartialText = result.text.trim() !== "";
const outcome = teacherFacingRunOutcome({
wallTimeExceeded,
interrupted,
resultStatus: result.status,
resultError: result.error,
maxTurns: runPolicy.maxTurns,
maxRunSeconds: runPolicy.maxRunSeconds,
hasPartialText,
});
const baseText =
result.text !== ""
? result.text
: result.status === "failed" && result.error !== undefined
: result.status === "failed" && result.error !== undefined && outcome.notice === undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text;
await card.finish(finalText, { interrupted });
const finalText = appendTeacherNotice(baseText, outcome.notice);
await card.finish(finalText, { interrupted, isError: outcome.isError });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
@@ -660,14 +689,36 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
});
await removeProcessingReaction();
// Mirror the start "Typing" reaction: drop processing, then stamp a
// terminal emoji so teachers see done/failed without reading the card.
const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) {
await addReaction(
rt,
msg.message_id,
outcome.isError ? "CrossMark" : "CheckMark",
);
}
})
.catch(async (e) => {
const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) {
await addReaction(rt, msg.message_id, "CrossMark");
}
await card.fail(e instanceof Error ? e.message : String(e));
await card.fail(
appendTeacherNotice(
"",
teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "failed",
resultError: e instanceof Error ? e.message : String(e),
maxTurns: runPolicy.maxTurns,
maxRunSeconds: runPolicy.maxRunSeconds,
hasPartialText: false,
}).notice ?? `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`,
),
);
try {
await deps.prisma.agentRun.update({
where: { id: run.id },
@@ -1826,8 +1877,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success.";
"When the user asks for a downloadable file attachment (PDF/DOCX/ZIP/etc.), call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success. " +
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax ![alt](relative/path.png) (or a public https image URL). The platform uploads those into the Feishu card. Prefer workspace files over remote URLs.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
}
@@ -1908,7 +1960,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
}
async function stageTriggerMessageResources(
rt: FeishuRuntime,
botCli: FeishuBotCli,
msg: MessageReceiveEvent["message"],
workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"],
@@ -1942,7 +1994,7 @@ async function stageTriggerMessageResources(
}
}
return stageMessageResources(
rt,
botCli,
msg.message_id,
requests,
workspaceRoot,
+51 -8
View File
@@ -1,7 +1,7 @@
import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
@@ -118,15 +118,22 @@ export async function startHub(): Promise<void> {
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding();
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
// Startup reset: clear stale locks + mark dead runs as FAILED. Capture the
// killed runs first so we can tell their Feishu chats after the listener is up.
const interruptedRuns = await prisma.agentRun.findMany({
where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
select: { id: true, projectId: true },
});
app.log.info("startup: cleared stale locks + dead runs");
await prisma.projectAgentLock.deleteMany({});
if (interruptedRuns.length > 0) {
await prisma.agentRun.updateMany({
where: { id: { in: interruptedRuns.map((run) => run.id) } },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
});
}
app.log.info({ killedRuns: interruptedRuns.length }, "startup: cleared stale locks + dead runs");
let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
let feishuRuntime: FeishuRuntime | undefined;
app.get("/api/healthz", async (_request, reply) => {
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
@@ -162,6 +169,7 @@ export async function startHub(): Promise<void> {
prisma,
settings: runtimeSettings,
logger: app.log,
secretEnvelope,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
@@ -170,7 +178,7 @@ export async function startHub(): Promise<void> {
allowLegacyFeishuIdentity: false,
maxFeishuEventsPerMinute: feishuEventsPerMinute,
});
feishuRuntime = await startFeishuListenerWithClient(
const runtime = await startFeishuListenerWithClient(
feishuConfig,
larkClient,
app.log,
@@ -185,6 +193,8 @@ export async function startHub(): Promise<void> {
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled");
},
);
feishuRuntime = runtime;
await notifyBoundChatsOfInterruptedRuns(runtime, prisma, interruptedRuns, app.log);
} else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
@@ -192,6 +202,39 @@ export async function startHub(): Promise<void> {
app.log.info({ address }, "hub listening");
}
async function notifyBoundChatsOfInterruptedRuns(
rt: FeishuRuntime,
db: typeof prisma,
interruptedRuns: ReadonlyArray<{ readonly id: string; readonly projectId: string }>,
logger: { info: (obj: unknown, msg?: string) => void; warn: (obj: unknown, msg?: string) => void },
): Promise<void> {
if (interruptedRuns.length === 0) return;
const byProject = new Map<string, string[]>();
for (const run of interruptedRuns) {
const list = byProject.get(run.projectId) ?? [];
list.push(run.id);
byProject.set(run.projectId, list);
}
for (const [projectId, runIds] of byProject) {
const binding = await db.projectGroupBinding.findFirst({
where: { projectId, archivedAt: null },
select: { chatId: true },
});
if (binding === null) continue;
const n = runIds.length;
const text =
n === 1
? `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C\u4E0A\u4E00\u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\uFF08run: ${runIds[0]}\uFF09\u3002`
: `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C${n} \u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\u3002`;
try {
await sendText(rt, binding.chatId, text);
logger.info({ projectId, chatId: binding.chatId, runIds }, "startup: notified chat of interrupted runs");
} catch (error) {
logger.warn({ projectId, chatId: binding.chatId, err: String(error) }, "startup: failed to notify chat of interrupted runs");
}
}
}
function positiveIntegerEnv(name: string): number {
const raw = requireEnv(name);
const value = Number(raw);
+3 -3
View File
@@ -1,9 +1,9 @@
/**
* Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
* Org capacity policy service (ADR-0022).
*
* Stores per-Organization lower `organizationLimit` overrides per
* `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be the
* platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be the
* platform ceiling for that dimension. The effective limit (min of the two)
* is the value capacity admission should use; dimensions with no org override
* fall back to the platform ceiling.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Organization membership management for org admin (ADR-0021).
*
* Role rules (product pin, not yet in Lean):
* Role rules (product pin, not yet recorded in an ADR):
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
* 3. Cannot revoke or demote the last remaining OWNER.

Some files were not shown because too many files have changed in this diff Show More