Compare commits

..

134 Commits

Author SHA1 Message Date
zeyu.fz 75699966d4 Merge remote-tracking branch 'origin/main' into feat/memory 2026-09-10 20:43:23 +08:00
Gong Shiqi 2220f932b5 Merge pull request #198 from modelstudioai/codex/release-1.23.0
chore(release): prepare 1.23.0
2026-09-10 17:37:40 +08:00
zeyu.fz d9134d6f0a feat(memory): 增加长度校验及限流提示并强化安全确认
- 在多条命令中统一添加对 userId 和 memoryLibraryId 长度的校验
- 新增 profile schema 名称、描述及属性字段长度断言
- 为 delete 内存节点命令添加高风险删除确认机制及 --yes 标志
- 增加账号级别限流说明,提示遇 HTTP 429 需降低请求频率
- 统一移除分散的长度常量,集中定义并使用共享常量
- 相关 E2E 测试覆盖长度限制和高风险操作确认情况
- 支持内存消息内容多样化,新增 MemoryContentPart 类型定义
- 内存文档中新增限流描述及删除命令风险提示,强调确认操作要求
2026-09-10 17:31:42 +08:00
若麒 5c390fad74 chore(release): prepare 1.23.0 2026-09-10 16:57:58 +08:00
Gong Shiqi 6218637f7c Merge pull request #194 from modelstudioai/feat/global-watermark-config
Feat/global watermark config
2026-09-10 16:49:33 +08:00
若麒 0c7ba721a7 Merge remote-tracking branch 'origin/main' into feat/global-watermark-config 2026-09-10 15:39:12 +08:00
Gong Shiqi 0dbf071368 Merge pull request #197 from modelstudioai/feat/speech-vocabulary
Add ASR instant hotwords, context, and vocabulary CRUD
2026-09-10 15:11:35 +08:00
zeyu.fz 75a7100702 feat(memory): 完善记忆及画像相关命令支持并统一参数
- 在 CLI 文档中新增记忆 live 场景说明与变量需求
- 扩展 CLI 命令集,添加记忆画像 Profile 的增删改查功能
- 统一所有记忆相关命令加入 workspace 和 library 参数支持
- 记忆添加命令支持自定义 meta_data,完善输入校验和互斥说明
- 记忆删除、列表、获取用户画像等命令增加工作空间参数支持
- 画像模板相关命令新增分页参数及属性操作接口
- 重构知识命令共享模块,移除重复的 workspace 解析实现,统一导出
- 优化命令输出格式,文本模式下显示更多详细信息和异常提示
- 提供完整的示例参数,增强命令使用说明和提示文档
2026-09-10 14:40:39 +08:00
clh02467605 16e30686f0 fix(speech): validate --words JSON before auth
Invalid vocabulary JSON was parsed in run(), so CI without an API key failed with AUTH(3) instead of USAGE(2).
2026-09-10 14:28:16 +08:00
clh02467605 7803d91e3f chore: fixed Formatting issues 2026-09-10 14:07:37 +08:00
clh02467605 c2d17707c0 feat(speech): add instant hotwords, context, and vocabulary CRUD 2026-09-10 14:02:05 +08:00
rendianmeng 1dfb4400cc Merge branch 'main' of github.com:modelstudioai/cli into feat/global-watermark-config 2026-09-10 10:33:23 +08:00
rendianmeng fd2077b373 feat: pipeline watermark 处理完善 2026-09-09 17:40:49 +08:00
chenanran555 2090293f85 Merge pull request #192 from modelstudioai/feat/managed-agent-ci
fix(managed-agent): improve project initialization and build validation
2026-09-08 19:39:03 +08:00
chenanran555 a245359792 chore(release): prepare 1.22.0 2026-09-08 19:26:17 +08:00
chenanran555 19bd5a5ea8 test(managed-agent): remove obsolete build confirmation flag 2026-09-08 19:01:47 +08:00
chenanran555 e262f2e574 chore(deps): bump OpenAgentPack SDK to 0.7.1 2026-09-08 18:01:41 +08:00
chenanran555 180aac9f28 fix(managed-agent): show absolute YAML paths during init 2026-09-08 16:57:15 +08:00
若麒 3477a08dad feat(config): add profile-level watermark setting 2026-09-08 16:33:40 +08:00
chenanran555 aa1484264e fix(managed-agent): improve project initialization and build validation 2026-09-08 14:46:56 +08:00
chenanran555 2767491ce6 Merge pull request #182 from modelstudioai/feat/managed-agent-ci
Feat/managed agent ci 添加managed agents模块的ci能力
2026-09-07 21:42:35 +08:00
chenanran555 efb832815a chore(release): prepare 1.21.0 2026-09-07 21:11:51 +08:00
chenanran555 902516bbad fix(managed-agent): check latest Playground version before launch
Resolve the requested npm version before reusing local builds or running processes, fetch mismatched versions on demand, and preserve explicit binary overrides. Add launcher regression tests and sync the SDK 0.7.0 lockfile.
2026-09-07 20:46:07 +08:00
chenanran555 f44fb2e95f refactor(managed-agent): use SDK project service subpaths
Replace standalone project packages with @openagentpack/sdk 0.7.0 and update project/version imports.

The pnpm lockfile remains pending until SDK 0.7.0 is published to npm; this integration has been validated against the local SDK build.
2026-09-07 19:58:47 +08:00
chenanran555 c6b9931d6f feat(managed-agent): integrate directory resource scaffolds and safe builds 2026-09-04 17:20:50 +08:00
chenanran555 a92c58420b fix(managed-agent): use CLI credentials for directory projects
Make project init fully offline, stop generating project-local credential files, bind Publish to the reviewed plan fingerprint, remove the unsafe Apply refresh-only mode, and document Agent-owned File mounts.
2026-09-04 11:22:11 +08:00
chenanran555 bcf68a31f3 Merge remote-tracking branch 'origin/main' into feat/managed-agent-ci
# Conflicts:
#	packages/commands/package.json
#	packages/commands/src/commands/managed-agent/apply.ts
#	packages/commands/src/commands/managed-agent/init.ts
#	packages/commands/tests/e2e/managed-agent.e2e.test.ts
#	skills/bailian-managed-agent/SKILL.md
#	skills/bailian-managed-agent/reference/index.md
#	skills/bailian-managed-agent/reference/managed-agent.md
2026-09-03 11:38:44 +08:00
chenanran555 8e5dd127a9 Merge pull request #177 from modelstudioai/feat/cma-command
Feat/cma command 补齐managed agent cli能力
2026-09-03 11:00:47 +08:00
chenanran555 4f41eaabc2 chore(release): prepare 1.20.0 2026-09-03 10:47:23 +08:00
chenanran555 e1634c40ed chore(deps): upgrade @openagentpack/sdk to 0.5.0 2026-09-02 22:26:33 +08:00
chenanran555 4c5688ad4a feat(managed-agent)!: restrict CLI to Bailian provider
Remove multi-provider CLI flags and reject non-Bailian provider configs while preserving SDK support.
2026-09-02 19:23:40 +08:00
chenanran555 176961dc56 feat(managed-agent): hide capabilities command from public CLI 2026-09-02 15:33:50 +08:00
Gong Shiqi 888031562f Merge pull request #189 from modelstudioai/docs/console-gateway-backend-attribution
docs(telemetry): update Console Gateway backend attribution
2026-09-02 15:18:51 +08:00
若麒 d017954ae8 docs(telemetry): update Console Gateway backend attribution 2026-09-02 15:11:24 +08:00
chenanran555 9d19558645 feat(managed-agent): enforce high-risk confirmation for runtime operations 2026-09-02 15:07:22 +08:00
chenanran555 a9200a3f85 Merge remote-tracking branch 'origin/main' into feat/cma-command
# Conflicts:
#	packages/commands/src/commands/managed-agent/session-delete.ts
#	packages/commands/tests/e2e/managed-agent.e2e.test.ts
#	skills/bailian-managed-agent/SKILL.md
#	skills/bailian-managed-agent/reference/managed-agent.md
2026-09-02 14:51:49 +08:00
chenanran555 83d7982ecb feat(managed-agent): support creating agents with remote and local skills 2026-09-02 14:36:22 +08:00
Gong Shiqi d33cb71cc7 Merge pull request #180 from modelstudioai/feat/runtime-risk-confirmation
feat(cli): add unified confirmation for high-risk operations
2026-09-02 14:31:18 +08:00
若麒 737f8db359 chore(release): prepare 1.19.0 2026-09-01 20:21:21 +08:00
若麒 a402026cce Merge remote-tracking branch 'origin/main' into feat/runtime-risk-confirmation 2026-09-01 19:49:04 +08:00
若麒 da6e1313e5 feat(commands): migrate delete commands to runtime risk confirmation 2026-09-01 19:25:50 +08:00
若麒 2d2ec1c628 Merge remote-tracking branch 'origin/feat/quota-delete-split' into feat/runtime-risk-confirmation 2026-09-01 19:16:40 +08:00
Gong Shiqi a78ed7ffe8 Merge pull request #187 from modelstudioai/release/1.18.2
chore(release): prepare 1.18.2
2026-09-01 19:02:29 +08:00
若麒 5a1dfa5227 chore(release): prepare 1.18.2 2026-09-01 18:56:28 +08:00
duck-bubi-ya b5dec926b7 Merge pull request #185 from modelstudioai/fix/skill-init-issue
fix(skill): retry registry fetches and tolerate blocked backup cleanup
2026-09-01 17:37:22 +08:00
故璃 fd96fd664c feat(quota): split --delete into a dedicated quota delete command
The destructive --delete flag sat on "quota update", but the --yes
high-risk confirmation guards command paths rather than individual flags.
Promote it to its own "quota delete" command with a --yes guardrail, and
reduce "quota update" to QPM/TPM updates only.
2026-09-01 17:35:46 +08:00
若麒 719c0b68bb Merge remote-tracking branch 'origin/main' into feat/runtime-risk-confirmation 2026-09-01 17:06:41 +08:00
故璃 9e6f5ca9cb fix(skill): retry registry fetches and tolerate blocked backup cleanup
- bl skill init: raise index timeout 10s→30s and retry transient network
  failures (3 attempts); advisor sync silent channel stays fail-fast
- make post-swap backup deletion best-effort so host safe-delete guards
  cannot fail a completed install (postinstall.js mirror included)
- add unit coverage for guard-blocked cleanup and registry retry policy
2026-09-01 16:37:12 +08:00
gujieye 3f78de2dc1 Merge pull request #183 from modelstudioai/feat/confirm-destructive-ops
feat: require --yes confirmation for destructive operations
2026-08-31 19:47:17 +08:00
故璃 38926aeb22 feat: require --yes confirmation for destructive operations
- finetune/deploy/dataset delete: add --yes flag + confirmDangerousAction
- quota update --delete: confirm before clearing rate limits
- non-TTY runs without --yes fail with a USAGE error
- add e2e coverage and regenerate skill references
2026-08-31 19:26:31 +08:00
chenanran555 e53daf05eb feat(managed-agent): add directory project workflow 2026-08-31 15:55:10 +08:00
chenanran555 67ae182ec3 feat(managed-agent): require confirmation for removals 2026-08-31 11:06:43 +08:00
Gong Shiqi e3f2996291 Merge pull request #181 from modelstudioai/perf/registry-smoke-inprocess
perf(test): reduce CLI E2E startup overhead
2026-08-31 10:59:51 +08:00
若麒 59250c4f44 perf(test): streamline command e2e execution 2026-08-28 19:14:06 +08:00
若麒 676b6c2ece test(runtime): update auth fallback fixture for confirmation context 2026-08-28 17:48:07 +08:00
若麒 4f59ca5118 Merge branch 'main' into feat/runtime-risk-confirmation 2026-08-28 17:42:57 +08:00
若麒 0872ff6a20 feat(cli): expose high-risk confirmation guidance in help and skills 2026-08-28 17:19:24 +08:00
若麒 98ebeb398c perf(test): run registry smoke help checks in process 2026-08-28 16:54:55 +08:00
Gong Shiqi 5c96077d9f Merge pull request #179 from modelstudioai/feat/cancel-key-auth
chore(release): prepare 1.18.1
2026-08-28 15:50:42 +08:00
若麒 8a7674e7c3 chore(release): prepare 1.18.1 2026-08-28 15:37:15 +08:00
若麒 afb547e0c8 refactor(commands): migrate remaining confirmations to runtime gate 2026-08-28 14:35:17 +08:00
Gong Shiqi 4fdd8389f8 Merge pull request #178 from modelstudioai/feat/cancel-key-auth
feat: cancel validate API key
2026-08-28 14:29:45 +08:00
rendianmeng bd8b08b617 feat: cancel validate API key 2026-08-28 13:10:56 +08:00
chenanran555 449eede237 fix(managed-agent): upgrade OpenAgentPack SDK to 0.4.0 beta 2026-08-27 20:47:51 +08:00
若麒 8906108744 refactor(knowledge): migrate high-risk commands to runtime confirmation 2026-08-27 19:42:51 +08:00
chenanran555 2fbb6efe67 Merge remote-tracking branch 'origin/main' into feat/cma-command 2026-08-27 17:27:55 +08:00
chenanran555 47e9bcc634 feat(managed-agent): add scoped resource create commands 2026-08-27 17:01:27 +08:00
若麒 00bcee36a6 feat(runtime): add unified confirmation gate for high-risk commands 2026-08-27 15:54:23 +08:00
Gong Shiqi e9479e4084 Merge pull request #176 from modelstudioai/feat/profile-capability-fallback
feat(auth): add Profile API key capability fallback
2026-08-27 11:45:03 +08:00
chenanran555 4c7ef0c7a5 feat(managed-agent): add scoped agent create command
Add `bl managed-agent agent create` with automatic YAML key generation,
atomic config updates, preview-by-default behavior, and create-only scoped
apply that ignores unrelated resource drift.
2026-08-27 11:44:00 +08:00
若麒 17ffde4ac2 chore(release): prepare 1.18.0 2026-08-27 11:33:50 +08:00
若麒 9600f06205 Merge remote-tracking branch 'origin/main' into feat/profile-capability-fallback
# Conflicts:
#	skills/bailian-gen/reference/index.md
2026-08-26 19:27:01 +08:00
若麒 fc3ac36d00 Merge branch 'main' into feat/profile-capability-fallback 2026-08-26 18:54:01 +08:00
Gong Shiqi fe11227789 Merge pull request #174 from modelstudioai/feat/update-def-video-model
Switch default video model to wan3.0-video
2026-08-26 18:00:38 +08:00
chenanran555 9469556671 refactor(managed-agent)!: replace Git versioning with local snapshots
Replace @openagentpack/local-git with @openagentpack/project-versions so
the CLI and Workbench share a Git-independent, lock-protected YAML
snapshot store.

- create versions only after a successful Apply
- migrate version commands to local version IDs
- remove Git project scaffolding and CI-specific Apply policy
- update tests and managed-agent skill references

BREAKING CHANGE: remove `managed-agent init --git` and
`managed-agent apply --ci`; version preview and restore now use
`--version-id` instead of `--commit`.
2026-08-26 18:00:05 +08:00
若麒 804a43f8ed docs(skill): use profile default model when unspecified 2026-08-26 17:55:44 +08:00
若麒 d343dc3265 fix(auth): only apply profile presets on login 2026-08-26 17:10:00 +08:00
clh02467605 0d24f46f73 fix(e2e): unbind wan3.0/--file dry-run tests from token-plan 2026-08-26 17:09:27 +08:00
Gong Shiqi cc209ce0b1 Merge pull request #173 from modelstudioai/feat/update-install.md
Clarify install guide skill fallback and post-install tips
2026-08-26 16:43:53 +08:00
clh02467605 8360a9f584 feat(e2e): add tests for video generation with --file option and mutual exclusivity checks 2026-08-26 15:48:08 +08:00
clh02467605 90d1ef9dc5 fix: format fix 2026-08-26 15:30:05 +08:00
clh02467605 124c425133 chore: 优化一键安装引导脚本中node版本兼容问题 2026-08-26 15:25:51 +08:00
若麒 670c15532b feat(auth): expand Token Plan capabilities and speech defaults 2026-08-26 13:42:27 +08:00
clark-fc 4067b2c2aa Merge pull request #175 from modelstudioai/feat/migrate-kb-dsh-plugin
Feat/migrate kb dsh plugin
2026-08-25 09:56:07 +08:00
zeyu.fz adb1ced45c chore(bailian-kb-dsh): 更新版本号至0.1.19
- 将package.json中的版本号从0.1.18更新为0.1.19
- 保持项目描述和关键词不变
- 为发布新版本做准备
2026-08-24 21:19:29 +08:00
chenanran555 69c1fb9f08 feat(managed-agent): 增加托管 Agent 全面管理命令集及功能实现
- 扩展 CLI 命令,新增托管 Agent 相关全部操作命令包括能力、Agent、环境、技能、保管库、部署、会话及文件管理
- 实现 Agent 列表、详情、版本、搜索等功能
- 实现环境列表、详情及搜索功能
- 实现部署列表、详情、搜索、运行、暂停与恢复功能
- 实现会话的搜索、更新、归档及事件操作功能
- 实现文件上传、列表、下载、删除及搜索功能
- 添加托管 Agent API 操作能力展示命令
- 统一异常处理及输出格式支持,支持 JSON 与表格展示
- 完善分页、查询参数及权限验证逻辑,支持按需筛选与翻页
- 新增输入输出文件支持与控制台保护机制,提升命令行体验和稳定性
2026-08-24 20:15:09 +08:00
zeyu.fz e023af5be3 docs(kb-dsh): 优化知识服务清单注入与错误处理提示
- 补查型工具(service_find)确认不暴露服务清单,避免与catalog冲突
- 清单内容策略扩展,0服务时注入明确禁止猜测id的提示
- 工具描述保持静态,上下文消息注入带source的UserMessage实现动态清单
- bl命令及安装提示仅在动态文本中出现,避免静态描述频繁消耗token
- 错误处理中4xx刷新并追加服务清单,0服务状态下明确提示不重试须创建部署
- service-catalog新增无服务提示及刷新服务列表构建函数
- service-context调整使用新清单构建逻辑,缓存空时注入无服务通知
- tools调整描述文案,提示来自上下文消息且拒绝猜测
- README补充bl CLI安装使用说明
- 测试补充无服务情况注入提示及刷新列表文本内容校验
2026-08-24 19:34:15 +08:00
zeyu.fz a28d95a763 fix(bailian-kb-dsh): 修正工具数量和agent_id要求描述错误
- 将知识工具数量描述从三个改为两个
- 将agent_id要求从可选改为必需
- 更新bailian-card-controller中credentials引用数量从三个到四个
- 维持失败读取时页面可用和写操作正常工作逻辑
2026-08-24 19:01:02 +08:00
zeyu.fz 491858be4a docs(readme): 更新 Bailian KB dsh 插件的文档和添加运行时说明
- 增补详细的 runtime-behavior.md,说明插件的内部运行逻辑和设计取舍
- 完善 packages/bailian-kb-dsh 的 README,添加英文版及使用要求说明
- 细化配置项说明,示例及环境变量配置方式展示
- 增加 LICENSE 文件,明确 Apache 2.0 许可证
- 说明插件安装、配置、和卸载的详细步骤
- 规范 README 和文档多语言版本的同步更新及说明管理
- 说明服务发现、缓存刷新及代理行为的设计和技术细节
- 细化 Web UI 配置页功能介绍和操作指导
2026-08-24 18:55:28 +08:00
clh02467605 18fd2ed8e5 fix: fixed format 2026-08-24 18:38:02 +08:00
clh02467605 4c51c95f31 feat(video): switch default video model to wan3.0-video and adapt All-in-One support
- Update default models for video generate/ref and config UI to wan3.0-video
- Add --file file-to-video support for wan3.0; route kf2v through media[] instead of image2video
- Emit reference voices as standalone reference_audio entries on wan3.0 ref
- Sync e2e/stress tests and bailian-gen reference docs
2026-08-24 18:00:52 +08:00
zeyu.fz 3f21f55541 docs(structure): 更新项目目录结构说明
- 新增 runtime、commands、kscli、e2e 和 bailian-kb-dsh 包说明
- 扩展 core 包功能描述,包含配置、错误等内容
- 明确 cli 包为完整产品入口
- 添加 skills 目录及对应功能说明
- 调整目录顺序,增强文档清晰度和完整性
2026-08-24 17:28:50 +08:00
zeyu.fz eb8b0892d6 docs(bailian-kb-dsh): 优化 README 文档及技能说明的表述内容
- 修正缓存管理中对 API key 和自动获取按钮的描述,更加准确表述工作区权限限制
- 更新缓存存储字段说明,明确 pipeline_list 字段不稳定,无法用作知识库标签
- 简化执行期无进展显示的描述,去除过度复杂说明
- 明确 top_k 参数为客户端截断,强调服务端返回记录数由检索服务配置决定
- 细化服务画像质量依赖服务名的说明,配合后端描述字段补齐做对应改动准备
- 微调技能最佳实践中关于服务命名指导的表述,强调无语义名称导致检索无法路由
- 说明后端描述字段补齐后,服务描述字段可自动生效,提升文档明确性
2026-08-24 15:51:16 +08:00
zeyu.fz a1023ce9a4 chore(kb-dsh): 精简 src 若干注释
删除子仓时期特有的溯源信息("从 bl CLI 类型镜像"、验证时间戳、
Endpoint.AccessDenied 归因等冗余展开)——迁入本仓后这些细节已无参照必要。
涉及 api-types.ts / bl-cli.ts / console-login.ts / index.ts /
service-cache.ts / web/bailian-card-controller.ts。
2026-08-24 15:46:43 +08:00
zeyu.fz 7bc1c49cb6 feat(release): 给 publish.yml 加 bailian-kb-dsh 通道
复用同一个 Publish workflow 入口(package 下拉多一项 bailian-kb-dsh),路由到
独立的 publish-kb-dsh.mjs 处理:
- 版本读自身 package.json(不广播全套 bl 版本)
- stable 打 bailian-kb-dsh-v<version> tag(与 bl 的 v<version> 错开命名空间)
- channel 临时 bump 到 0.0.0-beta-<sha>-<stamp>(形态与 bl channel 一致),
  finally 还原 package.json
- 走自身的 tsc + tsdown build,无 binary,无 OSS CDN
- 复用 lib/git.mjs / lib/npm.mjs / lib/proc.mjs 三个薄工具
- 复用 workflow 入口 UI 与 setup 步骤(checkout / pnpm / node 24 / gitleaks
  / install),stable 走 environment: production Required Reviewers gate

不复用 publish-stable.mjs / publish-channel.mjs:它们的 loadAndValidatePackages
会广播 core 版本给全套锁步包并强校验一致性,把 kb-dsh 塞进去第一步就 throw。
故意分开是为了保住这个隔离。

本地 --dry-run 端到端跑通:build → 幂等性查重 → pack + publint + gitleaks →
pnpm publish --tag latest|<channel> --provenance --dry-run;channel 模式的
finally 还原后 git diff 干净。

文档:dsh-plugin.md 补发布小节 + 已知待办(publint 那条 web bundle CJS/ESM
warning);publish.md 加 bailian-kb-dsh 定位;packages.mjs 与 AGENTS.md 的
注释同步指向新的 job 与 script 名。
2026-08-24 15:44:19 +08:00
zeyu.fz a393bdc56b refactor(kb-dsh): 删除已过期的 reference/*.md,参数详情交还 --help
插件不是 CLI,没有义务维护一份 bl 参数手册。已有的 8 个 reference 文件锚死在
bailian-cli 1.16.0:--description 改为必填后(1.17.1)它就在教一条必然失败的命令。

SKILL.md 备注列改为引导 agent 跑 `bl <命令> --help`(与其自身前置检查第 1 步
一致);核心示例补 --description;命令参考段从死链改为纯指引。

docs/agents/dsh-plugin.md D 项同步更新:不带 reference/,不由生成器产出。
2026-08-24 15:17:55 +08:00
clh02467605 f29f5fe632 feat: update install guide 2026-08-24 14:01:50 +08:00
zeyu.fz d24104f7dc feat(kb-dsh): 接入仓库工程约定并改名为公开包 bailian-kb-dsh
包名 @ali/bailian-kb-dsh → bailian-kb-dsh(公开 npm):package.json name +
cordis.patch.yml insert.name + tsdown PLUGIN_ID 三处同步(漏一处即 dsh 运行时崩)。

产物 lib/ → dist/(本仓 .gitignore 忽略 dist 不忽略 lib),连带 main/types/
exports/files/tsdown outDir 同步;.gitignore 补 *.tsbuildinfo。

依赖接 catalog(yaml/typescript/@types/node/vite-plus);测试导入 vitest →
vite-plus/test(全仓统一约定,消掉唯一的 vitest 依赖漂移)。

tsconfig 拆三件套:tsconfig.json 纯类型检查覆盖 src+tests(供 oxlint 自动发现,
含 jsx/DOM),tsconfig.build.json 产出 node 半,tsconfig.web.json 隔离检查 web 半。
补齐 tests 从未被类型检查暴露的一处 partial 输入类型错误。

根 vite.config.ts 新增两条 override:web 半 no-restricted-imports 把 tsdown 构建期
的 bundle purity gate 提前到 lint 期;全包放开 _ 前缀的 no-unused-vars。

文档:新增 docs/agents/dsh-plugin.md,AGENTS.md 项目地图/版本锁步例外/分层边界/
场景索引同步,packages.mjs 注释说明故意不进发布白名单。

格式化(单引号无分号 → 双引号加分号)由 pre-commit 的 vp check --fix 自动完成,
无法单独成 commit,一并纳入。

全仓 vp check 0 error;插件 13 文件 85 测试全绿;build + typecheck 通过。
2026-08-24 11:02:44 +08:00
zeyu.fz 9112a88c60 Add 'packages/bailian-kb-dsh/' from commit 'b11adcc6fe4d034a4f809b2d5a31ad488c433c5e'
git-subtree-dir: packages/bailian-kb-dsh
git-subtree-mainline: e76ebee681
git-subtree-split: b11adcc6fe
2026-08-24 10:41:32 +08:00
zeyu.fz b11adcc6fe feat(tool-bailian-kb): 增强服务缓存机制及默认服务选择功能
- 添加对服务清单缓存的动态刷新与过期时间优化,空缓存采用更短TTL以避免首次配置延迟
- 注册工具执行结果监听,检测到管理命令后立即使服务缓存失效并刷新
- 提供新的HTTP路由支持面板强制刷新和获取服务缓存快照
- 实现服务缓存状态接口,方便面板展示缓存健康状况和服务列表数量
- 在前端增加服务缓存视图,显示缓存状态、最后更新时间及刷新按钮
- 支持默认检索服务与对话服务的选择器,允许清除和从缓存服务列表选择
- 移除原有默认服务ID的文本框,避免与选择器内容重复且不同步
- 更新国际化文本,反映默认服务选择器和服务缓存状态相关内容
- 添加单元测试验证空缓存TTL行为及状态快照正确性
2026-08-23 15:44:52 +08:00
zeyu.fz 90dd038a2f feat(tool-bailian-kb): 引入服务缓存支持检索服务动态管理
- 引入 yaml 依赖,更新包依赖配置和锁文件
- 新增 ServiceCache 类,实现检索服务缓存机制,缓存位于本地缓存目录
- 实现服务缓存的读取、写入和异步刷新,支持数据的版本校验和过期控制
- 在插件主入口集成服务缓存,支持按 workspace 维度缓存隔离
- 改写默认 agent_id 获取逻辑,支持单服务自动选取和缓存中的默认服务回退
- 支持检测缓存失效时自动刷新,并能在错误消息中附加当前有效服务列表
- 使用 agent/pre-step 上下文消息注入服务目录,替代工具描述中的服务信息,支持动态更新
- 文档更新完善,说明服务清单的行为语义和管理面与检索面的职责分离
- 为管理面技能引入最佳实践,强调服务命名的重要性和描述字段的价值
- 取消暴露服务清单为模型工具,避免重新引入“先列再搜”的请求环节
2026-08-23 14:25:41 +08:00
zeyu.fz 5f44cb4402 refactor(tool-bailian-kb): 重命名管理技能及更新相关路径和描述
- 将管理技能名称从 bailian-kb-management 改为 bailian-kb
- 调整技能文件路径从 skills/bailian-kb-management 改为 skills/bailian-kb
- 更新 README.md 中关于管理技能的路径说明
- 修改 skill.ts 中注册技能的目录和名称
- 更新技能描述以准确反映当前功能内容
2026-08-21 16:40:17 +08:00
zeyu.fz 70dbc2110d feat(bailian): 增加高级配置区和相关交互优化
- 将配置字段调整为高级配置隐藏,支持折叠展开展示
- 为API Key和工作空间ID添加“去获取”外部链接,引导用户至控制台
- 优化填写状态提示,新增已配置完成的绿色成功提示
- 新增高级配置按钮,折叠字段组及保存、放弃操作
- 细化自动填充状态处理,支持失败、等待登录、成功及已配置等多状态显示
- 更新本地化文本,支持高级配置及“去获取”等新文案
- 调整CSS样式,新增高级配置相关样式和状态样式
- 升级版本号至0.1.15
2026-08-21 16:17:18 +08:00
zeyu.fz 8714f61496 feat(tool-bailian-kb): 改用自实现的控制台登录协议获取凭据
- 移除对 bl CLI 登录流程 `bl auth login --console` 的依赖
- 实现了自包含的控制台登录流程,直接使用控制台登录回调协议
- 始终要求签发新 API key,避免旧 key 与新 workspaceId 不匹配问题
- 新增本地 loopback HTTP 服务器接收登录回调并持久化凭据
- 变更面板的自动获取流程,改为通过新登录协议驱动登录
- 添加自动获取登录态的轮询状态,支持登录进度反馈
- 优化页面按钮状态及提示,支持登录 URL 手动打开
- 删除对 bl CLI 配置文件的读取与登录
2026-08-21 01:49:04 +08:00
zeyu.fz e1793e1f3a refactor(cli): 替换所有 kscli 命令为 bl CLI 命令
- README 文档中将管理面 CLI 名称由 kscli 改为 bl CLI
- 包描述与说明中更新 CLI 名称与对应命令用法
- 技能文档及其命令参考全面替换 kscli 为 bl
- 所有子命令示例和用法文档同步改为 bl 及对应子命令路径
- 更新服务发现命令由 kscli 改为 bl knowledge service list
- 更新鉴权说明改为 bl auth login 及相关配置命令
- 维护命令结构一致性,保证用户可无缝使用 bl 替代原 kscli
2026-08-21 00:19:29 +08:00
zeyu.fz 0d8d354ddc chore(package): 更新版本号至0.1.6
- 将@ali/bailian-kb-dsh包的版本号从0.1.5提升到0.1.6
- 保持其他package.json字段不变
2026-08-17 21:10:24 +08:00
zeyu.fz cd955211e3 docs(tool-bailian-kb): 完善阿里云百炼知识库管理文档与命令参考
- 更新技能描述,补充命令行工具 kscli 的使用范围与说明
- 细化安装与鉴权步骤,明确不同发行通道及 Node.js 版本要求
- 增加详细的命令用途对照表,便于用户区分不同操作命令
- 优化核心工作流示例,简化上传、建库、部署检索服务步骤
- 补充多种 ID 类型说明,帮助用户正确使用各类标识
- 添加关于危险和不可逆操作的说明及确认要求
- 强调服务版本状态及发布流程,规范草稿与发布版切换
- 新增详细的命令参考文档,覆盖 chunk、config、datacenter、
  doc、kb、query、service 等命令组
- 更新 package 版本号至 0.1.5,标识本次文档与功能更新
2026-08-17 20:56:18 +08:00
zeyu.fz 30c2be67a0 feat(tool-bailian-kb): 统一设定 kb_search 与 kb_chat 的 agent_id 为必填参数
- 修改文档和代码,将 kb_search 和 kb_chat 工具的 agent_id 参数在 schema 中标记为必填
- 明确模型路径调用时必须显式传递 agent_id,程序化调用缺省时回退默认服务
- 定义工具参数验证时强制 agent_id 必填,防止无效调用的运行时错误
- 调整默认服务回退逻辑为防御机制,保证 schema 校验优先拒绝缺失 agent_id 的请求
- 增强错误提示信息,引导用户正确配置和使用 agent_id 参数
- 更新测试覆盖相关改动,确保 agent_id 必填规则和回退机制符合预期
- 修改说明自动解析凭证和工作空间配置的行为,用户无需手工传递这些值
- 移除已废弃的 kb_service_list 服务发现,使用 kscli
2026-08-17 20:13:29 +08:00
zeyu.fz efcb1d90ed chore(tool-bailian-kb): 更新版本号至0.1.4
- 将包版本号从0.1.3更新为0.1.4
- 保持其他配置内容不变
2026-08-17 19:34:37 +08:00
zeyu.fz f36e044119 feat(shellEnv): 注册并导出 Bailian 工作空间 ID 环境变量
- 新增 ShellEnvRegistration 接口定义,避免对主包依赖
- 为上下文添加 shellEnv 注册功能支持
- 在注入阶段注册 Bailian 工作空间 ID 环境变量
- 使管理 CLI 命令可访问解析后的工作空间 ID
- 确保子进程环境变量继承设置服务解析结果
2026-08-17 19:30:09 +08:00
zeyu.fz 0ef7c829de fix(settings): 优化 bailian-kb 设置路由的配置管理逻辑
- 更新 package.json 版本至 0.1.3,完善导出和文件配置
- 引入 SettingsProvider 用于支持路径级 unset 操作
- 将 GET 和 POST 请求共用一个 /bailian-kb/settings 路由,避免重复注册
- POST 接口支持批量更新和删除配置字段,删除操作使用
2026-08-17 18:23:28 +08:00
zeyu.fz 6cedca21d9 refactor(config): 修改插件ID以符合命名规范
- 将插件ID由'dsh-tool-bailian-kb'更改为'@ali/bailian-kb-dsh'
- 统一插件ID格式,增强识别一致性
- 便于后续维护及模块加载管理
2026-08-17 17:57:14 +08:00
zeyu.fz f0427be625 chore(tool-bailian-kb): 更新版本号至0.1.1
- 将包版本号从0.1.0升级到0.1.1
- 保持包描述和主要文件路径不变
- 准备发布新版本以集成最新更改
2026-08-17 16:33:53 +08:00
zeyu.fz 328739b91a chore(deps): 更新依赖版本并切换部分包的发布渠道
- 将部分本地链接依赖改为公开版本号依赖
- 升级 vitest 依赖,增加 @types/debug 的版本信息
- 更新 @deepseek-ai 相关包的版本至0.1.0-rc.6和4.0.1
- 调整依赖树,优化部分包的可选 peerDependencies
- 增加多项 @deepseek-ai 相关包的校验和信息
- 解绑部分包的本地路径依赖,改为版本号引用,提高模块兼容性
2026-08-17 16:26:55 +08:00
zeyu.fz 4b3ea28a2d chore(deps): 更新依赖版本为rc.6预发布版本
- 修改peerDependencies中相关@deepseek-ai包版本为^0.1.0-rc.6
- 修改devDependencies
2026-08-17 15:40:42 +08:00
zeyu.fz 9000a5efbf refactor(package): 重命名包名为 @ali/bailian-kb-dsh
- 更新 README 中的安装与卸载命令,替换包名为 @ali/bailian-kb-dsh
- 修改 cordis.patch.yml 中的工具名为 '@ali/bailian-kb-dsh'
- 将 package.json 中的包名改为 @ali/bailian-kb-dsh
- 调整内部文档示例中的包名配置一致性
2026-08-17 15:26:08 +08:00
zeyu.fz dbf83c3e16 refactor(tool-bailian-kb): 合并 bundle 功能到插件包并简化结构
- 移除独立的 bundle 包及其配置文件和说明文档
- 将原 bundle 的 patch 配置迁移到插件包内的 cordis.patch.yml
- 在插件包 package.json 中声明 dsh.bundle.patch 指向新 patch 文件
- 更新 README,说明插件包即是 bundle,简化安装和本地联调流程
- 调整文档中插件名及卸载命令,统一使用 dsh-tool-bailian-kb
- 修正仓库结构描述,将包称为目录,更准确反映当前结构
- 改进配置解析链和 Web UI 配置页的说明,突出用户层设置及覆盖机制
2026-08-17 15:21:41 +08:00
zeyu.fz cda1e326f3 refactor(tool-bailian-kb): 移除 kb_service_list 工具,统一服务发现方式
- 移除 kb_service_list 相关代码,包括接口定义、服务查询函数和工具定义
- 服务发现改为通过 kscli 命令行工具 `kscli service list` 查询
- 更新文档和注释,删除关于 kb_service_list 的描述和示例
- 删除对服务列表接口路径的引用,保留 kb_search 和 kb_chat 两个工具
- 调整错误处理逻辑,不再附加可用服务信息,错误直接透传
- 修改默认服务 id 提示文本,均改为引用 `kscli service list` 作为服务发现手段
- 移除相关测试内容,包括对 kb_service_list 注册和服务查询测试
- 保持其他功能和接口不变,确保兼容和功能完整性
2026-08-17 15:07:38 +08:00
zeyu.fz 8a09881626 refactor(tool-bailian-kb): 重构默认服务配置管理及设置页同步逻辑
- 替换默认服务配置字段为 defaultRetrieveAgentId 和 defaultChatAgentId,支持分别管理检索和对话服务
- 增加 Bailian knowledge base 插件设置命名空间,配置支持用户层叠覆盖和实时生效
- 统一凭证与设置配置的回退链,新增一阶段设置用户层优先读取字段
- 实现凭证值向设置文档的单次迁移,辅助设置页首次展示历史值
- Bailian 页面控制器支持基于设置作用域的双域同步,回显设置字段且支持删除操作
- 工具代码切换为从设置作用域异步解析默认服务 id,agent_id 在运行时均为可选但调用时必填
- README 与文档更新,包含配置项扩展、回退链说明以及 UI 配置页操作说明
- 底层依赖由 `dsh-client-ui-settings-plugins` 切换为 `dsh-client-ui-settings`
- 修正 patch 配置与 API 参数命名,完善错误提示和超时配置读取
2026-08-17 13:52:05 +08:00
zeyu.fz 589e3a4f14 chore(deps): 更新 TypeScript 类型检查脚本及依赖锁定文件
- 修改 package.json 中 typecheck 脚本,新增对 tsconfig.web.json 的检查
- 更新 pnpm-lock.yaml 文件,增加多个依赖项和绑定包的版本信息及平台支持
- 新增 react、lightningcss 等多种平台及架构的预编译绑定库
- 添加多种类型定义依赖,提升类型覆盖范围
- 升级部分工具包及插件版本,优化构建和开发体验
2026-08-15 22:44:31 +08:00
zeyu.fz 50b59f8640 refactor(bundle): 重命名分发包为 bailian-kb-dsh
- 将分发包名称从 bailian-kb-bundle 改为 bailian-kb-dsh
- 同步更新 README.md 中的包名和卸载命令描述
- 更新 package.json 中的 name 字段
- 修改 cordis.patch.yml 中的注释以匹配新名称
- 确保所有文档和配置引用一致性
2026-08-15 19:36:00 +08:00
zeyu.fz 73233658bf docs: migrate design spec and implementation plan into this repo 2026-08-15 19:08:35 +08:00
zeyu.fz 188090af39 docs: widen error-hint contract note to all non-auth HTTP failures 2026-08-15 18:57:08 +08:00
zeyu.fz a9ac7c0bea docs: record link-install dev workaround for profile integration 2026-08-15 18:52:33 +08:00
zeyu.fz acccca9e2f docs: package and repository READMEs 2026-08-15 18:47:43 +08:00
zeyu.fz a6ee267071 feat: installable dsh bundle package 2026-08-15 18:46:32 +08:00
zeyu.fz b4129c6cbd feat: plugin apply wiring with credential-backed client and skill registration 2026-08-15 18:43:12 +08:00
zeyu.fz f2a3775516 feat: kb_service_list, kb_search, kb_chat tool factory 2026-08-15 18:41:46 +08:00
zeyu.fz 5800222ea4 feat: service discovery with scene merge and internalized pagination 2026-08-15 18:37:34 +08:00
zeyu.fz 95708ccf32 feat: SSE parser and buffered chat consumption 2026-08-15 18:36:43 +08:00
zeyu.fz cdc1f10882 feat: KbClient with per-call auth and error translation 2026-08-15 18:31:15 +08:00
zeyu.fz bbe96d35a5 feat: endpoint builder and API types 2026-08-15 18:30:06 +08:00
zeyu.fz fba98bf65e feat: plugin package skeleton with validated Config 2026-08-15 18:28:48 +08:00
zeyu.fz 3aedb7b7fb chore: scaffold pnpm workspace 2026-08-15 18:25:13 +08:00
389 changed files with 34655 additions and 4475 deletions
+50 -3
View File
@@ -10,6 +10,7 @@ on:
options:
- bailian-cli
- knowledge-studio-cli
- bailian-kb-dsh
mode:
description: "Publish mode"
required: true
@@ -18,7 +19,7 @@ on:
- channel
- stable
channel:
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only."
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli and bailian-kb-dsh are npm-only."
required: false
type: string
@@ -28,7 +29,7 @@ concurrency:
jobs:
publish-stable:
if: inputs.mode == 'stable'
if: inputs.mode == 'stable' && inputs.package != 'bailian-kb-dsh'
name: publish stable (${{ inputs.package }}) to npm + binary + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
@@ -83,7 +84,7 @@ jobs:
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
publish-channel:
if: inputs.mode == 'channel'
if: inputs.mode == 'channel' && inputs.package != 'bailian-kb-dsh'
name: publish channel (${{ inputs.package }}) to npm + binary
runs-on: ubuntu-latest
permissions:
@@ -138,3 +139,49 @@ jobs:
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
# bailian-kb-dsh is the dsh plugin (downstream host adapter): independent version,
# tsc + tsdown build, npm-only. It shares this workflow's entry UI and setup steps
# but NOT publish-stable.mjs / publish-channel.mjs — those broadcast one version
# across the locked bl package set and produce binary artifacts, neither of which
# applies here. See docs/agents/dsh-plugin.md.
publish-kb-dsh:
if: inputs.package == 'bailian-kb-dsh'
name: publish ${{ inputs.mode }} (bailian-kb-dsh) to npm
runs-on: ubuntu-latest
# stable goes through the Required Reviewers gate, same as the bl stable job;
# channel stays ungated so dist-tag drops need no approval.
environment: ${{ inputs.mode == 'stable' && 'production' || '' }}
permissions:
contents: write # push the bailian-kb-dsh-v<version> tag (stable only)
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- name: Require channel input
if: ${{ inputs.mode == 'channel' && inputs.channel == '' }}
run: |
echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin). Leave mode=stable if you do not need a dist-tag."
exit 1
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
registry-url: "https://registry.npmjs.org/"
- name: Install gitleaks
run: |
set -euo pipefail
GITLEAKS_VERSION=8.21.2
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version
- run: pnpm install --frozen-lockfile
- name: publish-kb-dsh
run: node tools/release/publish-kb-dsh.mjs ${{ inputs.mode == 'channel' && format('--channel "{0}"', inputs.channel) || '' }}
+1
View File
@@ -14,6 +14,7 @@ dist-bin
dist-ssr
tools/generated
.node-version
*.tsbuildinfo
*.local
+24 -19
View File
@@ -11,6 +11,7 @@ monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"
- `packages/commands``bailian-cli-commands`,可复用命令实现库,只导出 command,不决定产品路径
- `packages/cli``bailian-cli`,完整 `bl` 产品入口;`src/commands.ts` 组装 `bl` 暴露的命令路径
- `packages/kscli``knowledge-studio-cli`,Knowledge Studio 专用入口;`src/main.ts` 复用 commands 并重映射为 `kscli` 路径
- `packages/bailian-kb-dsh``bailian-kb-dsh`,**下游宿主适配层**(依赖方向朝外):百炼知识库的 DeepSeek Harness (dsh) 插件,消费 `bl` CLI 与知识库 API,不在上面这条分层链上;版本、构建、发布都独立,见 [docs/agents/dsh-plugin.md](docs/agents/dsh-plugin.md)
### 关键文件
@@ -56,25 +57,26 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐
按当前任务从下表挑一条进入对应文档:
| 场景 | 何时进入 | 详见 |
| ----------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
| 场景 | 何时进入 | 详见 |
| ----------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
| dsh 插件 | 改 `packages/bailian-kb-dsh`、dsh 依赖、插件发布 | [docs/agents/dsh-plugin.md](docs/agents/dsh-plugin.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
@@ -84,12 +86,15 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐
源码包的 `version` 当前保持一致: `packages/core``packages/runtime``packages/commands``packages/cli``packages/kscli`。做版本 bump 时一动多动。release 工具当前强校验 / 发布范围以 `tools/release/lib/packages.mjs` 为准;把新包纳入发布前必须同步该清单和 [publish.md](docs/agents/publish.md)。
**例外**: `packages/bailian-kb-dsh` 不参与这个锁步(独立 `0.1.x`,跟随 dsh rc 节奏),也不在 release 白名单里;它走 `publish.yml``package=bailian-kb-dsh` 的独立 job(`tools/release/publish-kb-dsh.mjs`)。
### 2. 分层边界
- `core` 是纯库:不依赖 `runtime` / `commands` / 产品入口;不调 `process.exit`;新增/改动时不硬编码 `bl` / `kscli` 命令名、控制台 URL 或渠道追踪参数。当前遗留项见 [error-hint-change.md](docs/agents/error-hint-change.md) 与 [url-change.md](docs/agents/url-change.md),触碰相关代码时顺手收敛
- `runtime` 是通用 CLI 框架:可以处理 TTY、help、错误输出、middleware,但不写具体业务命令逻辑
- `commands` 是命令实现库:不决定产品路径;不在 `usageArgs` / `exampleArgs` / hint 里硬编码产品 bin 前缀
- `cli` / `kscli` 是产品层:负责命令路径 map、产品 identity、README、技能 reference、发版入口
- `bailian-kb-dsh` 在这条链之外:它是别的宿主(dsh)里的插件,只允许依赖 `core`(且当前刻意零依赖),反过来 `core` / `runtime` / `commands` / 产品层**永远不许**依赖它
- URL 集中在 `packages/runtime/src/urls.ts`(用户面控制台)和 `packages/core/src/config/schema.ts` / client 层(API)
### 3. 错误处理边界:CLI 不翻译服务端错误
+101
View File
@@ -6,6 +6,107 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.23.0] - 2026-09-10
### Added
- **Profile-level watermark control** — configure `watermark` with `bl config set --key watermark --value true|false` to control the default watermark behavior for image generation and editing, video generation and editing, and reference-to-video commands.
- **ASR accuracy controls** — `bl speech recognize` now supports instant hot words with `--vocabulary`, contextual word enhancement with `--context`, and reusable pre-built vocabularies with `--vocabulary-id` for supported ASR models.
- **Speech vocabulary management** — added `bl speech vocabulary create|list|get|update|delete` to manage reusable pre-built hot-word vocabularies.
## [1.22.0] - 2026-09-08
### Changed
- **Project initialization** — `managed-agent project init` now creates `./managed-agent` by default. Use `--project .` to initialize in place. **(BREAKING)**
- **Build confirmation** — `managed-agent project build` no longer requires confirmation and rejects `--yes`. Use `--dry-run` for a read-only preview; Publish still requires confirmation. **(BREAKING)**
- **Managed Agent SDK** — upgrade to `0.7.1`. Build automatically associates active Agent-local resources while preserving explicit bindings, Skill versions, and File mount paths. Ambiguous Environment or Vault selections are rejected before writing.
### Fixed
- **Project diagnostics** — provide actionable project-root guidance and surface the underlying Build validation error.
- **YAML initialization paths** — show the absolute YAML path in creation messages and existing-file errors.
### Internal
- Expand project initialization and Build regression coverage, and remove the obsolete Build confirmation flag from the local lifecycle E2E test.
## [1.21.0] - 2026-09-07
### Added
- **Managed Agent directory projects** — initialize, validate, build, and publish local projects with Agent-scoped resource configuration and resource examples.
- **Local project versions** — enable or disable snapshot versioning, list and preview history, and restore project files without Git.
- **Project Workbench** — edit resources, review changes, publish updates, and manage local versions in a browser.
### Changed
- **Playground version resolution** — check npm before launch, reuse matching local versions, and fetch the latest version when needed; retain explicit version and binary overrides.
- **Managed Agent SDK** — upgrade to `0.7.0` and consume project workspace and version services through SDK subpath exports.
## [1.20.0] - 2026-09-03
> Managed Agents now combines YAML-first infrastructure management with direct Bailian AgentStudio resource and runtime operations.
### Added
- **Managed Agent API commands** — added direct list, get, search, version, upload, download, run, pause, archive, event, and diagnostic operations for Agents, Environments, Skills, Vaults, Deployments, Sessions, and Files.
- **Scoped YAML-backed resource creation** — `agent create`, `environment create`, `skill create`, `vault create`, `vault credential create`, and `deployment create` update `agents.yaml` and apply only the target resource without unrelated drift blocking the operation.
- **Agent Skill attachment** — Agent creation supports existing custom or official Skill IDs as well as local Skill directories and ZIP archives.
### Changed
- **Bailian-only Managed Agent CLI** — `bl managed-agent` now targets the Bailian provider exclusively; provider-selection flags were removed and configurations containing other providers are rejected.
- **Runtime mutation confirmation** — Deployment run/pause/unpause, Session archive/delete, and File delete operations require explicit high-risk confirmation.
### Fixed
- **Managed Agent error reporting** — Apply and scoped-create failures preserve the underlying provider diagnostic instead of ending with only `Apply failed.`.
### Security
- Credentials are resolved in memory and removed from the process environment; Vault credential declarations reference environment variables without persisting plaintext secrets.
## [1.19.0] - 2026-09-01
### Added
- **`bl quota delete`** — clears all custom QPM/TPM rate limits for a model.
### Changed
- **High-risk operation confirmation** — high-risk commands show risk details in `--help` and Skill command references. Without `--yes`, the high-risk operation is not executed; JSON output returns exit code `7` with `error.type: "requires_confirmation"`. After confirmation, re-run with `--yes`; `--dry-run` does not require confirmation.
## [1.18.2] - 2026-09-01
### Changed
- **Confirmation before deleting or clearing resources** — `bl finetune delete`, `bl deploy delete`, `bl dataset delete`, and `bl quota update --delete` now ask for confirmation; pass `--yes` for non-interactive use.
### Fixed
- **Skill installation reliability** — `bl skill init` now retries transient network failures, and completed Skill updates are no longer reported as failed when backup cleanup is blocked.
## [1.18.1] - 2026-08-28
### Removed
- Removed API Key validation from `bl auth login`.
## [1.18.0] - 2026-08-27
### Added
- **Automatic Profile API Key fallback** — unsupported commands use the API Key and endpoint from `default` without requiring a Profile switch; explicit credentials still take priority.
- **Independent speech defaults** — Profiles support separate default models for TTS and ASR.
- **Wan3.0 file-to-video** — `bl video generate --file` accepts local files or URLs and validates incompatible inputs.
### Changed
- **Wan3.0 is now the default video model** — regular API Key text, image, and reference video tasks default to `wan3.0-video`, with first/last-frame and reference-audio support. Token Plan defaults remain unchanged.
- **Expanded Token Plan preset** — login adds speech defaults and missing capabilities while preserving existing user configuration.
- **Improved speech and Skill installation experience** — streaming synthesis defaults to PCM; installation docs add a Node 18-compatible fallback and clarify the Node.js and Git prerequisites.
## [1.17.1] - 2026-08-22
### Fixed
+101
View File
@@ -6,6 +6,107 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.23.0] - 2026-09-10
### 新增
- **Profile 级水印控制** —— 可通过 `bl config set --key watermark --value true|false` 设置图片生成与编辑、视频生成与编辑以及参考生视频命令的默认水印行为。
- **ASR 准确率增强** —— `bl speech recognize` 现支持通过 `--vocabulary` 传入即时热词、通过 `--context` 增强上下文词表,以及通过 `--vocabulary-id` 使用适用于对应 ASR 模型的预编译热词表。
- **语音热词表管理** —— 新增 `bl speech vocabulary create|list|get|update|delete`,用于管理可复用的预编译热词表。
## [1.22.0] - 2026-09-08
### 变更
- **项目初始化** —— `managed-agent project init` 默认创建 `./managed-agent` 子目录;如需原地初始化,请使用 `--project .`。**(BREAKING)**
- **Build 确认机制** —— `managed-agent project build` 无需确认,并且不再接受 `--yes`。使用 `--dry-run` 可只读预览Publish 仍需显式确认。**(BREAKING)**
- **Managed Agent SDK** —— 升级至 `0.7.1`。Build 自动关联 Agent 目录下已启用的资源保留显式引用、Skill 版本和 File 挂载路径Environment 或 Vault 选择存在歧义时,在写入前报错。
### 修复
- **项目诊断** —— 提供可操作的项目根目录提示,并展示 Build 校验失败的具体原因。
- **YAML 初始化路径** —— 创建成功及文件已存在的错误信息均展示 YAML 绝对路径。
### 内部
- 补充项目初始化和 Build 回归覆盖,移除本地闭环 E2E 测试中过时的 Build 确认参数。
## [1.21.0] - 2026-09-07
### 新增
- **Managed Agent 目录项目** —— 支持初始化、校验、构建和发布本地项目,按 Agent 组织资源配置,并提供资源示例。
- **本地项目版本管理** —— 无需 Git 即可启停快照版本管理、查看和预览历史,以及恢复项目文件。
- **项目 Workbench** —— 在浏览器中编辑资源、审阅变更、发布更新和管理本地版本。
### 变更
- **Playground 版本选择** —— 启动前查询 npm复用版本一致的本地安装否则按需下载最新版保留显式版本和启动文件覆盖。
- **Managed Agent SDK** —— 升级至 `0.7.0`,通过 SDK 子路径复用项目目录和版本服务。
## [1.20.0] - 2026-09-03
> Managed Agent 现在同时提供 YAML-first 基础设施管理与百炼 AgentStudio 资源、运行时 API 操作。
### 新增
- **Managed Agent API 命令** —— 新增 Agent、Environment、Skill、Vault、Deployment、Session 和 File 的列表、详情、搜索、版本、上传、下载、运行、暂停、归档、事件及诊断等操作。
- **基于 YAML 的单资源创建** —— `agent create``environment create``skill create``vault create``vault credential create``deployment create` 会更新 `agents.yaml`,并且只 Apply 目标资源,不受无关资源 Drift 阻塞。
- **Agent Skill 挂载** —— 创建 Agent 时支持引用已有的自定义或官方 Skill ID也支持本地 Skill 目录和 ZIP 文件。
### 变更
- **Managed Agent CLI 限定为百炼 Provider** —— `bl managed-agent` 现在只面向百炼,移除 Provider 选择参数,并拒绝包含其他 Provider 的配置。
- **运行时变更增加确认** —— Deployment 运行/暂停/恢复、Session 归档/删除以及 File 删除操作需要显式进行高风险确认。
### 修复
- **Managed Agent 错误输出** —— Apply 和单资源创建失败时会保留底层 Provider 的具体诊断,不再只显示 `Apply failed.`
### 安全
- 凭证仅在内存中解析并从进程环境清除Vault Credential 声明通过环境变量引用 Secret不会持久化明文。
## [1.19.0] - 2026-09-01
### 新增
- **`bl quota delete`** — 清除指定模型的全部自定义 QPM/TPM 限流配置。
### 变更
- **高风险操作确认** — 高风险命令会在 `--help` 和 Skill 命令参考中展示风险说明。未传入 `--yes`高风险操作不会执行JSON 输出会返回退出码 `7``error.type: "requires_confirmation"`。确认后可添加 `--yes` 重新执行;`--dry-run` 无需确认。
## [1.18.2] - 2026-09-01
### 变更
- **删除与清除操作增加确认** —— `bl finetune delete``bl deploy delete``bl dataset delete``bl quota update --delete` 现在会在执行前要求确认;非交互场景请传入 `--yes`
### 修复
- **Skill 安装可靠性** —— `bl skill init` 现在会重试临时性网络故障;备份清理受阻时,已完成的 Skill 更新不再被误报为失败。
## [1.18.1] - 2026-08-28
### 已移除
- 移除 `bl auth login` 的 API Key 校验。
## [1.18.0] - 2026-08-27
### 新增
- **Profile API Key 自动回退** —— 当前 Profile 不支持某条命令时,自动使用 `default` 中的 API Key 和接入地址,无需手动切换 Profile显式凭证仍然优先。
- **语音合成与识别支持独立默认模型** —— Profile 支持分别配置 TTS 和 ASR 默认模型。
- **Wan3.0 文件生视频** —— `bl video generate --file` 支持本地文件或 URL并校验互斥输入。
### 变更
- **视频默认模型升级至 Wan3.0** —— 普通 API Key 的文生、图生和参考生视频默认使用 `wan3.0-video`并支持首尾帧与参考音频Token Plan 默认模型保持不变。
- **扩展 Token Plan 预设** —— 登录后自动补充语音默认模型和缺失能力,同时保留用户已有配置。
- **优化语音与 Skill 安装体验** —— 流式语音合成默认输出 PCMSkill 安装文档补充兼容 Node 18 的降级方式及 Node.js、Git 前置条件。
## [1.17.1] - 2026-08-22
### 修复
+11 -5
View File
@@ -16,11 +16,17 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
```
bailian-cli/
├── packages/
│ ├── cli/ # `bailian-cli` — CLI entry, commands, UI
── core/ # `bailian-cli-core` — auth, HTTP, types
├── docs/agents/ # Scenario-based maintenance guides
├── tools/ # Release automation & reference generation
├── AGENTS.md # Contract for AI agents
│ ├── core/ # `bailian-cli-core` — auth, config, HTTP client, errors, types
── runtime/ # `bailian-cli-runtime` — CLI framework: parsing, help, middleware, output
│ ├── commands/ # `bailian-cli-commands` — reusable command implementations
│ ├── cli/ # `bailian-cli` — the full `bl` product entry
│ ├── kscli/ # `knowledge-studio-cli` — `kscli` entry, reuses commands/
│ ├── e2e/ # Shared e2e harness utilities (private)
│ └── bailian-kb-dsh/ # `bailian-kb-dsh` — DeepSeek Harness plugin (independent version & release)
├── skills/ # Agent skills installed by `bl skill init`
├── docs/agents/ # Scenario-based maintenance guides
├── tools/ # Release automation & reference generation
├── AGENTS.md # Contract for AI agents
└── README.md
```
+11 -5
View File
@@ -16,11 +16,17 @@
```
bailian-cli/
├── packages/
│ ├── cli/ # `bailian-cli` —— CLI 入口、命令、UI
── core/ # `bailian-cli-core` —— 鉴权、HTTP、类型
├── docs/agents/ # 场景化维护文档
├── tools/ # 发版自动化与命令手册生成
├── AGENTS.md # AI agent 维护契约
│ ├── core/ # `bailian-cli-core` —— 鉴权、配置、HTTP client、错误、类型
── runtime/ # `bailian-cli-runtime` —— CLI 运行时:参数解析、help、middleware、输出
│ ├── commands/ # `bailian-cli-commands` —— 可复用命令实现库
│ ├── cli/ # `bailian-cli` —— 完整 `bl` 产品入口
│ ├── kscli/ # `knowledge-studio-cli` —— `kscli` 入口,复用 commands/
│ ├── e2e/ # e2e 共享工具(不发布)
│ └── bailian-kb-dsh/ # `bailian-kb-dsh` —— DeepSeek Harness 插件(版本与发布独立)
├── skills/ # `bl skill init` 安装的 Agent skill
├── docs/agents/ # 场景化维护文档
├── tools/ # 发版自动化与命令手册生成
├── AGENTS.md # AI agent 维护契约
└── README.md
```
+48 -16
View File
@@ -13,22 +13,25 @@ npm install -g bailian-cli
```
4. 校验:`bl --version`
5. 按下方「安装 skills」安装。
安装 skillsCLI 内置,无需 Git / npx skills
### 安装 skills
**Supported优先** 用 CLI 内置命令一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`
```bash
bl skill init
```
**Supported** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。
**Advanced / 按需子集:**
**降级** `bl skill init` 失败,先确认本机同时满足两个条件:`node -v` / `npm -v` 成功,**且** `git --version` 成功。两个条件都成立时,再回退到:
```bash
bl skill add --name bailian-protocol,bailian-gen
npx skills@1.5.18 add modelstudioai/cli --all -g
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
> 必须使用 `skills@1.5.18`:这是最后兼容 Node ≥18 的版本(与 Bailian CLI 的 Node ≥18.17.0 一致);
安装成功后,按第 5 节向用户说明语言切换与能力 tip若也装了 skills一并简要说明
## 2. 备选:二进制安装(无需 Node
@@ -67,8 +70,6 @@ bl --version
which bl # Windows: where.exe bl
```
若自动 skill 安装失败,再手动执行:`bl skill init`
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node回退到上方 npm 安装。
---
@@ -99,12 +100,43 @@ which bl # Windows: where.exe bl
bl auth status --output json
```
## 5. 常见问题
## 5. 安装成功后向用户说明(必做)
| 现象 | 可能原因 | 建议动作 |
| ------------------------ | ---------------------------- | ------------------------------------------------ |
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin``npm prefix -g` |
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node或改用 npm 版 CLI |
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
安装与校验完成后,**主动**用中文向用户说明下列两项;不要等用户追问。可执行 `bl`(已登录时)对照终端 tip但以下列表为准勿臆造。
### 5.1 界面语言
Bailian CLI 支持中英文界面help / 欢迎语 / Quick Start 主次文案)。默认 `en-US`,可切换为 `zh-CN`
```bash
bl config set --key language --value zh-CN
bl config set --key language --value en-US
```
向用户说明:可随时用上述命令切换语言。
### 5.2 能力 tipQuick Start
向用户展示「试试使用Bailian CLI完成这些任务」及下列 5 条(中英各一行,与 `bl` 根帮助 tip 一致):
1. 帮我创建一个能够生成短片分镜和视频的 Managed Agent。
Help me create a Managed Agent that can generate short-film storyboards and videos.
2. 生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。
Generate an image of a cat in a spacesuit standing on Mars, then turn it into a video.
3. 查看最近的模型用量、免费额度和限流情况。
Check my recent model usage, free quota, and rate limits.
4. 推荐一个适合图片理解和智能客服的模型。
Recommend a model suitable for image understanding and intelligent customer service.
5. 介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。
Explain what Bailian CLI can help me accomplish, and recommend how to use it based on my needs.
## 6. 常见问题
| 现象 | 可能原因 | 建议动作 |
| ------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------- |
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin``npm prefix -g` |
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node或改用 npm 版 CLI |
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
| `bl skill init` 失败 | 网络 / registry 不可达等 | 有 Node 且有 Git 时降级 `npx skills@1.5.18 add modelstudioai/cli --all -g`;否则重试或告知用户 |
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+14 -8
View File
@@ -33,7 +33,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
`~/.bailian/config.json` 可同时保存 `api_key``access_token``access_key_*`。登录任一种方式不得删除另一种:
- `bl auth login --api-key ...` 更新 `api_key` / `base_url`
- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址并把该预设的默认模型物化写入。API Key 落盘成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi``console` 域凭证
- `bl auth logout --console` 只清 `access_token`
@@ -44,8 +44,12 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `resolveApiKey()``auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin仅保留协议、host 和显式端口,去除 path、query、fragment
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级;内置套餐 Profile当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile未传
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`:
- `--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值
- 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile
- 字段缺失时不启用降级,包括命中内置套餐预设的 Profilepreset 只在 API Key 登录落盘成功后物化写入,升级 preset 需要重新登录
- fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败warning 与多行 `error` 对象以空行分隔stdout 仍只保留命令结果
- 显式 `auth login --config <name>` 在凭证落盘成功后自动激活目标 Profile未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
- `resolveConsole()``auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
- `resolveOpenApi()``auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
@@ -58,17 +62,19 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
`bl managed-agent *` 按调用链分两层:
- **离线命令** — `init``validate``state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
- **联网命令** — `plan``apply``destroy``state import``skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁authStage `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run``credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state不查 provider key其中 `--dry-run` 连登录也不要求authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
- **联网命令** — `plan``apply``destroy``state import``skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁authStage 经 `resolveApiKey(sources)` 解析 Bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层再断言 Bailian key 非空。例外:`plan --no-refresh` / `plan --dry-run``credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state不查 provider key其中 `--dry-run` 连登录也不要求authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
`bl managed-agent` 是 Bailian-only 产品入口:命令不暴露 `--provider``init` 只生成 `providers.bailian`,所有远端调用固定传 `provider: "bailian"``resolveAgentProjectConfig` 在创建 SDK runtime 前通过 `assertBailianOnlyProviders` 拒绝包含非 Bailian Provider 的手写配置;共享 `@openagentpack/sdk` 仍可保留多 Provider 能力。
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK管道五步:
1. `prepareProviderEnv()``bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
2. `resolveProjectConfig`插值发生:bailian 插值拿到占位空串claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
1. `prepareProviderEnv()`调用 SDK 的凭证 bootstrap,再把凭证类 env(`CREDENTIAL_ENV_KEYS`,含兼容别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值阶段能够完成并由 CLI 输出明确的 Bailian-only 配置错误
2. `resolveProjectConfig`完成插值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`避免空 key 在 SDK zod 层提前报 "received null"
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
5. `assertBailianOnlyProviders(providers)` — 拒绝非 Bailian Provider随后 `assertProviderCredentials(providers)` 在 Bailian `api_key` 为空时给出 CLI 权威 `AUTH` 错误和登录 hint离线命令传 `credentials: "none"` 跳过 key 断言,但仍执行 Bailian-only 配置校验
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
禁止命令层直接 `readConfigFile` 裸读凭证Bailian 字段以 CLI 鉴权链为唯一信源。SDK bootstrap 期间读取到的兼容凭证变量也会在配置解析后统一清扫。
## 必查清单
+32 -18
View File
@@ -2,16 +2,16 @@
## 架构分层
| 层级 | 路径 | 测什么 |
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参dry-runlivegated每用例最小路由 |
| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated`journeys/README.md` |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help根 help |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | `kscli/src/commands.ts` 推导 path/分组identity`--version``search --help` path |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
| 层级 | 路径 | 测什么 |
| --------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、registry help 捕获、output、globalSetup`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | 进程内 help、子进程缺参/dry-run/livegated每用例最小路由 |
| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated`journeys/README.md` |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path/分组的进程内 help根 help、鉴权域等代表性子进程冒烟 |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | map 全部 path/分组的进程内 help`--version``search --help` 等代表性子进程冒烟 |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
**依赖边界**`e2e``core``commands/tests``e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
**依赖边界**`e2e``core``commands/tests``e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src` + `runtime` 公共 API。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
## 触发条件
@@ -29,7 +29,8 @@
### commands E2E
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里)
- 子进程`runCommandE2e(routes, args)` from `./helpers.ts`spawn `harness/main.ts``routes` 为本 topic 最小 path → export 映射
- help`runCommandHelp(routes, [...path, "--help"])` from `./helpers.ts`当前 Vitest worker 内用真实 command + `CommandRegistry` 渲染,不启动子进程
- 子进程缺参、dry-run、live 使用 `runCommandE2e(routes, args)`spawn `harness/main.ts``routes` 为本 topic 最小 path → export 映射)
- fixtures`packages/commands/tests/e2e/fixtures/`
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map
@@ -37,10 +38,12 @@
- bl`runCli` from `packages/cli/tests/e2e/helpers.ts`
- kscli`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
- 全量 leaf/group help 使用产品 `commands` 创建 `CommandRegistry`,先通过 `resolve([...path, "--help"])` 检查 help 路由,再用 `captureRegistryHelp` 检查完整 Usage禁止在 `test.each(commandPaths/groupPaths)` 中逐条启动 `tsx` 子进程
- 真实子进程只保留根 help/version、产品身份、代表性叶子 help/鉴权域和缺参退出码等 shell/stdio/env 契约
### 共享
- gating / output / runner`e2e/gating``e2e/output``e2e/runner`
- gating / output / runner`e2e/gating``e2e/output``e2e/runner`runner 使用 `node --import tsx` 执行 TypeScript 入口,不启动 tsx CLI IPC server
- globalSetup`vite.config.ts``packages/e2e/src/global-setup.ts`
- 解析 JSON stdout`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
@@ -48,9 +51,12 @@
## 双层 describe固定结构
```ts
// 1) 不 skip--help无密钥、无真实 API分组 help 由 bl registry.smoke 覆盖)
// 1) 不 skip进程内 --help无密钥、无真实 API分组 help 由 bl registry.smoke 覆盖)
describe("e2e: <topic>", () => {
test("<subcommand> --help 正常退出", ...);
test("<subcommand> --help 正常退出", async () => {
const result = await runCommandHelp(FOO_ROUTES, ["foo", "bar", "--help"]);
expect(result.exitCode, result.stderr).toBe(0);
});
});
// 2) skipIf缺参 / dry-run / 真实集成
@@ -65,20 +71,28 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
| 场景 | 条件 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 文本/搜索/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| OpenAPI AK/SK | `isOpenApiE2EReady()``.env` 中必须同时提供完整 AK/SK |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()``knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID |
| 记忆 live | `isMemoryE2EReady()`(另需 `BAILIAN_E2E_MEMORY_LIBRARY_ID`;记忆服务需账号单独开通) |
## 用例类型
1. **--help**`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags
1. **--help**`runCommandHelp(ROUTES, [..., "--help"])` → stderr 含主要 flags;产品层另保留少量真实子进程 help 验证 shell/stdio/env
2. **缺参**:带无害全局 flag`--quiet`)且不传 required flag → `exitCode === 2`
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
4. **真实集成**:放在 skip 块**末尾**
高风险命令额外要求:
- `--help` 展示 runtime 注入的 `--yes`
-`--yes` 返回 exit code 7 和 JSON `type: "requires_confirmation"`
- `--dry-run` 无需 `--yes`,且必须证明在任何远端请求或本地写入之前返回
- runtime 的离线 high-risk fixture 必须覆盖带 `--yes` 确实进入 `run()`,并断言 `yes` 不进入 command 自有 flags
## Journey 层(用户旅程全链路)
- **定位**:命令 E2E 验单命令契约journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复
@@ -94,8 +108,8 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
## 安全与例外
- **禁止破坏真实用户配置**`auth logout` 默认只用 `--dry-run`需要验证实际落盘时,必须通过
`BAILIAN_CONFIG_DIR` 指向隔离 fixture`config set` 只用 `--dry-run`
- **禁止破坏真实用户配置**`auth logout``config set` 默认只用 `--dry-run`只有验证持久化契约时,才允许通过
`BAILIAN_CONFIG_DIR` 指向每个用例独占的临时目录实际落盘,并必须在 `finally` 中清理;禁止写入或复用真实 `~/.bailian`
- **不加 dry-run**`dryRun``resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit``speech recognize``--url`
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag缺参用例勿带该 flag
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
@@ -105,7 +119,7 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新各 `skills/<skill>/reference/` 并提交
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
- [ ] 子命令 `--help` 使用 `runCommandHelp`(分组 help 由 bl `registry.smoke` 覆盖)
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke不破坏已有集成用例顺序
- [ ] `vp test packages/commands/tests/e2e/<file>` 通过
+2
View File
@@ -71,7 +71,9 @@ packages/commands/src/index.ts
- `usageArgs`(不含 bin/path 前缀)
- `exampleArgs`(不含 bin/path 前缀)
- `validate`(跨 flag 校验)
- 高风险命令必须声明 `risk: { level: "high", message: <双语文案> }`;`--yes` 由 runtime 注入,命令不得自行声明
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
- 声明 `risk``run(ctx)` 必须在任何远端请求或本地写入之前处理 `ctx.settings.dryRun` 并返回预览;runtime 只负责确认闸门,不替命令实现 dry-run
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
- [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description``notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译
+1
View File
@@ -19,6 +19,7 @@
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
- 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。
### B. 鉴权 / 全局选项
+3
View File
@@ -32,6 +32,8 @@
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`
- [ ] API Key capability ID 由 host 按实际叶子命令路径生成(例如 `agent credential``agent.credential`Command Pack 不声明额外元数据Profile allowlist 对 pack 命令同样 fail closed。
- [ ] `ctx.credentials.apiKey()` 必须返回 `authStage` 已注入 `ctx.client` 的实际凭证,不能重新读取原 Profile 绕过 capability fallback。
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore``configStore`
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager避免 pack 安装或删除其他 pack。
- [ ] 单包失败必须 fail-open保留内置命令和其他合法 pack。
@@ -40,6 +42,7 @@
## 测试与文档
- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
- [ ] Command Pack 测试覆盖 capability 格式/鉴权域、adapter 保留字段,以及 raw API Key 委托使用 fallback 后的实际 Base URL。
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
+4
View File
@@ -29,6 +29,7 @@ config 文件 ─┘
- `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件)
- `parseConfigFile()` 解析新字段
- 如果是 enum 字段,加校验
- 如果是数组字段,明确“缺失 / 空数组 / 非法值”的不同语义;安全策略字段的非法值必须 fail closed
### B. 加载逻辑
@@ -51,6 +52,7 @@ config 文件 ─┘
- [ ] 配置展示 / 修改命令同步:
- `packages/commands/src/commands/config/show.ts` 显示新字段
- `packages/commands/src/commands/config/set.ts``VALID_KEYS` / `KEY_ALIASES` / description 允许 set
- `packages/commands/src/commands/config/ui.ts` / `ui-html.ts` 能按原类型往返数组字段,不能把 `[]` 保存成字段缺失
### E. 文档
@@ -61,6 +63,7 @@ config 文件 ─┘
- [ ] 单测覆盖优先级:flag > env > file
- [ ] 校验失败抛错(非法值)
- [ ] 默认值正确
- [ ] 数组配置覆盖 CLI 逗号/JSON 输入、Config UI 往返、去重和显式空数组
## 完成后自查
@@ -82,3 +85,4 @@ cat ~/.bailian/config.json
- ✗ 全局 switch 没标 `type: "switch"`,被当成需要值的 `--xxx <value>`
- ✗ 加了 env var 但 README 表格没更新,用户不知道有这条
-`config show` 不显示新字段,用户改了无法回查
- ✗ UI 用 `String([])` 把显式空数组渲染为空串,保存后意外关闭安全策略
+11 -2
View File
@@ -18,15 +18,19 @@
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
`auth login --config ...`,凭证落盘成功后自动激活该 Profile。
- 激活状态只选择配置 block不改变字段优先级字段仍为 flag > env > selected config > 默认值。
- API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。
- Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset升级内置 Plan Profile 的 preset 需要重新登录。
- 对命中内置 preset 的 ProfileAPI Key 登录落盘成功后会把当前 preset 中缺少的 capability 追加落盘同时保留已有项且不做删除Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。
- Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get``video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
## 3. 保持读写命令交互一致
- `auth login``config set` 等写命令未传 `--config` 时修改当前激活项。
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证实际落盘时
创建和激活;`config set --config <name>` 可创建但不自动激活。
- `config show``auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
@@ -55,6 +59,8 @@
- 旧配置无 `active_config` -> `default`
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
- 任意名称 Profile 的叶子路由 capability 命中时使用自身 API Key未命中或空白名单时使用 `default` API Key`--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一覆盖时跳过 fallback。
- 旧内置 Plan Profile 缺 capability 字段时不启用 fallback重新登录后使用并持久化当前 preset显式配置`[]`)按文件值生效。
- 显式命名 `--config``--config default` 均覆盖激活项且不修改磁盘状态。
- 激活不存在的 Profile 失败且不写盘。
- 悬空 `active_config` 明确失败。
@@ -63,6 +69,9 @@
- 显式 `auth login --config <name>` 成功后激活该 Profile失败或 dry-run 不创建、不切换;
`--config default` 成功后切回 `default`
- Console token 自动刷新不从其他 Profile 借用 AK/SK也不把新 token 写入其他 Profile。
- Console/OpenAPI/none 命令不参与 API Key capability fallbackfallback 后的 Client 特殊端点行为必须跟随最终解析的 `base_url`,不能根据原 `settings.configName` 推断端点类型。
- Fallback 反馈只描述 CLI 能权威确认的本地行为:当前 Profile 不支持空格分隔的用户可见叶子命令,本次将从 `default` 读取 API Key 配置;不得声称整个 Profile 已切换,也不得展示 capability ID 或 `<undeclared>` 等内部值。存在 `--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式覆盖时必须跳过 fallback 且不输出反馈。
- Fallback 反馈写 stderr`--quiet` 抑制text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象。dry-run 和后续鉴权失败仍保留反馈JSON 模式下多个 diagnostics 以空行分隔,任何模式都不得输出凭证值。
- `config list/show/use/ui``auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
+125
View File
@@ -0,0 +1,125 @@
# dsh 插件维护packages/bailian-kb-dsh
## 触发条件
-`packages/bailian-kb-dsh` 的工具(`kb_search` / `kb_chat`、服务缓存、settings / 凭据解析
- 改 web 半Settings 配置页 React 组件、CSS Modules
- 升级 `@deepseek-ai/dsh-*` peer 依赖
- 改插件包名、bundle 声明或产物布局
- 发布插件到 npm
## 这个包和其他 packages 不一样的地方
它是**下游宿主适配层**:依赖方向朝外(消费 `bl` CLI 与百炼 API装进 DeepSeek Harness 运行),不是 `core → runtime → commands → 产品入口` 这条链上的一环。由此带来四条与 `packages/*` 通行约定的**故意偏离**
| 项 | 本包 | 其他包 | 原因 |
| -------- | ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 版本 | 独立 `0.1.x` | core/runtime/commands/cli/kscli 锁步 | 跟随 dsh 的 rc 节奏,与 `bl` 发版无关;不在 `tools/release/lib/packages.mjs` 白名单里 |
| 构建 | `tsc` + `tsdown` | `vp pack` | 浏览器半需要 `__ModuleLoader__` banner/footer 与 lightningcss CSS Modules 内联,`vp pack` 产不出 |
| 发布 | `publish.yml``package=bailian-kb-dsh` job`publish-kb-dsh.mjs` | `publish.yml``publish-stable/channel.mjs` | 不在 `bailian-cli` 依赖闭包内,版本与构建都不同,不能与 `bl` 共用同一条 script |
| tsconfig | 三个 | 一个 | 见下 |
## tsconfig 三件套(改动前先读)
| 文件 | 谁在用 | 作用 |
| --------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `tsconfig.json` | oxlint / `vp check` 自动发现 | **纯类型检查**,覆盖 `src` + `tests` 两半:`noEmit` + `jsx: react-jsx` + DOM lib + `allowImportingTsExtensions` |
| `tsconfig.build.json` | `build` script`tsc -b` | **产出** node 半到 `dist/``exclude: src/web` |
| `tsconfig.web.json` | `build` / `typecheck` script | web 半的**隔离检查**`types: []`,确保浏览器代码不误用 node 全局 |
- 不要把 `tsconfig.json` 改成产出配置:`allowImportingTsExtensions` 与 emit 互斥,一改 oxlint 就再也检查不了 `.tsx`(报 TS17004 `--jsx` not set
- web 半的隔离检查挂在 `build` script 里,因为 CI 只跑 `build` / 根 `check` / 根 `test``typecheck` script 没有调用点。
## 必查清单
### A. 包身份(改包名时三处必须一起改)
- [ ] `package.json``name`
- [ ] `cordis.patch.yml``insert[].name`profile 层栈按这个名字解析插件)
- [ ] `tsdown.config.ts``PLUGIN_ID`(进 `window.__ModuleLoader__.load({ id })``<style data-plugin>`
漏任何一处都不会在构建期报错,只会在 dsh 里运行时崩。验证:`grep -rn "<新包名>" package.json cordis.patch.yml tsdown.config.ts` 三处齐全,且 `dist/web/client.js` 首行的 `id` 是新名。
### B. 产物布局
- [ ] 产物落 `dist/`node 半)与 `dist/web/client.js`(浏览器半);根 `.gitignore` 忽略 `dist``*.tsbuildinfo`**不要**改回 `lib/`(那会把产物提交进库)
- [ ] `package.json``main` / `types` / `exports["."]` / `exports["./client"]` / `files` 与实际产物一致
- [ ] tsdown 的 `clean` 保持 `false`:默认 clean 会清掉 `tsc` 刚产出的 node 半
### C. web 半的模块边界
- [ ] 只 import tsdown `CLIENT_EXTERNALS` 名单里的 `@deepseek-ai/*`(宿主 frozen module table 只能应答这些)——构建期由 `dsh-client-bundle-purity` 插件把关
- [ ] 不 import `node:*` 与本仓 CLI 包(`bailian-cli-core` 等)——根 `vite.config.ts``no-restricted-imports` override 在 lint 期把关
- [ ] 跨插件协作走 cordis service不做 value importtype-only import 会被擦除,不受限制)
### D. skill 资产
- [ ] `skills/bailian-kb/` 留在**包内**,不要挪到仓库顶层 `skills/``.github/workflows/publish-skills.yml``skills/**` 全量对账到 OSS registry`bl skill init` 会装给所有 `bl` 用户,而这个 skill 讲的 `kb_search` / `kb_chat` 原生工具只在 dsh 里存在
- [ ] skill 只有一个手写 `SKILL.md`**不带 `reference/`**:它不是 CLI没有义务维护一份 `bl` 参数手册。`bl` 命令的 flag 详情交给 `bl <命令> --help`(权威的 `bl` reference 由 `tools/generate-reference.ts` 写到 `skills/bailian-cli/reference/`与本包无关。SKILL.md 里写到的 `bl` 命令/flag 修改时手动核对 `packages/commands/src/commands/`,不要锚版本号
### E. 文档README 是 npm 主页,双语)
- [ ] 用户可见行为变了(工具参数、配置字段、环境变量、设置页、安装/卸载命令)→ `README.md``README.zh.md` **一起改**;两份互为镜像,头部互链
- [ ] README 只写用户要知道的事;"为什么这么设计"(上下文注入载体、缓存布局、刷新触发点、桥接路由)写进 [../kb-dsh/runtime-behavior.md](../kb-dsh/runtime-behavior.md),不要回流 README
- [ ] 包根保留 `LICENSE`npm 无条件打包),与 `package.json``license` 一致
### F. 依赖与测试约定
- [ ] `@deepseek-ai/dsh-*` 同时列在 `peerDependencies`(运行时由 dsh 安装闭包提供)和 `devDependencies`(本地类型检查)——升级时两处同步
- [ ] 测试从 `vite-plus/test` 导入(仓库统一约定),不要用 `vitest`
- [ ] 忽略的 catch 绑定与 mock 签名参数用 `_` 前缀(根 `vite.config.ts` 已为本包放开 `no-unused-vars` 的对应 pattern
### G. 改完跑
```sh
pnpm --filter bailian-kb-dsh run build # tsc 出 dist/ + web 半隔离检查 + tsdown 出 client.js
pnpm run check # 根 lint + 格式 + 类型
npx vp test packages/bailian-kb-dsh
```
手动集成(改了 bundle 声明 / web 半 / 工具 schema 时必做):
```sh
dsh plugin --profile dev add <本仓库>/packages/bailian-kb-dsh
dsh --profile dev --dump-config # 应能看到 tool-bailian-kb row
```
## 发布
入口与 `bl` 共用Actions → **Publish**`package=bailian-kb-dsh` + `mode=stable|channel`。共享的只有 workflow 入口与 checkout/pnpm/node/gitleaks/install 几步 setup它走自己的 `tools/release/publish-kb-dsh.mjs`**不**复用 `publish-stable.mjs` / `publish-channel.mjs`
| | stable | channel |
| --------- | ------------------------------------------------------------------- | ------------------------------------------------------- |
| 版本 | `package.json` 当前值(先手动 bump 并提交) | 临时 `0.0.0-beta-<sha>-<stamp>``finally` 还原,不提交 |
| npm tag | `latest` | 你传的 `channel`dist-tag |
| preflight | 工作区干净 + 必须在 `main` | 无 |
| git tag | `bailian-kb-dsh-v<version>`(与 `bl``v<version>` 错开命名空间) | 不打 tag |
| 审批 | `environment: production`Required Reviewers | 无 |
| 产物 | npm only | npm only |
两种模式都跑:`build`tsc + web 隔离检查 + tsdown→ 幂等性查重 → `pnpm pack``publint` + `gitleaks``pnpm publish --provenance`
本地验证(不真发,需本地装 gitleaks
```sh
node tools/release/publish-kb-dsh.mjs --dry-run
node tools/release/publish-kb-dsh.mjs --dry-run --channel dsh-beta
```
### 首发前的 npm 侧前置(仓外操作,一次性)
1. 占住包名 `bailian-kb-dsh`OIDC Trusted Publishing 无法给不存在的包首发)
2. 在 npm 包设置里绑 Trusted Publisher仓库 `modelstudioai/cli`、workflow `publish.yml`stable 走 environment `production`channel 无 environment
未完成这两步前触发 workflow 会在 `pnpm publish --provenance` 这一步失败(前面的 build / scan 仍会正常跑完)。
### 已知待办
- `publint` 会报一条 warning`exports["./client"]` 是 CJS 但包为 `type: module`,建议改 `.cjs` 扩展名。这是迁移前就存在的状态warning 不阻断发布;真要改得同时动宿主按 `exports["./client"]` 解析 `/plugins/<id>/client.js` 的 URL 契约,需 dsh 侧一起验证,不要单方面改
## 相关文档
- 设计与实现计划归档:[docs/kb-dsh/](../kb-dsh/)
- 运行时行为与设计取舍(内部):[docs/kb-dsh/runtime-behavior.md](../kb-dsh/runtime-behavior.md)
- 用户面说明:[packages/bailian-kb-dsh/README.md](../../packages/bailian-kb-dsh/README.md) / [README.zh.md](../../packages/bailian-kb-dsh/README.zh.md)
- skill 文案与路由约定:[skill-change.md](skill-change.md)
+1
View File
@@ -46,6 +46,7 @@ runtime/error-handler.ts:
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强)
- TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK
- Node fs errno → 翻成 GENERAL + errno hint
- `--output json` → stderr 输出两空格缩进的多行 error JSON前面已有结构化 warning 时,两个 diagnostics 以空行分隔
- 其它 Error → 默认走 cause 链
process.exit(err.exitCode)
+4
View File
@@ -24,6 +24,10 @@ publish-stable.mjs / publish-channel.mjs ← 唯一发版入口
`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。
### bailian-kb-dsh独立版本、npm-only
同一个 Publish 入口,`package=bailian-kb-dsh`。它走单独的 `tools/release/publish-kb-dsh.mjs`,不复用 `publish-stable.mjs` / `publish-channel.mjs`(版本独立、无 binary、无 OSS CDN。详见 [dsh-plugin.md](dsh-plugin.md#发布)。
两种模式:
| 模式 | 用途 | 触发方式 |
+3
View File
@@ -40,6 +40,8 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **高风险确认**:统一由 `bailian-protocol` 定义reference / leaf help 以 `risk: high` 明示风险,业务 skill 不得引导 Agent 自动补 `--yes`。遇到 exit code 7 / `requires_confirmation` 时停止执行并请求确认;目标或范围变化后重新确认
- [ ] **正常控制流**`requires_confirmation` 不是 CLI bug`assets/issue-reporting.md` 必须将 exit code 7 保持在 EXCLUDE 范围
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
- [ ] **Hub vs 领域**`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
- [ ] **渐进披露**SKILL 写意图路由与领域硬规则flags / usage / examples 以 `reference/``bl <command> --help` 为准,表后保留「勿猜 flag」指向句
@@ -55,6 +57,7 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search
- [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts``GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表hub 最多加一行 hand-off
- [ ]`pnpm run sync:skill-assets`(或 commit 走 pre-commit提交生成的 `reference/` 与 version 同步结果
- [ ] 高风险命令生成的 reference 必须包含 `Risk` / `Risk message` 和简短 Agent safety 提示;带 `--yes` 的示例必须标注只能在确认后执行,不要手改生成物
- [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对
## 完成后自查
+21 -18
View File
@@ -17,11 +17,12 @@
│ ├─ ~/.bailian/telemetry.jsonl
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
└─ authStage
apiKey → DashScope / 模型域
├─ console → Bailian Console Gateway
├─ openapi → 阿里云 OpenAPI
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
└─ confirmationStage
versionCheckStage → authStage
├─ apiKey → DashScope / 模型域
├─ console → Bailian Console Gateway
├─ openapi → 阿里云 OpenAPI
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
```
### 1. 三套鉴权与埋点标识
@@ -29,7 +30,7 @@
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识AEM |
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| `auth: "apiKey"` | API KeyDashScope / OpenAI-compatible 模型域 | `Client.request/requestJson``McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent``x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=apiKey` |
| `auth: "console"` | Console access tokenBailian Console Gateway | `callConsoleGateway()``/cli/api.json` | | 有:`pid=bailian-cli-node``authMethod=console` |
| `auth: "console"` | Console access tokenBailian Console Gateway | `callConsoleGateway()``/cli/api.json` | Console Gateway 后端直接标识 | 有:`pid=bailian-cli-node``authMethod=console` |
| `auth: "openapi"` | AccessKey ID/Secret可选 STS token阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=openapi` |
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node``authMethod=none` |
@@ -40,6 +41,7 @@
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
- Console Gateway 用户数据由后端直接区分,不依赖 `x-dashscope-source-config`AEM 的 `authMethod=console` 是独立的命令侧统计
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product``action``api` 是鉴权或路由字段,不计为埋点标识
### 2. 后端渠道参数
@@ -77,7 +79,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
### 3. 全命令 AEM 客户端埋点
`packages/runtime/src/middleware.ts``telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`
`packages/runtime/src/middleware.ts``telemetryStage` 包裹确认闸门、`authStage` 与命令执行,因此成功、确认未通过、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`确认闸门仍位于版本检查、鉴权和业务执行之前,不会因为埋点而放行高风险操作。
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`
@@ -92,7 +94,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
- `command``timestamp``durationMs``success`
- `cliVersion``nodeVersion``os`
- `authMethod`
- 失败时的 `errorMessage``httpStatus``requestId`
- 失败时的 `errorMessage``exitCode``httpStatus``requestId`
- 安全 allowlist 过滤后的 `params`
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts``PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
@@ -108,16 +110,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
AEM 映射:
| AEM 字段 | 内容 |
| ---------- | ----------------------------------------- |
| event name | 命令路径 |
| `et` | `EXP` |
| `ext` | 除 `command``params` 外的结构化事件字段 |
| `c1` | allowlist 参数 |
| `c2` | `success` / `failure` |
| `c3` | HTTP status |
| `c4` | 错误文案,最多 500 字符 |
| `c5` | request ID |
| AEM 字段 | 内容 |
| ---------- | ------------------------------------------------------------------ |
| event name | 命令路径 |
| `et` | `EXP` |
| `ext` | 除 `command``params` 外的结构化事件字段,包含失败时的 `exitCode` |
| `c1` | allowlist 参数 |
| `c2` | `success` / `failure` |
| `c3` | HTTP status |
| `c4` | 错误文案,最多 500 字符 |
| `c5` | request ID |
远端发送是 best-effort不得阻塞命令或改变退出码。正常退出最多等待 1 秒SIGINT 最多等待 500 ms。
@@ -144,6 +146,7 @@ AEM 映射:
- [ ] 更新 `TrackingEvent``createTrackingEvent()``buildRemoteAemOptions()` 的字段映射
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1``c5`
- [ ] 检查 `DO_NOT_TRACK=1``telemetry: false` 两个关闭入口
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
@@ -0,0 +1,195 @@
# 百炼知识库 dsh 插件out-of-tree bundle设计
> 状态设计已确认并实现v0.1.0 待真实 API smoke 后打 tag
## 1. 背景与目标
为 DeepSeek Harnessdsh提供阿里云百炼知识库Knowledge Studio / RAG垂类能力。经对比 MCP、CLI、API 三种接入通道后确定分层混合架构:
- **高频检索面走 API 直连原生工具**:结构化 schema、进程内 HTTP、结果可 snapshot、体验可打磨
- **低频管理长尾走 kscli + skill**`knowledge-studio-cli`(与 `bl knowledge` 同源实现的轻量发行面)覆盖建库/上传/部署等 34 个子命令,渐进式披露,零插件维护成本;
- **不做 MCP 通道**:托管 rag MCP 面向不拥有 API/CLI 的第三方宿主,非本方案投入点。
## 2. 范围
**做:**
- 三个模型面工具:`kb_service_list``kb_search``kb_chat`API 直连);
- 一个管理面 skill引导 agent 使用 kscli
- bundle 分发形态与配置、凭证、错误、测试设计。
**不做(含理由):**
| 项 | 理由 |
| ---------------------------- | ----------------------------------------------------------------------------- |
| `retrieve` 工具 | 服务端已弃用(`search` 取代);新表面不携带 deprecated 能力,避免近义工具混淆 |
| MCP 通道 | 见 §1 |
| chat 进展流式 UI模式 4 | 一期用缓冲式 + 期望管理,看真实使用反馈再决定(见 §7 与附录 A |
| `run_in_background` 后台模式 | dsh jobs 机制已备好,出现真实需求再加 |
| skills 生态独立分发B-3 | 一期 skill 随 bundle 注册;跨宿主分发留待后续 |
| 运行时 API/CLI fallback | 每个操作固定一条通道;双实现漂移与故障掩盖的代价大于收益 |
## 3. 总体形态
独立仓库维护的 **out-of-tree bundle**`package.json` 声明 `dsh.bundle` 指向 patch 文件,安装进 dsh profile 的 patch 层;不进入 deepseek-harness 主仓库,不改变 modelstudioai/cli 仓库的定位。
命名:
- bundle 包:`bailian-kb-bundle`
- 插件包:`dsh-tool-bailian-kb`
仓库为独立 pnpm workspace目录 `workspace/bailian-kb-bundle`,独立 git 仓库),两包结构:`packages/tool-bailian-kb`插件本体Config、client、三个工具、随包打包的 `skills/bailian-kb-management/SKILL.md`)与 `packages/bundle`(分发面:`dsh.bundle` 声明、`cordis.patch.yml``dependencies` 含插件包。拆分依据patch row 的 bare 插件名必须出现在 bundle 的 `dependencies`,插件包保持纯净(仅 `@deepseek-ai/cordis` peer + dsh 能力包依赖)。
插件为函数插件形态(`name` / `inject: ['tools']` / `Config` / `apply`),在 `apply(ctx, config)` 中构建共享 API client 并注册三个工具。
### 3.1 接入与配置流程
**bundle 侧接入契约**`@deepseek-ai/dsh-base` 为模板):
- `package.json` 声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,并在 `exports` 暴露 `./cordis.patch.yml`
- `cordis.patch.yml``insert` 插入插件 row 与 skill 注册 rowrow 中的 bare 插件名必须出现在 bundle 自身的 `dependencies`
- 发布到 npm或直接以 git spec 分发(`github:<org>/<repo>`)。
**用户安装**
```sh
dsh plugin --profile web add bailian-kb-bundle
```
CLI 转发 pnpm 将 bundle 装为 profile dependency安装后自动 reconcile——检测到 `dsh.bundle` 声明即加入 `dsh.profile.bundles` 层栈,无需手改 YAML。boot 层序为 `dsh-base` → … → 本 bundle patch → profile `cordis.patch.yml` → 家目录 `cordis.patch.yml`,用户 patch 层在本 bundle 之上,插入的任何 row 均可被按 id 覆盖或禁用。卸载 `dsh plugin --profile web remove bailian-kb-bundle` 自动收回层栈。
**配置落点**:用户 patch 是整 config 替换(无 deep-merge因此 bundle row 的 config 默认从环境读取:
```yaml
config:
workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
```
用户将 `BAILIAN_WORKSPACE_ID` 写入 `~/.dsh/.env` 即可运行(`DASHSCOPE_API_KEY` 放同处或 `.credentials.yaml`);需要精细控制的部署再以 id-targeted patch 覆盖整个 config。`workspaceId` 缺失时按 §6 在加载期 fail loud错误信息指向 `.env` 配置方式。验证入口:`dsh --profile web --dump-config` 可见本 bundle 的 row。
**本地开发迭代**checkout 内 `dsh plugin --profile dev add .`相对路径锚定调用目录patch 文件受 HMR 监听,编辑后自动 recompose。
## 4. 模型面工具
### 4.1 `kb_service_list`
发现当前 workspace 的检索/问答服务(百炼"检索服务",即 `agent_id` 的来源)。
| 参数 | 类型 | 必填 | 语义 |
| ------------- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ |
| `scene` | enum `chat` \| `search` | 否 | 省略时插件内部对两个 scene 各查一次并合并;每个条目携带 scene 标记(指明该服务配 `kb_chat` 还是 `kb_search` 使用) |
| `name_filter` | string | 否 | 服务名模糊匹配,透传服务端 `agent_name` |
返回:服务条目数组(`agent_id`、名称、描述、scene、status、绑定的知识库+ `total`
**分页内部消化**:固定 `page_size=100, page_number=1`(服务端上限 100`total > 100` 时结果末尾附提示 `listed first 100 of N services; narrow with name_filter`。不向模型暴露翻页参数——模型的导航原语是名字过滤,不是页码。
**status 不作为参数**:条目携带 `status` 字段description 提示优先使用 `deployed`draft 服务仅在 `agentVersion: beta` 的调试部署下可调(部署期概念,不占模型参数面)。
### 4.2 `kb_search`
语义检索,返回原始知识片段供 agent 综合与引用。
| 参数 | 类型 | 必填 | 语义 |
| ---------- | -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `query` | string | 是 | 检索文本 |
| `agent_id` | string | 见 §5 | 检索服务 idscene=search 的服务);检索范围与策略(多库加权、路由、重排)由服务端配置决定 |
| `top_k` | integer | 否,默认 5 | 返回片段数上限。服务端 search API 无此参数(条数由检索服务配置决定),插件对按 score 降序的 `nodes` 做客户端截断description 写明该语义 |
| `images` | string[] | 否 | 多模态检索的图片 URL |
返回chunks 数组(内容 + 来源引用)。
### 4.3 `kb_chat`
知识库成品问答。服务端为 agentic loop分析 → 多轮检索 → 生成),耗时可达分钟级。
| 参数 | 类型 | 必填 | 语义 |
| ---------- | ------ | ----- | ------------------------- |
| `message` | string | 是 | 问题 |
| `agent_id` | string | 见 §5 | 问答服务 idscene=chat |
返回:完整答案文本(含 API 提供的引用信息时一并返回)。
### 4.4 description 路由策略
`kb_search``kb_chat` 的 description **互相指名分界**,把"该用谁"写成可判断条件而非形容词:
- `kb_search`returns raw knowledge chunks with source references用于需要核实、引用、或与其他上下文结合推理的场景
- `kb_chat`a complete, domain-tuned answer produced by a specialized RAG pipeline (retrieval + reranking + grounded generation)知识问答场景通常优于自行检索综合typically outperforms searching and synthesizing yourself when the question can be answered by the knowledge base alone并注明 may take a few minutes。
## 5. `agent_id` 的三种场景覆盖
| 场景 | 机制 | 插件成本 |
| ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------ |
| 发现式 | `kb_service_list` → 选服务 → search/chat`agent_id` 缺失或无效时,错误信息直接附当前服务清单,模型一步纠正 | 发现工具 + 错误增强 |
| 用户习惯固定 | 宿主 memory / 项目指令记住常用 `agent_id` | 零skill 写入最佳实践) |
| 场景/部署固定 | Config 可选 `defaultAgentId`;配置后注册时将 `agent_id` 参数降为可选description 注明缺省服务 | 一个可选配置字段 |
**Schema 形态在加载期由配置静态决定**(未配 `defaultAgentId``agent_id` 必填),不是运行时 fallback每个部署只有一条清晰规则KV cache 前缀与 snapshot 均稳定。与 dsh preset 组合可实现按场景绑定(如客服 preset 固定客服库)。
## 6. 配置与凭证
```ts
interface Config {
/** 百炼工作空间 id。知识库 API 的 host 为 workspace 子域名:`https://<workspaceId>.<endpointHost>`。必填。 */
workspaceId: string;
/** 知识库 API 的 host 后缀。默认 `cn-beijing.maas.aliyuncs.com`;其他 region/私有化部署时替换。 */
endpointHost: string;
/** 场景固定式部署绑定的检索服务 id。可选。 */
defaultAgentId?: string;
/** 调用的服务版本beta草稿调试或已发布版本号。可选缺省最新发布版。不暴露给模型。 */
agentVersion?: string;
/** kb_chat 超时毫秒数。chat 为分钟级 loop部署必须可调。默认 3000005 分钟)。 */
chatTimeoutMs: number;
}
```
- schemastery 校验;缺失/非法配置在**加载期 fail loud**
- API Key 走 `ctx.credentials` 引用(`DASHSCOPE_API_KEY`env/.env provider不进 Config、不进会话日志、不被 `--dump-config` 打印;
- URL 拼接是 `(endpointHost, workspaceId, path) → endpoint` 的纯函数(`https://${workspaceId}.${endpointHost}${path}`,与 kscli 的 `ragEndpoint` 同构),与请求构造、错误翻译一起收在插件内部的共享 client 中(协议路径为代码常量,不进配置)。
## 7. 执行语义
- **`kb_chat` 缓冲式**(与 bash 前台/subagent 同构的仓库惯例dsh 中没有工具向模型或 UI 中途推流):`execute` 内部消费完 SSE一次性返回完整答案。UI 呈现为 `presentCall` pending 卡片 → `presentResult` 完成卡片(`generic`纯函数、replay-safe
- **期望管理**description 与 pending 卡片标题注明 may take a few minutes
- **超时**`chatTimeoutMs` 显式可配dsh tool-timeout guard 可另行部署级配置);
- **超长输出**:声明依赖 dsh spill 子系统兜底,插件不自造截断。
## 8. 管理面 skill
- SKILL.md 随插件包打包;插件在 skills 服务可用时通过 `ctx.inject(['skills'], …)``ctx.skills.register()` 运行时注册(`source: 'bundled'``resourceBase` 指向包内 skill 目录),无 skills 服务的组合不受影响;工具与 skill 同版本发布,互相引用不漂移;
- 内容kscli 安装引导(`npm install -g knowledge-studio-cli`、API Key 与 workspace 解析flag > `BAILIAN_WORKSPACE_ID` > 配置文件)、典型工作流(建库 → 上传 → 等解析 → 部署服务 → 检索验证)、"常用 `agent_id` 写入项目指令/记忆"最佳实践、检索面与管理面的分工说明search/chat 用原生工具,不走 kscli
- kscli 未安装时管理操作 fail loud 并给出安装命令;检索面不受影响。
## 9. 错误处理
- HTTP 错误翻译为模型可操作的文本:无效 `agent_id` → 附当前服务清单;鉴权失败 → 指向 API Key 获取与配置方式;超时 → 说明 chat 可能耗时并建议重试或改用 search
- 凭证缺失在首次可解析点大声失败,不静默降级;
- 服务端非 2xx 的响应体原样摘要进错误信息(截断至安全长度),便于模型与用户诊断。
## 10. 测试策略
| 层 | 内容 |
| -------- | --------------------------------------------------------------------------------------------- |
| 单元测试 | endpoint 拼接、请求体构造scene 合并、分页内化、`defaultAgentId` 解析)、错误翻译 |
| snapshot | mock HTTP fixture 的可重放 keyless snapshotmacOS/Linux 均可回放;覆盖三工具的调用与渲染卡片 |
| e2e | 真实 DashScope API`DASHSCOPE_API_KEY` 时自跳过 |
## 附录 A预留扩展已设计方向未排期
- **chat 进展流式(模式 4**`execute` 消费 SSE 时 append 工具自有会话事件(如 `bailian/chat-progress``ignorable: true`Web 客户端注册 `ConversationNodeDefinition` 渲染器实时显示模型面不变logged ≠ model-visible。触发条件真实用户对 chat 等待体验的负反馈;
- **后台模式**`kb_chat` 增加 `run_in_background`,挂 `ctx.jobs``job_output` 收取;
- **skills 生态分发B-3**:以 bundle 仓库的 SKILL.md 为唯一源,发布到 `npx skills add` 生态覆盖其他宿主;
- **能力缝升级**:出现第二种传输(如私有化内网网关)时,将共享 client 提为 `ctx.<key>` 服务,按 Service Definition / Provider / Consumer 三角色拆分。
## 附录 B关键决策记录
| 决策 | 结论 | 理由摘要 |
| --------------- | ------------------------------------------- | ------------------------------------------------------------------- |
| 接入通道 | API检索面+ CLI管理面不做 MCP | 频率×能力深度×控制权分层API/CLI 均为己方资产 |
| CLI 选型 | kscli 而非 bl | 同源实现零能力损失命令面窄、鉴权单一、onboarding 短 |
| `retrieve` | 不做 | 已弃用,避免近义工具 |
| `kb_chat` 门控 | 不门控,常驻注册 | 服务端 RAG 管线在知识问答场景更专业description 写明场景让模型路由 |
| `agent_id` 归属 | 模型参数 + 发现工具 + 可选 `defaultAgentId` | 检索服务是用户运行时资产,插件与部署配置不应假设 |
| chat 流式 | 一期缓冲式 | 仓库惯例bash/subagent 同构);进展流式留待反馈 |
| 分页 | 内部消化page_size=100 + 溢出提示) | 模型导航原语是 name 过滤,非页码 |
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
# bailian-kb-dsh 运行时行为(内部说明)
面向维护者:记录 `packages/bailian-kb-dsh` 里那些**为什么这么做**的选择。用户面文档在 [packages/bailian-kb-dsh/README.md](../../packages/bailian-kb-dsh/README.md)(中文版 `README.zh.md`),改动清单在 [docs/agents/dsh-plugin.md](../agents/dsh-plugin.md)。
## Bundle 声明与配置分层
`package.json``dsh.bundle.patch` 指向 `cordis.patch.yml`,向 profile 插入插件行:
```yaml
- insert:
- id: tool-bailian-kb
name: "bailian-kb-dsh"
config:
workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
```
`workspaceId` 只是解析链的一层不是唯一来源Config 同时注册为 `bailian-kb` settings namespacepatch entry 作 base 层,设置页 / 设置文档的用户层叠在其上;都未设置时 per-call 回退到 `BAILIAN_WORKSPACE_ID` credential。同样的回退覆盖 `defaultRetrieveAgentId``BAILIAN_DEFAULT_RETRIEVE_AGENT_ID`)、`defaultChatAgentId``BAILIAN_DEFAULT_CHAT_AGENT_ID`)与 API key`DASHSCOPE_API_KEY`,无 settings 面)。
settings 注册是**手写**的,没有用 `installSettingsSection`:需要两个它不带的东西 —— `expose` opt-in设置页从浏览器改这个 section和凭据迁移要写入的 scope handle。所有值每次调用经 source thunk 读取,因此设置改动无需重启或重注册工具。
### 四个值的解析链
| 值 | 1⃣ settings 用户层(设置页可编辑、回显) | 2⃣ entry configpatch 或用户覆盖,作 base 层) | 3⃣ credential`~/.dsh/.credentials.yaml` / env | 4⃣ 都缺失时 |
| ----------------------------------- | ---------------------------------------- | ----------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------- |
| `DASHSCOPE_API_KEY` | —(无 settings 面) | —(无 config 面) | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_WORKSPACE_ID` | ✅ `workspaceId` | ✅ `workspaceId` | ✅ | 工具调用报错并引导配置 |
| `BAILIAN_DEFAULT_RETRIEVE_AGENT_ID` | ✅ `defaultRetrieveAgentId` | ✅ `defaultRetrieveAgentId` | ✅ | 该 scene 只有一个已部署服务时取它;否则省略 `agent_id` 的调用报错 |
| `BAILIAN_DEFAULT_CHAT_AGENT_ID` | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | ✅ | 同上 |
"唯一服务即默认"这一层是 2C 部署的零配置路径:只有一个服务时没有可选项,逼用户在设置里点一次名字买不到任何东西。注意 `agent_id` 在两个工具的 schema 中**恒必填**,模型路径不会触发默认服务回退;回退保留是为程序化调用与 credential 热切换。
行为参数(`endpointHost` / `agentVersion` / `chatTimeoutMs`)只在 config/settings 层(设置文档可改,实时生效)。
解析后的 workspaceId 还经 `shellEnv` 注册导出为 `BAILIAN_WORKSPACE_ID`,否则 settings 文档里的值对 bash 子进程(`bl knowledge …`)不可见。
### 用户覆盖
用户 patch 层在本 bundle 之上,按 id 覆盖时**替换整个 config无 deep-merge**
```yaml
# ~/.dsh/cordis.patch.yml 或 profile 的 cordis.patch.yml
- id: tool-bailian-kb
config:
defaultRetrieveAgentId: aid-search-service
defaultChatAgentId: aid-chat-service
chatTimeoutMs: 600000
```
禁用:`- id: tool-bailian-kb` + `disabled: true`
## Web UI 配置页
装进 profile 后Settings 左侧导航出现"百炼知识库"页(`settings.section` 槽位):
- **DashScope API Key** — write-only`type=password` 遮罩输入草稿,仅显示 configured / 来自环境变量 徽标;写 `~/.dsh/.credentials.yaml`
- **Bailian Workspace ID / 默认检索服务 ID / 默认对话服务 ID** — 回显:读写 `bailian-kb` settings 用户层,预填当前解析值;清空保存 = 移除用户层,回退 entry config → credential。两个服务 ID 可从服务缓存里选
- **自动获取** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`Host 在宿主机拉起浏览器登录百炼控制台(不经 `bl` 命令),回调落到本机 loopback 端口后直接把 API 密钥写入凭据存储、工作空间 ID 写入 settings明文 key 不过浏览器;面板轮询到完成后自动刷新(无需再次点击)。登录 URL 始终请求签发新 key因此每次都与当前账号配对切换账号直接点一次即可
- **检索服务缓存** — 经 `/bailian-kb/services` 读缓存诊断(上次拉取时间、各 scene 条数、是否截断)并提供强制刷新按钮:面板存在的意义就是"开发者认为缓存不对"的那一刻
桥接路由(`/bailian-kb/settings``/bailian-kb/services``/bailian-kb/autofill`)而不是 settings wirewire 需要 apiproxy 白名单,而 composition 不给树外 namespace 授权。GET 和 POST 共用一次 exact-route 注册 —— webServer map 按 (kind, path) 建键,同路径注册两次会抛 "duplicate route"。
首次接入 seed启动时若 API key / workspaceId 从未被设置过settings、credential、env 均无值),自动从 `~/.bailian/config.json` 采纳一次;`seededFields` 字段settings 文档内,面板不可编辑)记账已消费 / 已由用户管理的字段,用户主动清空的值永不会被重新填回。
降级:远程浏览器(非 loopbacksettings RPC 不可达)或未组合 settings 服务时ID 字段退回旧的 write-only credential 控件,页面顶部显示提示。
## 检索服务缓存与上下文注入
模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。
补查型工具("只按关键词查、不列全部"的 `service_find`)也评估过,同样不做。理由不是成本而是**收益已被占掉**:唯一能支撑它的论据是"兜底走 bash + bl 不一定可用",而管理面本来就以 bl 为前提catalog 那条通道是零轮次、无条件注入、且带截断告知与默认收敛策略,再开第三个入口只会跟它抢事实源。真正要补的是兜底路径本身——见下面两小节。
### 载体:上下文消息,不是工具描述
两个工具的 **description 保持静态**(不含任何服务 id。清单经 `agent/pre-step` 注入为一条带 source 的 `UserMessage``{ kind: 'plugin', plugin: 'tool-bailian-kb/services', form: 'catalog' }`),而不是烘进 tool description。两个原因
1. **插件加载是每进程一次,不是每会话一次。** 描述在 `apply()` 时定型,长驻宿主里 TTL 只会被评估一次,用户在控制台新建的服务要等重启才能被感知;
2. **重注册工具会废掉 prompt 前缀缓存**(从第一个变化的 schema token 起)。走上下文消息则让 schema 永久稳定。
**变化抑制是正确性要求,不是优化**`pre-step` 每个"步"= 一次模型请求)触发一次,一轮里调 5 次工具就触发 6 次。只有清单内容变化时才重发,且判定叠加**可见性**`session.surface.nodes`)—— 压缩把清单消息裁掉后会自动重新注入,否则模型会静默失去清单。
### 清单内容策略
| 情形 | 注入内容 |
| ------------------------ | ------------------------------------------------------------------------------------ |
| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 |
| 未配默认deployed ≤ 10 | 全量 `agent_id` + 名称 |
| 未配默认deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 |
| 缓存里 0 个服务 | 注入 `buildNoServiceNotice()`:禁止编 id并给出建服务/部署服务的路径(控制台或 bl |
| 无缓存文档(含拉取失败) | 不注入(工具仍可用) |
英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。
最后两行的差别是**能不能下断言**:缓存里有文档但 0 条,是权威的"这个 workspace 没有可调服务",说出来比沉默好——`agent_id` 恒必填,沉默只会让模型编一个 id换来服务端裸报错或静默放弃检索两者在用户看来都像插件坏了。没有文档则意味着首拉还没落地或一直失败此时任何断言都是猜交给后台刷新在后续 step 自愈。
注意这与"空 scene 整节省略"不冲突:空的**节**是噪音(另一节仍在给 id空的**清单**是模型手里一个 id 都没有。
### bl 引导只出现在动态载体上
工具描述是静态的,每次请求都付;而"怎么进一步查"取决于当下部署了什么。所以 `agent_id` 的参数描述**只指向上下文清单**,不写 `bl` 命令;`bl` 出现在两处动态文本里,且**每次出现都带安装方式**`npm install -g bailian-cli`
- catalog 中真正推荐了命令的分支(截断、收敛到默认服务);
- 4xx 之后追加的服务清单(`buildRefreshedSceneList`)。
带安装行是必需的:插件走 API 直连、从不 shell out所以一个凭据齐全的部署完全可能没装 `bl`。而安装引导原本只写在 `skills/bailian-kb/SKILL.md` 的前置检查里,那是二阶决策——要模型先决定加载 skill 才读到,恰好漏掉走工具描述兜底的那个模型。
### 缓存与刷新
落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的api key 只能访问自己的 workspace而"自动获取"按钮就是为了切账号。
存:`agent_id` / `agent_name` / `scene` / `status` / `modify_time`,预留 `description`(待列表接口返回)。**不存 `pipeline_list`** —— 它不稳定携带 `pipeline_name`,做不了知识库标签。
| 刷新触发点 | 模型何时看见 |
| ---------------------------------------------------------------------------------- | ------------------------------------ |
| pre-step 间隔调度(超 TTL 30 分钟,后台异步,**不阻塞** | 下一步 |
| 控制台登录成功(`/bailian-kb/autofill` 回调) | 下一步 |
| agent 跑了 `bl knowledge service create/deploy/delete/copy``tools/result` 观察) | 下一步 |
| 面板强制刷新(`POST /bailian-kb/services` | 下一步 |
| 调用撞 4xxagent_id 已失效) | **本步**,刷新后的列表追加进错误消息 |
| workspaceId / apiKey 变更 | 下一步 |
`tools/result` 的匹配是在序列化参数里找命令串而不是认某个具体工具名agent 可能用 bash、终端工具或 run_code 跑 `bl`。宽匹配是故意的 —— 误判只多花一次 list 请求,漏判则退回 TTL。
刷新失败只 warn保留旧文档并发刷新共享一个请求pre-step 每步都会检查。pre-step 监听器**永不抛异常** —— 抛出会使用户当前这一步失败。未组合 `agents` 的 headless 装配只是没有清单,工具照常可用。
## 错误语义
- HTTP 错误4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);刷新后该 scene **一个服务都没有**时也照样追加说明(明确"别换 id 重试"+ 建服务路径),而不是放裸错误过去——裸的 `invalid agent_id` 读起来就是"再试一个"5xx 与刷新本身失败则原错误透传;
- 凭证缺失:指向 `~/.dsh/.env` / `.credentials.yaml` 配置方式与控制台取 key 页面;
- chat 超时:说明服务端多轮检索特性,建议重试或改用 `kb_search`
- 服务端错误体截断至 500 字符进入错误信息(优先 `code: message`)。
## 管理面 skill
`skills/bailian-kb/SKILL.md` 随包分发,插件通过 `ctx.inject(['skills'])` 在 skills 服务可用时以 `source: 'bundled'` 运行时注册;无 skills 服务的组合headless 最小装配)不受影响。文件的 YAML frontmatter 是 name / description 的**单一事实源**,注册时会被剥离(`SkillDefinition.content` 契约上是已去元数据的正文,而 runtime 注册路径不做任何解析)。
内容bl CLI 安装 / 鉴权 / workspace 解析、建库 → 上传 → 部署工作流、服务清单的行为语义。**skill 不承担"该不该检索"的引导**那是工具描述与上下文清单的事skill 正文要模型先决定加载才能读到,是二阶决策);它反过来承担一件工具做不到的事:**引导 agent 在 `service create` 时把服务名写清楚**。无 desc 时服务名是唯一语义来源,管理面的动作直接决定检索面的效果。
## 已知限制的成因
- kb_chat 执行期无进展显示(缓冲式消费 SSE
- `top_k` 是客户端截断:请求体不含该参数,服务端返回条数由检索服务配置决定,截断只影响进入模型上下文的量。
- **服务画像的质量上限取决于服务名**`service list` 接口当前不返回描述字段,所以模型只能靠 `agent_name` 判断一个服务能查什么。名字模糊的部署引导能力接近于零。列表接口补齐描述字段后只需改三处(`api-types` 补字段名 → `services.ts` 解析 → `buildServiceCatalog` 追加并截断到 200 字符),缓存已预留 `description` 键,无需迁移。
- 拉取每个 scene 最多 2 页,超出时标 `truncated` 并在清单里告知。
+1 -1
View File
@@ -213,7 +213,7 @@ bl knowledge chunk delete --index-id <id> --chunk-id <id> [flags]
| ----------------- | ------ | ---- | ------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | array | 是 | Chunk ID可重复每批最多 10 个,超出自动分批) |
| `--yes` | switch | 否 | 跳过确认提示 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -232,10 +232,10 @@ bl knowledge category delete --category-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ---------------------- |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+6 -6
View File
@@ -190,11 +190,11 @@ bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ----------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | array | 是 | 文档 ID可重复 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | array | 是 | 文档 ID可重复 |
| `--yes` | switch | 否 | 显式确认高风险操作 |
**输出**
@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
# 删除单个文档
bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
# 批量删除,跳过确认
# 用户明确确认后批量删除
bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
```
+4 -4
View File
@@ -120,10 +120,10 @@ bl knowledge file delete --file-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | ---------------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+5 -5
View File
@@ -254,10 +254,10 @@ bl knowledge delete --index-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。
- **不可逆操作**:知识库及所有索引内容被永久删除。
- 数据中心中的源文件不受影响,仅删除知识库索引。
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要
- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求
**示例**
+1 -1
View File
@@ -135,7 +135,7 @@
### 危险操作确认
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete``service deploy`在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete``service deploy`属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行
### Dry-run 模式
+9 -9
View File
@@ -270,11 +270,11 @@ bl knowledge service deploy --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -319,10 +319,10 @@ bl knowledge service delete --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+1 -1
View File
@@ -213,7 +213,7 @@ kscli chunk delete --index-id <id> --chunk-id <id> [flags]
| ----------------- | ------ | ---- | ------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | array | 是 | Chunk ID可重复每批最多 10 个,超出自动分批) |
| `--yes` | switch | 否 | 跳过确认提示 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -232,10 +232,10 @@ kscli category delete --category-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ---------------------- |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+6 -6
View File
@@ -190,11 +190,11 @@ kscli doc delete --index-id <id> --doc-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ----------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | array | 是 | 文档 ID可重复 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | array | 是 | 文档 ID可重复 |
| `--yes` | switch | 否 | 显式确认高风险操作 |
**输出**
@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
# 删除单个文档
kscli doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
# 批量删除,跳过确认
# 用户明确确认后批量删除
kscli doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
```
+4 -4
View File
@@ -120,10 +120,10 @@ kscli file delete --file-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | ---------------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+5 -5
View File
@@ -254,10 +254,10 @@ kscli kb delete --index-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。
- **不可逆操作**:知识库及所有索引内容被永久删除。
- 数据中心中的源文件不受影响,仅删除知识库索引。
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要
- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求
**示例**
+1 -1
View File
@@ -152,7 +152,7 @@ kscli --help
### 危险操作确认
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete`)以及 `service deploy` 在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete`)以及 `service deploy` 属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行
### Dry-run 模式
+9 -9
View File
@@ -270,11 +270,11 @@ kscli service deploy --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -319,10 +319,10 @@ kscli service delete --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Aliyun Model Studio (DashScope) AI Platform
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+159
View File
@@ -0,0 +1,159 @@
<div align="center">
# Bailian Knowledge Base for DeepSeek Harness
**Knowledge-base retrieval tools for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), backed by Aliyun Model Studio (Bailian).**
[![npm version](https://img.shields.io/npm/v/bailian-kb-dsh?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-kb-dsh)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Bailian Console](https://bailian.console.aliyun.com/) · [中文文档](README.zh.md) · [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) · [API Documentation](https://help.aliyun.com/zh/model-studio/)
</div>
## What is this?
`bailian-kb-dsh` is a plugin (and dsh bundle) that gives a DeepSeek Harness agent access to knowledge bases hosted on [Aliyun Model Studio](https://bailian.console.aliyun.com/) (Bailian). It registers two model-facing tools — `kb_search` for raw evidence and `kb_chat` for grounded answers — and ships a settings page plus a management skill for the [`bl` CLI](https://www.npmjs.com/package/bailian-cli).
Retrieval happens through **retrieval services** you deploy on Bailian: a service binds one or more knowledge bases to an embedding/rerank configuration and is addressed by its `agent_id`. The plugin keeps the list of deployed services in front of the model, so it can decide whether the question is answerable from your knowledge at all.
## Features
- **Two model-facing tools** — `kb_search` returns scored chunks with source references; `kb_chat` returns a complete grounded answer
- **Service awareness** — the workspace's deployed retrieval services are injected into the conversation, so the model knows what it can look up instead of guessing service ids
- **Low-friction setup** — sign in to the Bailian console from the settings page to fill in the API key and workspace id; an existing `bl` CLI login is adopted automatically
- **Settings page** — a "Bailian KB" section in the web UI for credentials, default services, and service-cache inspection
- **Management skill** — bundled `bailian-kb` skill teaching the agent the `bl` CLI workflow for creating knowledge bases, ingesting documents, and deploying services
## Requirements
- DeepSeek Harness with its plugin runtime (`@deepseek-ai/dsh-*`), Node.js >= 22.12
- An Aliyun Model Studio account: a **workspace id** and a **DashScope API key** ([get one](https://bailian.console.aliyun.com/?tab=app#/api-key))
- At least one **deployed** retrieval or Q&A service in that workspace — create one in the [console](https://bailian.console.aliyun.com/) or with `bl knowledge service create` / `bl knowledge service deploy`
- The [`bl` CLI](https://www.npmjs.com/package/bailian-cli) (`npm install -g bailian-cli`) for anything on the management side — creating knowledge bases, ingesting documents, deploying services. Retrieval itself calls the API directly and never shells out, so `kb_search` / `kb_chat` work without it
## Installation
```sh
dsh plugin --profile web add bailian-kb-dsh
```
The CLI adds the bundle to the profile's layer stack; no manual YAML editing required. To remove it:
```sh
dsh plugin --profile web remove bailian-kb-dsh
```
Verify the plugin is composed — `dsh --profile web --dump-config` should list a `tool-bailian-kb` row.
## Configuration
### Option 1 — Settings page (recommended)
After installation, **Settings → Bailian KB** appears in the web UI:
- **Fetch from console login** — opens the Bailian console in a browser on the host machine; when you finish signing in, the API key and workspace id of that account are stored on the host (the key never travels to the browser). Each login requests a freshly issued key, so switching accounts is one click.
- **API key** — write-only: the stored value is never echoed back, only reported as configured or not.
- **Workspace id / default retrieval service / default Q&A service** — editable with echo; the service ids can be picked from the cached service list. Clearing a value falls back to the layers below.
- **Retrieval service cache** — shows when the injected service list was last fetched, how many services it holds, and offers a manual refresh for a service you just created.
If you have already run `bl auth login`, the API key and workspace id are adopted once from `~/.bailian/config.json` at startup. A value you deliberately clear is never re-filled.
### Option 2 — Environment and credential files
```sh
# ~/.dsh/.env, or the credential store at ~/.dsh/.credentials.yaml
DASHSCOPE_API_KEY=sk-xxx # required
BAILIAN_WORKSPACE_ID=ws-xxx # required
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID=aid-xxx # optional
BAILIAN_DEFAULT_CHAT_AGENT_ID=aid-xxx # optional
```
### Option 3 — Profile patch
The bundle inserts its own entry into the profile; you can override it by id in `~/.dsh/cordis.patch.yml` or the profile's patch file. An override **replaces the whole config object** (no deep merge):
```yaml
- id: tool-bailian-kb
config:
defaultRetrieveAgentId: aid-search-service
defaultChatAgentId: aid-chat-service
chatTimeoutMs: 600000
```
Disable the plugin with `- id: tool-bailian-kb` plus `disabled: true`.
### Config fields
The config doubles as the `bailian-kb` settings section, so edits in the settings page or settings document apply to the next call without a restart.
| Field | Type | Default | Meaning |
| ------------------------ | ------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | string? | — | Bailian workspace id; the API host is the workspace subdomain `https://<workspaceId>.<endpointHost>` |
| `endpointHost` | string | `cn-beijing.maas.aliyuncs.com` | Host suffix; replace for another region or a private deployment |
| `defaultRetrieveAgentId` | string? | — | Service `kb_search` falls back to when the caller omits `agent_id` |
| `defaultChatAgentId` | string? | — | Service `kb_chat` falls back to when the caller omits `agent_id` |
| `agentVersion` | string? | — | `beta` (draft) or a published version number; defaults to the latest published version. Not exposed to the model |
| `chatTimeoutMs` | number | `300000` | `kb_chat` timeout — the server side is a minutes-scale agentic loop |
### Resolution order
| Value | Settings layer (settings page) | Entry config (profile patch) | Credential store / env |
| ------------------------- | ------------------------------ | ---------------------------- | ----------------------------------- |
| `DASHSCOPE_API_KEY` | write-only control | — | `DASHSCOPE_API_KEY` |
| workspace id | ✅ `workspaceId` | ✅ `workspaceId` | `BAILIAN_WORKSPACE_ID` |
| default retrieval service | ✅ `defaultRetrieveAgentId` | ✅ `defaultRetrieveAgentId` | `BAILIAN_DEFAULT_RETRIEVE_AGENT_ID` |
| default Q&A service | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | `BAILIAN_DEFAULT_CHAT_AGENT_ID` |
Every value is re-read per call, so a rotated key or a switched workspace takes effect immediately. The API key and workspace id are mandatory: without them the tools fail with a message pointing at these configuration paths. Default services are optional — when the workspace has exactly one deployed service for a scene, that one is used.
## Tools
| Tool | Parameters | Returns |
| ----------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `kb_search` | `query`, `agent_id` (required), `top_k?` (default 5, applied client-side), `images?` (image URLs for multimodal) | Scored chunks with source references, plus a total |
| `kb_chat` | `message`, `agent_id` (required) | The complete answer plus a `request_id` |
`agent_id` is required in both schemas: the schema cannot tell the model whether this deployment pins a default, and discovering a missing default at call time wastes a round-trip. The configured default is still honoured for programmatic calls that omit it.
The list of deployed services — id, name, and scene — is injected into the conversation as a context message, refreshed periodically and whenever a `bl knowledge service` command changes the inventory. Long lists are truncated with the total stated, so the model never mistakes a partial list for the full inventory.
## Errors
- **HTTP 4xx** — most often an `agent_id` that no longer exists, so the service list is refreshed and appended to the error message for immediate recovery
- **HTTP 5xx** — passed through unchanged
- **Missing credentials** — the message names the configuration paths (`~/.dsh/.env`, `~/.dsh/.credentials.yaml`, the settings page) and links to the console key page
- **`kb_chat` timeout** — the message explains the server-side multi-turn retrieval and suggests retrying or switching to `kb_search`
## Known limitations
- `kb_chat` buffers the server stream, so there is no progress output while it runs.
- `top_k` is a client-side cut: the request body carries no such parameter, and how many chunks the server returns is decided by the service configuration.
- **Service names carry the routing signal.** The service list API does not return a description field yet, so the model judges what a service covers from its name alone. Name your services after their content (`Product docs retrieval`, not `Service 1`).
- At most two pages per scene are fetched; beyond that the injected list is marked as truncated.
## Development
```sh
pnpm --filter bailian-kb-dsh run build # tsc → dist/ (node half) + tsdown → dist/web/client.js (browser half)
pnpm --filter bailian-kb-dsh run typecheck # node and web tsconfigs
pnpm --filter bailian-kb-dsh run test
```
For local integration, add the working copy to a dev profile (the patch file is watched by HMR):
```sh
dsh plugin --profile dev add <this-repo>/packages/bailian-kb-dsh
```
Internal design notes — context injection strategy, service cache layout, refresh triggers — live in [docs/kb-dsh/runtime-behavior.md](https://github.com/modelstudioai/cli/blob/main/docs/kb-dsh/runtime-behavior.md); the maintenance checklist is [docs/agents/dsh-plugin.md](https://github.com/modelstudioai/cli/blob/main/docs/agents/dsh-plugin.md).
## Contributing
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup and the contribution workflow.
## License
[Apache 2.0](LICENSE)
+159
View File
@@ -0,0 +1,159 @@
<div align="center">
# 百炼知识库 for DeepSeek Harness
**基于阿里云百炼Aliyun Model Studio的知识库检索工具供 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 使用。**
[![npm version](https://img.shields.io/npm/v/bailian-kb-dsh?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-kb-dsh)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[百炼控制台](https://bailian.console.aliyun.com/) · [English](README.md) · [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) · [API 文档](https://help.aliyun.com/zh/model-studio/)
</div>
## 这是什么?
`bailian-kb-dsh` 是一个 DeepSeek Harness 插件(同时是 dsh bundle让 agent 能检索托管在[阿里云百炼](https://bailian.console.aliyun.com/)上的知识库。它注册两个面向模型的工具 —— `kb_search` 取原始证据、`kb_chat` 出成品答案 —— 并附带一个设置页和一份面向 [`bl` CLI](https://www.npmjs.com/package/bailian-cli) 的管理 skill。
检索经由你在百炼上部署的**检索服务**完成:一个服务把一个或多个知识库绑定到指定的向量 / 排序配置上,通过 `agent_id` 寻址。插件会把已部署服务的清单持续呈现给模型,让它能判断用户的问题是否落在你的知识范围内。
## 功能特性
- **两个面向模型的工具** — `kb_search` 返回带分数和来源的知识切片;`kb_chat` 返回基于知识的完整答案
- **服务感知** — 工作空间里已部署的检索服务会注入到会话上下文,模型据此知道自己能查什么,不必猜 `agent_id`
- **低门槛配置** — 在设置页登录百炼控制台即可自动填入 API 密钥与工作空间 ID已有的 `bl` CLI 登录会被自动采纳
- **设置页** — Web UI 中的"百炼知识库"页,管理凭据、默认服务,并可查看服务缓存状态
- **管理 skill** — 随包分发的 `bailian-kb` skill教 agent 用 `bl` CLI 完成建库、文档导入、服务部署
## 环境要求
- DeepSeek Harness 及其插件运行时(`@deepseek-ai/dsh-*`Node.js >= 22.12
- 阿里云百炼账号:一个**工作空间 ID** 和一个 **DashScope API 密钥**[去获取](https://bailian.console.aliyun.com/?tab=app#/api-key)
- 该工作空间下至少有一个**已部署**的检索或问答服务 —— 可在[控制台](https://bailian.console.aliyun.com/)创建,或用 `bl knowledge service create` / `bl knowledge service deploy`
- 管理面的事(建库、导入文档、部署服务)需要 [`bl` CLI](https://www.npmjs.com/package/bailian-cli)`npm install -g bailian-cli`)。检索本身直连 API、从不起子进程所以 `kb_search` / `kb_chat` 不装也能用
## 安装
```sh
dsh plugin --profile web add bailian-kb-dsh
```
CLI 会自动把 bundle 加入 profile 的层栈,无需手改 YAML。卸载
```sh
dsh plugin --profile web remove bailian-kb-dsh
```
验证插件已装配:`dsh --profile web --dump-config` 应能看到 `tool-bailian-kb` row。
## 配置
### 方式一 — 设置页(推荐)
安装后Web UI 的 **Settings → 百炼知识库** 页出现:
- **自动获取** — 在宿主机浏览器中拉起百炼控制台登录;登录完成后,该账号的 API 密钥与工作空间 ID 直接落到宿主机(明文密钥不经过浏览器)。每次登录都会请求签发新密钥,因此切换账号点一次即可。
- **API 密钥** — 只写不回显:存下的值不会再次显示,只显示"已配置 / 未配置"。
- **工作空间 ID / 默认检索服务 / 默认对话服务** — 可编辑且回显;两个服务 ID 可从缓存的服务清单里选。清空保存则回退到下层来源。
- **检索服务缓存** — 展示注入清单的上次拉取时间、各场景服务条数、是否被截断,并提供手动刷新(刚新建完服务想立刻生效时用)。
如果此前已运行过 `bl auth login`,启动时会从 `~/.bailian/config.json` 一次性采纳 API 密钥与工作空间 ID。被你主动清空的值不会被重新填回。
### 方式二 — 环境变量与凭据文件
```sh
# ~/.dsh/.env或凭据存储 ~/.dsh/.credentials.yaml
DASHSCOPE_API_KEY=sk-xxx # 必填
BAILIAN_WORKSPACE_ID=ws-xxx # 必填
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID=aid-xxx # 选填
BAILIAN_DEFAULT_CHAT_AGENT_ID=aid-xxx # 选填
```
### 方式三 — Profile patch
bundle 会向 profile 插入自己的 entry你可以在 `~/.dsh/cordis.patch.yml` 或 profile 的 patch 文件里按 id 覆盖。覆盖时**替换整个 config 对象(无 deep-merge**
```yaml
- id: tool-bailian-kb
config:
defaultRetrieveAgentId: aid-search-service
defaultChatAgentId: aid-chat-service
chatTimeoutMs: 600000
```
禁用插件:`- id: tool-bailian-kb``disabled: true`
### 配置字段
Config 同时注册为 `bailian-kb` settings section因此在设置页或设置文档里的修改会在下一次调用生效无需重启。
| 字段 | 类型 | 默认值 | 语义 |
| ------------------------ | ------- | ------------------------------ | --------------------------------------------------------------------------------- |
| `workspaceId` | string? | — | 百炼工作空间 IDAPI host 为工作空间子域名 `https://<workspaceId>.<endpointHost>` |
| `endpointHost` | string | `cn-beijing.maas.aliyuncs.com` | host 后缀,其他 region 或私有化部署时替换 |
| `defaultRetrieveAgentId` | string? | — | 调用方省略 `agent_id``kb_search` 使用的服务 |
| `defaultChatAgentId` | string? | — | 调用方省略 `agent_id``kb_chat` 使用的服务 |
| `agentVersion` | string? | — | `beta`(草稿调试)或已发布版本号;默认调用最新发布版本。不暴露给模型 |
| `chatTimeoutMs` | number | `300000` | `kb_chat` 超时时间 —— 服务端是分钟级的多轮检索循环 |
### 解析优先级
| 值 | settings 用户层(设置页) | entry configprofile patch | 凭据存储 / 环境变量 |
| ------------------- | --------------------------- | ----------------------------- | ----------------------------------- |
| `DASHSCOPE_API_KEY` | 只写控件 | — | `DASHSCOPE_API_KEY` |
| 工作空间 ID | ✅ `workspaceId` | ✅ `workspaceId` | `BAILIAN_WORKSPACE_ID` |
| 默认检索服务 | ✅ `defaultRetrieveAgentId` | ✅ `defaultRetrieveAgentId` | `BAILIAN_DEFAULT_RETRIEVE_AGENT_ID` |
| 默认对话服务 | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | `BAILIAN_DEFAULT_CHAT_AGENT_ID` |
所有值每次调用重新解析因此轮换密钥或切换工作空间即时生效。API 密钥与工作空间 ID 是必填项:缺失时工具调用会报错并指出上述配置路径。默认服务是选填的 —— 当某个场景下工作空间只有一个已部署服务时,直接用它。
## 工具
| 工具 | 参数 | 返回 |
| ----------- | ---------------------------------------------------------------------------------------- | ------------------------------ |
| `kb_search` | `query``agent_id`(必填)、`top_k?`(默认 5客户端截断`images?`(多模态图片 URL | 带来源引用的评分切片,以及总数 |
| `kb_chat` | `message``agent_id`(必填) | 完整答案,以及 `request_id` |
两个工具的 schema 中 `agent_id` 均为必填schema 无法告诉模型这套部署是否配了默认服务,而"调用时才发现没有默认值"会白费一轮。配置的默认服务仍对省略该参数的程序化调用生效。
已部署服务的清单ID、名称、场景以上下文消息的形式注入会话周期性刷新`bl knowledge service` 命令改动服务清单时也会刷新。清单过长时会截断并注明总数,避免模型把部分清单当成全部。
## 错误处理
- **HTTP 4xx** — 多数情况是 `agent_id` 已失效,因此会刷新服务清单并追加到错误信息里,便于立即纠正
- **HTTP 5xx** — 原样透传
- **凭据缺失** — 错误信息指出配置路径(`~/.dsh/.env``~/.dsh/.credentials.yaml`、设置页)并给出控制台取密钥的链接
- **`kb_chat` 超时** — 错误信息说明服务端多轮检索的特性,建议重试或改用 `kb_search`
## 已知限制
- `kb_chat` 会缓冲服务端流式输出,执行期间没有进展显示。
- `top_k` 是客户端截断:请求体不含该参数,服务端返回多少切片由检索服务配置决定。
- **服务名承载了路由信号。** 服务列表接口目前不返回描述字段,模型只能靠服务名判断一个服务能查什么。请按内容命名(`产品文档检索`,而不是`检索服务1`)。
- 每个场景最多拉取两页,超出时注入的清单会标明已截断。
## 开发
```sh
pnpm --filter bailian-kb-dsh run build # tsc 出 dist/node 半)+ tsdown 出 dist/web/client.js浏览器半
pnpm --filter bailian-kb-dsh run typecheck # node 与 web 两套 tsconfig
pnpm --filter bailian-kb-dsh run test
```
本地联调时把工作副本装进 dev profilepatch 文件受 HMR 监听):
```sh
dsh plugin --profile dev add <本仓库>/packages/bailian-kb-dsh
```
内部设计说明(上下文注入策略、服务缓存布局、刷新触发点)见 [docs/kb-dsh/runtime-behavior.md](https://github.com/modelstudioai/cli/blob/main/docs/kb-dsh/runtime-behavior.md);维护清单见 [docs/agents/dsh-plugin.md](https://github.com/modelstudioai/cli/blob/main/docs/agents/dsh-plugin.md)。
## 参与贡献
欢迎提交 Bug 报告、功能建议和 PR。开发环境搭建与贡献流程见 [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md)。
## 许可证
[Apache 2.0](LICENSE)
+13
View File
@@ -0,0 +1,13 @@
# bailian-kb-dsh: inserts the Bailian knowledge-base consumer over dsh-base.
# workspaceId here is one resolution layer, not the only one: a config value
# (this env read included) wins per call; when it is unset the plugin resolves
# the BAILIAN_WORKSPACE_ID credential instead (web UI card or
# ~/.dsh/.credentials.yaml). The same fallback covers defaultRetrieveAgentId
# via BAILIAN_DEFAULT_RETRIEVE_AGENT_ID, defaultChatAgentId via
# BAILIAN_DEFAULT_CHAT_AGENT_ID, and the API key via DASHSCOPE_API_KEY.
- insert:
- id: tool-bailian-kb
name: "bailian-kb-dsh"
config:
workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
+118
View File
@@ -0,0 +1,118 @@
{
"name": "bailian-kb-dsh",
"version": "0.1.19",
"description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.",
"keywords": [
"alibaba-cloud",
"aliyun",
"bailian",
"dashscope",
"deepseek-harness",
"dsh",
"knowledge-base",
"rag"
],
"homepage": "https://github.com/modelstudioai/cli/tree/main/packages/bailian-kb-dsh",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
},
"license": "Apache-2.0",
"author": "Aliyun Model Studio",
"repository": {
"type": "git",
"url": "git+https://github.com/modelstudioai/cli.git",
"directory": "packages/bailian-kb-dsh"
},
"files": [
"dist",
"skills",
"cordis.patch.yml"
],
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"default": "./dist/web/client.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "tsc -b tsconfig.build.json && tsc -p tsconfig.web.json && tsdown",
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.web.json",
"test": "vp test",
"check": "vp check"
},
"dependencies": {
"yaml": "catalog:"
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
"@types/node": "catalog:",
"@types/react": "~18.3.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"tsdown": "^0.22.2",
"typescript": "catalog:",
"vite-plus": "catalog:",
"yaml": "catalog:"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
"react": "^18.2.0"
},
"engines": {
"node": ">=22.12.0"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
}
}
@@ -0,0 +1,96 @@
---
name: bailian-kb
description: >-
管理阿里云百炼知识库建库、上传文档、部署检索服务、Chunk 运维、数据中心文件管理),命令行工具为 blbailian-cli
当用户要创建/更新/删除知识库、上传或导入文档(本地/OSS、创建/部署/调参检索或问答服务、
增删改查 Chunk、管理数据中心类目/文件/集合时使用本 skill。
检索与问答不走本 skill——用原生工具 kb_search取证据/ kb_chat成品问答
bl knowledge search / chat 仅用于部署后的验证调试(如 --agent-version beta 调试草稿版)。
kb_search / kb_chat 的凭据与工作空间由插件自动解析(~/.dsh/settings.yaml 的 bailian-kb 段、
~/.dsh/.credentials.yaml 的 DASHSCOPE_API_KEY不要自己去读或传。
普通问答、编程、写作、翻译、泛搜索不触发本 skill。
---
# 百炼知识库管理bl
检索面与管理面的分工:**查知识用 `kb_search`(取证据)/ `kb_chat`(成品问答)原生工具;本 skill 只覆盖管理长尾**——知识库全生命周期、文档、检索服务、Chunk、数据中心。
本 skill **不负责**判断何时该检索。可用检索服务的清单(含 agent_id由插件自动注入到会话上下文里`kb_search` / `kb_chat` 直接取用;不需要为了检索先加载本 skill。
## 检索服务清单的行为语义
- 清单由插件从百炼 API 拉取后缓存,按会话周期性刷新(约 30 分钟),只含 **deployed** 状态的服务;
- **刚用 `bl` 新建或部署的服务不会立刻出现在清单里**。不用等刷新——命令输出里刚拿到的 `agent_id` 直接可用;
- 服务很多时清单只列最近修改的若干条并标明总数。要找特定服务用 `bl knowledge service list --scene search --name <关键词>`
- 清单里确实没有能回答用户问题的服务时,如实告知用户,**不要挑一个最像的 agent_id 去试**。
## 前置检查
1. 安装校验:运行 `bl knowledge list --help`。若报 `Unknown command` 或 bl 未安装,执行
`npm install -g bailian-cli`(需 Node.js ≥ 18.17);已安装但命令缺失时先 `bl update` 升级。
安装失败时把错误原样报告给用户,不要静默跳过。
2. 鉴权:需要 `DASHSCOPE_API_KEY`(环境变量,或 `bl auth login --api-key sk-xxx`,或 `bl config set --key api_key --value sk-xxx`)。
3. workspace 解析优先级:`--workspace-id` 参数 > 环境变量 `BAILIAN_WORKSPACE_ID` > `bl config set --key workspace_id --value ws-xxx`
## 何时用哪个命令
| 用户意图 | 命令 | 备注 |
| ---------------------------------------- | --------------------------------------------------- | -------------------------------- |
| 查知识 / 问答(日常检索) | 原生工具 `kb_search` / `kb_chat` | 不走 bl |
| 建库 / 查看 / 改名 / 删库 / 监控 | `bl knowledge create/list/info/update/delete/stats` | `bl knowledge create --help` |
| 上传本地文档、看解析状态、删文档、打标签 | `bl knowledge doc upload/list/status/delete/tag` | `bl knowledge doc upload --help` |
| 从 OSS 批量导入 | `bl knowledge doc import-oss` | Bucket 需预先授权服务角色 |
| 创建 / 部署 / 调参检索(问答)服务 | `bl knowledge service create/update/deploy/…` | `bl knowledge service --help` |
| 修正错误切片、屏蔽某段内容 | `bl knowledge chunk add/list/update/delete` | `bl knowledge chunk --help` |
| 数据中心类目 / 文件 / 集合管理 | `bl knowledge category/file/collection …` | `bl knowledge category --help` |
| CLI 配置、升级 | `bl config show/set``bl update` | `bl config --help` |
| 部署后验证、调试草稿版服务 | `bl knowledge search/chat --agent-version beta` | `bl knowledge search --help` |
## 核心工作流:建库到可检索
```bash
bl knowledge doc upload --file ./docs/ --workspace-id ws-xxx # 1. 上传本地文件/目录 → 得 fileId
bl knowledge create --name my-kb --description '产品文档' --doc-id <fileId> --wait # 2. 建库并导入 → 得 index-id (pipelineId)
bl knowledge service create --name my-search --scene search --index-id <index-id> # 3. 建检索服务 → 得 agent-iddraft
bl knowledge service deploy --agent-id <agent-id> --yes # 4. 发布服务(此后可被默认版本调用)
bl knowledge service list --scene search --status deployed # 5. 确认服务可见
```
部署完成后用原生工具 `kb_search` 带该 `agent_id` 验证检索;若要在部署前调试草稿配置,用 `bl knowledge search --agent-id <id> --agent-version beta`
已有文件再入库的简写:`bl knowledge doc upload --file ./a.md --index-id <index-id> --wait`(上传+导入一步完成)。
## ID 速查(极易混淆)
| ID | 来源 | 用在哪 |
| ----------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `index-id` | `knowledge create` 返回的 pipelineId / `knowledge list` | 所有 knowledge/doc/chunk 命令的 `--index-id` |
| `fileId` | `doc upload` / `doc import-oss` 返回 | 数据中心命令(`file get/delete``knowledge create --doc-id``doc tag` |
| `doc_id`(库内文档 ID | `doc list` 输出 | `doc delete``chunk add/update``--doc-id`**可能带 workspace 后缀,≠ fileId** |
| `job-id` | 导入命令返回的 ingestionId | `doc status`(必须同时给 `--index-id``--job-id` |
| chunk id | `chunk list` 输出的 `metadata._id` | `chunk update/delete``--chunk-id` |
| `agent-id` | `service create/list` | `service *``kb_search`/`kb_chat``bl knowledge search/chat` |
## 命令参考
执行不熟悉的命令前,跑 `bl <命令> --help` 查看完整 Usage / Flags / Notes / Examples。**不要猜 flag。**
全部命令支持 `--output json`(结构化输出)、`--dry-run`(预览请求)、`--quiet``--verbose`
## 危险与不可逆操作
执行以下操作前须向用户确认,脚本化时才用 `--yes` 跳过交互确认:
- `knowledge delete`:不可逆,库和全部索引内容永久删除(数据中心源文件保留)。
- `file delete`:不可逆,且引用该文件的知识库文档索引会失效;只想从单个库移除用 `doc delete`
- `chunk delete`:不可逆。
- `service deploy`:发布影响线上调用方;`service delete` 后 agent_id 不可再用(软删、幂等)。
- `collection create`**没有删除 API**,创建集合要慎重。
- 索引配置embedding 模型、chunk size 等)建库后不可改,只能重建。
## 最佳实践
- **建服务时必须把名字写清楚**`service create --name` 的名称是模型判断"这个服务能查什么"的主要依据(服务描述暂未随列表接口返回)。`检索服务1` 这类无语义的名字会让后续检索无法路由;写成 `产品文档检索``HR制度问答` 这种能看出覆盖内容的名字。同时填 `--description`≤1000 字符),列表接口返回该字段后即可自动生效。
- 服务有 draft/deployed 两种状态:只有 deployed 可被默认版本调用,也只有 deployed 会进入模型看到的服务清单draft 调试用 `--agent-version beta`。改已发布版本的配置:先改 beta 草稿(`service update`),验证后 `service deploy` 发新版本。
- 导入类命令(`knowledge create``doc upload --index-id``doc status`)优先带 `--wait` 轮询到终态,避免手工轮询;文档解析失败(如 PARSE_FAILED会以非零退出码透传错误。
- `chunk add` 有 10 QPS 限流,批量脚本注意节流;响应不带 chunk id需要 `chunk list` 反查。
- `service list` 必须带 `--scene chat|search`,两个场景要分别查询。
+72
View File
@@ -0,0 +1,72 @@
/** Request/response fields of the DashScope search and chat endpoints. */
/** Retrieval-service scenes; the server requires one per list query. */
export type ServiceScene = "chat" | "search";
export interface ServiceListRequest {
agent_scene: ServiceScene;
/** Filter to deployed services (spelling required by the server). */
agent_status?: "deployed";
agent_name?: string;
page_number: number;
page_size: number;
}
/**
* One row of the service list.
*
* `description` is not included: the listing endpoint does not currently return
* it, even for services created with a description. Add it here once it starts
* appearing on the wire.
*
* `pipeline_list` is typed but deliberately never consumed: it can omit
* `pipeline_name` or come back empty, so it cannot serve as a knowledge-base
* label.
*/
export interface ServiceListRow {
agent_id?: string;
agent_name?: string;
agent_scene?: string;
agent_status?: string;
agent_version?: string;
create_time?: string;
modify_time?: string;
pipeline_list?: { pipeline_id?: string; pipeline_name?: string }[];
}
export interface ServiceListResponse {
code?: string;
message?: string;
data?: { total_count?: number; rows?: ServiceListRow[] };
}
export interface SearchRequest {
query: string;
agent_id: string;
agent_version?: string;
images?: string[];
}
export interface SearchResponse {
request_id?: string;
data?: {
total?: number;
nodes?: { score: number; text: string; metadata?: Record<string, unknown> }[];
};
}
export interface ChatRequest {
input: { messages: { role: "user" | "assistant"; content: string }[] };
parameters: { agent_options: { agent_id: string; agent_version?: string } };
stream: true;
}
export interface ChatStreamChunk {
output?: {
choices?: {
message?: { content?: string; extra?: { step_change?: string } };
finish_reason?: string;
}[];
};
request_id?: string;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Host-side bridge to the locally installed `bl` CLI (bailian-cli): reads the
* credentials an earlier `bl auth login` already stored, so a user who has
* already set the CLI up does not have to configure this plugin a second time.
*
* Reading the CLI's credential file (`~/.bailian/config.json`) is the only way
* to obtain the value programmatically — CLI commands mask stored keys on
* output, so there is nothing to parse from stdout.
*
* Starting a login is NOT done through the CLI; see `console-login.ts`.
*/
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
/** The two values this plugin can adopt from the bl CLI credential file. */
export interface BlCliConfig {
/** DashScope api key (`api_key`, top-level default profile). */
apiKey?: string;
/** Bailian workspace id (`workspace_id`), present when the console login callback carried one. */
workspaceId?: string;
}
/** Default location of the bl CLI credential file (default profile at top level). */
export function blCliConfigPath(): string {
return join(homedir(), ".bailian", "config.json");
}
/**
* Read the api key and workspace id from the bl CLI credential file.
* Best-effort: a missing, unreadable, or malformed file reads as empty —
* callers treat that the same as "the CLI has not logged in yet".
* @param configPath - override for tests; defaults to `~/.bailian/config.json`.
* @returns the values found; fields are absent rather than blank.
*/
export function readBlCliConfig(configPath = blCliConfigPath()): BlCliConfig {
try {
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const record = parsed as Record<string, unknown>;
const apiKey =
typeof record.api_key === "string" && record.api_key.trim() !== ""
? record.api_key.trim()
: undefined;
const workspaceId =
typeof record.workspace_id === "string" && record.workspace_id.trim() !== ""
? record.workspace_id.trim()
: undefined;
return {
...(apiKey !== undefined ? { apiKey } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
};
} catch (_unreadable) {
return {};
}
}
+46
View File
@@ -0,0 +1,46 @@
/** Buffered consumption of the knowledge chat SSE stream: deltas concatenate into one complete answer. */
import type { ChatStreamChunk } from "./api-types.js";
import { KbApiError } from "./client.js";
import { parseSseStream } from "./sse.js";
export interface ChatResult {
answer: string;
requestId?: string;
}
/**
* Consume one chat SSE response to completion.
* @param res - the SSE response from KbClient.postSse.
* @returns the concatenated answer and the last seen request id.
*/
export async function consumeChatStream(res: Response): Promise<ChatResult> {
if (!res.body) throw new KbApiError("knowledge chat returned no response body");
let answer = "";
let requestId: string | undefined;
for await (const event of parseSseStream(res.body)) {
if (event.data === "[DONE]") break;
if (event.event === "error") {
let message = `knowledge chat stream error: ${event.data}`;
try {
const err = JSON.parse(event.data) as { code?: string; message?: string };
if (err.message)
message = `knowledge chat stream error${err.code ? ` (${err.code})` : ""}: ${err.message}`;
} catch {
/* non-JSON error payload: keep the raw data in the message */
}
throw new KbApiError(message);
}
let parsed: ChatStreamChunk;
try {
parsed = JSON.parse(event.data) as ChatStreamChunk;
} catch {
continue;
} // unparseable keep-alive/comment payloads carry no answer content
if (parsed.request_id) requestId = parsed.request_id;
for (const choice of parsed.output?.choices ?? []) {
if (choice.message?.content) answer += choice.message.content;
}
}
return { answer, requestId };
}
+101
View File
@@ -0,0 +1,101 @@
/** Shared HTTP client for the knowledge endpoints: per-call Bearer auth, JSON/SSE POST, and error translation. */
import { kbEndpoint } from "./endpoints.js";
/** Maximum error-body characters kept in a translated message. */
const ERROR_BODY_LIMIT = 500;
/** One knowledge API failure: HTTP status plus a bounded server-body summary. */
export class KbApiError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = "KbApiError";
}
}
export interface KbClientOptions {
/** Resolves the current workspace id per call (patch config or credential); throws with guidance when unconfigured. */
resolveWorkspaceId: () => Promise<string>;
endpointHost: string;
/** Service version forwarded on search/chat when set (deployment debug choice). */
agentVersion?: string;
/** Resolves the current DASHSCOPE_API_KEY per call; throws with guidance when unconfigured. */
resolveApiKey: () => Promise<string>;
/** Test seam; defaults to global fetch. */
fetchImpl?: typeof fetch;
}
export class KbClient {
constructor(private readonly opts: KbClientOptions) {}
/** The deployment's configured service version, exposed for request builders. */
get agentVersion(): string | undefined {
return this.opts.agentVersion;
}
private async post(
path: string,
body: unknown,
accept: string,
signal?: AbortSignal,
): Promise<Response> {
const [apiKey, workspaceId] = await Promise.all([
this.opts.resolveApiKey(),
this.opts.resolveWorkspaceId(),
]);
const fetchImpl = this.opts.fetchImpl ?? fetch;
const url = kbEndpoint(this.opts.endpointHost, workspaceId, path);
const res = await fetchImpl(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: accept,
},
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
const raw = (await res.text().catch(() => "")).slice(0, ERROR_BODY_LIMIT);
let detail = raw;
try {
const parsed = JSON.parse(raw) as { message?: string; code?: string };
if (parsed.message)
detail = parsed.code ? `${parsed.code}: ${parsed.message}` : parsed.message;
} catch {
/* non-JSON error body: keep the bounded raw text */
}
throw new KbApiError(
`knowledge API ${path} failed (HTTP ${res.status}): ${detail}`,
res.status,
);
}
return res;
}
/**
* POST one JSON request and parse the JSON response.
* @param path - one KB_PATHS value.
* @param body - JSON-serializable request body.
* @param signal - optional abort/timeout signal.
* @returns the parsed response.
*/
async postJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await this.post(path, body, "application/json", signal);
return (await res.json()) as T;
}
/**
* POST one JSON request expecting an SSE response stream.
* @param path - one KB_PATHS value.
* @param body - JSON-serializable request body.
* @param signal - abort/timeout signal (kb_chat passes its configured timeout).
* @returns the raw Response whose body is the SSE stream.
*/
async postSse(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
return await this.post(path, body, "text/event-stream", signal);
}
}
@@ -0,0 +1,311 @@
/**
* Self-contained Bailian console login: the plugin drives the console's browser
* login itself and persists what comes back, rather than shelling out to the
* `bl` CLI.
*
* Driving the flow here lets it always request a freshly issued api key, so the
* key and the workspace id both come from the account that just signed in, and
* the values land straight in the dsh stores.
*
* Shape of the flow: bind a loopback-only port, open the console login page
* pointed at that port, then accept one callback carrying the credentials as
* query parameters or a JSON / form-encoded body.
*/
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import http from "node:http";
/** Console web origins by site, keyed as the CLI's `--console-site` values. */
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: "https://bailian.console.aliyun.com",
international: "https://modelstudio.console.alibabacloud.com",
};
/** How long the loopback listener waits for the browser callback. */
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
/** Upper bound on a callback body. */
const MAX_CALLBACK_BODY = 65536;
/** Credentials the console callback can carry. */
export interface ConsoleLoginCredentials {
/** Freshly issued DashScope api key. */
apiKey?: string;
/** Workspace id of the account that signed in. */
workspaceId?: string;
}
/**
* Where the login flow stands. Deliberately carries no secret: the plain key
* is handed to the completion callback and never retained here, so polling
* this state from the browser cannot leak it.
*/
export type ConsoleLoginState =
| { phase: "idle" }
| { phase: "waiting"; loginUrl: string }
| { phase: "done"; fields: string[] }
| { phase: "failed"; reason: string };
/** The single in-flight flow: one browser login at a time. */
let active: { server: http.Server } | undefined;
let state: ConsoleLoginState = { phase: "idle" };
/** Read the current flow state (safe to expose to the panel). */
export function consoleLoginState(): ConsoleLoginState {
return state;
}
/** Pick the first non-blank string among the given keys. */
function stringField(source: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim() !== "") return value.trim();
}
return undefined;
}
/** Read a bounded UTF-8 request body; an oversized body reads as empty. */
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_CALLBACK_BODY) {
req.destroy();
resolve("");
return;
}
chunks.push(chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", () => {
resolve("");
});
});
}
/**
* Parse a callback body as JSON (optionally wrapped in `data`) or as form
* encoding. Content-type is a hint only: both shapes occur in practice, so both
* are attempted.
* @param raw - the raw request body.
* @returns the flattened fields; an unparseable body yields no fields.
*/
export function parseCallbackBody(raw: string): Record<string, unknown> {
const text = raw.replace(/^\uFEFF/, "").trim();
if (text === "") return {};
let json: unknown;
let parsedAsJson = false;
try {
json = JSON.parse(text);
parsedAsJson = true;
} catch (_notJson) {
/* fall through to form parsing */
}
if (parsedAsJson) {
// Valid JSON that is not an object carries no fields. Returning here rather
// than falling through matters: form parsing would turn the whole payload
// into one junk key.
if (json === null || typeof json !== "object" || Array.isArray(json)) return {};
const record = json as Record<string, unknown>;
const inner = record.data;
if (inner !== null && typeof inner === "object" && !Array.isArray(inner)) {
// Merge the envelope's `data` under the top level, top level winning.
return { ...(inner as Record<string, unknown>), ...record };
}
return record;
}
try {
return Object.fromEntries(new URLSearchParams(text));
} catch (_notForm) {
return {};
}
}
/**
* Pick the api key and workspace id out of a callback's fields, query
* parameters taking priority over the body.
* @param query - the callback URL's query parameters.
* @param body - the parsed callback body.
* @returns the credentials found; fields are absent rather than blank.
*/
export function pickCallbackCredentials(
query: Record<string, unknown>,
body: Record<string, unknown>,
): ConsoleLoginCredentials {
const apiKey = stringField(query, "api_key", "apiKey") ?? stringField(body, "api_key", "apiKey");
const workspaceId =
stringField(query, "workspace_id", "workspaceId") ??
stringField(body, "workspace_id", "workspaceId");
return {
...(apiKey !== undefined ? { apiKey } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
};
}
/** Extract the credentials from a callback, query parameters taking priority. */
async function extractCredentials(
req: http.IncomingMessage,
url: URL,
): Promise<ConsoleLoginCredentials> {
const method = req.method ?? "GET";
const body =
method === "POST" || method === "PUT" || method === "PATCH"
? parseCallbackBody(await readBody(req))
: {};
return pickCallbackCredentials(Object.fromEntries(url.searchParams), body);
}
/** Open a URL with the OS default handler; never routed through a shell. */
function openInBrowser(url: string): Promise<void> {
const cmd =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
/** Bind an http server to a loopback-only port chosen by the OS. */
function listenLoopback(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onError = (err: Error): void => {
reject(err);
};
server.once("error", onError);
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onError);
const address = server.address();
if (address === null || typeof address === "string") {
reject(new Error("expected a TCP address"));
return;
}
resolve(address.port);
});
});
}
/** Outcome of asking the host to start a console login. */
export type ConsoleLoginStart =
| { status: "started"; loginUrl: string }
| { status: "already-running"; loginUrl: string }
| { status: "failed"; reason: string };
/**
* Start a console login on the host: binds a loopback listener, opens the
* console login page in the host's default browser, and hands the credentials
* from the callback to `onComplete` (which persists them). Fire-and-forget —
* this resolves once the browser has been opened; poll {@link consoleLoginState}
* for the outcome.
* @param opts.site - console site, `domestic` (default) or `international`.
* @param opts.onComplete - persists the received credentials; its resolved
* field names become the `done` state's `fields`.
* @returns whether the flow started, plus the URL to open manually if needed.
*/
export async function startConsoleLogin(opts: {
site?: string;
onComplete: (credentials: ConsoleLoginCredentials) => Promise<string[]>;
}): Promise<ConsoleLoginStart> {
if (active !== undefined) {
return {
status: "already-running",
loginUrl: state.phase === "waiting" ? state.loginUrl : "",
};
}
const expectedState = randomBytes(16).toString("hex");
let settled = false;
const server = http.createServer((req, res) => {
void (async () => {
if (req.method === "OPTIONS") {
// The console page posts cross-origin; answer its preflight.
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.searchParams.get("state") !== expectedState) {
// Not our callback (or a forged one): refuse without ending the flow.
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("bad state\n");
return;
}
const credentials = await extractCredentials(req, url);
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
});
res.end("OK\n");
if (credentials.apiKey === undefined && credentials.workspaceId === undefined) {
// A callback with neither value (e.g. a bare probe): keep waiting.
return;
}
settled = true;
try {
const fields = await opts.onComplete(credentials);
state = { phase: "done", fields };
} catch (err) {
state = { phase: "failed", reason: err instanceof Error ? err.message : "persist failed" };
}
server.close();
})().catch(() => {
res.statusCode = 500;
res.end();
});
});
let port: number;
try {
port = await listenLoopback(server);
} catch (err) {
const reason = err instanceof Error ? err.message : "could not bind a local port";
state = { phase: "failed", reason };
return { status: "failed", reason };
}
// Ask for a freshly issued key, so the key and the workspace id cannot end up
// belonging to two different accounts.
const origin =
(opts.site !== undefined ? CONSOLE_ORIGINS[opts.site] : undefined) ?? CONSOLE_ORIGINS.domestic!;
const loginUrl =
`${origin}/console-login?notice=127.0.0.1:${port}` +
`?state=${encodeURIComponent(expectedState)}&needapikey=true`;
active = { server };
state = { phase: "waiting", loginUrl };
const timer = setTimeout(() => {
server.close();
}, LOGIN_TIMEOUT_MS);
timer.unref?.();
server.once("close", () => {
clearTimeout(timer);
active = undefined;
if (!settled && state.phase === "waiting") {
state = { phase: "failed", reason: "the login timed out before the console called back" };
}
});
try {
await openInBrowser(loginUrl);
} catch (_browserRefused) {
// Headless or locked-down host: the panel shows `loginUrl` to open by hand.
}
return { status: "started", loginUrl };
}
/** Abandon an in-flight login (closes the listener). */
export function cancelConsoleLogin(): void {
active?.server.close();
active = undefined;
state = { phase: "idle" };
}
+24
View File
@@ -0,0 +1,24 @@
/** Protocol path constants and the workspace-subdomain URL builder (external API spec; not configurable). */
/**
* DashScope knowledge API paths. `serviceList` backs the plugin's internal
* service cache only — it is deliberately NOT exposed as a model tool (a
* discovery tool reintroduces the "list before you search" round trip this
* design exists to remove); the management surface uses the bl CLI instead.
*/
export const KB_PATHS = {
serviceList: "/api/v1/indices/rag/app/list",
search: "/api/v1/indices/knowledge/search",
chat: "/api/v2/apps/knowledge/chat",
} as const;
/**
* Build one knowledge API endpoint.
* @param endpointHost - host suffix, e.g. `cn-beijing.maas.aliyuncs.com`.
* @param workspaceId - Bailian workspace id used as the subdomain.
* @param path - one {@link KB_PATHS} value.
* @returns the absolute endpoint URL.
*/
export function kbEndpoint(endpointHost: string, workspaceId: string, path: string): string {
return `https://${workspaceId}.${endpointHost}${path}`;
}
+615
View File
@@ -0,0 +1,615 @@
/**
* Bailian knowledge-base consumer plugin: registers kb_search and kb_chat over the DashScope RAG API,
* plus the bl management skill.
* @module dsh-tool-bailian-kb
*/
import type { Context } from "@deepseek-ai/cordis";
import type { IncomingMessage, ServerResponse } from "node:http";
import z from "@deepseek-ai/schemastery";
import { credentialRef } from "@deepseek-ai/dsh-credentials";
import {
settingsNamespace,
SettingsProvider,
type SettingsRegisterOptions,
type SettingsScope,
} from "@deepseek-ai/dsh-settings";
import { readBlCliConfig } from "./bl-cli.js";
import { consoleLoginState, startConsoleLogin } from "./console-login.js";
import { KbClient } from "./client.js";
import { registerSkill } from "./skill.js";
import { ServiceCache } from "./service-cache.js";
import { buildRefreshedSceneList } from "./service-catalog.js";
import { installServiceContext } from "./service-context.js";
import type { ServiceScene } from "./api-types.js";
import { createKbTools } from "./tools.js";
/** Minimal webServer route shape (declared inline to avoid a host-package dependency). */
interface WebRoute {
kind: "exact" | "prefix";
path: string;
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
}
/** Shell-environment registration shape (declared inline to avoid a host-package dependency). */
interface ShellEnvRegistration {
name: string;
variables: Record<string, { description: string }>;
resolve: () => Record<string, string | undefined>;
}
declare module "@deepseek-ai/cordis" {
interface Context {
webServer: {
register(route: WebRoute): () => void;
};
shellEnv: {
register(registration: ShellEnvRegistration): void;
};
}
}
export const name = "tool-bailian-kb";
export const inject = ["tools", "credentials"];
/** Settings namespace this plugin registers when a settings service is composed. */
const SETTINGS_NS = settingsNamespace("bailian-kb");
/** Settings fields seeded once from their credential references ({@link seedFromCredentials}). */
const CREDENTIAL_SEEDS = [
["workspaceId", "BAILIAN_WORKSPACE_ID"],
["defaultRetrieveAgentId", "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID"],
["defaultChatAgentId", "BAILIAN_DEFAULT_CHAT_AGENT_ID"],
] as const;
/**
* Every {@link Config} field the bridge route accepts. A static allowlist,
* NOT `key in current()`: optional fields with no default and no base
* (the default service ids) vanish from the resolved config once cleared,
* and a membership test against it would silently drop their next write.
*/
const CONFIG_FIELDS = new Set<string>([
"workspaceId",
"endpointHost",
"defaultRetrieveAgentId",
"defaultChatAgentId",
"agentVersion",
"chatTimeoutMs",
]);
/**
* One-time migration: before this section existed, the workspace and
* default-service ids lived only as credentials, which the wire never echoes.
* Seed each field the resolved section does not answer from the WRITABLE
* credential layer (`file`), so the page shows the value the deployment
* already runs with; env-sourced values stay where they are — freezing one
* into the document would shadow later environment changes.
* @param ctx - registrant context carrying credentials.
* @param scope - the registered `bailian-kb` scope the seed writes through.
*/
async function seedFromCredentials(ctx: Context, scope: SettingsScope<Config>): Promise<void> {
try {
const seeds: Partial<Record<(typeof CREDENTIAL_SEEDS)[number][0], string>> = {};
for (const [field, ref] of CREDENTIAL_SEEDS) {
if (scope.get()[field]) continue;
const resolved = await ctx.credentials.resolve(credentialRef(ref));
if (resolved?.source !== "file") continue;
seeds[field] = resolved.value;
}
if (Object.keys(seeds).length > 0) await scope.update(seeds);
} catch (_migrationFailure) {
// Best-effort: a failed seed leaves the credential fallback in place, so
// resolution still answers — the page merely starts blank.
}
}
/**
* One-time adoption of the bl CLI's stored login (`~/.bailian/config.json`):
* fields never set anywhere are filled from the CLI's credential file, so a
* `bl auth login --console` done before installing the plugin "just works".
* `seededFields` is the consumed-once ledger — a field is marked when it was
* seeded here, or when it already had a value (user-managed elsewhere) — so
* a value the user later clears deliberately is never resurrected.
* @param ctx - registrant context carrying credentials.
* @param scope - the registered `bailian-kb` scope the seed writes through.
*/
async function seedFromBlCli(ctx: Context, scope: SettingsScope<Config>): Promise<void> {
try {
const done = new Set(scope.get().seededFields ?? []);
if (done.has("apiKey") && done.has("workspaceId")) return;
const bl = readBlCliConfig();
const marks: string[] = [];
if (!done.has("apiKey")) {
const existing = await ctx.credentials.resolve(credentialRef("DASHSCOPE_API_KEY"));
if (existing) {
// Already configured (env or file): user-managed, never seed later.
marks.push("apiKey");
} else if (bl.apiKey !== undefined) {
try {
await ctx.credentials.set(credentialRef("DASHSCOPE_API_KEY"), bl.apiKey);
marks.push("apiKey");
} catch (_readOnlyShadow) {
// A read-only source refuses the write; leave unmarked so a later
// startup (once the shadow is gone) can still seed.
}
}
// Neither configured nor available from the CLI: leave unmarked so a
// later startup (after `bl auth login --console`) can seed.
}
if (!done.has("workspaceId")) {
const configured =
scope.get().workspaceId ||
(await ctx.credentials.resolve(credentialRef("BAILIAN_WORKSPACE_ID"))) !== undefined;
if (configured) {
marks.push("workspaceId");
} else if (bl.workspaceId !== undefined) {
await scope.update({ workspaceId: bl.workspaceId });
marks.push("workspaceId");
}
}
if (marks.length > 0) {
await scope.update({
seededFields: [...new Set([...(scope.get().seededFields ?? []), ...marks])],
});
}
} catch (_seedFailure) {
// Best-effort: an unseeded field still resolves through the normal
// credential fallback chain, and the panel's autofill button remains.
}
}
/** Bailian knowledge-base plugin configuration. */
export interface Config {
/** Bailian workspace id; the API host is the workspace subdomain `https://<workspaceId>.<endpointHost>`. Optional here: an unset value falls back per call to the BAILIAN_WORKSPACE_ID credential (env/.env or ~/.dsh/.credentials.yaml). Editable with echo on the Settings → 百炼知识库 page (settings layer). */
workspaceId?: string;
/** API host suffix; replace for other regions or private deployments. */
endpointHost: string;
/** Retrieval-service id pinned by this deployment; when unset, the per-call fallback reads the BAILIAN_DEFAULT_RETRIEVE_AGENT_ID credential. */
defaultRetrieveAgentId?: string;
/** Q&A-service id pinned by this deployment; when unset, the per-call fallback reads the BAILIAN_DEFAULT_CHAT_AGENT_ID credential. */
defaultChatAgentId?: string;
/** Service version to call: `beta` (draft) or a published number; defaults to the latest published version. Never model-visible. */
agentVersion?: string;
/** kb_chat timeout in milliseconds; the server side is a minutes-scale agentic loop. */
chatTimeoutMs: number;
/** Consumed-once ledger of {@link seedFromBlCli}: fields listed here are never auto-seeded again, so a deliberately cleared value stays cleared. Maintained by the host; not editable from the panel. */
seededFields?: string[];
}
/** Schemastery validation for {@link Config}; workspaceId and default agent ids are optional — both resolve per call with a credentials fallback. */
export const Config: z<Config> = z.object({
workspaceId: z.string(),
endpointHost: z.string().default("cn-beijing.maas.aliyuncs.com"),
defaultRetrieveAgentId: z.string(),
defaultChatAgentId: z.string(),
agentVersion: z.string(),
chatTimeoutMs: z.number().default(300_000),
seededFields: z.array(z.string()),
});
/**
* Register the two knowledge tools over one shared client, plus the
* management skill when a skills registry is composed. The Config doubles as
* the `bailian-kb` settings section (entry config as the base layer), so
* every value is read through the live source thunk per call — tool schemas
* are static (agent_id stays required regardless), so a settings edit needs
* no re-registration.
* @param ctx - registrant context carrying tools and credentials.
* @param config - deployment's workspace, host, pinning, and timeout choices.
*/
export function apply(ctx: Context, config: Config): void {
// The active configuration source: the composition entry until a settings
// service attaches, then the resolved section (schema defaults → entry
// base → user layer). Detach falls back to the entry automatically.
// Hand-rolled instead of `installSettingsSection` for two extras it does
// not carry: the `expose` opt-in (this page edits the section from the
// browser) and the scope handle the credential migration writes through.
let current: () => Config = () => config;
let scope: SettingsScope<Config> | undefined;
/** The settings provider, captured for `mutate` (path-level unset) access. */
let settings: SettingsProvider | undefined;
/**
* Mark fields as consumed in the {@link seedFromBlCli} ledger — called on
* every user-driven write or clear, so a managed field is never re-seeded.
* Best-effort: a failed mark only risks one extra seed attempt.
*/
const markSeeded = async (fields: readonly string[]): Promise<void> => {
if (!scope) return;
try {
const done = new Set(scope.get().seededFields ?? []);
const added = fields.filter((field) => !done.has(field));
if (added.length === 0) return;
await scope.update({ seededFields: [...done, ...added] });
} catch (_markFailure) {
/* best-effort */
}
};
ctx.inject(["settings"], (sctx) => {
settings = sctx.settings;
// `expose` is the wire opt-in the harness documents as deferred work; the
// assertion keeps this compiling against pristine upstream types, which do
// not declare it yet. Until upstream lands it the option is ignored and
// the browser page degrades to its credentials-only fallback.
const options = { base: config, expose: true } as SettingsRegisterOptions<Config>;
scope = sctx.settings.register(SETTINGS_NS, Config, options);
current = () => scope!.get();
sctx.effect(
() => () => {
current = () => config;
},
"tool-bailian-kb: settings source fallback",
);
void seedFromCredentials(ctx, scope).then(() => seedFromBlCli(ctx, scope!));
// Any api-key write or clear — this panel, the Models page, an external
// file edit — means the user manages the credential: consume the seed so
// a deliberately cleared key is never resurrected at the next startup.
sctx.on("credentials/updated", (ref) => {
if (ref === "DASHSCOPE_API_KEY") void markSeeded(["apiKey"]);
});
});
const client = new KbClient({
resolveWorkspaceId: async () => {
const pinned = current().workspaceId;
if (pinned) return pinned;
const resolved = await ctx.credentials.resolve(credentialRef("BAILIAN_WORKSPACE_ID"));
if (!resolved) {
throw new Error(
"BAILIAN_WORKSPACE_ID is not configured. Set the workspace id in the web UI (Settings → 百炼知识库) " +
"or in ~/.dsh/.credentials.yaml; it appears as the subdomain of your Bailian endpoints.",
);
}
return resolved.value;
},
// Live settings reads: the client keeps no copy, so a committed edit to
// the section applies on the next call.
get endpointHost() {
return current().endpointHost;
},
get agentVersion() {
return current().agentVersion;
},
resolveApiKey: async () => {
const resolved = await ctx.credentials.resolve(credentialRef("DASHSCOPE_API_KEY"));
if (!resolved) {
throw new Error(
"DASHSCOPE_API_KEY is not configured. Set it in the web UI (Settings → 百炼知识库) " +
"or in ~/.dsh/.credentials.yaml (create a key at https://bailian.console.aliyun.com/?tab=app#/api-key).",
);
}
return resolved.value;
},
});
/** The workspace id if configured, without the client's guidance throw. */
const resolveWorkspaceIdOrUndefined = async (): Promise<string | undefined> => {
const pinned = current().workspaceId;
if (pinned) return pinned;
const resolved = await ctx.credentials.resolve(credentialRef("BAILIAN_WORKSPACE_ID"));
return resolved?.value;
};
const serviceCache = new ServiceCache({
client,
resolveWorkspaceId: async () => {
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) throw new Error("workspace id is not configured");
return workspaceId;
},
get endpointHost() {
return current().endpointHost;
},
warn: (message) => {
ctx.logger.warn(message);
},
});
/** The user's explicitly configured default for one scene: settings layer, then credential. */
const configuredDefaultAgentId = async (scene: ServiceScene): Promise<string | undefined> => {
const pinned =
scene === "search" ? current().defaultRetrieveAgentId : current().defaultChatAgentId;
if (pinned) return pinned;
const ref =
scene === "search" ? "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" : "BAILIAN_DEFAULT_CHAT_AGENT_ID";
const resolved = await ctx.credentials.resolve(credentialRef(ref));
return resolved?.value;
};
/**
* The default service for one scene, falling back to the sole deployed service
* when the workspace has exactly one. That last layer is the zero-configuration
* path for the common 2C deployment: with one service there is nothing to
* choose, so making the user name it in settings buys nothing.
*/
const resolveDefaultAgentId = async (scene: ServiceScene): Promise<string | undefined> => {
const configured = await configuredDefaultAgentId(scene);
if (configured !== undefined) return configured;
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) return undefined;
const forScene =
serviceCache.peek(workspaceId)?.entries.filter((entry) => entry.scene === scene) ?? [];
return forScene.length === 1 ? forScene[0]?.agent_id : undefined;
};
for (const tool of createKbTools({
client,
resolveDefaultRetrieveAgentId: async () => await resolveDefaultAgentId("search"),
resolveDefaultChatAgentId: async () => await resolveDefaultAgentId("chat"),
// Self-heal for a cached id the server has since rejected: refresh once and
// put the current list in the error, which reaches the model this step. An
// empty result is reported too rather than dropped — a bare "invalid
// agent_id" reads as "try another one", and when nothing is deployed no id
// can work.
describeServicesAfterRefresh: async (scene) => {
await serviceCache.refresh();
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) return undefined;
return buildRefreshedSceneList(scene, serviceCache.entriesFor(workspaceId, scene));
},
get chatTimeoutMs() {
return current().chatTimeoutMs;
},
})) {
ctx.tools.register(tool);
}
registerSkill(ctx);
// A management command that changes the service inventory invalidates the
// cache immediately, so the next session sees the new service instead of
// waiting out the TTL. `tools/result` is observe-only (it returns undefined and
// sits after the pipeline), so listening here cannot affect tool execution.
//
// The command string is matched inside the serialized arguments rather than
// against a specific tool name: the agent may run `bl` through bash, a
// terminal tool, or a run_code program. A loose match is deliberate — a false
// positive costs one list request, while a miss falls back to the TTL.
ctx.on("tools/result", (_exec, result) => {
if (result.isError) return;
const args = JSON.stringify((_exec as { arguments?: unknown }).arguments ?? "");
if (!/bl\s+knowledge\s+service\s+(create|deploy|delete|copy)/.test(args)) return;
serviceCache.invalidate();
void serviceCache.refresh();
});
// The service catalog rides an `agent/pre-step` context message rather than the
// tool descriptions: descriptions freeze at plugin load, and a plugin loads
// once per process, so in a long-running host a service created elsewhere
// would never be seen. Optional inject — a headless assembly without `agents`
// simply gets no catalog, and both tools keep working.
ctx.inject(["agents"], (actx) => {
installServiceContext(actx, {
cache: serviceCache,
resolveWorkspaceId: resolveWorkspaceIdOrUndefined,
resolveDefaultRetrieveAgentId: async () => await configuredDefaultAgentId("search"),
resolveDefaultChatAgentId: async () => await configuredDefaultAgentId("chat"),
warn: (message) => {
actx.logger.warn(message);
},
});
});
// Export the resolved workspace id as a shell environment variable so
// management CLI commands (`bl knowledge list`, `bl knowledge service list`, etc.)
// running in bash can see the value the settings service resolved.
// Without this, the settings.yaml value is invisible to child processes.
ctx.inject(["shellEnv"], (envCtx) => {
envCtx.shellEnv.register({
name: "bailian-kb",
variables: {
BAILIAN_WORKSPACE_ID: {
description:
"Bailian workspace id resolved from settings (Settings → 百炼知识库) or credentials.",
},
},
resolve: () => {
const wsId = current().workspaceId;
return wsId ? { BAILIAN_WORKSPACE_ID: wsId } : {};
},
});
});
// Bridge routes let the browser settings page read and write the resolved
// section without riding the settings wire (which requires an apiproxy
// allowlist entry the composition does not grant out-of-tree namespaces).
// GET and POST share one exact-route registration: the webServer map keys
// on (kind, path), so two registrations for the same path throw
// "duplicate route" and the second handler silently replaces the first.
ctx.inject(["webServer"], (wctx) => {
wctx.effect(
() =>
wctx.webServer.register({
kind: "exact",
path: "/bailian-kb/settings",
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method === "GET" || req.method === "HEAD") {
sendJson(res, 200, current());
return;
}
if (req.method !== "POST") {
sendJson(res, 405, { error: "use GET or POST" });
return;
}
if (!scope) {
sendJson(res, 503, { error: "settings service unavailable" });
return;
}
let body: unknown;
try {
body = await readJsonBody(req);
} catch (err) {
sendJson(res, 400, { error: err instanceof Error ? err.message : "bad request" });
return;
}
if (typeof body !== "object" || body === null) {
sendJson(res, 400, { error: "expected JSON object" });
return;
}
// Build a settings update patch. null-valued keys are removals (the
// field falls back to the entry config and then the credential store).
const patch: Record<string, unknown> = {};
const removals = new Set<string>();
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (!CONFIG_FIELDS.has(key)) continue;
if (value === null) {
removals.add(key);
continue;
}
patch[key] = value;
}
try {
// Apply non-removal patches first (scope.update merges into the user
// layer without disturbing other fields).
if (Object.keys(patch).length > 0) await scope.update(patch);
// Remove fields via path-level unset ops: this deletes the key from
// the user layer so it re-inherits the entry base and schema defaults.
// Using scope.replace() with the resolved config would bake defaults
// (endpointHost, chatTimeoutMs) and entry values into the user layer,
// shadowing future entry changes and polluting the stored document.
if (removals.size > 0 && settings) {
for (const key of removals) {
await settings.mutate(SETTINGS_NS, [{ op: "unset", path: [key] }]);
}
}
// A user-driven workspace write or clear consumes its bl-CLI seed:
// a deliberately cleared value must never be resurrected at startup.
if ("workspaceId" in patch || removals.has("workspaceId"))
await markSeeded(["workspaceId"]);
sendJson(res, 200, scope.get());
} catch (err) {
sendJson(res, 500, {
error: err instanceof Error ? err.message : "settings write failed",
});
}
},
}),
"tool-bailian-kb: settings bridge route",
);
// Service cache bridge: the panel's only window into cache freshness.
// GET returns the diagnostic snapshot plus the pickable services; POST
// forces a refresh and returns the same shape, so the numbers the developer
// sees update in place.
wctx.effect(
() =>
wctx.webServer.register({
kind: "exact",
path: "/bailian-kb/services",
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "POST") {
sendJson(res, 405, { error: "use GET or POST" });
return;
}
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) {
sendJson(res, 200, { configured: false });
return;
}
if (req.method === "POST") {
// Force a fetch regardless of TTL: the button exists precisely for the
// case where the developer believes the cache is wrong.
serviceCache.invalidate();
await serviceCache.refresh();
}
sendJson(res, 200, {
configured: true,
status: serviceCache.status(workspaceId),
search: serviceCache.entriesFor(workspaceId, "search"),
chat: serviceCache.entriesFor(workspaceId, "chat"),
});
},
}),
"tool-bailian-kb: service cache bridge route",
);
// Autofill bridge: fetch credentials by signing in to the Bailian console
// (panel button). `login` drives the console's callback protocol on the
// host and persists what comes back — the plain key never rides the wire
// to the browser; `loginStatus` lets the panel poll for the outcome.
wctx.effect(
() =>
wctx.webServer.register({
kind: "exact",
path: "/bailian-kb/autofill",
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "use POST" });
return;
}
let action = "login";
try {
const body = await readJsonBody(req);
if (
typeof body === "object" &&
body !== null &&
(body as { action?: unknown }).action === "loginStatus"
)
action = "loginStatus";
} catch (_emptyOrMalformedBody) {
/* default to login */
}
if (action === "loginStatus") {
sendJson(res, 200, consoleLoginState());
return;
}
// Drive the console flow ourselves, requesting a freshly issued key,
// so the key and the workspace id both belong to the account signing
// in now. Persisting here keeps the plain key on the host.
const started = await startConsoleLogin({
onComplete: async (credentials) => {
const written: string[] = [];
if (credentials.apiKey !== undefined) {
await ctx.credentials.set(credentialRef("DASHSCOPE_API_KEY"), credentials.apiKey);
written.push("apiKey");
}
if (credentials.workspaceId !== undefined && scope) {
await scope.update({ workspaceId: credentials.workspaceId });
written.push("workspaceId");
}
if (written.length > 0) await markSeeded(written);
// A completed console login is the one unambiguous signal that the
// account may have changed. Without this the next session would
// build its catalog from the previous account's services, which is
// worse than having no cache at all.
if (written.length > 0) {
serviceCache.invalidate();
void serviceCache.refresh();
}
return written;
},
});
sendJson(res, 200, started);
},
}),
"tool-bailian-kb: autofill bridge route",
);
});
}
/** Write a JSON response. */
function sendJson(res: ServerResponse, status: number, data: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
/** Read a UTF-8 JSON body up to a size limit. */
function readJsonBody(req: IncomingMessage, maxBytes = 16384): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
req.destroy();
reject(new Error("body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
}
@@ -0,0 +1,290 @@
/**
* On-disk cache of the workspace's deployed retrieval services.
*
* Landing spot: `${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`.
* Per-workspace files are required: an api key is scoped to one workspace, and
* the panel's "autofill" button exists to switch accounts, so one shared file
* would blend services from different accounts.
*
* Why not `ctx.storage`: the storage hub is absent from every shipped agent
* preset, so `inject(['storage'])` may never fire for a third-party plugin, and
* the JSON backend's on-disk location is decided by its own `root` config — the
* plugin could not tell anyone where the data went. Why not `settings.yaml`: that
* document is the user's, and it hot-reloads, so writing machine-refreshed data
* there both fights the user for the file and republishes configuration for no
* reason.
*
* Every read is best-effort and total: a missing, malformed, foreign, or
* stale-schema file reads as a miss. Callers run inside `agent/pre-step`, where a
* throw fails the user's step.
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { KbClient } from "./client.js";
import { listServices, type ServiceEntry } from "./services.js";
/** Bumped whenever the stored shape changes; a mismatch reads as a miss (no migration). */
const CACHE_VERSION = 1;
/** Refresh interval. Evaluated per `agent/pre-step`, so a short window genuinely takes effect. */
export const CACHE_TTL_MS = 30 * 60 * 1000;
/**
* Refresh interval applied when the cached list is EMPTY.
*
* An empty list is almost never a settled fact — it is the intermediate state of
* a workspace being set up. Caching that negative result for the full TTL breaks
* the standard first-run path: configure the plugin against a fresh workspace (0
* services) → create a knowledge base and a service → and then wait up to half an
* hour before the catalog appears. Re-asking every minute while the answer is
* "nothing yet" has a bounded cost and removes that trap.
*/
export const EMPTY_CACHE_TTL_MS = 60 * 1000;
/** The stored document. */
export interface ServiceCacheDocument {
version: number;
/** Epoch millis of the fetch that produced `entries`. */
fetchedAt: number;
/** Guards against reading a file written for another account or region. */
workspaceId: string;
endpointHost: string;
entries: ServiceEntry[];
total: number;
truncated: boolean;
}
/** Resolve the harness home the same way `settings-file` does. */
function dshHome(): string {
const fromEnv = process.env.DSH_HOME;
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : join(homedir(), ".dsh");
}
/**
* Build the cache path for one workspace.
* @param workspaceId - the resolved Bailian workspace id.
* @param home - override for tests; defaults to `$DSH_HOME` or `~/.dsh`.
* @returns the absolute file path.
*/
export function serviceCachePath(workspaceId: string, home: string = dshHome()): string {
return join(home, "cache", "bailian-kb", `services-${workspaceId}.json`);
}
/**
* Read a cache document, validating it belongs to this workspace and schema.
* @param path - the cache file path.
* @param workspaceId - the workspace the caller is serving.
* @param endpointHost - the host the caller is serving.
* @returns the document, or undefined for any miss (absent, malformed, foreign, or wrong version).
*/
export function readServiceCache(
path: string,
workspaceId: string,
endpointHost: string,
): ServiceCacheDocument | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (_unreadableOrMalformed) {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const doc = parsed as Partial<ServiceCacheDocument>;
if (doc.version !== CACHE_VERSION) return undefined;
if (doc.workspaceId !== workspaceId || doc.endpointHost !== endpointHost) return undefined;
if (typeof doc.fetchedAt !== "number" || !Array.isArray(doc.entries)) return undefined;
return {
version: CACHE_VERSION,
fetchedAt: doc.fetchedAt,
workspaceId,
endpointHost,
entries: doc.entries,
total: typeof doc.total === "number" ? doc.total : doc.entries.length,
truncated: doc.truncated === true,
};
}
/**
* Publish a cache document atomically: a reader either sees the previous file or
* the complete new one, never a half-written mix.
* @param path - the cache file path.
* @param doc - the document to store.
*/
export function writeServiceCache(path: string, doc: ServiceCacheDocument): void {
const dir = dirname(path);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, `${JSON.stringify(doc, undefined, 2)}\n`, { mode: 0o600 });
renameSync(temp, path);
}
/** What the settings panel shows about the cache; see {@link ServiceCache.status}. */
export interface ServiceCacheStatus {
workspaceId: string;
/** Epoch millis of the last successful fetch; absent when nothing is cached. */
fetchedAt?: number;
searchCount: number;
chatCount: number;
/** Server-reported total, which exceeds the counts above when the fetch was capped. */
total: number;
truncated: boolean;
stale: boolean;
}
export interface ServiceCacheOptions {
client: KbClient;
/** Resolves the current workspace id; a failure means "not configured yet". */
resolveWorkspaceId: () => Promise<string>;
endpointHost: string;
/** Reports refresh failures without escalating them. */
warn: (message: string) => void;
/** Test seams. */
home?: string;
now?: () => number;
}
/**
* The service cache: a synchronous in-memory view over the on-disk document,
* plus a deduplicated background refresh.
*/
export class ServiceCache {
/** Last document read or written; undefined until one is available. */
private document: ServiceCacheDocument | undefined;
/** The in-flight refresh, if any. One per instance: `pre-step` asks on every model request. */
private inFlight: Promise<void> | undefined;
/** Workspace of {@link document}, so a workspace switch invalidates in memory too. */
private loadedFor: string | undefined;
constructor(private readonly opts: ServiceCacheOptions) {}
private get now(): number {
return (this.opts.now ?? Date.now)();
}
/**
* The cached entries for a workspace, loading the file on first use.
* Synchronous and total — safe to call from `agent/pre-step`.
* @param workspaceId - the workspace being served.
* @returns the document, or undefined when nothing usable is cached.
*/
peek(workspaceId: string): ServiceCacheDocument | undefined {
if (this.loadedFor !== workspaceId) {
this.document = readServiceCache(
serviceCachePath(workspaceId, this.opts.home ?? dshHome()),
workspaceId,
this.opts.endpointHost,
);
this.loadedFor = workspaceId;
}
return this.document;
}
/**
* Whether the cached document is missing or older than its TTL.
* An empty list expires on the much shorter {@link EMPTY_CACHE_TTL_MS}.
* @param workspaceId - the workspace being served.
* @returns true when a refresh is due.
*/
isStale(workspaceId: string): boolean {
const doc = this.peek(workspaceId);
if (doc === undefined) return true;
const ttl = doc.entries.length === 0 ? EMPTY_CACHE_TTL_MS : CACHE_TTL_MS;
return this.now - doc.fetchedAt >= ttl;
}
/** Drop the in-memory view and force the next `peek` to re-read from disk. */
invalidate(): void {
this.document = undefined;
this.loadedFor = undefined;
}
/**
* A diagnostic snapshot for the settings panel.
*
* The panel exists because this cache's staleness is otherwise invisible: a
* developer whose agent silently stops retrieving cannot tell an empty
* workspace from a stale list without reading the JSON file. `fetchedAt` plus
* the per-scene counts answer that in one glance.
* @param workspaceId - the workspace being served.
* @returns the snapshot; `fetchedAt` is undefined when nothing is cached.
*/
status(workspaceId: string): ServiceCacheStatus {
const doc = this.peek(workspaceId);
if (doc === undefined) {
return { workspaceId, searchCount: 0, chatCount: 0, total: 0, truncated: false, stale: true };
}
return {
workspaceId,
fetchedAt: doc.fetchedAt,
searchCount: doc.entries.filter((entry) => entry.scene === "search").length,
chatCount: doc.entries.filter((entry) => entry.scene === "chat").length,
total: doc.total,
truncated: doc.truncated,
stale: this.isStale(workspaceId),
};
}
/**
* The cached entries of one scene, most recently modified first.
* Backs the panel's service picker, which exists so a default service can be
* chosen by name instead of by pasting a 36-character hex id.
* @param workspaceId - the workspace being served.
* @param scene - `search` or `chat`.
* @returns the entries, newest first.
*/
entriesFor(workspaceId: string, scene: ServiceEntry["scene"]): ServiceEntry[] {
const doc = this.peek(workspaceId);
if (doc === undefined) return [];
return doc.entries
.filter((entry) => entry.scene === scene)
.sort((left, right) => (right.modify_time ?? "").localeCompare(left.modify_time ?? ""));
}
/**
* Fetch and store the current service list.
* Never rejects: failures are warned and leave the previous document in place.
* Concurrent calls share one request.
* @returns a promise resolving once the attempt finishes.
*/
async refresh(): Promise<void> {
// Without this guard `pre-step` would start a fetch on every model request
// while the first is still outstanding.
this.inFlight ??= this.runRefresh().finally(() => {
this.inFlight = undefined;
});
return await this.inFlight;
}
private async runRefresh(): Promise<void> {
try {
const workspaceId = await this.opts.resolveWorkspaceId();
const list = await listServices(this.opts.client);
// Both scenes failing means the fetch produced nothing; keep the old file.
if (list.failedScenes.length === 2) {
this.opts.warn("bailian-kb service cache not refreshed: both scene queries failed");
return;
}
if (list.failedScenes.length > 0) {
this.opts.warn(
`bailian-kb service cache refreshed without scene(s): ${list.failedScenes.join(", ")}`,
);
}
const doc: ServiceCacheDocument = {
version: CACHE_VERSION,
fetchedAt: this.now,
workspaceId,
endpointHost: this.opts.endpointHost,
entries: list.entries,
total: list.total,
truncated: list.truncated,
};
writeServiceCache(serviceCachePath(workspaceId, this.opts.home ?? dshHome()), doc);
this.document = doc;
this.loadedFor = workspaceId;
} catch (failed) {
this.opts.warn(`bailian-kb service cache refresh failed: ${String(failed)}`);
}
}
}
@@ -0,0 +1,246 @@
/**
* Renders the deployed-service catalog the model reads before deciding whether
* to retrieve, plus the notice for a workspace that deploys nothing at all.
*
* These are pure functions on purpose: the selection branches below are the
* whole routing policy, and they are far easier to pin down in tests than
* through a live pre-step.
*
* Rendering conventions (settled):
* - English frame, service names verbatim — same language as the tool
* descriptions, so the model is not switched between languages mid-prompt.
* - Truncation is always stated. Silently cutting the list makes the model treat
* it as complete and flatly answer "there is no such knowledge base".
* - An empty scene omits its whole section. `no chat services` is pure noise and
* invites the model to handle a case that does not exist. An empty CATALOG is
* the opposite case and does get a message: see {@link buildNoServiceNotice}.
* - `bl` is only ever recommended together with how to get it. This plugin talks
* to the API directly and never shells out, so a fully configured deployment
* can have no `bl` on PATH at all.
*/
import type { ServiceScene } from "./api-types.js";
import type { ServiceEntry } from "./services.js";
/** Entries rendered per scene before switching to "most recently modified" mode. */
export const CATALOG_ENTRY_LIMIT = 10;
/** Truncation applied to a service description once the backend returns one. */
const DESCRIPTION_LIMIT = 200;
/**
* Appended once whenever the text tells the model to look further with `bl`.
*
* The install line belongs next to the recommendation, not in the tool
* descriptions: those are static and every token there is spent on every
* request, while this is needed only in the branches that actually name the
* command. A single-service catalog never carries it.
*/
const BL_AVAILABILITY_NOTE =
"(`bl` is the Bailian CLI — install it with `npm install -g bailian-cli` if the command is not found.)";
export interface CatalogInput {
entries: readonly ServiceEntry[];
/** Server-reported total, which may exceed `entries` when the fetch itself was capped. */
total: number;
/** True when the fetch stopped before the server ran out of rows. */
truncated: boolean;
defaultRetrieveAgentId?: string;
defaultChatAgentId?: string;
}
const SCENE_LABEL: Record<ServiceScene, string> = {
search: "kb_search (retrieval)",
chat: "kb_chat (grounded Q&A)",
};
/** Render one entry as a single line. */
function renderEntry(entry: ServiceEntry): string {
const name = entry.agent_name === "" ? "(unnamed)" : entry.agent_name;
const description =
entry.description === undefined || entry.description.trim() === ""
? undefined
: entry.description.trim().length > DESCRIPTION_LIMIT
? `${entry.description.trim().slice(0, DESCRIPTION_LIMIT - 1)}`
: entry.description.trim();
return `- ${entry.agent_id}${name}${description === undefined ? "" : `: ${description}`}`;
}
/** Most recently modified first; entries without a timestamp sort last. */
function byRecency(left: ServiceEntry, right: ServiceEntry): number {
const l = left.modify_time ?? "";
const r = right.modify_time ?? "";
if (l === r) return 0;
if (l === "") return 1;
if (r === "") return -1;
return l < r ? 1 : -1;
}
/**
* Render one scene's section, or undefined when the scene has no services.
* @param entries - all cached entries (any scene).
* @param scene - the scene to render.
* @param defaultAgentId - this scene's configured default service, when set.
* @param truncatedFetch - whether the fetch itself left rows unread.
* @returns the section lines, or undefined to omit the section entirely.
*/
function renderScene(
entries: readonly ServiceEntry[],
scene: ServiceScene,
defaultAgentId: string | undefined,
truncatedFetch: boolean,
): { lines: string[]; usedLookupHint: boolean } | undefined {
const forScene = entries.filter((entry) => entry.scene === scene);
if (forScene.length === 0) return undefined;
const lines = [`${SCENE_LABEL[scene]}:`];
const configured =
defaultAgentId === undefined
? undefined
: forScene.find((entry) => entry.agent_id === defaultAgentId);
if (configured !== undefined) {
// A configured default is the user's own pick: the highest-quality signal
// available, so it is the only entry worth spending context on.
lines.push(renderEntry(configured));
const others = forScene.length - 1;
if (others > 0) {
lines.push(
` (default service; ${others} other${others === 1 ? "" : "s"} exist — ` +
`run \`bl knowledge service list --scene ${scene}\` to see them)`,
);
return { lines, usedLookupHint: true };
}
return { lines, usedLookupHint: false };
}
if (forScene.length <= CATALOG_ENTRY_LIMIT && !truncatedFetch) {
lines.push(...forScene.map(renderEntry));
return { lines, usedLookupHint: false };
}
const shown = [...forScene].sort(byRecency).slice(0, CATALOG_ENTRY_LIMIT);
lines.push(...shown.map(renderEntry));
// State the shortfall: the model must know this list is partial before it
// concludes no service covers the question.
const knownTotal = Math.max(forScene.length, shown.length);
lines.push(
` (showing ${shown.length} most recently modified of ${truncatedFetch ? "more than " : ""}` +
`${knownTotal} deployed ${scene} services — run \`bl knowledge service list --scene ${scene} ` +
"--name <keyword>` to look for others)",
);
return { lines, usedLookupHint: true };
}
/**
* The notice injected when the workspace is reachable but deploys nothing the
* tools can call.
*
* This deliberately contradicts the "omit what does not exist" rule above, and
* the cases are not symmetric: an empty SECTION is noise because the other
* section still hands the model ids, whereas an empty CATALOG leaves it with no
* id at all while `agent_id` stays required. With nothing said, the model either
* invents an id — earning a raw server rejection — or silently drops the
* retrieval; both read to the user as a broken plugin when the real answer is
* "deploy a service". Naming the fix is the only way out that does not require
* the model to already have loaded the management skill.
* @returns the model-facing notice text.
*/
export function buildNoServiceNotice(): string {
return [
"<system-reminder>",
"The Bailian knowledge tools (kb_search / kb_chat) are configured, but this workspace has no " +
"deployed knowledge service, so neither tool can be called: both require an `agent_id` and there " +
"is none to use. Do not call them, and do not invent an id.",
"",
"If the user asks for something that should come from their knowledge base, tell them a service has " +
"to be created and deployed first — in the Bailian console (https://bailian.console.aliyun.com/), " +
"or with `bl knowledge service create` followed by `bl knowledge service deploy`. " +
BL_AVAILABILITY_NOTE,
"</system-reminder>",
].join("\n");
}
/**
* The service list appended to a call the server rejected, rendered from a
* just-refreshed cache.
*
* Lives here rather than at the call site so every model-facing rendering obeys
* the one set of conventions documented at the top of this file — in particular
* that a shortfall is stated and that `bl` never appears without its install
* line.
* @param scene - the scene the failed call targeted.
* @param entries - that scene's entries as of the refresh, newest first.
* @returns the text to append to the failure.
*/
export function buildRefreshedSceneList(
scene: ServiceScene,
entries: readonly ServiceEntry[],
): string {
if (entries.length === 0) return buildNoSceneServiceNotice(scene);
const shown = entries.slice(0, CATALOG_ENTRY_LIMIT);
const more = entries.length - shown.length;
return [
`Deployed ${scene} services in this workspace, re-read just now:`,
...shown.map(renderEntry),
...(more > 0
? [
`(and ${more} more — run \`bl knowledge service list --scene ${scene}\` to see them) ` +
BL_AVAILABILITY_NOTE,
]
: []),
].join("\n");
}
/**
* What a rejected call is told when the refreshed list holds no service for that
* scene at all.
*
* The tool has already failed by this point, so this text is the model's only
* instruction. Without it the bare server rejection ("invalid agent_id") invites
* a retry with another guess, and no guess can succeed.
* @param scene - the scene whose services came back empty.
* @returns the text to append to the failure.
*/
function buildNoSceneServiceNotice(scene: ServiceScene): string {
return (
`This workspace has no deployed ${scene} services at all (re-checked just now), so no agent_id can ` +
"work — do not retry with a different id. Tell the user one has to be created and deployed, in the " +
"Bailian console (https://bailian.console.aliyun.com/) or with `bl knowledge service create` " +
`followed by \`bl knowledge service deploy\`. ${BL_AVAILABILITY_NOTE}`
);
}
/**
* Build the catalog text for one cached service list.
* @param input - the cached entries plus the deployment's configured defaults.
* @returns the model-facing text, or undefined when there is nothing worth injecting.
*/
export function buildServiceCatalog(input: CatalogInput): string | undefined {
const search = renderScene(
input.entries,
"search",
input.defaultRetrieveAgentId,
input.truncated,
);
const chat = renderScene(input.entries, "chat", input.defaultChatAgentId, input.truncated);
if (search === undefined && chat === undefined) return undefined;
const usedLookupHint = search?.usedLookupHint === true || chat?.usedLookupHint === true;
return [
"<system-reminder>",
// The header must not name the tools: a scene with no services omits its
// section, and naming that tool anyway would invite passing an id from the
// other scene, which the service rejects. Section labels carry the mapping.
"Bailian knowledge services deployed in this workspace, grouped by the tool that accepts them. " +
"Pass an id from the matching section as that tool's `agent_id` argument — it is required and " +
"cannot be guessed.",
"",
...(search?.lines ?? []),
...(search !== undefined && chat !== undefined ? [""] : []),
...(chat?.lines ?? []),
"",
"If none of these services covers what the user is asking about, say so plainly rather than " +
"trying the closest-looking id — an unrelated retrieval result is worse than none.",
...(usedLookupHint ? [BL_AVAILABILITY_NOTE] : []),
"</system-reminder>",
].join("\n");
}
@@ -0,0 +1,152 @@
/**
* Publishes the cached service catalog into each request as a sourced context
* message, on the `agent/pre-step` waterfall.
*
* Why a context message rather than the tool descriptions: a tool description is
* fixed when the plugin loads, and a plugin loads once per PROCESS, not once per
* session. In a long-running host the TTL would be evaluated exactly once at
* `apply()` and a service created elsewhere would never be noticed until a
* restart. Re-registering tools to refresh a description instead invalidates the
* prompt prefix cache from the first changed schema token. Injecting context
* keeps the tool schemas byte-stable forever and still refreshes per step.
*
* Two hard constraints follow from `agent/pre-step` semantics:
*
* 1. `pre-step` fires once per STEP, and a step is one model request — a turn with
* five tool calls fires it six times. Re-injecting each time would insert six
* copies into one turn and void the KV cache from the first insertion onward,
* so change suppression is a correctness requirement, not an optimization.
* 2. A throwing listener fails the proposed step, i.e. the user's turn stalls.
* Everything here is therefore wrapped: any failure degrades to "inject
* nothing this step".
*/
import type { Context } from "@deepseek-ai/cordis";
import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import type { UserMessage } from "@deepseek-ai/dsh-session";
import { buildNoServiceNotice, buildServiceCatalog } from "./service-catalog.js";
import type { ServiceCache } from "./service-cache.js";
/** Marks this plugin's own injections in the durable log. */
const SOURCE_PLUGIN = "tool-bailian-kb/services";
export interface ServiceContextOptions {
cache: ServiceCache;
/** Resolves the workspace being served; undefined means "not configured yet". */
resolveWorkspaceId: () => Promise<string | undefined>;
/** Resolves the configured default retrieval service (settings then credential). */
resolveDefaultRetrieveAgentId: () => Promise<string | undefined>;
/** Resolves the configured default chat service (settings then credential). */
resolveDefaultChatAgentId: () => Promise<string | undefined>;
warn: (message: string) => void;
}
/** Whether one durable message came from this module. */
function isOwnInjection(source: { kind: string; plugin?: string }): boolean {
return source.kind === "plugin" && source.plugin === SOURCE_PLUGIN;
}
/** Concatenate a message's text parts, which is what the model actually reads. */
function messageText(message: UserMessage): string {
return message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
}
/**
* The catalog text this session last injected AND still shows the model.
*
* The visibility test is the subtle half. Scanning only for "did we ever publish
* this" would make compaction permanent data loss: once the catalog message is
* dropped from the surface, an identical digest would suppress every future
* injection and the model would silently spend the rest of the session without a
* service list.
* @param agent - the subject agent.
* @returns the visible catalog text, or undefined when none is currently visible.
*/
function visibleCatalogText(agent: Agent): string | undefined {
const visible = new Set(agent.session.surface.nodes);
const events = agent.session.events;
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index];
if (event === undefined) continue;
if (event.type !== "user/message" || !isOwnInjection(event.data.source)) continue;
return visible.has(event.seq) ? messageText(event.data) : undefined;
}
return undefined;
}
/** This module's proposed-but-not-yet-entered message, if the batch already carries one. */
function pendingCatalog(messages: readonly UserMessage[]): UserMessage | undefined {
return messages.find((message) => isOwnInjection(message.source));
}
/**
* Install the pre-step listener that keeps the catalog present and current.
* @param ctx - a context with `agents` available.
* @param opts - the cache plus the deployment's resolved workspace and defaults.
*/
export function installServiceContext(ctx: Context, opts: ServiceContextOptions): void {
ctx.on(
"agent/pre-step",
async ({ agent, signal }, next): Promise<PreStepDecision> => {
const decision = await next();
if (decision.kind === "reject" || signal.aborted) return decision;
try {
const workspaceId = await opts.resolveWorkspaceId();
// Nothing is configured yet: the tools themselves will explain that.
if (workspaceId === undefined || workspaceId === "") return decision;
// Refresh scheduling lives here, not at plugin load, so a long-running
// process still notices services created elsewhere. Never awaited: a slow
// list request must not delay the user's request.
if (opts.cache.isStale(workspaceId)) void opts.cache.refresh();
const document = opts.cache.peek(workspaceId);
// No document at all means the first fetch has not landed (or is
// failing): we know nothing, so claiming anything would be a guess. The
// refresh above self-corrects this on a later step.
if (document === undefined) return decision;
const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([
opts.resolveDefaultRetrieveAgentId(),
opts.resolveDefaultChatAgentId(),
]);
if (signal.aborted) return decision;
// A document with zero entries is the opposite: an authoritative "this
// workspace deploys nothing callable". Saying so beats silence, which
// leaves the model to invent a required agent_id.
const text =
buildServiceCatalog({
entries: document.entries,
total: document.total,
truncated: document.truncated,
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
}) ?? buildNoServiceNotice();
// Identical to what the model already sees: stay out of the way. This is
// the branch that runs on nearly every step.
if (visibleCatalogText(agent) === text) return decision;
const pending = pendingCatalog(decision.messages);
if (pending !== undefined && messageText(pending) === text) return decision;
const catalog = createUserMessage({
content: [{ type: "text", text }],
source: { kind: "plugin", plugin: SOURCE_PLUGIN, form: "catalog" },
});
return {
kind: "enter",
messages:
pending === undefined
? [...decision.messages, catalog]
: decision.messages.map((message) => (message.id === pending.id ? catalog : message)),
};
} catch (failed) {
// A throw here would fail the user's step; a missing catalog is far cheaper.
opts.warn(`bailian-kb service catalog not injected: ${String(failed)}`);
return decision;
}
},
{ prepend: true },
);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Retrieval-service discovery for the plugin's internal cache. Not a model tool:
* see `KB_PATHS.serviceList`.
*
* Listing is paged, and a workspace can hold far more services than are useful
* for routing, so this module stops after {@link MAX_PAGES} and reports the
* shortfall rather than paging through everything. Only deployed services are
* requested: anything else is not callable by the default service version, so it
* would be noise in the catalog the model reads.
*/
import type { ServiceListResponse, ServiceScene } from "./api-types.js";
import type { KbClient } from "./client.js";
import { KB_PATHS } from "./endpoints.js";
/** Page size requested per listing call. */
const PAGE_SIZE = 100;
/** Pages fetched per scene before reporting truncation; well past the useful routing range. */
const MAX_PAGES = 2;
/** One deployed retrieval or Q&A service, reduced to the fields that inform routing. */
export interface ServiceEntry {
agent_id: string;
agent_name: string;
scene: ServiceScene;
/** Deployment status as reported for the service; only callable states are listed. */
status: string;
/** Last modification timestamp; the only signal for "which of these is in use". */
modify_time?: string;
/** Service description, when the listing provides one. */
description?: string;
}
export interface ServiceList {
entries: ServiceEntry[];
/** Server-reported total across the queried scenes, including rows never fetched. */
total: number;
/** True when a scene reported more rows than {@link MAX_PAGES} pages returned. */
truncated: boolean;
/** Scenes whose query failed; a partial list stays usable. */
failedScenes: ServiceScene[];
}
const SCENES: readonly ServiceScene[] = ["search", "chat"];
/**
* Fetch the deployed services of one scene, stopping at the page cap.
* @param client - the shared knowledge API client.
* @param scene - `search` or `chat`.
* @returns the scene's entries, its server-reported total, and whether rows were left unfetched.
*/
async function listScene(
client: KbClient,
scene: ServiceScene,
): Promise<{ entries: ServiceEntry[]; total: number; truncated: boolean }> {
const entries: ServiceEntry[] = [];
let total = 0;
for (let page = 1; page <= MAX_PAGES; page += 1) {
const res = await client.postJson<ServiceListResponse>(KB_PATHS.serviceList, {
agent_scene: scene,
agent_status: "deployed",
page_number: page,
page_size: PAGE_SIZE,
});
total = res.data?.total_count ?? total;
const rows = res.data?.rows ?? [];
for (const row of rows) {
const agentId = row.agent_id ?? "";
// A row without an id cannot be called, so it has no reason to exist here.
if (agentId === "") continue;
entries.push({
agent_id: agentId,
agent_name: row.agent_name ?? "",
scene,
status: row.agent_status ?? "",
...(typeof row.modify_time === "string" ? { modify_time: row.modify_time } : {}),
});
}
// A short page is the last page; the server has nothing further to give.
if (rows.length < PAGE_SIZE) return { entries, total, truncated: false };
}
return { entries, total, truncated: total > entries.length };
}
/**
* List the deployed services of both scenes.
* A scene that fails is recorded and skipped rather than failing the whole
* refresh: half a list still routes better than none.
* @param client - the shared knowledge API client.
* @returns merged entries plus totals, truncation, and per-scene failures.
*/
export async function listServices(client: KbClient): Promise<ServiceList> {
const entries: ServiceEntry[] = [];
const failedScenes: ServiceScene[] = [];
let total = 0;
let truncated = false;
for (const scene of SCENES) {
try {
const result = await listScene(client, scene);
entries.push(...result.entries);
total += result.total;
truncated = truncated || result.truncated;
} catch (_sceneFailed) {
failedScenes.push(scene);
}
}
return { entries, total, truncated, failedScenes };
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Runtime skill registration: the packaged bailian-kb SKILL.md joins the catalog
* when a skills registry is composed.
*
* The file's YAML frontmatter is the single source of truth for the routing name
* and description — duplicating them here drifts, and the copy that loses is the
* one nobody reads. The frontmatter must also be STRIPPED from the registered
* body: `SkillDefinition.content` is contractually the body a provider has
* already cleaned of its own metadata, and `ctx.skills.register()` parses
* nothing, so handing over the raw file ships the YAML block into the model's
* context.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import type { Context } from "@deepseek-ai/cordis";
// Type-only: resolves ctx.skills for the optional inject below.
import type {} from "@deepseek-ai/dsh-skill";
const SKILL_DIR = fileURLToPath(new URL("../skills/bailian-kb/", import.meta.url));
/** The registrable fields carried by one skill file. */
export interface ParsedSkillFile {
/** Kebab-case skill name from frontmatter. */
name: string;
/** Routing description from frontmatter (the catalog truncates at 500 chars). */
description: string;
/** Optional extra routing guidance. */
whenToUse?: string;
/** Optional frontmatter `metadata` object. */
metadata?: Record<string, unknown>;
/** Markdown body with the frontmatter block removed. */
content: string;
}
/**
* Locate the frontmatter block, mirroring the filesystem provider's delimiters
* so a file that loads from disk behaves identically when bundled.
* @param raw - the file's full text.
* @returns the frontmatter YAML and the body after it, or undefined when unfenced.
*/
function splitFrontmatter(raw: string): { yaml: string; body: string } | undefined {
const firstLineEnd = raw.indexOf("\n");
if (firstLineEnd < 0) return undefined;
if (raw.slice(0, firstLineEnd).replace(/\r$/, "") !== "---") return undefined;
const start = firstLineEnd + 1;
let lineStart = start;
while (lineStart <= raw.length) {
const nextNewline = raw.indexOf("\n", lineStart);
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
if (raw.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
return {
yaml: raw.slice(start, lineStart),
body: raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1),
};
}
if (nextNewline < 0) return undefined;
lineStart = nextNewline + 1;
}
return undefined;
}
/**
* Parse one skill file into its registrable fields.
* @param raw - the file's full text.
* @returns the parsed fields, or undefined when the frontmatter is absent, unparsable, or missing name/description.
*/
export function parseSkillFile(raw: string): ParsedSkillFile | undefined {
const split = splitFrontmatter(raw);
if (split === undefined) return undefined;
let data: unknown;
try {
data = parseYaml(split.yaml);
} catch (_invalidYaml) {
return undefined;
}
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
const record = data as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
// The registry rejects a blank description outright; failing here keeps the
// diagnostic on the file instead of on the registration call.
if (name === "" || description === "") return undefined;
const whenToUse = typeof record.whenToUse === "string" ? record.whenToUse.trim() : "";
const metadata =
typeof record.metadata === "object" &&
record.metadata !== null &&
!Array.isArray(record.metadata)
? (record.metadata as Record<string, unknown>)
: undefined;
return {
name,
description,
...(whenToUse !== "" ? { whenToUse } : {}),
...(metadata !== undefined ? { metadata } : {}),
content: split.body,
};
}
/**
* Register the management skill when the skills registry is composed; headless
* assemblies without the seam stay unaffected. An unreadable or malformed file
* degrades to a warning — a broken bundled asset must not fail plugin load.
* @param ctx - the plugin context.
*/
export function registerSkill(ctx: Context): void {
ctx.inject(["skills"], (skillCtx) => {
const path = join(SKILL_DIR, "SKILL.md");
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch (unreadable) {
skillCtx.logger.warn(
`bailian-kb skill not registered: cannot read ${path}: ${String(unreadable)}`,
);
return;
}
const parsed = parseSkillFile(raw);
if (parsed === undefined) {
skillCtx.logger.warn(
`bailian-kb skill not registered: ${path} needs YAML frontmatter carrying name and description`,
);
return;
}
skillCtx.skills.register({
name: parsed.name,
description: parsed.description,
...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
content: parsed.content,
source: "bundled",
path,
resourceBase: { kind: "directory", path: SKILL_DIR },
});
});
}
+51
View File
@@ -0,0 +1,51 @@
/** Minimal SSE parser for the knowledge chat stream: `event:`/`data:` lines, events split on blank lines. */
export interface SseEvent {
event?: string;
data: string;
}
/**
* Parse one SSE byte stream into events.
* @param body - the response body stream.
* @returns events in stream order; multi-`data:` events join with newlines per the SSE spec.
*/
export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
const decoder = new TextDecoder();
let buffer = "";
let event: string | undefined;
let data: string[] = [];
const flush = (): SseEvent | undefined => {
if (data.length === 0) return undefined;
const out = { event, data: data.join("\n") };
event = undefined;
data = [];
return out;
};
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
buffer += done ? "" : decoder.decode(value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line === "") {
const out = flush();
if (out) yield out;
} else if (line.startsWith("event:")) {
event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data.push(line.slice(5).trimStart());
}
// comment/id/retry lines are irrelevant to this API and are skipped
}
if (done) {
const out = flush();
if (out) yield out;
return;
}
}
}
+292
View File
@@ -0,0 +1,292 @@
/**
* The two model-facing knowledge tools (kb_search, kb_chat). agent_id is REQUIRED in the schema:
* a model cannot know from the tool spec whether this deployment configures a default service, and a
* missing default previously only surfaced at call time, forcing a wasted round-trip. The per-call
* fallback to a configured default (settings/config or credential) is retained as defense-in-depth,
* but note defineTool validates args against the schema before execute, so through that entry point
* the fallback is inert; the model-facing contract is explicit.
*
* These descriptions are deliberately STATIC. The available service ids are
* deployment state that changes while the process runs, and re-registering a tool
* to refresh its description invalidates the prompt prefix cache from the first
* changed schema token. The live catalog therefore rides an `agent/pre-step`
* context message instead (see `service-context.ts`), leaving these schemas
* byte-stable for the life of the process.
*
* Being static is also why recovery instructions do NOT live here: how to look
* further depends on what is actually deployed, and a token spent in a
* description is spent on every request. The dynamic carriers own that — the
* catalog message states a shortfall where one exists, and a rejected call comes
* back with the services that do exist (`service-catalog.ts`).
*/
import { defineTool } from "@deepseek-ai/dsh-tools";
import type { SearchRequest, SearchResponse } from "./api-types.js";
import { KbApiError, type KbClient } from "./client.js";
import { consumeChatStream } from "./chat.js";
import { KB_PATHS } from "./endpoints.js";
/** Client-side chunk cap applied when the model omits top_k. */
const DEFAULT_TOP_K = 5;
export interface KbToolDeps {
client: KbClient;
/** Resolves the default retrieval agent id per call (settings/patch config or credential); omitted means no default for kb_search. */
resolveDefaultRetrieveAgentId?: () => Promise<string | undefined>;
/** Resolves the default chat agent id per call (settings/patch config or credential); omitted means no default for kb_chat. */
resolveDefaultChatAgentId?: () => Promise<string | undefined>;
/**
* Refreshes the service cache and summarizes what the workspace currently
* deploys for one scene. Called only after a client-side API failure, so a
* stale cached id self-corrects within the same step instead of waiting for the
* next scheduled refresh.
*/
describeServicesAfterRefresh?: (scene: "search" | "chat") => Promise<string | undefined>;
/** Read per call (a live-settings deployment supplies a getter). */
chatTimeoutMs: number;
}
/**
* Rethrow an API failure, appending a freshly refreshed service list when the
* server rejected the request.
*
* On these two endpoints `agent_id` is the only caller-supplied identifier, so a
* 4xx is most often a service id that no longer exists — the recovery the model
* needs is the current list, delivered in the error message. The message is
* ordinary conversation text appended at the tail, so unlike a re-registered
* description it does not disturb the request prefix.
*/
async function withServiceHint(
err: unknown,
scene: "search" | "chat",
describe: KbToolDeps["describeServicesAfterRefresh"],
): Promise<never> {
if (describe === undefined || !(err instanceof KbApiError)) throw err;
const status = err.status;
if (status === undefined || status < 400 || status >= 500) throw err;
// Best-effort enrichment: a failing refresh must not replace the real error.
const summary = await describe(scene).catch(() => undefined);
if (summary === undefined) throw err;
throw new KbApiError(
`${err.message}
${summary}`,
status,
);
}
/**
* Build the two tool definitions over one shared client.
* @param deps - client plus the deployment's explicit pinning and timeout choices.
* @returns definitions ready for `ctx.tools.register()`.
*/
export function createKbTools(deps: KbToolDeps) {
// chatTimeoutMs is deliberately NOT destructured: reading it off deps at
// execute time keeps a live-settings getter live.
const { client, resolveDefaultRetrieveAgentId, resolveDefaultChatAgentId } = deps;
const agentIdParam = {
type: "string" as const,
required: true as const,
description:
"Retrieval/Q&A service id. REQUIRED: the schema cannot know whether this deployment " +
"configures a default service, so always pass one. This conversation carries a context message " +
"listing the services deployed in the workspace — take the id from the section matching the tool " +
"you are calling. When that list is absent, or none of its services covers the question, say so " +
"rather than guessing: a rejected id is answered with the services that do exist, so a guess buys " +
"nothing.",
};
const resolveRetrieveAgentId = async (supplied: string | undefined): Promise<string> => {
if (supplied !== undefined) return supplied;
const defaultId =
resolveDefaultRetrieveAgentId === undefined
? undefined
: await resolveDefaultRetrieveAgentId();
if (defaultId === undefined) {
throw new Error(
"agent_id is required: no default retrieval service is configured. Pass agent_id explicitly " +
"(find ids: `bl knowledge service list --scene search --workspace-id <workspaceId>`), or configure a " +
"default: bailian-kb.defaultRetrieveAgentId in ~/.dsh/settings.yaml or " +
"BAILIAN_DEFAULT_RETRIEVE_AGENT_ID in ~/.dsh/.credentials.yaml.",
);
}
return defaultId;
};
const resolveChatAgentId = async (supplied: string | undefined): Promise<string> => {
if (supplied !== undefined) return supplied;
const defaultId =
resolveDefaultChatAgentId === undefined ? undefined : await resolveDefaultChatAgentId();
if (defaultId === undefined) {
throw new Error(
"agent_id is required: no default chat service is configured. Pass agent_id explicitly " +
"(find ids: `bl knowledge service list --scene chat --workspace-id <workspaceId>`), or configure a " +
"default: bailian-kb.defaultChatAgentId in ~/.dsh/settings.yaml or " +
"BAILIAN_DEFAULT_CHAT_AGENT_ID in ~/.dsh/.credentials.yaml.",
);
}
return defaultId;
};
const search = defineTool({
name: "kb_search",
description:
"Semantic search over a Bailian knowledge base. Returns raw knowledge chunks with scores and source " +
"references for you to verify, cite, or combine with other context. Retrieval scope and strategy " +
"(multi-KB weighting, routing, reranking) come from the service configuration. " +
"top_k caps how many chunks return (client-side cut of the score-ranked results). " +
"Use kb_chat instead when the user question can be answered by the knowledge base alone. " +
"Credentials and workspace resolve automatically from DSH config " +
"(bailian-kb in ~/.dsh/settings.yaml, DASHSCOPE_API_KEY in ~/.dsh/.credentials.yaml) — " +
"never read or pass them yourself. agent_id is REQUIRED (see its parameter description). " +
"If no listed service covers what the user is asking about, say so plainly rather than trying " +
"the closest-looking id: unrelated evidence is worse for the user than none.",
parameters: {
query: { type: "string", required: true, description: "Search query text." },
agent_id: agentIdParam,
top_k: {
type: "integer",
description: `Maximum chunks to return; defaults to ${DEFAULT_TOP_K}.`,
},
images: {
type: "array",
items: { type: "string" },
description: "Image URLs for multimodal retrieval.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
chunks: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
text: { type: "string", required: true },
score: { type: "number", required: true },
doc_name: { type: "string" },
doc_id: { type: "string" },
title: { type: "string" },
},
},
},
total: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text:
value.chunks.length === 0
? "No matching knowledge chunks."
: value.chunks
.map(
(c, i) =>
`[${i + 1}] (score ${c.score.toFixed(2)}${c.doc_name ? `, ${c.doc_name}` : ""}) ${c.text}`,
)
.join("\n"),
},
],
},
async execute(args) {
const topK = args.top_k ?? DEFAULT_TOP_K;
const body: SearchRequest = {
query: args.query,
agent_id: await resolveRetrieveAgentId(args.agent_id),
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
...(args.images && args.images.length > 0 ? { images: args.images } : {}),
};
const res = await client
.postJson<SearchResponse>(KB_PATHS.search, body)
.catch(
async (err) => await withServiceHint(err, "search", deps.describeServicesAfterRefresh),
);
const nodes = (res.data?.nodes ?? []).slice(0, topK);
return {
chunks: nodes.map((n) => ({
text: n.text,
score: n.score,
...(typeof n.metadata?.doc_name === "string" ? { doc_name: n.metadata.doc_name } : {}),
...(typeof n.metadata?.doc_id === "string" ? { doc_id: n.metadata.doc_id } : {}),
...(typeof n.metadata?.title === "string" ? { title: n.metadata.title } : {}),
})),
total: res.data?.total ?? nodes.length,
};
},
presentCall: (args) => ({
card: "generic",
title: "Search knowledge base",
kind: "search",
rawInput: args,
}),
});
const chat = defineTool({
name: "kb_chat",
description:
"Ask the knowledge base directly and get a complete, domain-tuned answer from a specialized RAG pipeline " +
"(multi-round retrieval + reranking + grounded generation). For knowledge Q&A this typically outperforms " +
"searching and synthesizing yourself when the question can be answered by the knowledge base alone; " +
"use kb_search instead when you need raw chunks to verify, cite, or combine with other work. " +
"The pipeline runs an internal analysis/retrieval loop and may take a few minutes. " +
"Credentials and workspace resolve automatically from DSH config " +
"(bailian-kb in ~/.dsh/settings.yaml, DASHSCOPE_API_KEY in ~/.dsh/.credentials.yaml) — " +
"never read or pass them yourself. agent_id is REQUIRED (see its parameter description). " +
"If no listed service covers what the user is asking about, say so plainly rather than trying " +
"the closest-looking id.",
parameters: {
message: { type: "string", required: true, description: "The question to ask." },
agent_id: agentIdParam,
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
answer: { type: "string", required: true },
request_id: { type: "string" },
},
},
render: (_args, value) => [
{ type: "text", text: value.answer.length === 0 ? "(empty answer)" : value.answer },
],
},
async execute(args) {
const chatTimeoutMs = deps.chatTimeoutMs;
const body = {
input: { messages: [{ role: "user" as const, content: args.message }] },
parameters: {
agent_options: {
agent_id: await resolveChatAgentId(args.agent_id),
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
},
},
stream: true as const,
};
let res: Response;
try {
res = await client.postSse(KB_PATHS.chat, body, AbortSignal.timeout(chatTimeoutMs));
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") {
throw new Error(
`knowledge chat timed out after ${chatTimeoutMs}ms; the pipeline runs a multi-round retrieval loop ` +
"and long questions can exceed the deployment timeout. Retry, or use kb_search for raw chunks instead.",
);
}
return await withServiceHint(err, "chat", deps.describeServicesAfterRefresh);
}
const { answer, requestId } = await consumeChatStream(res);
return { answer, ...(requestId ? { request_id: requestId } : {}) };
},
presentCall: (args) => ({
card: "generic",
title: "Ask knowledge base (may take a few minutes)",
kind: "fetch",
rawInput: args,
}),
});
return [search, chat];
}
@@ -0,0 +1,319 @@
/* Bailian settings page: title row, credential fields, clear control, and
save footer, in the settings-panel design language (16/24 title, 14/22
intro, `--dsw-alias-*` tokens — an out-of-tree bundle cannot value-import
the host section components, only their platform primitives). */
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.headRow {
display: flex;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
/* The three controls grouped as one outlined object on the panel fill. */
.form {
margin-top: 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 4px 16px 12px;
background: var(--dsw-alias-bg-layer-3);
}
/* Autofill control between the intro and the form: button + one-line status. */
.autofillRow {
display: flex;
align-items: center;
gap: 10px;
}
/* Carried beside the title so a scrolled page still says it holds edits. */
.pending {
flex: none;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
font-weight: 500;
white-space: nowrap;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
/* Degraded-transport notice: the write-only fallback explains itself once. */
.notice {
margin: 8px 0 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
}
.field + .field {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.label {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.labelWrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.getLink {
flex: none;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-brand-primary);
text-decoration: none;
white-space: nowrap;
}
.getLink:hover {
text-decoration: underline;
}
.badges {
display: inline-flex;
align-items: center;
gap: 8px;
}
.badge {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
}
.badgeSuccess {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
font-weight: 500;
background: var(--dsw-alias-state-success-tertiary);
color: var(--dsw-alias-state-success-primary);
}
.badgeMuted {
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
line-height: 17px;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.clear {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.clear:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.clear:disabled {
cursor: default;
}
.input {
height: 34px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
font: inherit;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.input:focus-visible {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
.autofillNoticeSuccess {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-state-success-primary);
}
.advancedSection {
border-top: none;
}
.field + .advancedSection,
.notice + .advancedSection {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.advancedToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 0;
border: none;
background: none;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.advancedToggle:hover {
color: var(--dsw-alias-label-secondary);
}
.advancedToggle::after {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateY(-2px) rotate(45deg);
transition: transform 0.2s ease;
}
.advancedToggle[aria-expanded="true"]::after {
transform: translateY(1px) rotate(-135deg);
}
.advancedFields {
display: flex;
flex-direction: column;
}
.advancedFields .field:first-child {
padding-top: 0;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 0;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.failed {
flex: 1;
min-width: 0;
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--dsw-alias-label-error);
}
.discard,
.save {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
}
.discard {
border-color: var(--dsw-alias-border-l2);
background: none;
color: var(--dsw-alias-label-secondary);
}
.discard:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-label-dimmed);
}
.save {
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.discard:disabled,
.save:disabled {
opacity: 0.4;
cursor: default;
}
.discard:focus-visible,
.save:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
@@ -0,0 +1,403 @@
/**
* The Bailian knowledge-base settings page: one section page in the
* Settings left nav. The workspace, default-retrieval-service and
* default-chat-service ids echo from the `bailian-kb` settings section
* while the scope is ready (clearing one falls back down the resolution
* chain), and degrade to write-only credential controls otherwise; the
* API key is always write-only — it drafts behind a password mask,
* starts blank, and reports only configured/unconfigured.
*/
import { useState } from "react";
import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
import {
dirtyOf,
echoedValue,
SETTINGS_FIELDS,
type BailianCardFace,
type BailianCardState,
type BailianFieldKey,
} from "./bailian-card-controller.ts";
import type { BailianKbLocaleKey } from "./locales.ts";
import css from "./BailianCard.module.css";
/** Bailian console URL that lets the user create or copy an API key. */
const BAILIAN_CONSOLE_API_KEY_URL =
"https://bailian.console.aliyun.com/cn-beijing?tab=globalset#/efm/api_key";
/** Props the renderer binds for the Bailian section page. */
export type BailianCardProps = PropsRuntime<"settings.section"> &
PropsLocale<"tool-bailian-kb"> &
InjectFace<BailianCardFace>;
/** One field's render description. */
interface FieldView {
key: BailianFieldKey;
labelKey: BailianKbLocaleKey;
/** Locale key for the "get" link shown next to the label. */
getKey?: BailianKbLocaleKey;
/** Echo-mode explanation (settings-backed value, blank save = fall back). */
hintKey: BailianKbLocaleKey;
/** Write-only explanation (credential store, blank = keep the stored value). */
fallbackHintKey: BailianKbLocaleKey;
setKey: BailianKbLocaleKey;
unsetKey: BailianKbLocaleKey;
/** Password-masked drafting; only the API key is an actual secret. */
secret: boolean;
/** When true the field is hidden inside the collapsible advanced section. */
advanced?: boolean;
/** External console URL for the "get" link. */
getUrl?: string;
}
/**
* The controls, in page order.
*
* The two default-service ids are NOT here: they render as pickers inside the
* advanced section instead, driven by the cached service list. A free-text id
* field beside a picker for the same setting is the same value twice, and the
* pair drifts the moment one of them writes.
*/
const FIELDS: readonly FieldView[] = [
{
key: "DASHSCOPE_API_KEY",
labelKey: "apiKey",
getKey: "apiKeyGet",
hintKey: "apiKeyHint",
fallbackHintKey: "apiKeyHint",
setKey: "apiKeySet",
unsetKey: "apiKeyUnset",
secret: true,
advanced: true,
getUrl: BAILIAN_CONSOLE_API_KEY_URL,
},
{
key: "BAILIAN_WORKSPACE_ID",
labelKey: "workspaceId",
getKey: "workspaceIdGet",
hintKey: "workspaceIdHint",
fallbackHintKey: "workspaceIdHintFallback",
setKey: "workspaceIdSet",
unsetKey: "workspaceIdUnset",
secret: false,
advanced: true,
getUrl: BAILIAN_CONSOLE_API_KEY_URL,
},
];
const ADVANCED_FIELDS = FIELDS.filter((field) => field.advanced);
/** Whether both the API key and the workspace id already hold values. */
function fullyConfigured(state: BailianCardState): boolean {
const apiKeyConfigured = state.credentials.DASHSCOPE_API_KEY.configured;
const workspaceConfigured =
state.credentials.BAILIAN_WORKSPACE_ID.configured ||
echoedValue(state, "BAILIAN_WORKSPACE_ID") !== "";
return apiKeyConfigured && workspaceConfigured;
}
/**
* Render the Bailian section page.
* @param props - locale copy, the page snapshot, and its actions.
* @returns the section page.
*/
export function BailianCard(props: BailianCardProps) {
const { t } = props;
const [advancedOpen, setAdvancedOpen] = useState(false);
const state = props.useBailianCard((snapshot) => snapshot);
const dirty = dirtyOf(state);
const busy = state.saving || state.clearing;
// The flow spans a browser login, so the button stays disabled until it settles.
const autofillBusy = state.autofill === "running" || state.autofill === "awaitingLogin";
// Pick the notice under the autofill button: settled outcome first, then
// a green "already configured" hint, then the default invitation.
let autofillNotice: BailianKbLocaleKey | undefined;
let autofillNoticeSuccess = false;
if (state.autofill === "done") {
autofillNotice = "autofillDone";
autofillNoticeSuccess = true;
} else if (state.autofill === "awaitingLogin") {
autofillNotice = "autofillAwaitingLogin";
} else if (state.autofill === "failed") {
autofillNotice = "autofillFailed";
} else if (fullyConfigured(state)) {
autofillNotice = "autofillConfigured";
autofillNoticeSuccess = true;
}
const renderField = (field: FieldView) => {
const credential = state.credentials[field.key];
// Echo mode: the settings scope answers with the resolved value, so
// the control is an ordinary pre-filled input. Otherwise the control
// is write-only and the badge is all the state there is.
const echo = SETTINGS_FIELDS[field.key] !== undefined && state.settings.status === "ready";
const echoed = echoedValue(state, field.key);
const value = state.drafts[field.key] ?? (echo ? echoed : "");
const disabled = busy || (echo ? !state.settings.writable : !credential.writable);
// The launch environment wins over the credential store and refuses
// writes; in echo mode a non-empty settings value shadows both, so
// the badge only reports the fallback under an empty input.
const badge = echo
? echoed !== ""
? undefined
: credential.configured
? { label: t("fallbackConfigured"), set: true }
: { label: t(field.unsetKey), set: false }
: credential.configured
? { label: credential.writable ? t(field.setKey) : t("fromEnv"), set: true }
: { label: t(field.unsetKey), set: false };
const showClear =
(field.key === "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" ||
field.key === "BAILIAN_DEFAULT_CHAT_AGENT_ID") &&
(credential.configured || (echo && echoed !== ""));
return (
<div className={css.field} key={field.key}>
<div className={css.head}>
<span className={css.labelWrap}>
<label className={css.label} htmlFor={`bailian-kb-${field.key}`}>
{t(field.labelKey)}
</label>
{field.getUrl !== undefined && field.getKey !== undefined ? (
<a className={css.getLink} href={field.getUrl} target="_blank" rel="noreferrer">
{t(field.getKey)}
</a>
) : null}
</span>
<span className={css.badges}>
{showClear ? (
<button
type="button"
className={css.clear}
disabled={busy}
onClick={() => {
void props.clearDefaultAgent(
field.key as
| "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID"
| "BAILIAN_DEFAULT_CHAT_AGENT_ID",
);
}}
>
{t(state.clearing ? "clearing" : "clear")}
</button>
) : null}
{badge !== undefined ? (
<span className={badge.set ? css.badgeSuccess : css.badgeMuted}>{badge.label}</span>
) : null}
</span>
</div>
<input
id={`bailian-kb-${field.key}`}
className={css.input}
type={field.secret ? "password" : "text"}
autoComplete="off"
value={value}
disabled={disabled}
onChange={(event) => {
props.edit(field.key, event.target.value);
}}
/>
<p className={css.hint}>{t(echo ? field.hintKey : field.fallbackHintKey)}</p>
</div>
);
};
/**
* One scene's default-service picker — the sole control for that setting.
*
* The cached list is the menu, but a value already pinned outside this list
* (the fetch is capped, so an older service can be absent) is prepended as its
* own option: dropping it would make the panel silently forget a live setting.
*/
function renderPicker(scene: "search" | "chat") {
const cache = state.cache;
const entries = scene === "search" ? cache.search : cache.chat;
const pinned =
scene === "search"
? state.settings.values.defaultRetrieveAgentId
: state.settings.values.defaultChatAgentId;
const isPinned = pinned !== undefined && pinned !== "";
const pinnedIsListed = isPinned && entries.some((entry) => entry.agent_id === pinned);
return (
<div className={css.field}>
<div className={css.head}>
<span className={css.label}>
{t(scene === "search" ? "retrieveAgentId" : "chatAgentId")}
</span>
{isPinned ? (
<button
type="button"
className={css.clear}
disabled={busy}
onClick={() => {
void props.selectDefaultAgent(scene, undefined);
}}
>
{t(state.clearing ? "clearing" : "pickerClear")}
</button>
) : null}
</div>
<select
className={css.input}
value={pinned ?? ""}
disabled={busy || cache.status !== "ready"}
onChange={(event) => {
const next = event.target.value;
// The empty option is the clear path, which must remove the value
// from both the settings layer and the credential store.
void props.selectDefaultAgent(scene, next === "" ? undefined : next);
}}
>
<option value="">{t("pickerNone")}</option>
{isPinned && !pinnedIsListed ? <option value={pinned}>{pinned}</option> : null}
{entries.map((entry) => (
<option key={entry.agent_id} value={entry.agent_id}>
{entry.agent_name === "" ? entry.agent_id : entry.agent_name}
</option>
))}
</select>
<p className={css.hint}>
{entries.length === 0
? t("cacheEmpty")
: t(scene === "search" ? "retrieveAgentIdHint" : "chatAgentIdHint")}
</p>
</div>
);
}
/**
* The service-cache diagnostics: last fetch, per-scene counts, refresh.
*
* This stays OUTSIDE the advanced fold on purpose. It is the answer to "why did
* the agent stop retrieving" — an empty workspace and a stale list look
* identical from the outside, and before this the only way to tell them apart
* was reading the cache JSON off disk.
*/
function renderCacheStatus() {
const cache = state.cache;
if (cache.status === "loading") return <p className={css.hint}>{t("cacheLoading")}</p>;
if (cache.status === "unconfigured")
return <p className={css.notice}>{t("cacheUnconfigured")}</p>;
if (cache.status === "unavailable")
return <p className={css.notice}>{t("cacheUnavailable")}</p>;
const fetched =
cache.fetchedAt === undefined ? t("cacheNever") : new Date(cache.fetchedAt).toLocaleString();
return (
<div className={css.field}>
<div className={css.head}>
<span className={css.label}>{t("cacheTitle")}</span>
<button
type="button"
className={css.clear}
disabled={cache.refreshing}
onClick={() => {
void props.refreshServices();
}}
>
{t(cache.refreshing ? "cacheRefreshing" : "cacheRefresh")}
</button>
</div>
<p className={css.hint}>
{t("cacheFetchedAt")}: {fetched}
{cache.stale ? ` (${t("cacheStale")})` : ""}
{" · "}
{t("cacheSearchCount")}: {cache.searchCount}
{" · "}
{t("cacheChatCount")}: {cache.chatCount}
</p>
{cache.truncated ? <p className={css.notice}>{t("cacheTruncated")}</p> : null}
{cache.searchCount === 0 && cache.chatCount === 0 ? (
<p className={css.notice}>{t("cacheEmpty")}</p>
) : null}
<p className={css.hint}>{t("cacheHint")}</p>
</div>
);
}
return (
<section className={css.section}>
<div className={css.headRow}>
<h2 className={css.title}>{t("title")}</h2>
{dirty ? <span className={css.pending}>{t("unsaved")}</span> : null}
</div>
<p className={css.intro}>{t("description")}</p>
<div className={css.autofillRow}>
<button
type="button"
className={css.discard}
disabled={busy || autofillBusy}
onClick={() => {
void props.autofill();
}}
>
{t(state.autofill === "running" ? "autofilling" : "autofill")}
</button>
<span className={autofillNoticeSuccess ? css.autofillNoticeSuccess : css.hint}>
{autofillNotice !== undefined ? t(autofillNotice) : t("autofillHint")}
{/* The host opens the page itself; this link is the fallback when it cannot. */}
{state.autofillLoginUrl !== undefined && state.autofillLoginUrl !== "" ? (
<>
{" "}
<a href={state.autofillLoginUrl} target="_blank" rel="noreferrer">
{t("autofillOpenUrl")}
</a>
</>
) : null}
</span>
</div>
<div className={css.form}>
{state.settings.status === "unavailable" ? (
<p className={css.notice}>{t("settingsUnavailable")}</p>
) : null}
{renderCacheStatus()}
<div className={css.advancedSection}>
<button
type="button"
className={css.advancedToggle}
aria-expanded={advancedOpen}
onClick={() => {
setAdvancedOpen((open) => !open);
}}
>
{t("advancedConfig")}
</button>
{advancedOpen ? (
<div className={css.advancedFields}>
{ADVANCED_FIELDS.map(renderField)}
{renderPicker("search")}
{renderPicker("chat")}
<div className={css.footer}>
{state.failed ? (
<p className={css.failed} role="status">
{t("saveFailed")}
</p>
) : null}
<button
type="button"
className={css.discard}
disabled={!dirty || busy}
onClick={props.discard}
>
{t("discard")}
</button>
<button
type="button"
className={css.save}
disabled={!dirty || busy}
onClick={() => {
void props.save();
}}
>
{t(state.saving ? "saving" : "save")}
</button>
</div>
</div>
) : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,652 @@
/**
* The Bailian page's controller: a hybrid form over two domains.
*
* The workspace, default-retrieval-service and default-chat-service ids live
* in the `bailian-kb` settings section the Host half registers. The Host
* exposes them over a bridge route (`/bailian-kb/settings`) so the page can
* read and write without riding the settings wire (which requires an apiproxy
* allowlist entry the composition does not grant out-of-tree namespaces).
*
* The API key always rides its credential reference (write-only by design:
* the wire is structurally value-free), so that control starts blank and
* reports only configured/unconfigured.
*/
import type { IApiClient } from "@deepseek-ai/dsh-client-connection/client";
import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-runtime/client";
/** The credential references this page addresses, keyed by their ref names. */
export const BAILIAN_CARD_REFS = [
"DASHSCOPE_API_KEY",
"BAILIAN_WORKSPACE_ID",
"BAILIAN_DEFAULT_RETRIEVE_AGENT_ID",
"BAILIAN_DEFAULT_CHAT_AGENT_ID",
] as const;
/** One page field, addressed by its credential reference. */
export type BailianFieldKey = (typeof BAILIAN_CARD_REFS)[number];
/** Settings-section field names of the echoing controls. */
export type BailianSettingsField = "workspaceId" | "defaultRetrieveAgentId" | "defaultChatAgentId";
/** Credential reference → settings-section field, for the hybrid controls. */
export const SETTINGS_FIELDS: Partial<Record<BailianFieldKey, BailianSettingsField>> = {
BAILIAN_WORKSPACE_ID: "workspaceId",
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: "defaultRetrieveAgentId",
BAILIAN_DEFAULT_CHAT_AGENT_ID: "defaultChatAgentId",
};
/** The section subset this page reads and writes (the namespace holds the whole plugin Config). */
export interface BailianKbSection {
workspaceId?: string;
defaultRetrieveAgentId?: string;
defaultChatAgentId?: string;
}
/** What the credentials domain reports for one reference (never the value). */
export interface BailianCredentialView {
/** Whether any layer supplies a value for the reference. */
configured: boolean;
/** Whether `credentials.set` can affect it; false disables the control. */
writable: boolean;
}
/** The page's mirror of the settings scope. */
export interface BailianSettingsView {
/** `ready` enables echo; `unavailable` degrades to write-only credentials. */
status: "loading" | "ready" | "unavailable";
/** Whether the Host settings document accepts writes. */
writable: boolean;
/** Resolved section values (entry base + user layer) for the two hybrid controls. */
values: BailianKbSection;
}
/** Where the autofill flow (adopt a Bailian console login) currently stands. */
export type BailianAutofillStatus = "idle" | "running" | "awaitingLogin" | "done" | "failed";
/** One cached retrieval or Q&A service, as the picker lists it. */
export interface BailianServiceEntry {
agent_id: string;
agent_name: string;
scene: "search" | "chat";
status: string;
modify_time?: string;
}
/**
* The service cache as the panel shows it.
*
* This exists because cache staleness is otherwise invisible: an agent that
* silently stops retrieving looks identical whether the workspace is empty or the
* list is merely out of date. `fetchedAt` with the per-scene counts distinguishes
* those two in one glance, which is the whole reason the panel earns its space.
*/
export interface BailianCacheView {
/** `unconfigured` = no workspace id yet; `unavailable` = the bridge route failed. */
status: "loading" | "ready" | "unconfigured" | "unavailable";
/** Epoch millis of the last successful fetch; absent when nothing is cached. */
fetchedAt?: number;
searchCount: number;
chatCount: number;
/** Server-reported total, which exceeds the counts when the fetch was capped. */
total: number;
truncated: boolean;
stale: boolean;
search: BailianServiceEntry[];
chat: BailianServiceEntry[];
/** Whether a forced refresh is in flight. */
refreshing: boolean;
}
/** What the Bailian page renders. */
export interface BailianCardState {
/** Staged drafts; undefined = untouched (the control shows the echoed value). */
drafts: Record<BailianFieldKey, string | undefined>;
/** Last credentials-domain answer per reference; unknown refs read as writable. */
credentials: Record<BailianFieldKey, BailianCredentialView>;
/** Settings-scope echo state for the id fields. */
settings: BailianSettingsView;
/** Whether a save is in flight. */
saving: boolean;
/** Whether a default-service clear is in flight. */
clearing: boolean;
/** Whether the last save or clear was refused; drafts are kept for correction. */
failed: boolean;
/** The autofill flow's state; feeds the button label and its result notice. */
autofill: BailianAutofillStatus;
/** Console login URL while `awaitingLogin`, shown in case the host could not open a browser. */
autofillLoginUrl?: string;
/** Service cache diagnostics and the pickable services. */
cache: BailianCacheView;
}
/** The registration-side face the page's slot entry injects. */
export interface BailianCardFace {
hooks: {
/** Page snapshot bound by the renderer as useBailianCard. */
bailianCard: SnapshotStore<BailianCardState>;
};
/** Stage one draft. */
edit: (key: BailianFieldKey, text: string) => void;
/** Write every staged draft through its domain, then re-read. */
save: () => Promise<void>;
/** Drop every staged draft. */
discard: () => void;
/** Remove the stored default service from every writable layer, then re-read. */
clearDefaultAgent: (
key: "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" | "BAILIAN_DEFAULT_CHAT_AGENT_ID",
) => Promise<void>;
/** Adopt a Bailian console login (api key + workspace id) via the Host. */
autofill: () => Promise<void>;
/** Force a service-cache refresh, bypassing the TTL. */
refreshServices: () => Promise<void>;
/**
* Pin one scene's default service, or clear it when `agentId` is undefined.
* Clearing removes the value from the settings user layer AND the credential
* store, so the fallback chain cannot resurrect what the user just cleared.
*/
selectDefaultAgent: (scene: "search" | "chat", agentId: string | undefined) => Promise<void>;
}
/** The text a field's control shows when its draft is untouched. */
export function echoedValue(state: BailianCardState, key: BailianFieldKey): string {
const field = SETTINGS_FIELDS[key];
if (field === undefined || state.settings.status !== "ready") return "";
return state.settings.values[field] ?? "";
}
/** Whether one field stages a change a save would write. */
function staged(state: BailianCardState, key: BailianFieldKey): boolean {
const draft = state.drafts[key];
if (draft === undefined) return false;
const field = SETTINGS_FIELDS[key];
if (field !== undefined && state.settings.status === "ready") {
return draft !== echoedValue(state, key);
}
// Write-only control: blank means untouched, never "erase the stored value".
return draft !== "";
}
/** Whether any field stages a change (enables Save/Discard). */
export function dirtyOf(state: BailianCardState): boolean {
return BAILIAN_CARD_REFS.some((key) => staged(state, key));
}
/** Bridge the settings bridge route and the credentials domain onto the page. */
export class BailianCardController {
private readonly store: SnapshotStore<BailianCardState>;
/**
* @param api - wire face used for the four credential references.
*/
constructor(private readonly api: Pick<IApiClient, "credentials">) {
this.store = createSnapshotStore<BailianCardState>({
drafts: {
DASHSCOPE_API_KEY: undefined,
BAILIAN_WORKSPACE_ID: undefined,
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: undefined,
BAILIAN_DEFAULT_CHAT_AGENT_ID: undefined,
},
credentials: {
DASHSCOPE_API_KEY: { configured: false, writable: true },
BAILIAN_WORKSPACE_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_CHAT_AGENT_ID: { configured: false, writable: true },
},
settings: { status: "loading", writable: false, values: {} },
saving: false,
clearing: false,
failed: false,
autofill: "idle",
autofillLoginUrl: undefined,
cache: {
status: "loading",
searchCount: 0,
chatCount: 0,
total: 0,
truncated: false,
stale: true,
search: [],
chat: [],
refreshing: false,
},
});
void this.fetchSettings();
void this.fetchServices();
void this.read();
}
/**
* Fetch the current settings from the Host bridge route. Called at
* construction and after every mutation (save, clear).
*/
async fetchSettings(): Promise<void> {
try {
const resp = await fetch("/bailian-kb/settings");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const value = (await resp.json()) as Record<string, unknown>;
this.store.update((draft) => {
draft.settings = {
status: "ready",
writable: true,
values: {
...(typeof value.workspaceId === "string" ? { workspaceId: value.workspaceId } : {}),
...(typeof value.defaultRetrieveAgentId === "string"
? { defaultRetrieveAgentId: value.defaultRetrieveAgentId }
: {}),
...(typeof value.defaultChatAgentId === "string"
? { defaultChatAgentId: value.defaultChatAgentId }
: {}),
},
};
});
} catch (_fetchFailure) {
this.store.update((draft) => {
draft.settings = { status: "unavailable", writable: false, values: {} };
});
}
}
/**
* Read the service cache snapshot from the Host bridge route.
* @param force - POST instead of GET, making the Host refetch regardless of TTL.
*/
async fetchServices(force = false): Promise<void> {
if (force)
this.store.update((draft) => {
draft.cache.refreshing = true;
});
try {
const resp = await fetch("/bailian-kb/services", { method: force ? "POST" : "GET" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const value = (await resp.json()) as {
configured?: boolean;
status?: {
fetchedAt?: number;
searchCount?: number;
chatCount?: number;
total?: number;
truncated?: boolean;
stale?: boolean;
};
search?: BailianServiceEntry[];
chat?: BailianServiceEntry[];
};
this.store.update((draft) => {
draft.cache.refreshing = false;
if (value.configured !== true) {
// No workspace id yet: the panel says so rather than showing zeros,
// which would read as "the workspace has no services".
draft.cache.status = "unconfigured";
return;
}
draft.cache.status = "ready";
draft.cache.fetchedAt = value.status?.fetchedAt;
draft.cache.searchCount = value.status?.searchCount ?? 0;
draft.cache.chatCount = value.status?.chatCount ?? 0;
draft.cache.total = value.status?.total ?? 0;
draft.cache.truncated = value.status?.truncated === true;
draft.cache.stale = value.status?.stale === true;
draft.cache.search = value.search ?? [];
draft.cache.chat = value.chat ?? [];
});
} catch (_routeFailure) {
this.store.update((draft) => {
draft.cache.refreshing = false;
draft.cache.status = "unavailable";
});
}
}
/** Force a refresh, bypassing the TTL, and show the updated numbers. */
async refreshServices(): Promise<void> {
if (this.store.getSnapshot().cache.refreshing) return;
await this.fetchServices(true);
}
/**
* Pin or clear one scene's default service.
*
* Clearing delegates to {@link clearDefaultAgent}, which removes the value from
* the settings user layer AND the credential store — without the second
* removal the fallback chain would resurrect what the user just cleared.
* @param scene - which tool's default to set.
* @param agentId - the service id to pin, or undefined to clear.
*/
async selectDefaultAgent(scene: "search" | "chat", agentId: string | undefined): Promise<void> {
const key =
scene === "search" ? "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" : "BAILIAN_DEFAULT_CHAT_AGENT_ID";
if (agentId === undefined) {
await this.clearDefaultAgent(key);
return;
}
const field: BailianSettingsField =
scene === "search" ? "defaultRetrieveAgentId" : "defaultChatAgentId";
this.store.update((draft) => {
draft.saving = true;
});
try {
const resp = await fetch("/bailian-kb/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: agentId }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
this.store.update((draft) => {
draft.failed = false;
});
} catch (_writeFailure) {
this.store.update((draft) => {
draft.failed = true;
});
} finally {
this.store.update((draft) => {
draft.saving = false;
});
await this.fetchSettings();
}
}
/**
* Stage one draft; any edit clears the failure mark so the banner does not
* outlive the correction it asks for.
* @param key - the field's credential reference.
* @param text - the staged text.
*/
edit(key: BailianFieldKey, text: string): void {
this.store.update((draft) => {
draft.drafts[key] = text;
draft.failed = false;
});
}
/**
* Write every staged draft through its domain: echoing fields go to the
* settings user layer via the bridge route (blank = removal, falling back
* to entry config and the credential store), write-only fields go to
* `credentials.set`. A refused credential write keeps its draft; a refused
* settings write self-heals by re-fetching the Host value.
*/
async save(): Promise<void> {
const state = this.store.getSnapshot();
if (state.saving || !dirtyOf(state)) return;
this.store.update((draft) => {
draft.saving = true;
});
let failed = false;
const settingsPatch: Record<string, unknown> = {};
let hasSettingsWrite = false;
const credentialWrites: Promise<void>[] = [];
const settled: BailianFieldKey[] = [];
for (const key of BAILIAN_CARD_REFS) {
if (!staged(state, key)) continue;
const text = state.drafts[key] as string;
const field = SETTINGS_FIELDS[key];
if (field !== undefined && state.settings.status === "ready") {
settingsPatch[field] = text === "" ? null : text;
hasSettingsWrite = true;
settled.push(key);
continue;
}
credentialWrites.push(
(async () => {
try {
const response = await this.api.credentials.set({ ref: key, value: text });
if (response.result.ok) settled.push(key);
else failed = true;
} catch (_credentialWriteFailure) {
failed = true;
}
})(),
);
}
if (hasSettingsWrite) {
try {
await this.saveSettings(settingsPatch);
} catch (_settingsWriteFailure) {
failed = true;
}
}
await Promise.all(credentialWrites);
this.store.update((draft) => {
draft.saving = false;
draft.failed = failed;
for (const key of settled) draft.drafts[key] = undefined;
});
await this.fetchSettings();
await this.read();
}
/** Drop every staged draft and the failure mark. */
discard(): void {
this.store.update((draft) => {
for (const ref of BAILIAN_CARD_REFS) draft.drafts[ref] = undefined;
draft.failed = false;
});
}
/**
* Fetch credentials by signing in to the Bailian console. The Host drives
* the console browser login itself, always requesting a freshly issued api
* key, then persists the key into the credential store and the workspace id
* into the settings section — so both values belong to the account that just
* signed in, and the plain key never rides the wire to this page.
*
* Deliberately does NOT adopt the bl CLI's stored login: that path can pair
* one account's key with another account's workspace id, and nothing would
* flag the mismatch until a knowledge-base call fails.
*/
async autofill(): Promise<void> {
const phase = this.store.getSnapshot().autofill;
if (phase === "running" || phase === "awaitingLogin") return;
this.store.update((draft) => {
draft.autofill = "running";
draft.autofillLoginUrl = undefined;
});
await this.runConsoleLogin();
await this.fetchSettings();
await this.read();
}
/**
* Ask the Host to open the console login page, then poll for the outcome.
* The Host persists the credentials itself when the callback lands, always
* requesting a freshly issued key — so the key and the workspace id both come
* from the account signing in.
*/
private async runConsoleLogin(): Promise<void> {
let started: { status?: string; loginUrl?: string };
try {
started = (await this.postAutofill("login")) as { status?: string; loginUrl?: string };
} catch (_routeFailure) {
this.store.update((draft) => {
draft.autofill = "failed";
});
return;
}
if (started.status !== "started" && started.status !== "already-running") {
this.store.update((draft) => {
draft.autofill = "failed";
});
return;
}
this.store.update((draft) => {
draft.autofill = "awaitingLogin";
draft.autofillLoginUrl = started.loginUrl;
});
await this.pollConsoleLogin();
}
/**
* Poll the Host until the console login resolves. Bounded so a login the
* user abandons does not leave the button spinning forever; the Host keeps
* its own (longer) timeout, so a late callback still persists and shows up
* on the next page read.
*/
private async pollConsoleLogin(): Promise<void> {
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
let phase: string | undefined;
try {
phase = ((await this.postAutofill("loginStatus")) as { phase?: string }).phase;
} catch (_pollFailure) {
continue;
}
if (phase === "done") {
this.store.update((draft) => {
draft.autofill = "done";
draft.autofillLoginUrl = undefined;
});
return;
}
if (phase === "failed") {
this.store.update((draft) => {
draft.autofill = "failed";
draft.autofillLoginUrl = undefined;
});
return;
}
}
this.store.update((draft) => {
draft.autofill = "failed";
});
}
/**
* Remove the stored default service from every writable layer — the
* settings user layer AND the credential store, so the fallback chain does
* not resurrect the value the user just cleared. Both removals are
* idempotent; the credential unset is skipped when nothing is stored there.
* @param key - which default service credential to clear.
*/
async clearDefaultAgent(
key: "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" | "BAILIAN_DEFAULT_CHAT_AGENT_ID",
): Promise<void> {
const settingsField: BailianSettingsField =
key === "BAILIAN_DEFAULT_RETRIEVE_AGENT_ID" ? "defaultRetrieveAgentId" : "defaultChatAgentId";
const state = this.store.getSnapshot();
if (state.clearing) return;
this.store.update((draft) => {
draft.clearing = true;
});
let failed = false;
if (state.settings.status === "ready") {
try {
await this.saveSettings({ [settingsField]: null });
} catch (_settingsWriteFailure) {
failed = true;
}
}
if (state.credentials[key].configured) {
try {
const response = await this.api.credentials.unset({ ref: key });
if (!response.result.ok) failed = true;
} catch (_credentialWriteFailure) {
failed = true;
}
}
this.store.update((draft) => {
draft.clearing = false;
draft.failed = failed;
draft.drafts[key] = undefined;
});
await this.fetchSettings();
await this.read();
}
/**
* Re-read after the Host reports a change to a reference this page watches.
*
* A value can be written from somewhere else — the Models page addresses
* DASHSCOPE_API_KEY too, and the file store accepts external edits — so
* without this the badges keep reporting a state the Host already replaced.
* (Settings-document changes reach the page through the scope instead.)
* @param ref - the reference the Host reports as changed.
*/
refresh(ref: string): void {
if (!(BAILIAN_CARD_REFS as readonly string[]).includes(ref)) return;
void this.read();
}
/**
* Build the face the page's slot registration injects.
* @returns the page's snapshot and its actions.
*/
inject(): BailianCardFace {
return {
hooks: { bailianCard: this.store },
edit: (key, text) => {
this.edit(key, text);
},
save: () => this.save(),
discard: () => {
this.discard();
},
clearDefaultAgent: (key) => this.clearDefaultAgent(key),
autofill: () => this.autofill(),
refreshServices: () => this.refreshServices(),
selectDefaultAgent: (scene, agentId) => this.selectDefaultAgent(scene, agentId),
};
}
/**
* Post one autofill action to the Host bridge route.
* @param action - `login` starts the console browser flow; `loginStatus`
* reads that flow's progress.
* @returns the route's JSON answer.
*/
private async postAutofill(action: "login" | "loginStatus"): Promise<unknown> {
const resp = await fetch("/bailian-kb/autofill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
/**
* Send a settings patch to the Host bridge route. `null`-valued keys are
* removals (the field falls back to the entry config and then the
* credential store); other values are merged into the user layer.
* @param patch - the partial settings update.
*/
private async saveSettings(patch: Record<string, unknown>): Promise<void> {
const resp = await fetch("/bailian-kb/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!resp.ok) {
const body = (await resp.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${resp.status}`);
}
}
/**
* Ask the credentials domain about all four references and publish the
* answer. A failed read keeps the last known state: the page stays usable
* and a write still reaches the Host.
*/
private async read(): Promise<void> {
let response: Awaited<ReturnType<IApiClient["credentials"]["describe"]>>;
try {
response = await this.api.credentials.describe({ refs: [...BAILIAN_CARD_REFS] });
} catch (_credentialReadFailure) {
return;
}
if (!response.result.ok) return;
const view = response.result.value.credentials;
this.store.update((draft) => {
for (const ref of BAILIAN_CARD_REFS) {
// An unknown reference reads as writable: the control stays usable and
// the Host is what refuses, rather than the page guessing a refusal.
draft.credentials[ref] = {
configured: view[ref]?.configured ?? false,
writable: view[ref]?.writable ?? true,
};
}
});
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* CSS Modules for the browser half: the bundler (tsdown client preset) inlines
* `*.module.css` imports as hashed class maps, this declaration gives the
* import its type in the browser-only project.
*/
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Bailian knowledge-base plugin, browser half: one section page in the
* Settings left nav. The workspace, default-retrieval-service and
* default-chat-service ids ride the Host bridge route
* (`/bailian-kb/settings`) the Host half registers, bypassing the settings
* wire (which requires an apiproxy allowlist entry the composition does not
* grant out-of-tree namespaces); the API key stays pure credentials-domain
* and never echoes.
*/
import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client";
// Type-only: the locale plugin's Context merge (ctx.locale).
import type {} from "@deepseek-ai/dsh-client-locale/client";
import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
// Type-only: the remote service's Context merge (ctx.remote) and the forwarded
// credential-update events.
import type {} from "@deepseek-ai/dsh-api-remotes/client";
import type {} from "@deepseek-ai/dsh-client-ui-slots";
// The 'settings.section' SlotMap merge AND the ctx.settingsScope service,
// both declared by the settings domain base (type-only: the service arrives
// through cordis, never a value import).
import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
import { BailianCard } from "./BailianCard.tsx";
import { BailianCardController } from "./bailian-card-controller.ts";
import { en, zh, type BailianKbLocaleKey } from "./locales.ts";
declare module "@deepseek-ai/dsh-client-ui-slots" {
interface LocaleNamespaceMap {
/** The Bailian section page's copy. */
"tool-bailian-kb": BailianKbLocaleKey;
}
}
/** Dictionary namespace owned by this plugin. */
const NS = "tool-bailian-kb";
/** Required services (cordis fiber inject). */
export const inject = ["slots", "locale", "connection", "remote"];
/**
* Mount the Bailian section page into the Settings left nav.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get("connection") as ConnectionHandle;
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "tool-bailian-kb: dictionaries");
// Registration-time text: the nav label is a thunk the shell resolves per
// render, so copy freshness rides the locale revision without re-registering.
const t = ctx.locale.bind(NS);
// The echo transport: the Host bridge route (`/bailian-kb/settings`) lets
// the page read and write the resolved section without riding the settings
// wire (which requires an apiproxy allowlist entry).
const card = new BailianCardController(api);
// Values can change elsewhere (Models page, external file edits); the badges
// must follow the Host, not the card's last write.
ctx.effect(
() =>
ctx.remote.$on("credentials/updated", (ref) => {
card.refresh(ref);
}),
"tool-bailian-kb: credential invalidations",
);
ctx.slots.inject("settings.section", () =>
ctx.slots.register(
{
name: "settings.section",
id: "bailian-kb",
order: 20,
label: () => t("nav"),
locale: NS,
inject: () => card.inject(),
},
BailianCard,
),
);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Locale bundles for the Bailian knowledge-base settings page. The workspace,
* default-retrieval-service and default-chat-service ids echo from the
* settings section while it is available and fall back to write-only
* credential controls otherwise; the API key copy is always written for a
* write-only control: state is reported as configured/unconfigured, and a
* stored key is never echoed back.
*/
/** Locale keys this page renders. */
export type BailianKbLocaleKey =
| "nav"
| "title"
| "description"
| "settingsUnavailable"
| "fallbackConfigured"
| "apiKey"
| "apiKeyHint"
| "apiKeySet"
| "apiKeyUnset"
| "apiKeyGet"
| "workspaceId"
| "workspaceIdHint"
| "workspaceIdHintFallback"
| "workspaceIdSet"
| "workspaceIdUnset"
| "workspaceIdGet"
| "retrieveAgentId"
| "retrieveAgentIdHint"
| "chatAgentId"
| "chatAgentIdHint"
| "fromEnv"
| "clear"
| "clearing"
| "save"
| "saving"
| "discard"
| "unsaved"
| "saveFailed"
| "advancedConfig"
| "autofill"
| "autofilling"
| "autofillHint"
| "autofillDone"
| "autofillAwaitingLogin"
| "autofillOpenUrl"
| "autofillFailed"
| "autofillConfigured"
| "cacheTitle"
| "cacheHint"
| "cacheLoading"
| "cacheUnconfigured"
| "cacheUnavailable"
| "cacheFetchedAt"
| "cacheNever"
| "cacheStale"
| "cacheSearchCount"
| "cacheChatCount"
| "cacheTruncated"
| "cacheEmpty"
| "cacheRefresh"
| "cacheRefreshing"
| "pickerNone"
| "pickerClear";
/** English copy. */
export const en: Record<BailianKbLocaleKey, string> = {
nav: "Bailian KB",
title: "Bailian knowledge base",
description: "Account for the knowledge tools: API key, workspace, and default services.",
settingsUnavailable:
"The settings document is not reachable from this browser; values below are write-only and stored in the credential store.",
fallbackConfigured: "Falling back to a configured credential-store value.",
apiKey: "API key",
apiKeyHint:
"DashScope API key. Stored in the credentials store and never shown again; leave blank to keep the current one.",
apiKeySet: "A key is configured.",
apiKeyUnset: "No key is configured; knowledge tools fail until one is.",
apiKeyGet: "Get",
workspaceId: "Workspace id",
workspaceIdHint:
"Bailian workspace id — the subdomain of your endpoints. Stored in the settings document; clear and save to fall back to the credential store.",
workspaceIdHintFallback:
"Bailian workspace id — the subdomain of your endpoints. Leave blank to keep the current one.",
workspaceIdSet: "A workspace is configured.",
workspaceIdUnset: "No workspace is configured; knowledge tools fail until one is.",
workspaceIdGet: "Get",
retrieveAgentId: "Default retrieval service",
retrieveAgentIdHint:
"The agent_id kb_search falls back to. Left unset, the injected catalog lists every deployed service instead.",
chatAgentId: "Default Q&A service",
chatAgentIdHint:
"The agent_id kb_chat falls back to. Left unset, the injected catalog lists every deployed service instead.",
fromEnv: "Set by the environment (read-only here)",
clear: "Clear default",
clearing: "Clearing…",
save: "Save",
saving: "Saving…",
discard: "Discard",
unsaved: "Unsaved",
saveFailed: "The Host did not accept these values; they were left for you to correct.",
advancedConfig: "Advanced configuration",
autofill: "Fetch from console login",
autofilling: "Starting…",
autofillHint:
"Sign in to the Bailian console to fill in that accounts API key and workspace id.",
autofillDone: "Credentials adopted; the fields below reflect the new values.",
autofillAwaitingLogin:
"Waiting for the Bailian console login to finish in a browser on the host machine…",
autofillOpenUrl: "Open the login page manually",
autofillFailed:
"Auto-fill failed — the credential may be locked by an environment variable, the Host refused the write, or the login was abandoned.",
autofillConfigured:
"Configured. Click button to fetch this accounts API key and workspace id again.",
cacheTitle: "Retrieval service cache",
cacheHint:
"The service list injected into each conversation. Refreshes on its own; refresh here when you have just created a service and want it picked up now.",
cacheLoading: "Reading…",
cacheUnconfigured: "Set a workspace id first.",
cacheUnavailable: "Not reachable from this browser.",
cacheFetchedAt: "Last fetched",
cacheNever: "never",
cacheStale: "refresh due",
cacheSearchCount: "Retrieval services",
cacheChatCount: "Q&A services",
cacheTruncated: "List truncated — the workspace holds more than were fetched.",
cacheEmpty: "No deployed services cached. If you just created one, refresh.",
cacheRefresh: "Refresh",
cacheRefreshing: "Refreshing…",
pickerNone: "Not set — the full list is injected instead",
pickerClear: "Clear",
};
/** Simplified Chinese copy. */
export const zh: Record<BailianKbLocaleKey, string> = {
nav: "百炼知识库",
title: "百炼知识库",
description: "知识库工具的账号信息API 密钥、工作空间与默认服务。",
settingsUnavailable: "当前浏览器无法访问设置文档;以下字段仅可写入凭据存储,不回显。",
fallbackConfigured: "回退:凭据存储中已有值。",
apiKey: "API 密钥",
apiKeyHint: "DashScope API key。保存在凭据存储中且不会再次显示留空表示保持当前值。",
apiKeySet: "已配置密钥。",
apiKeyUnset: "未配置密钥;配置前知识库工具不可用。",
apiKeyGet: "去获取",
workspaceId: "工作空间 ID",
workspaceIdHint:
"百炼工作空间 ID即终端节点地址的子域名。存入设置文档清空并保存则回退到凭据存储。",
workspaceIdHintFallback: "百炼工作空间 ID即终端节点地址的子域名。留空表示保持当前值。",
workspaceIdSet: "已配置工作空间。",
workspaceIdUnset: "未配置工作空间;配置前知识库工具不可用。",
workspaceIdGet: "去获取",
retrieveAgentId: "默认检索服务",
retrieveAgentIdHint: "kb_search 缺省使用的 agent_id。不设置时注入的清单会列出全部已部署服务。",
chatAgentId: "默认对话服务",
chatAgentIdHint: "kb_chat 缺省使用的 agent_id。不设置时注入的清单会列出全部已部署服务。",
fromEnv: "来自环境变量(此处只读)",
clear: "清除默认",
clearing: "清除中…",
save: "保存",
saving: "保存中…",
discard: "放弃",
unsaved: "未保存",
saveFailed: "宿主未接受这些值,已保留供你修改。",
advancedConfig: "高级配置",
autofill: "自动获取",
autofilling: "启动中…",
autofillHint: "登录百炼控制台,自动填入该账号的 API 密钥与工作空间 ID。",
autofillDone: "已回填凭据,下方字段已更新。",
autofillAwaitingLogin: "等待在宿主机浏览器中完成百炼控制台登录…",
autofillOpenUrl: "手动打开登录页",
autofillFailed: "自动获取失败——凭据可能被环境变量锁定、宿主拒绝了写入,或登录未完成。",
autofillConfigured: "已配置完成,点击按钮重新获取该账号的 API 密钥与工作空间 ID。",
cacheTitle: "检索服务缓存",
cacheHint: "注入到每次对话的服务清单。会自动刷新;刚建完服务想立即生效时在这里刷一下。",
cacheLoading: "读取中…",
cacheUnconfigured: "请先设置工作空间 ID。",
cacheUnavailable: "当前浏览器无法访问。",
cacheFetchedAt: "上次拉取",
cacheNever: "尚未拉取",
cacheStale: "待刷新",
cacheSearchCount: "检索服务",
cacheChatCount: "问答服务",
cacheTruncated: "清单已截断 —— 工作空间里的服务多于已拉取的数量。",
cacheEmpty: "缓存里没有已部署的服务。如果刚创建过,请刷新。",
cacheRefresh: "刷新",
cacheRefreshing: "刷新中…",
pickerNone: "未设置 —— 会注入完整清单",
pickerClear: "清空",
};
@@ -0,0 +1,47 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vite-plus/test";
import { readBlCliConfig } from "../src/bl-cli.js";
const dir = mkdtempSync(join(tmpdir(), "bl-cli-test-"));
function fileWith(name: string, content: string): string {
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
describe("readBlCliConfig", () => {
it("reads api_key and workspace_id from the default profile (top level)", () => {
const path = fileWith(
"full.json",
JSON.stringify({
api_key: "sk-abc",
workspace_id: "ws-1",
output: "text",
timeout: 600,
}),
);
expect(readBlCliConfig(path)).toEqual({ apiKey: "sk-abc", workspaceId: "ws-1" });
});
it("omits absent, blank, and non-string fields instead of returning empties", () => {
const path = fileWith(
"partial.json",
JSON.stringify({
api_key: " ",
workspace_id: 42,
}),
);
expect(readBlCliConfig(path)).toEqual({});
const keyOnly = fileWith("key-only.json", JSON.stringify({ api_key: "sk-abc" }));
expect(readBlCliConfig(keyOnly)).toEqual({ apiKey: "sk-abc" });
});
it("reads a missing, malformed, or non-object file as empty", () => {
expect(readBlCliConfig(join(dir, "nope.json"))).toEqual({});
expect(readBlCliConfig(fileWith("broken.json", "{oops"))).toEqual({});
expect(readBlCliConfig(fileWith("array.json", '["sk-abc"]'))).toEqual({});
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vite-plus/test";
import { consumeChatStream } from "../src/chat.js";
function sse(text: string): Response {
return new Response(text, { status: 200 });
}
function chunk(content: string, finish = ""): string {
return `data: ${JSON.stringify({ output: { choices: [{ message: { content }, finish_reason: finish }] }, request_id: "r-1" })}\n\n`;
}
describe("consumeChatStream", () => {
it("concatenates delta content across chunks until [DONE]", async () => {
const res = sse(chunk("Hello") + chunk(" world", "stop") + "data: [DONE]\n\n");
const out = await consumeChatStream(res);
expect(out.answer).toBe("Hello world");
expect(out.requestId).toBe("r-1");
});
it("ignores step_change progress chunks with empty content", async () => {
const progress = `data: ${JSON.stringify({ output: { choices: [{ message: { content: "", extra: { step_change: "tool_calling" } }, finish_reason: "" }] } })}\n\n`;
const res = sse(progress + chunk("answer", "stop") + "data: [DONE]\n\n");
expect((await consumeChatStream(res)).answer).toBe("answer");
});
it("throws on an SSE error event with the server message", async () => {
const res = sse('event: error\ndata: {"code":"Throttling","message":"rate limited"}\n\n');
await expect(consumeChatStream(res)).rejects.toThrow(/Throttling.*rate limited/);
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { KbApiError, KbClient } from "../src/client.js";
function makeClient(fetchImpl: typeof fetch) {
return new KbClient({
resolveWorkspaceId: async () => "ws-1",
endpointHost: "cn-beijing.maas.aliyuncs.com",
resolveApiKey: async () => "sk-test",
fetchImpl,
});
}
describe("KbClient.postJson", () => {
it("sends Bearer auth to the workspace endpoint and returns parsed JSON", async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: 1 }), { status: 200 }));
const client = makeClient(fetchImpl as unknown as typeof fetch);
const result = await client.postJson<{ ok: number }>("/api/v1/indices/knowledge/search", {
query: "q",
});
expect(result.ok).toBe(1);
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search");
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-test");
expect(init.method).toBe("POST");
});
it("translates a non-2xx into KbApiError with status and a bounded body summary", async () => {
const body = JSON.stringify({ code: "InvalidParameter", message: "agent not found" });
const fetchImpl = vi.fn(async () => new Response(body, { status: 400 }));
const client = makeClient(fetchImpl as unknown as typeof fetch);
const err = await client
.postJson("/api/v1/indices/knowledge/search", {})
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(KbApiError);
expect((err as KbApiError).status).toBe(400);
expect((err as KbApiError).message).toContain("agent not found");
});
it("re-resolves the API key per call (credential hot-swap contract)", async () => {
const resolveApiKey = vi.fn(async () => "sk-test");
const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 }));
const client = new KbClient({
resolveWorkspaceId: async () => "ws-1",
endpointHost: "h",
resolveApiKey,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await client.postJson("/p", {});
await client.postJson("/p", {});
expect(resolveApiKey).toHaveBeenCalledTimes(2);
});
it("re-resolves the workspace id per call (credential hot-swap contract)", async () => {
let workspaceId = "ws-1";
const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 }));
const client = new KbClient({
resolveWorkspaceId: async () => workspaceId,
endpointHost: "h",
resolveApiKey: async () => "sk-test",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await client.postJson("/p", {});
workspaceId = "ws-2";
await client.postJson("/p", {});
const urls = fetchImpl.mock.calls.map((call) => (call as unknown as [string])[0]);
expect(urls).toEqual(["https://ws-1.h/p", "https://ws-2.h/p"]);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vite-plus/test";
import { Config } from "../src/index.js";
describe("Config", () => {
it("applies defaults and accepts a pinned workspaceId", () => {
const resolved = new Config({ workspaceId: "ws-1" } as never);
expect(resolved.workspaceId).toBe("ws-1");
expect(resolved.endpointHost).toBe("cn-beijing.maas.aliyuncs.com");
expect(resolved.chatTimeoutMs).toBe(300_000);
expect(resolved.defaultRetrieveAgentId).toBeUndefined();
expect(resolved.defaultChatAgentId).toBeUndefined();
});
it("accepts a missing workspaceId (per-call credentials fallback)", () => {
const resolved = new Config({} as never);
expect(resolved.workspaceId).toBeUndefined();
expect(resolved.endpointHost).toBe("cn-beijing.maas.aliyuncs.com");
});
it("carries the bl-CLI seed ledger through validation", () => {
const resolved = new Config({ seededFields: ["apiKey"] } as never);
expect(resolved.seededFields).toEqual(["apiKey"]);
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vite-plus/test";
import {
consoleLoginState,
parseCallbackBody,
pickCallbackCredentials,
} from "../src/console-login.js";
describe("parseCallbackBody", () => {
it("reads a plain JSON callback", () => {
expect(parseCallbackBody('{"api_key":"sk-abc","workspace_id":"ws-1"}')).toMatchObject({
api_key: "sk-abc",
workspace_id: "ws-1",
});
});
it("flattens a `data` envelope, the top level winning", () => {
const parsed = parseCallbackBody(
'{"data":{"api_key":"sk-inner","workspace_id":"ws-1"},"api_key":"sk-outer"}',
);
expect(parsed).toMatchObject({ api_key: "sk-outer", workspace_id: "ws-1" });
});
it("reads a form-encoded callback", () => {
expect(parseCallbackBody("api_key=sk-abc&workspace_id=ws-1")).toMatchObject({
api_key: "sk-abc",
workspace_id: "ws-1",
});
});
it("tolerates a BOM, surrounding space, and an empty or broken body", () => {
expect(parseCallbackBody('\uFEFF {"api_key":"sk-abc"} ')).toMatchObject({
api_key: "sk-abc",
});
expect(parseCallbackBody("")).toEqual({});
expect(parseCallbackBody(" ")).toEqual({});
// A non-object JSON value carries no fields, and neither does an array.
expect(parseCallbackBody('["sk-abc"]')).toEqual({});
});
});
describe("pickCallbackCredentials", () => {
it("accepts both snake_case and camelCase field names", () => {
expect(pickCallbackCredentials({}, { apiKey: "sk-abc", workspaceId: "ws-1" })).toEqual({
apiKey: "sk-abc",
workspaceId: "ws-1",
});
expect(pickCallbackCredentials({}, { api_key: "sk-abc", workspace_id: "ws-1" })).toEqual({
apiKey: "sk-abc",
workspaceId: "ws-1",
});
});
it("lets query parameters win over the body", () => {
expect(pickCallbackCredentials({ api_key: "sk-query" }, { api_key: "sk-body" })).toEqual({
apiKey: "sk-query",
});
});
it("omits absent, blank, and non-string fields instead of returning empties", () => {
expect(pickCallbackCredentials({}, {})).toEqual({});
expect(pickCallbackCredentials({}, { api_key: " ", workspace_id: 42 })).toEqual({});
expect(pickCallbackCredentials({}, { api_key: " sk-abc " })).toEqual({ apiKey: "sk-abc" });
});
});
describe("consoleLoginState", () => {
it("starts idle, carrying no secret", () => {
expect(consoleLoginState()).toEqual({ phase: "idle" });
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vite-plus/test";
import { KB_PATHS, kbEndpoint } from "../src/endpoints.js";
describe("kbEndpoint", () => {
it("builds the workspace-subdomain URL", () => {
expect(kbEndpoint("cn-beijing.maas.aliyuncs.com", "ws-1", KB_PATHS.search)).toBe(
"https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search",
);
});
it("keeps protocol paths as constants", () => {
expect(KB_PATHS.chat).toBe("/api/v2/apps/knowledge/chat");
expect(KB_PATHS.search).toBe("/api/v1/indices/knowledge/search");
});
});
@@ -0,0 +1,301 @@
import {
mkdtempSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
mkdirSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it, vi } from "vite-plus/test";
import type { KbClient } from "../src/client.js";
import {
CACHE_TTL_MS,
EMPTY_CACHE_TTL_MS,
ServiceCache,
readServiceCache,
serviceCachePath,
writeServiceCache,
type ServiceCacheDocument,
} from "../src/service-cache.js";
const HOST = "cn-beijing.maas.aliyuncs.com";
function tempHome(): string {
return mkdtempSync(join(tmpdir(), "bailian-kb-cache-"));
}
function doc(overrides: Partial<ServiceCacheDocument> = {}): ServiceCacheDocument {
return {
version: 1,
fetchedAt: 1_000,
workspaceId: "llm-a",
endpointHost: HOST,
entries: [{ agent_id: "aid-1", agent_name: "svc", scene: "search", status: "deployed" }],
total: 1,
truncated: false,
...overrides,
};
}
/** Write raw text to the cache path, bypassing the writer's validation. */
function seedRaw(home: string, workspaceId: string, text: string): string {
const path = serviceCachePath(workspaceId, home);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, text);
return path;
}
describe("serviceCachePath", () => {
it("separates workspaces by filename", () => {
// An api key only reaches its own workspace, so one shared file would blend accounts.
expect(serviceCachePath("llm-a", "/home")).toBe("/home/cache/bailian-kb/services-llm-a.json");
expect(serviceCachePath("llm-b", "/home")).not.toBe(serviceCachePath("llm-a", "/home"));
});
});
describe("readServiceCache", () => {
it("round-trips a document written by writeServiceCache", () => {
const home = tempHome();
const path = serviceCachePath("llm-a", home);
writeServiceCache(path, doc());
expect(readServiceCache(path, "llm-a", HOST)).toEqual(doc());
});
it("reads every unusable file as a miss instead of throwing", () => {
const home = tempHome();
// Absent.
expect(readServiceCache(serviceCachePath("nope", home), "nope", HOST)).toBeUndefined();
// Malformed JSON.
expect(readServiceCache(seedRaw(home, "a", "{oops"), "a", HOST)).toBeUndefined();
// Non-object roots.
expect(readServiceCache(seedRaw(home, "b", '["x"]'), "b", HOST)).toBeUndefined();
// Newer or older schema: treated as a miss, never migrated.
expect(
readServiceCache(seedRaw(home, "c", JSON.stringify(doc({ version: 2 }))), "c", HOST),
).toBeUndefined();
// Required fields of the wrong type.
expect(
readServiceCache(
seedRaw(home, "d", JSON.stringify(doc({ fetchedAt: "soon" as never }))),
"d",
HOST,
),
).toBeUndefined();
});
it("refuses a document belonging to another workspace or host", () => {
const home = tempHome();
const path = seedRaw(home, "llm-a", JSON.stringify(doc({ workspaceId: "llm-other" })));
expect(readServiceCache(path, "llm-a", HOST)).toBeUndefined();
const hostPath = seedRaw(
home,
"llm-b",
JSON.stringify(doc({ workspaceId: "llm-b", endpointHost: "other.host" })),
);
expect(readServiceCache(hostPath, "llm-b", HOST)).toBeUndefined();
});
});
describe("writeServiceCache", () => {
it("publishes atomically and leaves no temp file behind", () => {
const home = tempHome();
const path = serviceCachePath("llm-a", home);
writeServiceCache(path, doc());
const names = readdirSync(dirname(path));
expect(names).toEqual(["services-llm-a.json"]);
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(doc());
});
it("creates the cache directory owner-only", () => {
const home = tempHome();
const path = serviceCachePath("llm-a", home);
writeServiceCache(path, doc());
expect(statSync(dirname(path)).mode & 0o777).toBe(0o700);
});
});
/** A cache wired to a client returning one page per scene. */
function cacheWith(home: string, onPost: () => Promise<unknown>, now: () => number = () => 5_000) {
const warn = vi.fn();
const postJson = vi.fn(onPost);
const cache = new ServiceCache({
client: { postJson } as unknown as KbClient,
resolveWorkspaceId: async () => "llm-a",
endpointHost: HOST,
warn,
home,
now,
});
return { cache, warn, postJson };
}
describe("ServiceCache", () => {
const emptyPage = { code: "Success", data: { total_count: 0, rows: [] } };
it("treats a missing document as stale and a fresh one as current", () => {
const home = tempHome();
const { cache } = cacheWith(home, async () => emptyPage);
expect(cache.isStale("llm-a")).toBe(true);
writeServiceCache(serviceCachePath("llm-a", home), doc({ fetchedAt: 5_000 }));
cache.invalidate();
expect(cache.isStale("llm-a")).toBe(false);
});
it("goes stale once the TTL elapses", () => {
const home = tempHome();
writeServiceCache(serviceCachePath("llm-a", home), doc({ fetchedAt: 0 }));
const { cache } = cacheWith(
home,
async () => emptyPage,
() => CACHE_TTL_MS,
);
expect(cache.isStale("llm-a")).toBe(true);
});
it("expires an EMPTY list on the much shorter negative TTL", () => {
// The trap this closes: configure the plugin against a fresh workspace (0
// services) → create a service → and then wait out the full TTL before the
// catalog appears. An empty list is a setup-in-progress state, not a fact.
const home = tempHome();
writeServiceCache(
serviceCachePath("llm-a", home),
doc({ fetchedAt: 0, entries: [], total: 0 }),
);
const justAfterEmptyTtl = cacheWith(
home,
async () => emptyPage,
() => EMPTY_CACHE_TTL_MS,
);
expect(justAfterEmptyTtl.cache.isStale("llm-a")).toBe(true);
// A non-empty list of the same age is still fresh, so the short window costs
// nothing once services exist.
writeServiceCache(serviceCachePath("llm-b", home), doc({ workspaceId: "llm-b", fetchedAt: 0 }));
const nonEmpty = cacheWith(
home,
async () => emptyPage,
() => EMPTY_CACHE_TTL_MS,
);
expect(nonEmpty.cache.isStale("llm-b")).toBe(false);
expect(EMPTY_CACHE_TTL_MS).toBeLessThan(CACHE_TTL_MS);
});
it('reports a panel snapshot that separates "empty workspace" from "stale list"', () => {
const home = tempHome();
const { cache } = cacheWith(
home,
async () => emptyPage,
() => 5_000,
);
// Nothing cached at all: the panel must be able to say "never fetched"
// rather than showing a zero that reads as "the workspace is empty".
const empty = cache.status("llm-a");
expect(empty).not.toHaveProperty("fetchedAt");
expect(empty).toMatchObject({ searchCount: 0, chatCount: 0, stale: true });
writeServiceCache(
serviceCachePath("llm-a", home),
doc({
fetchedAt: 5_000,
entries: [
{
agent_id: "aid-1",
agent_name: "a",
scene: "search",
status: "deployed",
modify_time: "2026-08-01",
},
{
agent_id: "aid-2",
agent_name: "b",
scene: "search",
status: "deployed",
modify_time: "2026-08-09",
},
{ agent_id: "aid-3", agent_name: "c", scene: "chat", status: "deployed" },
],
total: 900,
truncated: true,
}),
);
cache.invalidate();
expect(cache.status("llm-a")).toMatchObject({
fetchedAt: 5_000,
searchCount: 2,
chatCount: 1,
total: 900,
truncated: true,
stale: false,
});
// The picker lists newest first so the likely-in-use service is on top.
expect(cache.entriesFor("llm-a", "search").map((e) => e.agent_id)).toEqual(["aid-2", "aid-1"]);
expect(cache.entriesFor("llm-a", "chat").map((e) => e.agent_id)).toEqual(["aid-3"]);
});
it("re-reads from disk when the workspace changes", () => {
const home = tempHome();
writeServiceCache(serviceCachePath("llm-a", home), doc({ fetchedAt: 5_000 }));
const { cache } = cacheWith(home, async () => emptyPage);
expect(cache.peek("llm-a")?.workspaceId).toBe("llm-a");
// Switching accounts must not keep serving the previous workspace's list.
expect(cache.peek("llm-b")).toBeUndefined();
});
it("shares one in-flight request across concurrent refreshes", async () => {
const home = tempHome();
// pre-step runs on every model request, so an unguarded refresh would pile up.
const { cache, postJson } = cacheWith(home, async () => emptyPage);
await Promise.all([cache.refresh(), cache.refresh(), cache.refresh()]);
// Two calls total: one per scene, from a single shared refresh.
expect(postJson).toHaveBeenCalledTimes(2);
});
it("stores a fetched list and serves it synchronously afterwards", async () => {
const home = tempHome();
const { cache } = cacheWith(home, async () => ({
code: "Success",
data: {
total_count: 1,
rows: [{ agent_id: "aid-9", agent_name: "svc", agent_status: "deployed" }],
},
}));
await cache.refresh();
const stored = cache.peek("llm-a");
expect(stored?.entries.map((e) => e.agent_id)).toEqual(["aid-9", "aid-9"]); // one per scene
expect(stored?.fetchedAt).toBe(5_000);
// And it survives as a file for the next process.
expect(readServiceCache(serviceCachePath("llm-a", home), "llm-a", HOST)).toEqual(stored);
});
it("never rejects on failure and keeps the previous document", async () => {
const home = tempHome();
writeServiceCache(serviceCachePath("llm-a", home), doc({ fetchedAt: 5_000 }));
const { cache, warn } = cacheWith(home, async () => {
throw new Error("network down");
});
expect(cache.peek("llm-a")?.entries).toHaveLength(1);
await expect(cache.refresh()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
// The stale-but-usable list is still there.
expect(cache.peek("llm-a")?.entries).toHaveLength(1);
});
it("does not reject when the workspace is not configured yet", async () => {
const home = tempHome();
const warn = vi.fn();
const cache = new ServiceCache({
client: { postJson: vi.fn() } as unknown as KbClient,
resolveWorkspaceId: async () => {
throw new Error("workspace id is not configured");
},
endpointHost: HOST,
warn,
home,
});
await expect(cache.refresh()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("refresh failed"));
});
});
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vite-plus/test";
import {
CATALOG_ENTRY_LIMIT,
buildNoServiceNotice,
buildRefreshedSceneList,
buildServiceCatalog,
} from "../src/service-catalog.js";
import type { ServiceEntry } from "../src/services.js";
function entry(overrides: Partial<ServiceEntry> & { agent_id: string }): ServiceEntry {
return {
agent_name: `name-${overrides.agent_id}`,
scene: "search",
status: "deployed",
...overrides,
};
}
function catalog(
entries: ServiceEntry[],
extra: Partial<Parameters<typeof buildServiceCatalog>[0]> = {},
) {
return buildServiceCatalog({ entries, total: entries.length, truncated: false, ...extra });
}
describe("buildServiceCatalog", () => {
it("returns undefined when there is no service to render", () => {
// Nothing to route to; the caller substitutes buildNoServiceNotice(), which
// is the branch that tells the model what to do about it.
expect(catalog([])).toBeUndefined();
});
it("lists every service when the count is within the limit", () => {
const text = catalog([
entry({ agent_id: "aid-1", agent_name: "RAG学习-检索" }),
entry({ agent_id: "aid-2", agent_name: "产品文档" }),
]);
expect(text).toContain("aid-1 — RAG学习-检索");
expect(text).toContain("aid-2 — 产品文档");
// Nothing was cut, so nothing should claim otherwise.
expect(text).not.toContain("showing");
});
it("omits a scene with no services entirely", () => {
const text = catalog([entry({ agent_id: "aid-1" })]);
expect(text).toContain("kb_search");
// "no chat services" is noise that invites handling a case that does not exist.
expect(text).not.toContain("kb_chat");
expect(text).not.toMatch(/no chat/i);
});
it("renders both scenes without mixing their services", () => {
const text =
catalog([
entry({ agent_id: "aid-s", scene: "search" }),
entry({ agent_id: "aid-c", scene: "chat" }),
]) ?? "";
const searchAt = text.indexOf("kb_search");
const chatAt = text.indexOf("kb_chat");
expect(searchAt).toBeGreaterThanOrEqual(0);
expect(chatAt).toBeGreaterThan(searchAt);
// Each id belongs to its own section.
expect(text.indexOf("aid-s")).toBeLessThan(chatAt);
expect(text.indexOf("aid-c")).toBeGreaterThan(chatAt);
});
it("shows only the configured default and says how many others exist", () => {
const entries = Array.from({ length: 5 }, (_x, i) => entry({ agent_id: `aid-${i}` }));
const text = catalog(entries, { defaultRetrieveAgentId: "aid-3" }) ?? "";
expect(text).toContain("aid-3");
expect(text).not.toContain("aid-0");
expect(text).toContain("4 others exist");
});
it("falls back to the full list when the configured default is not in the cache", () => {
// A stale or mistyped default must not hide the services that do exist.
const text =
catalog([entry({ agent_id: "aid-1" })], { defaultRetrieveAgentId: "aid-gone" }) ?? "";
expect(text).toContain("aid-1");
expect(text).not.toContain("default service");
});
it("caps the list at the limit, newest first, and states the shortfall", () => {
const entries = Array.from({ length: 14 }, (_x, i) =>
entry({
agent_id: `aid-${i}`,
// aid-13 newest, aid-0 oldest.
modify_time: `2026-08-${String(i + 1).padStart(2, "0")}T00:00:00`,
}),
);
const text = catalog(entries) ?? "";
expect(text).toContain("aid-13");
// The four oldest fall outside the window.
expect(text).not.toContain("aid-0 ");
expect(text).toContain(`showing ${CATALOG_ENTRY_LIMIT} most recently modified of 14`);
// Silent truncation would make the model treat the list as complete.
expect(text).toContain("--name <keyword>");
});
it("marks a capped fetch as a lower bound rather than an exact total", () => {
// The fetch itself stopped early, so even the count is unknown.
const entries = Array.from({ length: 12 }, (_x, i) => entry({ agent_id: `aid-${i}` }));
const text = catalog(entries, { truncated: true, total: 900 }) ?? "";
expect(text).toContain("more than");
});
it("states that agent_id is required and forbids guessing one", () => {
const text = catalog([entry({ agent_id: "aid-1" })]) ?? "";
expect(text).toContain("required");
// Picking the closest-looking service returns unrelated evidence, which is
// worse for the user than an honest "no such knowledge base".
expect(text).toMatch(/say so plainly/i);
});
it("labels an unnamed service instead of rendering a bare dash", () => {
const text = catalog([entry({ agent_id: "aid-1", agent_name: "" })]) ?? "";
expect(text).toContain("(unnamed)");
});
it("renders a description when one arrives, truncated to its budget", () => {
// Forward compatibility: the backend has not shipped this field yet.
const long = "x".repeat(250);
const text = catalog([entry({ agent_id: "aid-1", description: long })]) ?? "";
expect(text).toContain("x".repeat(199));
expect(text).not.toContain("x".repeat(201));
expect(text).toContain("…");
});
it("explains how to get bl only where it recommends running it", () => {
// The plugin never shells out, so a fully configured deployment can have no
// bl on PATH. Naming the command without the install line is a dead end.
const complete = catalog([entry({ agent_id: "aid-1" })]) ?? "";
expect(complete).not.toContain("bl knowledge service list");
expect(complete).not.toContain("npm install -g bailian-cli");
const capped =
catalog(Array.from({ length: 12 }, (_x, index) => entry({ agent_id: `aid-${index}` }))) ?? "";
expect(capped).toContain("bl knowledge service list");
expect(capped).toContain("npm install -g bailian-cli");
// Same rule on the collapsed-to-default branch, which also names the command.
const collapsed =
catalog([entry({ agent_id: "aid-1" }), entry({ agent_id: "aid-2" })], {
defaultRetrieveAgentId: "aid-1",
}) ?? "";
expect(collapsed).toContain("bl knowledge service list");
expect(collapsed).toContain("npm install -g bailian-cli");
});
});
describe("buildNoServiceNotice", () => {
it("names the way out instead of leaving the model to invent an agent_id", () => {
const text = buildNoServiceNotice();
// agent_id is required, so silence here means a guessed id or a silently
// dropped retrieval — both look like a broken plugin to the user.
expect(text).toMatch(/do not invent an id/i);
expect(text).toContain("bl knowledge service create");
expect(text).toContain("bl knowledge service deploy");
// The management skill is a second-order path: a model that never loads it
// must still learn how to get bl.
expect(text).toContain("npm install -g bailian-cli");
expect(text).toContain("https://bailian.console.aliyun.com/");
});
});
describe("buildRefreshedSceneList", () => {
it("lists the scene's services for a rejected call", () => {
const text = buildRefreshedSceneList("search", [
entry({ agent_id: "aid-1", agent_name: "产品文档" }),
]);
expect(text).toContain("aid-1 — 产品文档");
expect(text).toMatch(/re-read just now/i);
expect(text).not.toContain("npm install -g bailian-cli");
});
it("states the shortfall and how to get bl when the list is capped", () => {
const entries = Array.from({ length: CATALOG_ENTRY_LIMIT + 3 }, (_x, index) =>
entry({ agent_id: `aid-${index}` }),
);
const text = buildRefreshedSceneList("search", entries);
expect(text).toContain("and 3 more");
expect(text).toContain("npm install -g bailian-cli");
});
it("tells a rejected call to stop retrying when the scene has no services", () => {
// Returning nothing here used to let the bare "invalid agent_id" through,
// which reads as an invitation to try a different id.
const text = buildRefreshedSceneList("chat", []);
expect(text).toMatch(/no deployed chat services/i);
expect(text).toMatch(/do not retry with a different id/i);
expect(text).toContain("bl knowledge service deploy");
});
});
@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { KbClient } from "../src/client.js";
import { ServiceCache, serviceCachePath, writeServiceCache } from "../src/service-cache.js";
import { installServiceContext } from "../src/service-context.js";
const HOST = "cn-beijing.maas.aliyuncs.com";
const WS = "llm-a";
const SOURCE_PLUGIN = "tool-bailian-kb/services";
type Injected = { id: string; source: { plugin?: string }; content: { text?: string }[] };
type Decision = { kind: string; messages: Injected[] };
interface HarnessOptions {
entries?: { agent_id: string; agent_name: string; scene: "search" | "chat"; status: string }[];
cacheOverride?: ServiceCache;
postJson?: () => Promise<unknown>;
/** Skip seeding the cache file, i.e. the very first step of a fresh install. */
withoutCacheFile?: boolean;
}
/** Drives the installed `agent/pre-step` listener against a fake session. */
function harness(options: HarnessOptions = {}) {
const home = mkdtempSync(join(tmpdir(), "bailian-kb-ctx-"));
const entries = options.entries ?? [
{ agent_id: "aid-1", agent_name: "svc-one", scene: "search" as const, status: "deployed" },
];
if (options.withoutCacheFile !== true) {
writeServiceCache(serviceCachePath(WS, home), {
version: 1,
fetchedAt: Date.now(),
workspaceId: WS,
endpointHost: HOST,
entries,
total: entries.length,
truncated: false,
});
}
const postJson = vi.fn(
options.postJson ?? (async () => ({ code: "Success", data: { total_count: 0, rows: [] } })),
);
const cache =
options.cacheOverride ??
new ServiceCache({
client: { postJson } as unknown as KbClient,
resolveWorkspaceId: async () => WS,
endpointHost: HOST,
warn: () => {},
home,
});
let listener!: (payload: unknown, next: () => Promise<unknown>) => Promise<unknown>;
const warn = vi.fn();
const ctx = {
on: (_event: string, handler: typeof listener) => {
listener = handler;
},
};
installServiceContext(ctx as never, {
cache,
resolveWorkspaceId: async () => WS,
resolveDefaultRetrieveAgentId: async () => undefined,
resolveDefaultChatAgentId: async () => undefined,
warn,
});
const events: { type: string; seq: number; data: unknown }[] = [];
const agent = {
session: {
events,
surface: { nodes: [] as number[] },
header: { cwd: "/tmp" },
},
};
const run = async (proposed: Injected[], downstream: "enter" | "reject"): Promise<Decision> =>
(await listener({ agent, signal: { aborted: false, throwIfAborted: () => {} } }, async () =>
downstream === "reject" ? { kind: "reject" } : { kind: "enter", messages: proposed },
)) as Decision;
return {
cache,
warn,
postJson,
/** One step whose downstream decision enters with `proposed`. */
step: async (proposed: Injected[] = []) => await run(proposed, "enter"),
/** One step whose downstream decision rejects. */
stepRejecting: async () => await run([], "reject"),
/** Record an entered message into the durable log and make it visible. */
commit: (message: Injected) => {
events.push({ type: "user/message", seq: events.length + 1, data: message });
agent.session.surface.nodes = events.map((event) => event.seq);
},
/** Simulate compaction: the events remain, the surface shrinks. */
setSurface: (seqs: number[]) => {
agent.session.surface.nodes = seqs;
},
/** This module's own messages within a decision. */
injected: (decision: Decision) =>
decision.messages.filter((m) => m.source.plugin === SOURCE_PLUGIN),
};
}
describe("installServiceContext", () => {
it("injects the catalog as a sourced message on the first step", async () => {
const h = harness();
const decision = await h.step();
expect(decision.kind).toBe("enter");
const own = h.injected(decision);
expect(own).toHaveLength(1);
expect(own[0]?.content[0]?.text).toContain("aid-1");
});
it("does not re-inject on later steps of the same turn", async () => {
// pre-step fires once per model request, so a turn with five tool calls
// fires it six times. Re-injecting each time would insert six copies and
// void the KV cache from the first insertion onward — this is a correctness
// guard, not an optimization.
const h = harness();
const first = await h.step();
const own = h.injected(first);
expect(own).toHaveLength(1);
h.commit(own[0]!);
for (let toolRoundTrip = 0; toolRoundTrip < 5; toolRoundTrip += 1) {
expect(h.injected(await h.step())).toHaveLength(0);
}
});
it("re-injects once compaction drops the catalog from the visible surface", async () => {
const h = harness();
const first = await h.step();
h.commit(h.injected(first)[0]!);
expect(h.injected(await h.step())).toHaveLength(0);
// The event stays in the durable log but leaves the surface. Comparing only
// "did we ever publish this" would suppress every future injection and the
// model would finish the session with no service list at all.
h.setSurface([]);
expect(h.injected(await h.step())).toHaveLength(1);
});
it("replaces its own pending message instead of adding a second one", async () => {
const h = harness();
const pending = h.injected(await h.step())[0]!;
const second = await h.step([pending]);
expect(h.injected(second)).toHaveLength(1);
});
it("injects the no-service notice when the cache holds no services", async () => {
// A cached empty list is authoritative, not a missing fetch: staying silent
// would leave the model to invent the required agent_id.
const h = harness({ entries: [] });
const own = h.injected(await h.step());
expect(own).toHaveLength(1);
expect(own[0]?.content[0]?.text).toMatch(/no\s+deployed knowledge service/i);
});
it("injects nothing while no cache document exists yet", async () => {
// Nothing has been fetched, so any claim about the workspace would be a
// guess; the background refresh corrects this on a later step.
const h = harness({ withoutCacheFile: true });
expect(h.injected(await h.step())).toHaveLength(0);
});
it("never awaits the network refresh", async () => {
// A slow list request must not delay the user's request.
let settle: ((value: unknown) => void) | undefined;
const h = harness({
postJson: async () =>
await new Promise((resolve) => {
settle = resolve;
}),
});
// Force staleness so this step schedules a refresh.
h.cache.invalidate();
const raced = await Promise.race([
h.step().then(() => "stepped"),
new Promise((resolve) => {
setTimeout(() => resolve("timed out"), 200);
}),
]);
expect(raced).toBe("stepped");
settle?.({ data: { rows: [] } });
});
it("degrades to no injection when anything throws, never failing the step", async () => {
// A throwing pre-step listener fails the proposed step, i.e. stalls the
// user's turn. A missing catalog is far cheaper than that.
const exploding = {
isStale: () => {
throw new Error("corrupt cache");
},
peek: () => undefined,
refresh: async () => {},
invalidate: () => {},
} as unknown as ServiceCache;
const h = harness({ cacheOverride: exploding });
const decision = await h.step();
expect(decision.kind).toBe("enter");
expect(h.injected(decision)).toHaveLength(0);
expect(h.warn).toHaveBeenCalled();
});
it("passes a rejected downstream decision straight through", async () => {
const h = harness();
expect((await h.stepRejecting()).kind).toBe("reject");
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vite-plus/test";
import type { ServiceListResponse } from "../src/api-types.js";
import type { KbClient } from "../src/client.js";
import { listServices } from "../src/services.js";
/** A page of `count` rows, all deployed unless overridden. */
function page(
count: number,
total: number,
overrides: Record<string, unknown> = {},
): ServiceListResponse {
return {
code: "Success",
data: {
total_count: total,
rows: Array.from({ length: count }, (_row, index) => ({
agent_id: `aid-${index}`,
agent_name: `service-${index}`,
agent_status: "deployed",
modify_time: "2026-08-20T10:00:00",
...overrides,
})),
},
};
}
/** A client whose postJson is driven by a queue of per-call responses or errors. */
function clientReturning(...outcomes: (ServiceListResponse | Error)[]): {
client: KbClient;
postJson: ReturnType<typeof vi.fn>;
} {
const postJson = vi.fn(async () => {
const next = outcomes.shift();
if (next === undefined) throw new Error("unexpected extra request");
if (next instanceof Error) throw next;
return next;
});
return { client: { postJson } as unknown as KbClient, postJson };
}
describe("listServices", () => {
it("queries both scenes for deployed services only and tags each entry with its scene", async () => {
const { client, postJson } = clientReturning(page(1, 1), page(1, 1));
const result = await listServices(client);
expect(postJson.mock.calls[0]?.[1]).toMatchObject({
agent_scene: "search",
// Verified server-side: this filter is honored and excludes drafts.
agent_status: "deployed",
page_number: 1,
page_size: 100,
});
expect(postJson.mock.calls[1]?.[1]).toMatchObject({ agent_scene: "chat" });
expect(result.entries.map((e) => e.scene)).toEqual(["search", "chat"]);
expect(result.total).toBe(2);
expect(result.truncated).toBe(false);
expect(result.failedScenes).toEqual([]);
});
it("stops at a short page without asking for another", async () => {
// 3 rows on a 100-row page is the last page; a second request would be waste.
const { client, postJson } = clientReturning(page(3, 3), page(0, 0));
const result = await listServices(client);
expect(postJson).toHaveBeenCalledTimes(2); // one per scene, not one per page
expect(result.entries).toHaveLength(3);
});
it("caps at two pages per scene and reports the shortfall as truncated", async () => {
// A workspace claiming 500 rows: fetch 200, flag the rest as unfetched.
const { client, postJson } = clientReturning(page(100, 500), page(100, 500), page(0, 0));
const result = await listServices(client);
// 2 pages for search + 1 short page for chat: the cap holds.
expect(postJson).toHaveBeenCalledTimes(3);
expect(result.entries).toHaveLength(200);
expect(result.truncated).toBe(true);
});
it("keeps one scene when the other fails instead of losing the whole list", async () => {
const { client } = clientReturning(page(2, 2), new Error("chat scene exploded"));
const result = await listServices(client);
expect(result.entries).toHaveLength(2);
expect(result.entries.every((e) => e.scene === "search")).toBe(true);
expect(result.failedScenes).toEqual(["chat"]);
});
it("reports both scenes as failed without throwing", async () => {
const { client } = clientReturning(new Error("down"), new Error("down"));
const result = await listServices(client);
expect(result.entries).toEqual([]);
expect(result.failedScenes).toEqual(["search", "chat"]);
});
it("drops rows without an agent_id and never reads pipeline_list", async () => {
// pipeline_list is unreliable in production (missing names, sometimes empty),
// so entries must not carry any knowledge-base label derived from it.
const { client } = clientReturning(
{
code: "Success",
data: {
total_count: 2,
rows: [
{ agent_name: "no id", agent_status: "deployed" },
{
agent_id: "aid-1",
agent_name: "ok",
agent_status: "deployed",
pipeline_list: [{ pipeline_id: "p1" }],
},
],
},
},
page(0, 0),
);
const result = await listServices(client);
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toEqual({
agent_id: "aid-1",
agent_name: "ok",
scene: "search",
status: "deployed",
});
expect(JSON.stringify(result.entries)).not.toContain("p1");
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vite-plus/test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseSkillFile } from "../src/skill.js";
const SKILL_PATH = fileURLToPath(new URL("../skills/bailian-kb/SKILL.md", import.meta.url));
describe("parseSkillFile", () => {
it("takes name and description from frontmatter and strips the block from the body", () => {
const parsed = parseSkillFile(
[
"---",
"name: demo-skill",
"description: >-",
" First line of the folded description,",
" continued on the next source line.",
"---",
"",
"# Heading",
"",
"Body text.",
"",
].join("\n"),
);
expect(parsed?.name).toBe("demo-skill");
// A folded scalar joins its lines with spaces; the value must arrive unfolded.
expect(parsed?.description).toBe(
"First line of the folded description, continued on the next source line.",
);
// The registry performs no parsing of its own, so the YAML must already be gone.
expect(parsed?.content).toBe("\n# Heading\n\nBody text.\n");
expect(parsed?.content).not.toContain("---");
expect(parsed?.content).not.toContain("description:");
});
it("carries optional whenToUse and metadata, omitting them when absent or blank", () => {
const withExtras = parseSkillFile(
[
"---",
"name: demo-skill",
"description: Routing text.",
"whenToUse: Extra guidance.",
"metadata:",
" owner: platform",
"---",
"Body.",
].join("\n"),
);
expect(withExtras?.whenToUse).toBe("Extra guidance.");
expect(withExtras?.metadata).toEqual({ owner: "platform" });
const bare = parseSkillFile("---\nname: demo-skill\ndescription: Routing text.\n---\nBody.");
expect(bare).not.toHaveProperty("whenToUse");
expect(bare).not.toHaveProperty("metadata");
});
it("rejects a file without usable frontmatter instead of throwing", () => {
// No fence at all: a plain markdown file.
expect(parseSkillFile("# Just markdown\n")).toBeUndefined();
// Opening fence never closes.
expect(parseSkillFile("---\nname: demo-skill\n")).toBeUndefined();
// Unparsable YAML inside the fence.
expect(parseSkillFile("---\nname: [unclosed\n---\nBody.")).toBeUndefined();
// Scalar and sequence roots are not frontmatter records.
expect(parseSkillFile("---\njust a string\n---\nBody.")).toBeUndefined();
expect(parseSkillFile("---\n- one\n- two\n---\nBody.")).toBeUndefined();
// name or description missing, blank, or the wrong type.
expect(parseSkillFile("---\ndescription: Routing text.\n---\nBody.")).toBeUndefined();
expect(parseSkillFile("---\nname: demo-skill\n---\nBody.")).toBeUndefined();
expect(parseSkillFile('---\nname: demo-skill\ndescription: " "\n---\nBody.')).toBeUndefined();
expect(parseSkillFile("---\nname: 42\ndescription: Routing text.\n---\nBody.")).toBeUndefined();
});
});
describe("the packaged bailian-kb SKILL.md", () => {
const parsed = parseSkillFile(readFileSync(SKILL_PATH, "utf8"));
it("parses, and is the single source of the registered name and description", () => {
expect(parsed?.name).toBe("bailian-kb");
expect(parsed?.description).toContain("bl");
// The frontmatter must state where credentials come from: that sentence used
// to live in skill.ts and would otherwise be lost with the duplicate.
expect(parsed?.description).toContain("DASHSCOPE_API_KEY");
});
it("keeps its description within the catalog truncation budget", () => {
// tool-skill publishes catalog entries through catalogDescription(), which
// whitespace-normalizes and hard-truncates at 500 characters.
const normalized = (parsed?.description ?? "").replaceAll(/\s+/g, " ").trim();
expect(normalized.length).toBeLessThanOrEqual(500);
});
it("registers a body with no frontmatter residue", () => {
expect(parsed?.content.startsWith("---")).toBe(false);
expect(parsed?.content).not.toContain("description: >-");
// The real body still begins with the document heading.
expect(parsed?.content.trimStart().startsWith("#")).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vite-plus/test";
import { parseSseStream } from "../src/sse.js";
function streamOf(text: string): ReadableStream<Uint8Array> {
return new Response(text).body as ReadableStream<Uint8Array>;
}
async function collect(text: string) {
const events: { event?: string; data: string }[] = [];
for await (const e of parseSseStream(streamOf(text))) events.push(e);
return events;
}
describe("parseSseStream", () => {
it("yields data events split on blank lines", async () => {
const events = await collect('data: {"a":1}\n\ndata: [DONE]\n\n');
expect(events).toEqual([
{ event: undefined, data: '{"a":1}' },
{ event: undefined, data: "[DONE]" },
]);
});
it("carries the event field and parses CRLF lines", async () => {
const events = await collect('event: error\r\ndata: {"message":"boom"}\r\n\r\n');
expect(events[0]).toEqual({ event: "error", data: '{"message":"boom"}' });
});
it("flushes a final event not terminated by a blank line", async () => {
const events = await collect("data: tail\n");
expect(events).toEqual([{ event: undefined, data: "tail" }]);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { KbApiError, KbClient } from "../src/client.js";
import { createKbTools } from "../src/tools.js";
const EXEC = {} as never;
function toolsWith(
postJson: unknown,
postSse?: unknown,
resolveDefaultRetrieveAgentId?: () => Promise<string | undefined>,
resolveDefaultChatAgentId?: () => Promise<string | undefined>,
describeServicesAfterRefresh?: (scene: "search" | "chat") => Promise<string | undefined>,
) {
const client = { postJson, postSse, agentVersion: undefined } as unknown as KbClient;
const list = createKbTools({
client,
...(resolveDefaultRetrieveAgentId ? { resolveDefaultRetrieveAgentId } : {}),
...(resolveDefaultChatAgentId ? { resolveDefaultChatAgentId } : {}),
...(describeServicesAfterRefresh ? { describeServicesAfterRefresh } : {}),
chatTimeoutMs: 1000,
});
const byName = Object.fromEntries(list.map((t) => [t.name, t]));
return { byName, list };
}
const searchResponse = {
request_id: "r1",
data: {
total: 3,
nodes: [
{ score: 0.9, text: "A", metadata: { doc_name: "d1" } },
{ score: 0.8, text: "B", metadata: {} },
{ score: 0.7, text: "C", metadata: {} },
],
},
};
describe("createKbTools", () => {
it("registers kb_search and kb_chat", () => {
const { list } = toolsWith(vi.fn());
expect(list.map((t) => t.name).sort()).toEqual(["kb_chat", "kb_search"]);
});
it("keeps both descriptions free of service ids so the schemas stay prefix-stable", () => {
// The live service list rides an `agent/pre-step` context message precisely
// because re-registering a tool to refresh its description would void the
// prompt prefix cache. Any id leaking in here means that decision regressed.
const { byName } = toolsWith(vi.fn());
for (const tool of [byName.kb_search!, byName.kb_chat!]) {
const text = `${tool.description} ${JSON.stringify(tool.parameters)}`;
expect(text).not.toMatch(/aid-[0-9a-f]/);
}
});
it("kb_search truncates nodes client-side to top_k and never sends top_k to the server", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const { byName } = toolsWith(postJson);
const out = (await byName.kb_search!.execute(
{ query: "q", agent_id: "aid-1", top_k: 2 },
EXEC,
)) as { chunks: unknown[] };
expect(out.chunks).toHaveLength(2);
const body = postJson.mock.calls[0]![1] as Record<string, unknown>;
expect(body).not.toHaveProperty("top_k");
expect(body.agent_id).toBe("aid-1");
});
it("agent_id is required in the schema for both tools", () => {
const { byName } = toolsWith(vi.fn());
// defineTool compiles the spec into JSON Schema: requiredness lives in the top-level `required` array.
const requiredList = (tool: { parameters: Record<string, unknown> }) =>
(tool.parameters.required ?? []) as string[];
expect(requiredList(byName.kb_search!)).toContain("agent_id");
expect(requiredList(byName.kb_chat!)).toContain("agent_id");
});
it("a missing agent_id is rejected by schema validation before execute (even with a default resolver)", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const resolveDefaultRetrieveAgentId = vi.fn(async () => "aid-fixed");
const { byName } = toolsWith(postJson, undefined, resolveDefaultRetrieveAgentId);
// defineTool validates args against the compiled schema before execute runs,
// so with agent_id required the per-call default fallback is never consulted
// through this entry point; it stays as defense-in-depth only.
const err = await byName.kb_search!.execute({ query: "q" }, EXEC).catch((e: unknown) => e);
expect((err as Error).message).toContain("agent_id");
expect(resolveDefaultRetrieveAgentId).not.toHaveBeenCalled();
expect(postJson).not.toHaveBeenCalled();
});
it("a missing agent_id without any default is also a schema rejection, not the runtime guidance error", async () => {
const { byName } = toolsWith(vi.fn());
const err = await byName.kb_search!.execute({ query: "q" }, EXEC).catch((e: unknown) => e);
expect((err as Error).message).toContain("missing required property");
});
it("a supplied agent_id bypasses the default resolver entirely", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const resolveDefaultRetrieveAgentId = vi.fn(async () => "aid-default");
const { byName } = toolsWith(postJson, undefined, resolveDefaultRetrieveAgentId);
await byName.kb_search!.execute({ query: "q", agent_id: "aid-explicit" }, EXEC);
expect(resolveDefaultRetrieveAgentId).not.toHaveBeenCalled();
expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_id).toBe("aid-explicit");
});
it("a 4xx failure passes the original error through unchanged when no refresh hook is wired", async () => {
const postJson = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const { byName } = toolsWith(postJson);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "bad" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toBe("agent not found");
});
it("a 4xx failure appends the refreshed service list for the calling scene", async () => {
// agent_id is the only caller-supplied identifier on these endpoints, so a
// rejected request most often means the cached id is gone. The recovery the
// model needs is the current list, and an error message carries it without
// disturbing the request prefix.
const postJson = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const describe = vi.fn(async (scene: "search" | "chat") => `services for ${scene}: aid-new`);
const { byName } = toolsWith(postJson, undefined, undefined, undefined, describe);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "stale" }, EXEC)
.catch((e: unknown) => e);
expect(describe).toHaveBeenCalledWith("search");
expect((err as Error).message).toContain("agent not found");
expect((err as Error).message).toContain("services for search: aid-new");
});
it("leaves a 5xx failure and a failing refresh alone", async () => {
// A server-side fault is not an id problem, and a refresh that itself fails
// must not replace the real error with its own.
const serverError = vi.fn(async (_path: string) => {
throw new KbApiError("upstream exploded", 502);
});
const describe = vi.fn(async () => "never used");
const { byName } = toolsWith(serverError, undefined, undefined, undefined, describe);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect(describe).not.toHaveBeenCalled();
expect((err as Error).message).toBe("upstream exploded");
const badRequest = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const failing = vi.fn(async () => {
throw new Error("refresh also down");
});
const { byName: byName2 } = toolsWith(badRequest, undefined, undefined, undefined, failing);
const err2 = await byName2
.kb_search!.execute({ query: "q", agent_id: "bad" }, EXEC)
.catch((e: unknown) => e);
expect((err2 as Error).message).toBe("agent not found");
});
it("kb_chat buffers the SSE stream into one answer", async () => {
const sse =
'data: {"output":{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]},"request_id":"r2"}\n\ndata: [DONE]\n\n';
const postSse = vi.fn(async () => new Response(sse, { status: 200 }));
const { byName } = toolsWith(vi.fn(), postSse);
const out = (await byName.kb_chat!.execute({ message: "q", agent_id: "aid-1" }, EXEC)) as {
answer: string;
};
expect(out.answer).toBe("hi");
});
it("kb_chat translates a timeout into retry-or-search guidance", async () => {
const timeout = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
const postSse = vi.fn(async () => {
throw timeout;
});
const { byName } = toolsWith(vi.fn(), postSse);
const err = await byName
.kb_chat!.execute({ message: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toMatch(/timed out.*kb_search/s);
});
it("kb_chat reads chatTimeoutMs off deps per call (live-settings getter stays live)", async () => {
const timeout = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
const postSse = vi.fn(async () => {
throw timeout;
});
const client = { postJson: vi.fn(), postSse, agentVersion: undefined } as unknown as KbClient;
// Mirrors the host apply: a getter over the mutable settings source.
let timeoutMs = 1000;
const list = createKbTools({
client,
get chatTimeoutMs() {
return timeoutMs;
},
});
const chat = list.find((t) => t.name === "kb_chat")!;
timeoutMs = 2222;
const err = await chat
.execute({ message: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toContain("2222ms");
});
});
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"declaration": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["src/web"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src", "tests"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": true,
"types": []
},
"include": ["src/web"]
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Browser bundle for the plugin's client half, mirroring the host's tsdown
* client preset (packages/client/tsdown.client.ts — spelled out here because
* an out-of-tree package cannot import it): a closure-factory artifact that
* calls window.__ModuleLoader__.load({id, factory}) and resolves externals
* through the injected require. CSS Modules are compiled by lightningcss
* inside the bundle: importing `x.module.css` yields the hashed class map and
* auto-injects a <style data-plugin> tag at factory execution.
*/
import { readFile } from "node:fs/promises";
import { basename, dirname, resolve as resolvePath } from "node:path";
import { defineConfig } from "tsdown";
import { transform } from "lightningcss";
/** Plugin id stamped into the __ModuleLoader__.load handoff and style tags. */
const PLUGIN_ID = "bailian-kb-dsh";
/** The module specifiers the shell shares into the frozen module table. */
const PLATFORM_MODULES = [
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"@deepseek-ai/cordis",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-web-react",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-attachment",
"@deepseek-ai/dsh-client-schema-form",
];
/**
* Documented host exemption (not a platform module): the snapshot-store
* engine lives in runtime pending its rehoming; at runtime the lazy CJS table
* answers the require natively.
*/
const RUNTIME_STORE_EXEMPTION = "@deepseek-ai/dsh-client-runtime/client";
/** Externals resolved from the loader module table. */
const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION];
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = "\0dsh-css:";
const CSS_VIRTUAL_SUFFIX = ".mjs";
export default defineConfig({
name: `${PLUGIN_ID}/client`,
entry: { client: "src/web/index.ts" },
// Browser bundle lands in its own dist/web subdir: the tsc node half owns
// dist/ directly, and a shared outDir would clobber dist/client.js (the KbClient
// module) with this artifact. The entryFileNames pin keeps it exactly
// dist/web/client.js; the host serves it at /plugins/<id>/client.js via
// exports["./client"]. clean must stay off — a default clean would wipe the
// tsc-emitted node half.
outDir: "dist/web",
format: "cjs",
platform: "browser",
dts: false,
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
// tsdown auto-externalizes package dependencies; anything NOT in the loader
// module table must inline instead. A require() the table cannot answer is a
// guaranteed runtime throw, so the rule is the table list itself.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
"import.meta.env.MODE": JSON.stringify(process.env.NODE_ENV ?? "production"),
"import.meta.env": JSON.stringify({ MODE: process.env.NODE_ENV ?? "production" }),
},
plugins: [
{
// Bundle purity gate (build-time mirror of the module-edge rules):
// platform seed entries stay external; every other @deepseek-ai value
// import is a build error — a cross-plugin value import either inlines a
// duplicate runtime instance or requires a specifier the frozen module
// table cannot answer. Cross-plugin collaboration goes through cordis
// services instead (type-only imports are erased and never reach this gate).
name: "dsh-client-bundle-purity",
resolveId(source: string) {
if (!source.startsWith("@deepseek-ai/")) return null;
if (CLIENT_EXTERNALS.includes(source)) return null; // platform module: external wins
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) — ` +
"cross-plugin value imports are forbidden; collaborate through cordis services " +
"(type-only imports are erased and never reach this gate)",
);
},
},
{
name: "dsh-css-modules-inline",
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith(".module.css")) return null;
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source;
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX;
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null;
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length);
// The virtual id otherwise hides the physical stylesheet from the watch graph.
this.addWatchFile(fileId);
const source = await readFile(fileId);
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: "[hash]_[local]" },
minify: true,
});
const classMap: Record<string, string> = {};
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name;
// One <style data-plugin> per module file; idempotent under re-evaluation.
return [
`const css = ${JSON.stringify(code.toString())};`,
`const tagId = ${JSON.stringify(`${PLUGIN_ID}/${basename(fileId)}`)};`,
"if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {",
" const tag = document.createElement('style');",
` tag.dataset.plugin = ${JSON.stringify(PLUGIN_ID)};`,
" tag.dataset.pluginCss = tagId;",
" tag.textContent = css;",
" document.head.appendChild(tag);",
"}",
`export default ${JSON.stringify(classMap)};`,
].join("\n");
},
},
],
outputOptions: {
entryFileNames: "client.js",
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
footer: "return module.exports; } });",
intro: "var module = { exports: {} }; var exports = module.exports;",
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite-plus";
// 本包不用 `vp pack`:产物是 tsc 出的 node 半dist/+ tsdown 出的浏览器 bundle
// dist/web/client.js带 __ModuleLoader__ banner 与 lightningcss CSS Modules 内联),
// 由包内 `build` script 负责。这里只接管 lint / fmt / test。
export default defineConfig({
lint: {
options: {
typeAware: true,
typeCheck: true,
},
},
fmt: {},
});
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+2 -2
View File
@@ -116,9 +116,9 @@
**Flags**`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`
**实现方案**`doc-delete.ts`确认摘要含 index_id + doc_id 列表≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**实现方案**`doc-delete.ts`命令在 `risk` 对象中同时声明 `level: "high"` 和双语 `message`,由 runtime 在 `run()` 前统一确认;输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**测试方案**help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY`--yes` exitCode 2 / live 配合 upload 清理链。
**测试方案**help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 无 `--yes` 返回 exitCode 7 + `requires_confirmation` / live 配合 upload 清理链。
## doc tag — 批量更新文档标签
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.17.1",
"version": "1.23.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+12 -2
View File
@@ -181,7 +181,13 @@ function atomicSwap(tmpDir, catalogDir) {
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
throw err;
}
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
// Best-effort cleanup (symmetric with core skills/extract.ts): the swap already
// succeeded, so a backup deletion failure must not fail the pre-download
try {
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
} catch {
/* keep the backup on disk rather than report a completed swap as failed */
}
}
async function main() {
@@ -214,7 +220,11 @@ async function main() {
}
atomicSwap(tmpDir, catalogDir);
} catch (err) {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
try {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* cleanup must not mask the original error */
}
throw err;
}
+127 -14
View File
@@ -29,6 +29,10 @@ import {
memoryUpdate,
memoryDelete,
memoryProfileCreate,
memoryProfileList,
memoryProfileShow,
memoryProfileUpdate,
memoryProfileDelete,
memoryProfileGet,
knowledgeRetrieve,
knowledgeSearch,
@@ -70,6 +74,11 @@ import {
searchWeb,
speechSynthesize,
speechRecognize,
speechVocabularyCreate,
speechVocabularyList,
speechVocabularyGet,
speechVocabularyUpdate,
speechVocabularyDelete,
fileUpload,
consoleCall,
usageFree,
@@ -85,6 +94,7 @@ import {
workspaceList,
quotaList,
quotaUpdate,
quotaDelete,
quotaHistory,
quotaCheck,
permissionList,
@@ -139,14 +149,18 @@ import {
managedAgentPlan,
managedAgentApply,
managedAgentDestroy,
managedAgentWorkbench,
managedAgentPlayground,
managedAgentVersionEnable,
managedAgentVersionDisable,
managedAgentVersionStatus,
managedAgentVersionList,
managedAgentVersionPreview,
managedAgentVersionRestore,
managedAgentProjectInit,
managedAgentProjectValidate,
managedAgentProjectBuild,
managedAgentProjectPublish,
managedAgentProjectWorkbench,
managedAgentProjectVersionEnable,
managedAgentProjectVersionDisable,
managedAgentProjectVersionStatus,
managedAgentProjectVersionList,
managedAgentProjectVersionPreview,
managedAgentProjectVersionRestore,
managedAgentStateList,
managedAgentStateShow,
managedAgentStateRm,
@@ -159,6 +173,48 @@ import {
managedAgentSessionSend,
managedAgentSessionEvents,
managedAgentSkillList,
managedAgentAgentCreate,
managedAgentEnvironmentCreate,
managedAgentSkillCreate,
managedAgentVaultCreate,
managedAgentVaultCredentialCreate,
managedAgentDeploymentCreate,
managedAgentAgentList,
managedAgentAgentGet,
managedAgentAgentSearch,
managedAgentAgentVersions,
managedAgentEnvironmentList,
managedAgentEnvironmentGet,
managedAgentEnvironmentSearch,
managedAgentSkillGet,
managedAgentSkillSearch,
managedAgentSkillVersions,
managedAgentSkillDownload,
managedAgentVaultList,
managedAgentVaultGet,
managedAgentVaultSearch,
managedAgentDeploymentList,
managedAgentDeploymentGet,
managedAgentDeploymentSearch,
managedAgentDeploymentRunsList,
managedAgentDeploymentRunsGet,
managedAgentDeploymentRun,
managedAgentDeploymentPause,
managedAgentDeploymentUnpause,
managedAgentSessionSearch,
managedAgentSessionUpdate,
managedAgentSessionArchive,
managedAgentSessionEventList,
managedAgentSessionEventSend,
managedAgentSessionEventStream,
managedAgentSessionDebug,
managedAgentSessionExport,
managedAgentFileUpload,
managedAgentFileList,
managedAgentFileGet,
managedAgentFileSearch,
managedAgentFileDownload,
managedAgentFileDelete,
} from "bailian-cli-commands";
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -196,6 +252,10 @@ export const commands: Record<string, AnyCommand> = {
"memory update": memoryUpdate,
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile list": memoryProfileList,
"memory profile show": memoryProfileShow,
"memory profile update": memoryProfileUpdate,
"memory profile delete": memoryProfileDelete,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"knowledge search": knowledgeSearch,
@@ -239,6 +299,11 @@ export const commands: Record<string, AnyCommand> = {
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"speech vocabulary create": speechVocabularyCreate,
"speech vocabulary list": speechVocabularyList,
"speech vocabulary get": speechVocabularyGet,
"speech vocabulary update": speechVocabularyUpdate,
"speech vocabulary delete": speechVocabularyDelete,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
@@ -254,6 +319,7 @@ export const commands: Record<string, AnyCommand> = {
"workspace list": workspaceList,
"quota list": quotaList,
"quota update": quotaUpdate,
"quota delete": quotaDelete,
"quota history": quotaHistory,
"quota check": quotaCheck,
"permission list": permissionList,
@@ -308,14 +374,18 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent plan": managedAgentPlan,
"managed-agent apply": managedAgentApply,
"managed-agent destroy": managedAgentDestroy,
"managed-agent workbench": managedAgentWorkbench,
"managed-agent playground": managedAgentPlayground,
"managed-agent version enable": managedAgentVersionEnable,
"managed-agent version disable": managedAgentVersionDisable,
"managed-agent version status": managedAgentVersionStatus,
"managed-agent version list": managedAgentVersionList,
"managed-agent version preview": managedAgentVersionPreview,
"managed-agent version restore": managedAgentVersionRestore,
"managed-agent project init": managedAgentProjectInit,
"managed-agent project validate": managedAgentProjectValidate,
"managed-agent project build": managedAgentProjectBuild,
"managed-agent project publish": managedAgentProjectPublish,
"managed-agent project workbench": managedAgentProjectWorkbench,
"managed-agent project version enable": managedAgentProjectVersionEnable,
"managed-agent project version disable": managedAgentProjectVersionDisable,
"managed-agent project version status": managedAgentProjectVersionStatus,
"managed-agent project version list": managedAgentProjectVersionList,
"managed-agent project version preview": managedAgentProjectVersionPreview,
"managed-agent project version restore": managedAgentProjectVersionRestore,
"managed-agent state list": managedAgentStateList,
"managed-agent state show": managedAgentStateShow,
"managed-agent state rm": managedAgentStateRm,
@@ -328,6 +398,49 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent session send": managedAgentSessionSend,
"managed-agent session events": managedAgentSessionEvents,
"managed-agent skill-list": managedAgentSkillList,
"managed-agent agent create": managedAgentAgentCreate,
"managed-agent agent list": managedAgentAgentList,
"managed-agent agent get": managedAgentAgentGet,
"managed-agent agent search": managedAgentAgentSearch,
"managed-agent agent versions": managedAgentAgentVersions,
"managed-agent environment create": managedAgentEnvironmentCreate,
"managed-agent environment list": managedAgentEnvironmentList,
"managed-agent environment get": managedAgentEnvironmentGet,
"managed-agent environment search": managedAgentEnvironmentSearch,
"managed-agent skill create": managedAgentSkillCreate,
"managed-agent skill list": managedAgentSkillList,
"managed-agent skill get": managedAgentSkillGet,
"managed-agent skill search": managedAgentSkillSearch,
"managed-agent skill versions": managedAgentSkillVersions,
"managed-agent skill download": managedAgentSkillDownload,
"managed-agent vault create": managedAgentVaultCreate,
"managed-agent vault credential create": managedAgentVaultCredentialCreate,
"managed-agent vault list": managedAgentVaultList,
"managed-agent vault get": managedAgentVaultGet,
"managed-agent vault search": managedAgentVaultSearch,
"managed-agent deployment create": managedAgentDeploymentCreate,
"managed-agent deployment list": managedAgentDeploymentList,
"managed-agent deployment get": managedAgentDeploymentGet,
"managed-agent deployment search": managedAgentDeploymentSearch,
"managed-agent deployment runs list": managedAgentDeploymentRunsList,
"managed-agent deployment runs get": managedAgentDeploymentRunsGet,
"managed-agent deployment run": managedAgentDeploymentRun,
"managed-agent deployment pause": managedAgentDeploymentPause,
"managed-agent deployment unpause": managedAgentDeploymentUnpause,
"managed-agent session search": managedAgentSessionSearch,
"managed-agent session update": managedAgentSessionUpdate,
"managed-agent session archive": managedAgentSessionArchive,
"managed-agent session event list": managedAgentSessionEventList,
"managed-agent session event send": managedAgentSessionEventSend,
"managed-agent session event stream": managedAgentSessionEventStream,
"managed-agent session debug": managedAgentSessionDebug,
"managed-agent session export": managedAgentSessionExport,
"managed-agent file upload": managedAgentFileUpload,
"managed-agent file list": managedAgentFileList,
"managed-agent file get": managedAgentFileGet,
"managed-agent file search": managedAgentFileSearch,
"managed-agent file download": managedAgentFileDownload,
"managed-agent file delete": managedAgentFileDelete,
};
/**
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,7 +9,19 @@ const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures",
let configDir: string;
function env(): NodeJS.ProcessEnv {
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
return {
BAILIAN_CONFIG_DIR: configDir,
DO_NOT_TRACK: "1",
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
};
}
function parseStderrJsonDiagnostics(stderr: string): unknown[] {
return stderr
.trim()
.split(/\n\s*\n/)
.map((diagnostic) => JSON.parse(diagnostic) as unknown);
}
describe("e2e: Command Pack", () => {
@@ -53,6 +65,7 @@ describe("e2e: Command Pack", () => {
expect(linkedJson.linked.commands).toEqual([
"agent credential",
"agent credential-denied",
"agent dangerous",
"agent fail",
"agent output",
"agent ping",
@@ -71,6 +84,68 @@ describe("e2e: Command Pack", () => {
expect(executed.exitCode, executed.stderr).toBe(0);
expect(executed.stdout).toContain("command-pack:hello");
await writeFile(
join(configDir, "config.json"),
JSON.stringify(
{
base_url: "https://default.example.com",
active_config: "company-plan",
"company-plan": {
api_key: "sk-plan",
base_url: "https://plan.example.com",
api_key_capabilities: [],
},
},
null,
2,
) + "\n",
);
const fallbackFailure = await runCli(["agent", "credential", "--output", "json"], env());
expect(fallbackFailure.exitCode).toBe(3);
expect(fallbackFailure.stderr).toMatch(/^\{\n {2}"warning": \{/);
expect(fallbackFailure.stderr).toContain("\n\n{\n");
expect(parseStderrJsonDiagnostics(fallbackFailure.stderr)).toEqual([
{
warning: {
code: "PROFILE_API_KEY_FALLBACK",
message:
'Profile "company-plan" does not support command "agent credential"; API Key settings will be read from Profile "default" for this run.',
profile: "company-plan",
command_path: ["agent", "credential"],
fallback_profile: "default",
credential_fields: ["api_key", "base_url"],
},
},
{
error: expect.objectContaining({
code: 3,
message: "No API key found.",
}),
},
]);
await writeFile(
join(configDir, "config.json"),
JSON.stringify(
{
api_key: "sk-default",
base_url: "https://default.example.com",
active_config: "company-plan",
"company-plan": {
api_key: "sk-plan",
base_url: "https://plan.example.com",
api_key_capabilities: [],
},
},
null,
2,
) + "\n",
);
const fallbackCredential = await runCli(["agent", "credential"], env());
expect(fallbackCredential.exitCode, fallbackCredential.stderr).toBe(0);
expect(fallbackCredential.stderr).toContain('command "agent credential"');
expect(fallbackCredential.stdout).toContain("credential-base-url:https://default.example.com");
const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env());
expect(credential.exitCode, credential.stderr).toBe(0);
expect(credential.stdout).toContain("credential-source:flag");
@@ -96,6 +171,34 @@ describe("e2e: Command Pack", () => {
expect(failed.stderr).toContain("Use agent fail only in tests.");
});
test("high-risk 命令由 runtime 统一确认并支持安全 dry-run", async () => {
const dangerousHelp = await runCli(["agent", "dangerous", "--help"], env());
expect(dangerousHelp.exitCode, dangerousHelp.stderr).toBe(0);
expect(dangerousHelp.stderr).toContain("--yes");
const unconfirmed = await runCli(["agent", "dangerous", "--output", "json"], env());
expect(unconfirmed.exitCode).toBe(7);
expect(JSON.parse(unconfirmed.stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
const confirmed = await runCli(["agent", "dangerous", "--yes", "--output", "json"], env());
expect(confirmed.exitCode, confirmed.stderr).toBe(0);
expect(parseStdoutJson(confirmed.stdout)).toEqual({
executed: true,
dry_run: false,
command_flags: [],
});
const preview = await runCli(["agent", "dangerous", "--dry-run", "--output", "json"], env());
expect(preview.exitCode, preview.stderr).toBe(0);
expect(parseStdoutJson(preview.stdout)).toEqual({
executed: false,
dry_run: true,
command_flags: [],
});
});
test("plugin list 输出加载状态", async () => {
const result = await runCli(["plugin", "list", "--output", "json"], env());
expect(result.exitCode, result.stderr).toBe(0);
@@ -109,6 +212,7 @@ describe("e2e: Command Pack", () => {
commands: [
"agent credential",
"agent credential-denied",
"agent dangerous",
"agent fail",
"agent output",
"agent ping",
@@ -1,14 +1,26 @@
import { describe, expect, test } from "vite-plus/test";
import { deriveGroupPaths } from "e2e/registry-smoke";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterAll, describe, expect, test } from "vite-plus/test";
import { captureRegistryHelp, deriveGroupPaths } from "e2e/registry-smoke";
import { CommandRegistry, resolve } from "bailian-cli-runtime";
import { commands } from "../../src/commands.ts";
import { runCli } from "./helpers.ts";
const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
const registry = new CommandRegistry(commands, "bl");
const isolatedConfigDir = mkdtempSync(join(tmpdir(), "bl-registry-smoke-"));
afterAll(() => rmSync(isolatedConfigDir, { recursive: true, force: true }));
function runCliSmoke(args: string[]) {
return runCli(args, { BAILIAN_CONFIG_DIR: isolatedConfigDir });
}
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
const { stderr, exitCode } = await runCliSmoke(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
@@ -24,7 +36,7 @@ describe("e2e: bl registry smoke", () => {
});
test("分组帮助按叶子命令展示不同鉴权域", async () => {
const { stderr, exitCode } = await runCli(["app", "--help"]);
const { stderr, exitCode } = await runCliSmoke(["app", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
@@ -36,13 +48,13 @@ describe("e2e: bl registry smoke", () => {
[["token-plan", "list-seats"], "AK/SK"],
[["config", "show"], "No Auth"],
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
const { stderr, exitCode } = await runCliSmoke([...commandPath, "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain(`Authentication: ${authLabel}`);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
const { stderr, exitCode } = await runCliSmoke(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global Flags:/);
expect(stderr).toMatch(/--console-region <region>/);
@@ -52,13 +64,25 @@ describe("e2e: bl registry smoke", () => {
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
test.each(commandPaths)("已注册命令 %s --help 成功", (path) => {
const commandPath = path.split(" ");
expect(resolve([...commandPath, "--help"], registry)).toEqual({
kind: "help",
path: commandPath,
});
expect(captureRegistryHelp(registry, commandPath)).toContain(`Usage: bl ${path}`);
});
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
test.each(groupPaths)("命令分组 %s --help 成功", (path) => {
const commandPath = path.split(" ");
expect(resolve([...commandPath, "--help"], registry)).toEqual({
kind: "help",
path: commandPath,
});
expect(captureRegistryHelp(registry, commandPath)).toContain(
`Usage: bl ${path} <command> [flags]`,
);
});
});
+24 -1
View File
@@ -19,12 +19,34 @@ const ping = {
},
};
const dangerous = {
description: "Exercise runtime confirmation for a high-risk Command Pack command",
auth: "none",
risk: {
level: "high",
message: {
"en-US": "This fixture represents a high-risk operation.",
"zh-CN": "该测试命令代表高风险操作。",
},
},
async run(ctx) {
const dryRun = ctx.settings.dryRun;
ctx.output.result({
executed: !dryRun,
dry_run: dryRun,
command_flags: Object.keys(ctx.flags),
});
},
};
const credential = {
description: "Read an API key through the Command Pack host adapter",
auth: "apiKey",
async run(ctx) {
const apiKey = ctx.credentials.apiKey();
process.stdout.write(`credential-source:${apiKey.source}\n`);
process.stdout.write(
`credential-source:${apiKey.source} credential-base-url:${apiKey.baseUrl}\n`,
);
},
};
@@ -55,6 +77,7 @@ const fail = {
export default {
"agent credential": credential,
"agent credential-denied": credentialDenied,
"agent dangerous": dangerous,
"agent fail": fail,
"agent output": output,
"agent ping": ping,
@@ -0,0 +1,69 @@
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");
const skillsRoot = join(repositoryRoot, "skills");
const scopedCreateCommands = new Set([
"bl managed-agent agent create",
"bl managed-agent deployment create",
"bl managed-agent environment create",
"bl managed-agent skill create",
"bl managed-agent vault create",
"bl managed-agent vault credential create",
]);
test("generated references distinguish runtime high-risk confirmation from scoped create execution", () => {
let highRiskCommandCount = 0;
const seenScopedCreateCommands = new Set<string>();
for (const skillDirectory of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!skillDirectory.isDirectory()) continue;
const referenceDirectory = join(skillsRoot, skillDirectory.name, "reference");
let referenceFiles: string[];
try {
referenceFiles = readdirSync(referenceDirectory).filter(
(fileName) => fileName.endsWith(".md") && fileName !== "index.md",
);
} catch {
continue;
}
for (const referenceFile of referenceFiles) {
const markdown = readFileSync(join(referenceDirectory, referenceFile), "utf8");
const commandSections = markdown.split(/(?=^### `bl )/m).slice(1);
for (const commandSection of commandSections) {
const commandName = commandSection.match(/^### `([^`]+)`/m)?.[1];
const hasConfirmationFlag = commandSection.includes("`--yes`");
const hasHighRiskMetadata = /\|\s+\*\*Risk\*\*\s+\|\s+`high`\s+\|/.test(commandSection);
if (!hasHighRiskMetadata) {
if (!hasConfirmationFlag) continue;
expect(commandName).toBeDefined();
expect(scopedCreateCommands.has(commandName ?? "")).toBe(true);
expect(commandSection).toMatch(/Without --yes, .*preview/i);
seenScopedCreateCommands.add(commandName ?? "");
continue;
}
highRiskCommandCount += 1;
expect(hasConfirmationFlag).toBe(true);
expect(commandSection).toMatch(/\|\s+\*\*Risk message\*\*\s+\|\s+.+\|/);
expect(commandSection).toMatch(/type=.*requires_confirmation/);
const agentSafetyLine = commandSection
.split("\n")
.find((line) => line.startsWith("> **Agent safety:**"));
expect(agentSafetyLine).toBeDefined();
expect(agentSafetyLine).toMatch(/never add `--yes` automatically/i);
expect(agentSafetyLine).toMatch(/explicit user confirmation/i);
expect(agentSafetyLine).not.toContain("`--dry-run`");
}
}
}
expect(highRiskCommandCount).toBeGreaterThan(0);
expect([...seenScopedCreateCommands].sort()).toEqual([...scopedCreateCommands].sort());
});
+1 -1
View File
@@ -178,7 +178,7 @@ export async function ensurePrerequisites(ctx) {
"video",
"generate",
"--model",
"happyhorse-1.1-t2v",
"wan3.0-video",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -130,7 +130,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
"video",
"generate",
"--model",
"happyhorse-1.1-t2v",
"wan3.0-video",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -16,7 +16,7 @@ const motions = [
export const runStress = defineStressTarget({
canonical: "video-i2v",
defaultModel: "happyhorse-1.1-i2v",
defaultModel: "wan3.0-video",
batchDirPrefix: "video-i2v-batch",
helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2",
@@ -16,7 +16,7 @@ const prompts = [
export const runStress = defineStressTarget({
canonical: "video-ref",
defaultModel: "happyhorse-1.1-r2v",
defaultModel: "wan3.0-video",
batchDirPrefix: "video-ref-batch",
helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2",
@@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
export const runStress = defineStressTarget({
canonical: "video-t2v",
defaultModel: "happyhorse-1.1-t2v",
defaultModel: "wan3.0-video",
batchDirPrefix: "video-t2v-batch",
helpText: `用法pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3
详见 docs/agents/stress-batch-tests.md`,
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.17.1",
"version": "1.23.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -40,8 +40,7 @@
"check": "vp check"
},
"dependencies": {
"@openagentpack/local-git": "0.4.0",
"@openagentpack/sdk": "0.4.0",
"@openagentpack/sdk": "0.7.1",
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
@@ -1,91 +1,35 @@
import {
BailianError,
ExitCode,
chatPath,
requestJson,
normalizeModelBaseUrl,
type AuthPersistPatch,
type AuthStore,
type Identity,
type Settings,
} from "bailian-cli-core";
import { normalizeModelBaseUrl, type AuthPersistPatch, type AuthStore } from "bailian-cli-core";
interface ApiKeyLoginDeps {
identity: Identity;
settings: Settings;
authStore: AuthStore;
}
interface ApiKeyLoginProfile {
baseUrl: string;
persistBaseUrl?: string;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
defaultReferenceToVideoModel?: string;
defaultImageModel?: string;
defaultSpeechModel?: string;
defaultSpeechRecognitionModel?: string;
apiKeyCapabilities?: readonly string[];
persistPatch?: AuthPersistPatch;
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(error: unknown): boolean {
if (error instanceof BailianError) {
if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true;
const status = error.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (error instanceof Error) {
return (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
error.message.includes("timed out") ||
error.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
/**
* Persist an API key (and optional profile defaults) without a live model probe.
* Login is credential storage; connectivity is verified on the first API command.
* A former chat/completions smoke test conflated quota/model-access 403s with bad keys.
*/
export async function persistApiKey(
deps: ApiKeyLoginDeps,
key: string,
profile: ApiKeyLoginProfile,
): Promise<void> {
process.stderr.write("Testing key... ");
const httpDeps = { identity: deps.identity, settings: deps.settings };
const baseUrl = normalizeModelBaseUrl(profile.baseUrl);
const persistBaseUrl = profile.persistBaseUrl
? normalizeModelBaseUrl(profile.persistBaseUrl)
: undefined;
const validationModel = "qwen3.8-max";
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.timeout, 30),
body: {
model: validationModel,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
break;
} catch (error) {
if (attempt >= 3 || !canRetry(error)) {
process.stderr.write("Failed\n");
throw error;
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
await deps.authStore.login({
...profile.persistPatch,
api_key: key,
@@ -95,5 +39,8 @@ export async function validateAndPersistApiKey(
default_image_to_video_model: profile.defaultImageToVideoModel,
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
default_image_model: profile.defaultImageModel,
default_speech_model: profile.defaultSpeechModel,
default_speech_recognition_model: profile.defaultSpeechRecognitionModel,
api_key_capabilities: profile.apiKeyCapabilities ? [...profile.apiKeyCapabilities] : undefined,
});
}

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