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 通过。
This commit is contained in:
zeyu.fz
2026-08-24 11:02:44 +08:00
parent 9112a88c60
commit d24104f7dc
88 changed files with 9058 additions and 8629 deletions
+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 白名单里,由独立 workflow 发布。
### 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 不翻译服务端错误
+85
View File
@@ -0,0 +1,85 @@
# 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` 产不出 |
| 发布 | `.github/workflows/publish-kb-dsh.yml` | `publish.yml` | 不在 `bailian-cli` 依赖闭包内,走独立通道 |
| 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.md` 手写(承载"检索走原生工具、管理走 `bl`"的路由决策);`reference/``tools/generate-reference.ts` 生成,不要手改
### E. 依赖与测试约定
- [ ] `@deepseek-ai/dsh-*` 同时列在 `peerDependencies`(运行时由 dsh 安装闭包提供)和 `devDependencies`(本地类型检查)——升级时两处同步
- [ ] 测试从 `vite-plus/test` 导入(仓库统一约定),不要用 `vitest`
- [ ] 忽略的 catch 绑定与 mock 签名参数用 `_` 前缀(根 `vite.config.ts` 已为本包放开 `no-unused-vars` 的对应 pattern
### F. 改完跑
```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
```
## 相关文档
- 设计与实现计划归档:[docs/kb-dsh/](../kb-dsh/)
- 包内实现说明:[packages/bailian-kb-dsh/README.md](../../packages/bailian-kb-dsh/README.md)
- skill 文案与路由约定:[skill-change.md](skill-change.md)
@@ -20,14 +20,14 @@
**不做(含理由):**
| 项 | 理由 |
|---|---|
| `retrieve` 工具 | 服务端已弃用(`search` 取代);新表面不携带 deprecated 能力,避免近义工具混淆 |
| MCP 通道 | 见 §1 |
| chat 进展流式 UI模式 4 | 一期用缓冲式 + 期望管理,看真实使用反馈再决定(见 §7 与附录 A |
| `run_in_background` 后台模式 | dsh jobs 机制已备好,出现真实需求再加 |
| skills 生态独立分发B-3 | 一期 skill 随 bundle 注册;跨宿主分发留待后续 |
| 运行时 API/CLI fallback | 每个操作固定一条通道;双实现漂移与故障掩盖的代价大于收益 |
| 项 | 理由 |
| ---------------------------- | ----------------------------------------------------------------------------- |
| `retrieve` 工具 | 服务端已弃用(`search` 取代);新表面不携带 deprecated 能力,避免近义工具混淆 |
| MCP 通道 | 见 §1 |
| chat 进展流式 UI模式 4 | 一期用缓冲式 + 期望管理,看真实使用反馈再决定(见 §7 与附录 A |
| `run_in_background` 后台模式 | dsh jobs 机制已备好,出现真实需求再加 |
| skills 生态独立分发B-3 | 一期 skill 随 bundle 注册;跨宿主分发留待后续 |
| 运行时 API/CLI fallback | 每个操作固定一条通道;双实现漂移与故障掩盖的代价大于收益 |
## 3. 总体形态
@@ -75,10 +75,10 @@ config:
发现当前 workspace 的检索/问答服务(百炼"检索服务",即 `agent_id` 的来源)。
| 参数 | 类型 | 必填 | 语义 |
|---|---|---|---|
| `scene` | enum `chat` \| `search` | 否 | 省略时插件内部对两个 scene 各查一次并合并;每个条目携带 scene 标记(指明该服务配 `kb_chat` 还是 `kb_search` 使用) |
| `name_filter` | string | 否 | 服务名模糊匹配,透传服务端 `agent_name` |
| 参数 | 类型 | 必填 | 语义 |
| ------------- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ |
| `scene` | enum `chat` \| `search` | 否 | 省略时插件内部对两个 scene 各查一次并合并;每个条目携带 scene 标记(指明该服务配 `kb_chat` 还是 `kb_search` 使用) |
| `name_filter` | string | 否 | 服务名模糊匹配,透传服务端 `agent_name` |
返回:服务条目数组(`agent_id`、名称、描述、scene、status、绑定的知识库+ `total`
@@ -90,12 +90,12 @@ config:
语义检索,返回原始知识片段供 agent 综合与引用。
| 参数 | 类型 | 必填 | 语义 |
|---|---|---|---|
| `query` | string | 是 | 检索文本 |
| `agent_id` | string | 见 §5 | 检索服务 idscene=search 的服务);检索范围与策略(多库加权、路由、重排)由服务端配置决定 |
| `top_k` | integer | 否,默认 5 | 返回片段数上限。服务端 search API 无此参数(条数由检索服务配置决定),插件对按 score 降序的 `nodes` 做客户端截断description 写明该语义 |
| `images` | string[] | 否 | 多模态检索的图片 URL |
| 参数 | 类型 | 必填 | 语义 |
| ---------- | -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `query` | string | 是 | 检索文本 |
| `agent_id` | string | 见 §5 | 检索服务 idscene=search 的服务);检索范围与策略(多库加权、路由、重排)由服务端配置决定 |
| `top_k` | integer | 否,默认 5 | 返回片段数上限。服务端 search API 无此参数(条数由检索服务配置决定),插件对按 score 降序的 `nodes` 做客户端截断description 写明该语义 |
| `images` | string[] | 否 | 多模态检索的图片 URL |
返回chunks 数组(内容 + 来源引用)。
@@ -103,9 +103,9 @@ config:
知识库成品问答。服务端为 agentic loop分析 → 多轮检索 → 生成),耗时可达分钟级。
| 参数 | 类型 | 必填 | 语义 |
|---|---|---|---|
| `message` | string | 是 | 问题 |
| 参数 | 类型 | 必填 | 语义 |
| ---------- | ------ | ----- | ------------------------- |
| `message` | string | 是 | 问题 |
| `agent_id` | string | 见 §5 | 问答服务 idscene=chat |
返回:完整答案文本(含 API 提供的引用信息时一并返回)。
@@ -119,11 +119,11 @@ config:
## 5. `agent_id` 的三种场景覆盖
| 场景 | 机制 | 插件成本 |
|---|---|---|
| 发现式 | `kb_service_list` → 选服务 → search/chat`agent_id` 缺失或无效时,错误信息直接附当前服务清单,模型一步纠正 | 发现工具 + 错误增强 |
| 用户习惯固定 | 宿主 memory / 项目指令记住常用 `agent_id` | 零skill 写入最佳实践) |
| 场景/部署固定 | Config 可选 `defaultAgentId`;配置后注册时将 `agent_id` 参数降为可选description 注明缺省服务 | 一个可选配置字段 |
| 场景 | 机制 | 插件成本 |
| ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------ |
| 发现式 | `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 固定客服库)。
@@ -132,15 +132,15 @@ config:
```ts
interface Config {
/** 百炼工作空间 id。知识库 API 的 host 为 workspace 子域名:`https://<workspaceId>.<endpointHost>`。必填。 */
workspaceId: string
workspaceId: string;
/** 知识库 API 的 host 后缀。默认 `cn-beijing.maas.aliyuncs.com`;其他 region/私有化部署时替换。 */
endpointHost: string
endpointHost: string;
/** 场景固定式部署绑定的检索服务 id。可选。 */
defaultAgentId?: string
defaultAgentId?: string;
/** 调用的服务版本beta草稿调试或已发布版本号。可选缺省最新发布版。不暴露给模型。 */
agentVersion?: string
agentVersion?: string;
/** kb_chat 超时毫秒数。chat 为分钟级 loop部署必须可调。默认 3000005 分钟)。 */
chatTimeoutMs: number
chatTimeoutMs: number;
}
```
@@ -169,11 +169,11 @@ interface Config {
## 10. 测试策略
| 层 | 内容 |
|---|---|
| 单元测试 | endpoint 拼接、请求体构造scene 合并、分页内化、`defaultAgentId` 解析)、错误翻译 |
| 层 | 内容 |
| -------- | --------------------------------------------------------------------------------------------- |
| 单元测试 | endpoint 拼接、请求体构造scene 合并、分页内化、`defaultAgentId` 解析)、错误翻译 |
| snapshot | mock HTTP fixture 的可重放 keyless snapshotmacOS/Linux 均可回放;覆盖三工具的调用与渲染卡片 |
| e2e | 真实 DashScope API`DASHSCOPE_API_KEY` 时自跳过 |
| e2e | 真实 DashScope API`DASHSCOPE_API_KEY` 时自跳过 |
## 附录 A预留扩展已设计方向未排期
@@ -184,12 +184,12 @@ interface Config {
## 附录 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 过滤,非页码 |
| 决策 | 结论 | 理由摘要 |
| --------------- | ------------------------------------------- | ------------------------------------------------------------------- |
| 接入通道 | API检索面+ CLI管理面不做 MCP | 频率×能力深度×控制权分层API/CLI 均为己方资产 |
| CLI 选型 | kscli 而非 bl | 同源实现零能力损失命令面窄、鉴权单一、onboarding 短 |
| `retrieve` | 不做 | 已弃用,避免近义工具 |
| `kb_chat` 门控 | 不门控,常驻注册 | 服务端 RAG 管线在知识问答场景更专业description 写明场景让模型路由 |
| `agent_id` 归属 | 模型参数 + 发现工具 + 可选 `defaultAgentId` | 检索服务是用户运行时资产,插件与部署配置不应假设 |
| chat 流式 | 一期缓冲式 | 仓库惯例bash/subagent 同构);进展流式留待反馈 |
| 分页 | 内部消化page_size=100 + 溢出提示) | 模型导航原语是 name 过滤,非页码 |
-3
View File
@@ -1,3 +0,0 @@
node_modules/
lib/
*.tsbuildinfo
-1
View File
@@ -1 +0,0 @@
@deepseek-ai:registry=https://registry.npmjs.org
+143 -27
View File
@@ -1,55 +1,171 @@
# bailian-kb-dsh
阿里云百炼知识库能力的 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件 bundle三个 API 直连模型工具(`kb_service_list` / `kb_search` / `kb_chat`+ bl CLI 管理面 skill
百炼知识库的 dsh 插件包(同时是 dsh bundle`ctx.tools` 注册两个检索模型工具kb_search、kb_chat并在 skills 服务可用时注册管理面 skill。服务发现通过 bl CLIbailian-cli完成
设计文档[docs/specs/2026-08-15-bailian-kb-bundle-design.md](docs/specs/2026-08-15-bailian-kb-bundle-design.md) · 实现计划:[docs/plans/2026-08-15-bailian-kb-bundle.md](docs/plans/2026-08-15-bailian-kb-bundle.md)
## 仓库结构
| 目录 | 职责 |
|---|---|
| [`packages/tool-bailian-kb`](packages/tool-bailian-kb/README.md) | 插件包(同时是 dsh bundleConfig、KbClient、工具、skill、cordis.patch.yml、浏览器端配置页 |
设计文档与实现计划归档在仓库 [`docs/kb-dsh/`](../../docs/kb-dsh/);维护约定见 [`docs/agents/dsh-plugin.md`](../../docs/agents/dsh-plugin.md)
## 安装dsh 用户)
```sh
dsh plugin --profile web add @ali/bailian-kb-dsh # npm 发布后;本地开发用绝对/相对路径
dsh plugin --profile web add bailian-kb-dsh # 本地开发用绝对/相对路径
```
安装后 CLI 自动把 bundle 加入 profile 的层栈,无需手改 YAML。
配置写入 `~/.dsh/.env`
安装后 CLI 自动把 bundle 加入 profile 的层栈,无需手改 YAML。配置写入 `~/.dsh/.env`
```sh
BAILIAN_WORKSPACE_ID=ws-xxx # 必填:百炼工作空间 id
DASHSCOPE_API_KEY=sk-xxx # 必填:也可放 ~/.dsh/.credentials.yaml
DASHSCOPE_API_KEY=sk-xxx # 必填:也可放 ~/.dsh/.credentials.yaml,或用设置页的“自动获取”
```
验证:`dsh --profile web --dump-config` 应能看到 `tool-bailian-kb` row。缺 `BAILIAN_WORKSPACE_ID` 时加载期直接报错fail loud不会静默跳过。
卸载:`dsh plugin --profile web remove @ali/bailian-kb-dsh`
验证:`dsh --profile web --dump-config` 应能看到 `tool-bailian-kb` row。缺 `BAILIAN_WORKSPACE_ID` 时加载期直接报错fail loud不会静默跳过。卸载:`dsh plugin --profile web remove bailian-kb-dsh`
## 开发
依赖 dsh 的运行时包(`@deepseek-ai/dsh-tools` 等)以 peerDependencies 声明、由 dsh 安装闭包在运行时提供;开发期通过 `link:` 指向同级的 `../deepseek-harness` checkoutnpm registry 尚未发布完整 dsh 闭包)
依赖 dsh 的运行时包(`@deepseek-ai/dsh-tools` 等)以 peerDependencies 声明、由 dsh 安装闭包在运行时提供。
```sh
pnpm install
pnpm run test # vitest 单元测试
pnpm run typecheck
pnpm run build # tsc 产出 lib/
pnpm --filter bailian-kb-dsh run build # tsc 出 dist/ + tsdown 出 dist/web/client.js
pnpm --filter bailian-kb-dsh run typecheck # node 半 + web 半两套 tsconfig
pnpm --filter bailian-kb-dsh run test
```
本地联调:只需 add 一次(包同时声明 `dsh.bundle` 和插件代码
本地联调patch 文件受 HMR 监听
```sh
dsh plugin --profile dev add <本仓库>/packages/tool-bailian-kb
dsh plugin --profile dev add <本仓库>/packages/bailian-kb-dsh
```
patch 文件受 HMR 监听。
## Bundle 声明
`package.json``dsh.bundle.patch` 指向 [`cordis.patch.yml`](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 面)。
### 四个值的解析链
| 值 | 1⃣ settings 用户层(设置页可编辑、回显) | 2⃣ entry config本 patch 或用户覆盖,作 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` | ✅ | `kb_search``agent_id` 的**程序化**调用报错并附配置指引(模型侧 schema 恒必填) |
| `BAILIAN_DEFAULT_CHAT_AGENT_ID` | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | ✅ | `kb_chat``agent_id` 的**程序化**调用报错并附配置指引(模型侧 schema 恒必填) |
行为参数(`endpointHost`/`agentVersion`/`chatTimeoutMs`)在 config/settings 层(设置文档可改,实时生效)。
### 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
- **自动获取** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`Host **自己走百炼控制台登录回调协议**(不经 `bl` 命令,也不读 `~/.bailian/config.json`)在宿主机拉起浏览器登录,回调落到本机 loopback 端口后直接把 API 密钥写入凭据存储、工作空间 ID 写入 settings明文 key 不过浏览器;面板轮询到完成后自动刷新(无需再次点击)。登录 URL 始终带 `needapikey=true`,因此**每次都由本次登录的账号签发新 key**key 与 workspaceId 必然同账号,切换账号直接点一次即可;`bl auth login --console` 自身做不到这点(它硬编码 `needApiKey: !hasApiKey`,已存 key 时不再签发,会把旧账号的 key 和新账号的 workspaceId 配在一起且无任何提示)
首次接入 seed启动时若 API key / workspaceId 从未被设置过settings、credential、env 均无值),自动从 `~/.bailian/config.json` 采纳一次;`seededFields` 字段settings 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
降级:远程浏览器(非 loopbacksettings RPC 不可达)或未组合 settings 服务时ID 字段退回旧的 write-only credential 控件,页面顶部显示提示。
### 用户覆盖
用户 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`
## Config
Config 同时注册为 `bailian-kb` settings namespace`installSettingsSection`profile patch 的 entry config 作为 base 层,用户在设置页/设置文档的修改叠在其上且实时生效(所有值每次调用经 source thunk 读取,无需重启或重注册工具)。
| 字段 | 类型 | 默认 | 语义 |
| ------------------------ | ------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | string? | — | 百炼工作空间 idAPI host 为 workspace 子域名 `https://<workspaceId>.<endpointHost>`。未设置时每次调用回退 `BAILIAN_WORKSPACE_ID` credential |
| `endpointHost` | string | `cn-beijing.maas.aliyuncs.com` | host 后缀,其他 region/私有化时替换 |
| `defaultRetrieveAgentId` | string? | — | 默认检索服务;`kb_search``agent_id` 参数 schema **恒必填**(模型永远显式传),此默认仅作用于省略 `agent_id` 的程序化调用每次调用运行时解析settings/config → credential |
| `defaultChatAgentId` | string? | — | 默认对话服务;`kb_chat``agent_id` 参数 schema **恒必填**(模型永远显式传),此默认仅作用于省略 `agent_id` 的程序化调用每次调用运行时解析settings/config → credential |
| `agentVersion` | string? | — | `beta`(草稿调试)或已发布版本号;不暴露给模型 |
| `chatTimeoutMs` | number | 300000 | kb_chat 超时;服务端是分钟级 agentic loop |
凭证与回退链:`DASHSCOPE_API_KEY` 只走 `ctx.credentials` 引用write-only每次调用重新解析热更换生效`workspaceId`/`defaultRetrieveAgentId`/`defaultChatAgentId` 先取 settings 解析值(用户层 > entry config缺失时回退同名 credential`BAILIAN_WORKSPACE_ID`/`BAILIAN_DEFAULT_RETRIEVE_AGENT_ID`/`BAILIAN_DEFAULT_CHAT_AGENT_ID`),都没有时报错并附配置指引。注意:`agent_id` 在两个工具的 schema 中恒必填,模型路径不会触发默认服务回退;回退保留是为程序化调用与 credential 热切换。
## 工具
| 工具 | 参数 | 返回 |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `kb_search` | `query``agent_id`**必填**;程序化省略时回退 defaultRetrieveAgentId`top_k?`(默认 5**客户端截断**——服务端无此参数)、`images?` | chunkstext/score/来源)+ total |
| `kb_chat` | `message``agent_id`**必填**;程序化省略时回退 defaultChatAgentId | 完整答案(内部消费 SSE 流缓冲返回)+ request_id |
两个工具的 **description 保持静态**(不含任何服务 id可用服务清单由下述服务缓存经 `agent/pre-step` 注入为上下文消息。
## 检索服务缓存与上下文注入
模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。
### 载体:上下文消息,不是工具描述
清单经 `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 个 / 拉取失败 / 无缓存 | 不注入(工具仍可用) |
英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。
### 缓存与刷新
落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的api key 只能访问自己的 workspace交叉组合返回 `Endpoint.AccessDenied`),而"自动获取"按钮就是为了切账号。
存:`agent_id` / `agent_name` / `scene` / `status` / `modify_time`,预留 `description`(待后端补齐)。**不存 `pipeline_list`**——实测它常缺 `pipeline_name`、有时整个为空,做不了知识库标签。
| 刷新触发点 | 模型何时看见 |
| --------------------------------------------------------- | ------------------------------------ |
| pre-step 间隔调度(超 TTL 30 分钟,后台异步,**不阻塞** | 下一步 |
| 控制台登录成功(`/bailian-kb/autofill` 回调) | 下一步 |
| 调用撞 4xxagent_id 已失效) | **本步**,刷新后的列表追加进错误消息 |
| workspaceId / apiKey 变更 | 下一步 |
刷新失败只 warn保留旧文档并发刷新共享一个请求pre-step 每步都会检查。pre-step 监听器**永不抛异常**——抛出会使用户当前这一步失败。未组合 `agents` 的 headless 装配只是没有清单,工具照常可用。
## 错误语义
- HTTP 错误4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 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 时服务名是唯一语义来源,管理面的动作直接决定检索面的效果。
## Known Limitations
- **无 keyless snapshot / e2e 基建**:首版以单元测试 + 手动集成验收覆盖snapshot/e2e 依赖 dsh snapshot harness 对 out-of-tree bundle 的支持情况v0.2 跟进
- **kb_chat 执行期无进展显示**:服务端是分钟级 agentic loopUI 只有 pending → 完成两态;进展会话事件 + Web 渲染器的设计见 spec 附录 A等真实使用反馈再排期
- **服务清单单 scene 上限 100 条**:超出部分靠 `name_filter` 收窄(结果带 truncated 提示)。
- kb_chat 执行期无进展显示(缓冲式;进展会话事件设计见仓库根 README 与 spec 附录 A
- `top_k` 是客户端截断:请求体不含该参数,服务端返回条数由检索服务配置决定,截断只影响进入模型上下文的量
- **服务画像的质量上限取决于服务名**`service list` 接口当前不返回描述(已对两个 workspace 实测确认),所以模型只能靠 `agent_name` 判断一个服务能查什么。名字形如 `test-0819` 的部署,引导能力接近于零。后端补齐描述字段后只需改三处(`api-types` 补字段名 → `services.ts` 解析 → `buildServiceCatalog` 追加并截断到 200 字符),缓存已预留 `description` 键,无需迁移。
- 拉取每个 scene 最多 2 页 / 200 条(`page_size` 服务端硬顶 100超出时标 `truncated` 并在清单里告知。
@@ -8,6 +8,6 @@
- insert:
- id: tool-bailian-kb
name: '@ali/bailian-kb-dsh'
name: "bailian-kb-dsh"
config:
workspaceId: !!js process.env.BAILIAN_WORKSPACE_ID
+111 -7
View File
@@ -1,14 +1,118 @@
{
"name": "bailian-kb-workspace",
"private": true,
"name": "bailian-kb-dsh",
"version": "0.1.18",
"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": "pnpm -r run build",
"test": "vitest run",
"typecheck": "tsc -b packages/tool-bailian-kb && tsc -p packages/tool-bailian-kb/tsconfig.web.json"
"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": {
"typescript": "^5.7.2",
"vitest": "^3.0.0"
"@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"
}
}
}
@@ -1,138 +0,0 @@
# dsh-tool-bailian-kb
百炼知识库的 dsh 插件包(同时是 dsh bundle`ctx.tools` 注册两个检索模型工具kb_search、kb_chat并在 skills 服务可用时注册管理面 skill。服务发现通过 bl CLIbailian-cli完成。
## Bundle 声明
`package.json``dsh.bundle.patch` 指向 [`cordis.patch.yml`](cordis.patch.yml),向 profile 插入插件行:
```yaml
- insert:
- id: tool-bailian-kb
name: '@ali/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 面)。
### 四个值的解析链
| 值 | 1⃣ settings 用户层(设置页可编辑、回显) | 2⃣ entry config本 patch 或用户覆盖,作 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` | ✅ | `kb_search``agent_id` 的**程序化**调用报错并附配置指引(模型侧 schema 恒必填) |
| `BAILIAN_DEFAULT_CHAT_AGENT_ID` | ✅ `defaultChatAgentId` | ✅ `defaultChatAgentId` | ✅ | `kb_chat``agent_id` 的**程序化**调用报错并附配置指引(模型侧 schema 恒必填) |
行为参数(`endpointHost`/`agentVersion`/`chatTimeoutMs`)在 config/settings 层(设置文档可改,实时生效)。
### 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
- **自动获取** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`Host **自己走百炼控制台登录回调协议**(不经 `bl` 命令,也不读 `~/.bailian/config.json`)在宿主机拉起浏览器登录,回调落到本机 loopback 端口后直接把 API 密钥写入凭据存储、工作空间 ID 写入 settings明文 key 不过浏览器;面板轮询到完成后自动刷新(无需再次点击)。登录 URL 始终带 `needapikey=true`,因此**每次都由本次登录的账号签发新 key**key 与 workspaceId 必然同账号,切换账号直接点一次即可;`bl auth login --console` 自身做不到这点(它硬编码 `needApiKey: !hasApiKey`,已存 key 时不再签发,会把旧账号的 key 和新账号的 workspaceId 配在一起且无任何提示)
首次接入 seed启动时若 API key / workspaceId 从未被设置过settings、credential、env 均无值),自动从 `~/.bailian/config.json` 采纳一次;`seededFields` 字段settings 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
降级:远程浏览器(非 loopbacksettings RPC 不可达)或未组合 settings 服务时ID 字段退回旧的 write-only credential 控件,页面顶部显示提示。
### 用户覆盖
用户 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`
## Config
Config 同时注册为 `bailian-kb` settings namespace`installSettingsSection`profile patch 的 entry config 作为 base 层,用户在设置页/设置文档的修改叠在其上且实时生效(所有值每次调用经 source thunk 读取,无需重启或重注册工具)。
| 字段 | 类型 | 默认 | 语义 |
|---|---|---|---|
| `workspaceId` | string? | — | 百炼工作空间 idAPI host 为 workspace 子域名 `https://<workspaceId>.<endpointHost>`。未设置时每次调用回退 `BAILIAN_WORKSPACE_ID` credential |
| `endpointHost` | string | `cn-beijing.maas.aliyuncs.com` | host 后缀,其他 region/私有化时替换 |
| `defaultRetrieveAgentId` | string? | — | 默认检索服务;`kb_search``agent_id` 参数 schema **恒必填**(模型永远显式传),此默认仅作用于省略 `agent_id` 的程序化调用每次调用运行时解析settings/config → credential |
| `defaultChatAgentId` | string? | — | 默认对话服务;`kb_chat``agent_id` 参数 schema **恒必填**(模型永远显式传),此默认仅作用于省略 `agent_id` 的程序化调用每次调用运行时解析settings/config → credential |
| `agentVersion` | string? | — | `beta`(草稿调试)或已发布版本号;不暴露给模型 |
| `chatTimeoutMs` | number | 300000 | kb_chat 超时;服务端是分钟级 agentic loop |
凭证与回退链:`DASHSCOPE_API_KEY` 只走 `ctx.credentials` 引用write-only每次调用重新解析热更换生效`workspaceId`/`defaultRetrieveAgentId`/`defaultChatAgentId` 先取 settings 解析值(用户层 > entry config缺失时回退同名 credential`BAILIAN_WORKSPACE_ID`/`BAILIAN_DEFAULT_RETRIEVE_AGENT_ID`/`BAILIAN_DEFAULT_CHAT_AGENT_ID`),都没有时报错并附配置指引。注意:`agent_id` 在两个工具的 schema 中恒必填,模型路径不会触发默认服务回退;回退保留是为程序化调用与 credential 热切换。
## 工具
| 工具 | 参数 | 返回 |
|---|---|---|
| `kb_search` | `query``agent_id`**必填**;程序化省略时回退 defaultRetrieveAgentId`top_k?`(默认 5**客户端截断**——服务端无此参数)、`images?` | chunkstext/score/来源)+ total |
| `kb_chat` | `message``agent_id`**必填**;程序化省略时回退 defaultChatAgentId | 完整答案(内部消费 SSE 流缓冲返回)+ request_id |
两个工具的 **description 保持静态**(不含任何服务 id可用服务清单由下述服务缓存经 `agent/pre-step` 注入为上下文消息。
## 检索服务缓存与上下文注入
模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。
### 载体:上下文消息,不是工具描述
清单经 `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 个 / 拉取失败 / 无缓存 | 不注入(工具仍可用) |
英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。
### 缓存与刷新
落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的api key 只能访问自己的 workspace交叉组合返回 `Endpoint.AccessDenied`),而"自动获取"按钮就是为了切账号。
存:`agent_id` / `agent_name` / `scene` / `status` / `modify_time`,预留 `description`(待后端补齐)。**不存 `pipeline_list`**——实测它常缺 `pipeline_name`、有时整个为空,做不了知识库标签。
| 刷新触发点 | 模型何时看见 |
|---|---|
| pre-step 间隔调度(超 TTL 30 分钟,后台异步,**不阻塞** | 下一步 |
| 控制台登录成功(`/bailian-kb/autofill` 回调) | 下一步 |
| 调用撞 4xxagent_id 已失效) | **本步**,刷新后的列表追加进错误消息 |
| workspaceId / apiKey 变更 | 下一步 |
刷新失败只 warn保留旧文档并发刷新共享一个请求pre-step 每步都会检查。pre-step 监听器**永不抛异常**——抛出会使用户当前这一步失败。未组合 `agents` 的 headless 装配只是没有清单,工具照常可用。
## 错误语义
- HTTP 错误4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 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 时服务名是唯一语义来源,管理面的动作直接决定检索面的效果。
## Known Limitations
- kb_chat 执行期无进展显示(缓冲式;进展会话事件设计见仓库根 README 与 spec 附录 A
- `top_k` 是客户端截断:请求体不含该参数,服务端返回条数由检索服务配置决定,截断只影响进入模型上下文的量。
- **服务画像的质量上限取决于服务名**`service list` 接口当前不返回描述(已对两个 workspace 实测确认),所以模型只能靠 `agent_name` 判断一个服务能查什么。名字形如 `test-0819` 的部署,引导能力接近于零。后端补齐描述字段后只需改三处(`api-types` 补字段名 → `services.ts` 解析 → `buildServiceCatalog` 追加并截断到 200 字符),缓存已预留 `description` 键,无需迁移。
- 拉取每个 scene 最多 2 页 / 200 条(`page_size` 服务端硬顶 100超出时标 `truncated` 并在清单里告知。
@@ -1,85 +0,0 @@
{
"name": "@ali/bailian-kb-dsh",
"version": "0.1.18",
"description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.",
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/web/client.js"
},
"./package.json": "./package.json"
},
"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"
}
},
"files": [
"lib",
"skills",
"cordis.patch.yml"
],
"scripts": {
"build": "tsc -b && tsdown"
},
"dependencies": {
"yaml": "^2.4.2"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^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-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "^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/schemastery": "^3.18.1",
"@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",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^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-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "^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/schemastery": "^3.18.1",
"@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",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"tsdown": "^0.22.2",
"yaml": "^2.4.2"
}
}
@@ -1,76 +0,0 @@
# `bl` 知识库命令参考索引
> 由 `bailian-cli`1.16.0)各命令 `--help` 输出整理。
> 命令详情在同目录 `<group>.md`;本索引只放速查表、全局 flag 与鉴权说明。
> 版本更新后以 `bl <命令> --help` 为准。
## 速查表
| 命令 | 鉴权 | 说明 | 详情 |
| --- | --- | --- | --- |
| `bl knowledge list` | API Key | 列出 workspace 内知识库 | [kb.md](kb.md) |
| `bl knowledge info` | API Key | 查看知识库配置详情 | [kb.md](kb.md) |
| `bl knowledge create` | API Key | 建库并导入数据中心文件或类目 | [kb.md](kb.md) |
| `bl knowledge update` | API Key | 改名、描述或 rerank 阈值 | [kb.md](kb.md) |
| `bl knowledge delete` | API Key | 删库(含全部文档与 chunk不可逆 | [kb.md](kb.md) |
| `bl knowledge stats` | API Key | 存储量与 QPS 监控数据 | [kb.md](kb.md) |
| `bl knowledge doc list` | API Key | 列出库内文档及解析/索引状态 | [doc.md](doc.md) |
| `bl knowledge doc status` | API Key | 查看导入任务状态 | [doc.md](doc.md) |
| `bl knowledge doc upload` | API Key | 上传本地文件/目录,可选同时入库 | [doc.md](doc.md) |
| `bl knowledge doc delete` | API Key | 从库中删除文档及其 chunk | [doc.md](doc.md) |
| `bl knowledge doc tag` | API Key | 批量更新数据中心文件标签 | [doc.md](doc.md) |
| `bl knowledge doc import-oss` | API Key | 从已授权 OSS bucket 批量导入 | [doc.md](doc.md) |
| `bl knowledge service list` | API Key | 列出检索/问答服务 | [service.md](service.md) |
| `bl knowledge service get` | API Key | 查看服务各版本配置 | [service.md](service.md) |
| `bl knowledge service create` | API Key | 创建服务(初始为 draft/beta | [service.md](service.md) |
| `bl knowledge service update` | API Key | 更新名称、描述或草稿配置 | [service.md](service.md) |
| `bl knowledge service deploy` | API Key | 把 beta 草稿发布为新版本 | [service.md](service.md) |
| `bl knowledge service delete` | API Key | 删除服务(软删、幂等) | [service.md](service.md) |
| `bl knowledge service copy` | API Key | 复制服务为新草稿(名称加 copy\_ 前缀) | [service.md](service.md) |
| `bl knowledge chunk add` | API Key | 直接向库内添加 chunk | [chunk.md](chunk.md) |
| `bl knowledge chunk list` | API Key | 列出 chunk 内容与状态 | [chunk.md](chunk.md) |
| `bl knowledge chunk update` | API Key | 改 chunk 内容或切换检索可见性 | [chunk.md](chunk.md) |
| `bl knowledge chunk delete` | API Key | 删除 chunk不可逆 | [chunk.md](chunk.md) |
| `bl knowledge category list` | API Key | 列出数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge category add` | API Key | 创建数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge category delete` | API Key | 删除数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge file list` | API Key | 列出类目下的文件 | [datacenter.md](datacenter.md) |
| `bl knowledge file get` | API Key | 查看文件详情(大小/MD5/标签/时间) | [datacenter.md](datacenter.md) |
| `bl knowledge file delete` | API Key | 永久删除数据中心文件 | [datacenter.md](datacenter.md) |
| `bl knowledge collection create` | API Key | 创建 FILE 数据集合(无删除 API | [datacenter.md](datacenter.md) |
| `bl knowledge collection get` | API Key | 查看数据集合详情 | [datacenter.md](datacenter.md) |
| `bl config show` | 无需 | 显示当前配置 | [config.md](config.md) |
| `bl config set` | 无需 | 设置配置项 | [config.md](config.md) |
| `bl config list` / `use` | 无需 | 列出 / 切换配置 profile | [config.md](config.md) |
| `bl auth login` | 无需 | 存储 API key 等凭据 | [config.md](config.md) |
| `bl update` | 无需 | 升级 CLI | [config.md](config.md) |
| `bl knowledge search` | API Key | RAG 语义检索(部署验证用;日常检索走原生工具 kb_search | [query.md](query.md) |
| `bl knowledge chat` | API Key | RAG 问答SSE 流式(部署验证用;日常问答走原生工具 kb_chat | [query.md](query.md) |
| `bl knowledge retrieve` | API Key | 已废弃,改用 `search` | [query.md](query.md) |
## 全局 flag所有命令可用
| Flag | 说明 |
| --- | --- |
| `--output <format>` | 输出格式text、json |
| `--timeout <seconds>` | 请求超时 |
| `--quiet` | 抑制非必要输出 |
| `--verbose` | 打印 HTTP 请求/响应详情 |
| `--dry-run` | 只预览请求不执行 |
| `--config <name>` | 本次命令使用指定配置 profile |
| `--help` / `--version` | 帮助 / 版本 |
## 鉴权 flagAPI Key 类命令可用)
| Flag | 说明 |
| --- | --- |
| `--api-key <key>` | API key优先于环境变量 `DASHSCOPE_API_KEY` 与 config |
| `--base-url <url>` | API base URL |
| `--workspace-id <id>` | Workspace ID或环境变量 `BAILIAN_WORKSPACE_ID`,或 config `workspace_id` |
## 说明
- 所有知识库管理命令使用 DashScope API KeyBearer token鉴权无需 console 登录态API key 也可用 `bl auth login --api-key sk-xxx` 存储。
- 除知识库外bl 还有模型调用/应用/用量等其他命令组,不在本 skill 范围,见 `bl --help`
- 默认输出为 textagent 解析结果时建议显式加 `--output json`
- 分页有两种风格knowledge/doc/service/chunk 用 `--page-number/--page-size`page-size 上限 100category/file 用游标 `--next-token/--max-result`
@@ -1,159 +0,0 @@
# `bl knowledge service` — 检索 / 问答服务agent
> 通用鉴权/全局 flag 见 [index.md](index.md)。以下 Flags 只列命令专属项。
> 服务状态机create → draftbeta 草稿,用 `--agent-version beta` 调试)→ deploy → deployed版本号自增可被默认版本调用
## `bl knowledge service list`
列出检索/问答服务。
```
Usage: bl knowledge service list --scene <chat|search> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--scene <scene>` | 服务场景chat问答或 search检索。**服务端必填** |
| `--status <status>` | 按状态过滤draft、deployed含 edited或 deleted |
| `--name <text>` | 按名称过滤(模糊匹配) |
| `--agent-id <id>` | 按 agent ID 精确过滤 |
| `--index-id <id>` | 按关联知识库 ID 精确过滤 |
| `--page-number <n>` / `--page-size <n>` | 分页 |
Notes
- 场景必填——要看全两类服务需分别执行两次。
- 返回的 agent_id 用于 search/chat 调用及 service 管理命令。
```bash
bl knowledge service list --scene chat --workspace-id ws-xxx
bl knowledge service list --scene search --status deployed
```
## `bl knowledge service get`
查看服务各版本配置。
```
Usage: bl knowledge service get --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--agent-version <version>` | 只看指定版本beta 或已发布版本号);缺省返回全部版本 |
```bash
bl knowledge service get --agent-id aid-xxx --workspace-id ws-xxx
bl knowledge service get --agent-id aid-xxx --agent-version beta
```
## `bl knowledge service create`
创建检索/问答服务(初始 status: draftversion: beta
```
Usage: bl knowledge service create --name <text> --scene <chat|search> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 服务名≤200 字符,同场景内唯一)。**名称是模型判断该服务能查什么的主要依据**(描述暂未随 `service list` 返回),写清楚覆盖内容,避开 `检索服务1` / `test-xxx` |
| `--scene <scene>` | chat问答或 search检索 |
| `--description <text>` | 描述≤1000 字符)。建议始终填写:说明覆盖什么内容、适合回答什么问题 |
| `--index-id <id>` | 绑定知识库;其余配置用服务端默认值 |
Notes
- 草稿beta版可在 deploy 前用 search/chat 的 `--agent-version beta` 测试。
- 需要 workspace 的知识库创建权限。
```bash
bl knowledge service create --name my-qa --scene chat --workspace-id ws-xxx
bl knowledge service create --name my-search --scene search --index-id idx-xxx
```
## `bl knowledge service update`
更新名称、描述或草稿配置。
```
Usage: bl knowledge service update --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` / `--description <text>` | 新名称 / 描述 |
| `--agent-version <version>` | 目标版本(默认 beta 草稿)。已发布版本只接受 `--version-desc` |
| `--version-desc <text>` | 版本描述 |
| `--policy <policy>` | Agent 策略turbo或 agentic多轮 |
| `--model <name>` | 生成模型 code须在平台白名单内 |
| `--temperature <n>` | 采样温度0-2 |
| `--max-llm-calls <n>` | 单请求最大 LLM 调用次数1-30 |
| `--enable-session-file/-refusal/-anti-leak/-rich-text/-citation <bool>` | 功能开关true/false |
| `--config-file <path>` | JSON 文件整体替换 agent_config含 kb_search_configs 等嵌套配置);与标量配置 flag 互斥 |
Notes
- 配置变更只作用于 beta 草稿;已发布版本只能改 `--version-desc`
- 要改已发布版本的配置:先改 beta 草稿 → `--agent-version beta` 验证 → `service deploy` 发新版本。
- 标量 flag 合并进当前草稿配置(读-合-写);`--config-file` 整体替换,二者互斥。
- 需要 workspace 的知识库修改权限。
```bash
bl knowledge service update --agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx
bl knowledge service update --agent-id aid-xxx --config-file ./agent-config.json
bl knowledge service update --agent-id aid-xxx --agent-version 1 --version-desc 'first stable release'
```
## `bl knowledge service deploy`
把 beta 草稿发布为新版本。**发布影响线上调用方,执行前须向用户确认。**
```
Usage: bl knowledge service deploy --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--version-desc <text>` | 新版本描述 |
| `--yes` | 跳过交互确认 |
Notes
- 版本号自增;状态变为 deployed。需要 workspace 的知识库修改权限。
```bash
bl knowledge service deploy --agent-id aid-xxx --version-desc 'tuned rerank params' --yes
```
## `bl knowledge service delete`
删除服务(软删、幂等)。**删除后 agent_id 不可再用于 search/chat执行前须向用户确认。**
```
Usage: bl knowledge service delete --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--yes` | 跳过交互确认 |
```bash
bl knowledge service delete --agent-id aid-xxx --yes
```
## `bl knowledge service copy`
复制服务为新草稿(名称加 copy\_ 前缀)。
```
Usage: bl knowledge service copy --agent-id <id> [flags]
```
Notes
- 副本以 beta 草稿开始;用 `--agent-version beta` 测试后 deploy 发布。需要知识库创建权限。
```bash
bl knowledge service copy --agent-id aid-xxx --workspace-id ws-xxx
```
@@ -1,41 +0,0 @@
/** 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 }
}
@@ -1,554 +0,0 @@
/**
* 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 { CATALOG_ENTRY_LIMIT } 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 three 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 optional 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.
describeServicesAfterRefresh: async (scene) => {
await serviceCache.refresh()
const workspaceId = await resolveWorkspaceIdOrUndefined()
if (workspaceId === undefined) return undefined
const forScene = serviceCache.peek(workspaceId)?.entries.filter(entry => entry.scene === scene) ?? []
if (forScene.length === 0) return undefined
const lines = forScene.slice(0, CATALOG_ENTRY_LIMIT)
.map(entry => `- ${entry.agent_id}${entry.agent_name === '' ? '(unnamed)' : entry.agent_name}`)
const more = forScene.length - lines.length
return [
`Deployed ${scene} services in this workspace, re-read just now:`,
...lines,
...(more > 0 ? [`(and ${more} more — \`bl knowledge service list --scene ${scene}\`)`] : []),
].join('\n')
},
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 with `needapikey=true`, 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)
})
}
@@ -1,143 +0,0 @@
/**
* 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 { 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)
if (document === undefined) return decision
const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([
opts.resolveDefaultRetrieveAgentId(),
opts.resolveDefaultChatAgentId(),
])
if (signal.aborted) return decision
const text = buildServiceCatalog({
entries: document.entries,
total: document.total,
truncated: document.truncated,
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
})
if (text === undefined) return decision
// 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 })
}
@@ -1,51 +0,0 @@
/** 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
}
}
}
@@ -1,247 +0,0 @@
/**
* 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.
*/
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. The deployed services of this workspace, '
+ 'with their ids, are listed in a context message in this conversation; take the id from the '
+ 'section matching the tool you are calling. If that list is absent or none of its services '
+ 'covers the question, run `bl knowledge service list --scene search --name <keyword>` to look '
+ '(workspaceId resolves automatically from DSH settings: bailian-kb.workspaceId in '
+ '~/.dsh/settings.yaml).',
}
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]
}
@@ -1,356 +0,0 @@
/**
* 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>
)
}
@@ -1,139 +0,0 @@
/**
* 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: '清空',
}
@@ -1,41 +0,0 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
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({})
})
})
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
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/)
})
})
@@ -1,64 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
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'])
})
})
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Config } from '../src/index.js'
describe('Config', () => {
it('applies defaults and accepts a pinned workspaceId', () => {
const resolved = new Config({ workspaceId: 'ws-1' })
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'])
})
})
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
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' })
})
})
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
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')
})
})
@@ -1,246 +0,0 @@
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 'vitest'
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'))
})
})
@@ -1,115 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CATALOG_ENTRY_LIMIT, 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 nothing worth injecting', () => {
// No services at all: the tool descriptions alone keep the tools usable.
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('…')
})
})
@@ -1,177 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
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>
}
/** 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' },
]
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 nothing when the cache holds no services', async () => {
const h = harness({ entries: [] })
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')
})
})
@@ -1,119 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
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')
})
})
@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest'
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)
})
})
@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest'
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' }])
})
})
@@ -1,163 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
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')
})
})
@@ -1,6 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib" },
"include": ["src"],
"exclude": ["src/web"]
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +0,0 @@
packages:
- packages/*
allowBuilds:
esbuild: true
@@ -34,17 +34,17 @@ description: >-
## 何时用哪个命令
| 用户意图 | 命令 | 备注 |
| --- | --- | --- |
| 查知识 / 问答(日常检索) | 原生工具 `kb_search` / `kb_chat` | 不走 bl |
| 建库 / 查看 / 改名 / 删库 / 监控 | `bl knowledge create/list/info/update/delete/stats` | [reference/kb.md](reference/kb.md) |
| 上传本地文档、看解析状态、删文档、打标签 | `bl knowledge doc upload/list/status/delete/tag` | [reference/doc.md](reference/doc.md) |
| 从 OSS 批量导入 | `bl knowledge doc import-oss` | Bucket 需预先授权服务角色 |
| 创建 / 部署 / 调参检索(问答)服务 | `bl knowledge service create/update/deploy/…` | [reference/service.md](reference/service.md) |
| 修正错误切片、屏蔽某段内容 | `bl knowledge chunk add/list/update/delete` | [reference/chunk.md](reference/chunk.md) |
| 数据中心类目 / 文件 / 集合管理 | `bl knowledge category/file/collection …` | [reference/datacenter.md](reference/datacenter.md) |
| CLI 配置、升级 | `bl config show/set``bl update` | [reference/config.md](reference/config.md) |
| 部署后验证、调试草稿版服务 | `bl knowledge search/chat --agent-version beta` | [reference/query.md](reference/query.md) |
| 用户意图 | 命令 | 备注 |
| ---------------------------------------- | --------------------------------------------------- | -------------------------------------------------- |
| 查知识 / 问答(日常检索) | 原生工具 `kb_search` / `kb_chat` | 不走 bl |
| 建库 / 查看 / 改名 / 删库 / 监控 | `bl knowledge create/list/info/update/delete/stats` | [reference/kb.md](reference/kb.md) |
| 上传本地文档、看解析状态、删文档、打标签 | `bl knowledge doc upload/list/status/delete/tag` | [reference/doc.md](reference/doc.md) |
| 从 OSS 批量导入 | `bl knowledge doc import-oss` | Bucket 需预先授权服务角色 |
| 创建 / 部署 / 调参检索(问答)服务 | `bl knowledge service create/update/deploy/…` | [reference/service.md](reference/service.md) |
| 修正错误切片、屏蔽某段内容 | `bl knowledge chunk add/list/update/delete` | [reference/chunk.md](reference/chunk.md) |
| 数据中心类目 / 文件 / 集合管理 | `bl knowledge category/file/collection …` | [reference/datacenter.md](reference/datacenter.md) |
| CLI 配置、升级 | `bl config show/set``bl update` | [reference/config.md](reference/config.md) |
| 部署后验证、调试草稿版服务 | `bl knowledge search/chat --agent-version beta` | [reference/query.md](reference/query.md) |
## 核心工作流:建库到可检索
@@ -62,14 +62,14 @@ bl knowledge service list --scene search --status deployed # 5.
## 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` |
| 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` |
## 命令参考(权威)
@@ -11,14 +11,14 @@
Usage: bl knowledge chunk add --index-id <id> (--content <text> | --field <k=v>) [flags]
```
| Flag | 说明 |
| --- | --- |
| `--doc-id <id>` | 归属文档 ID取自 `doc list`**实践中所有库类型都必填** |
| `--content <text>` | Chunk 正文≤6000 字符(文档型库);与 `--content-file` 二选一 |
| `--content-file <path>` | 从 UTF-8 纯文本文件读正文(.md/.txt 等) |
| `--title <text>` | Chunk 标题≤50 字符 |
| `--image-url <url>` | Chunk 图片 URL可重复≤10 个) |
| `--field <key=value>` | 表格/图片型库的任意字段可重复key 为 Excel 列头);与 content/title/image 互斥 |
| Flag | 说明 |
| ----------------------- | --------------------------------------------------------------------------------- |
| `--doc-id <id>` | 归属文档 ID取自 `doc list`**实践中所有库类型都必填** |
| `--content <text>` | Chunk 正文≤6000 字符(文档型库);与 `--content-file` 二选一 |
| `--content-file <path>` | 从 UTF-8 纯文本文件读正文(.md/.txt 等) |
| `--title <text>` | Chunk 标题≤50 字符 |
| `--image-url <url>` | Chunk 图片 URL可重复≤10 个) |
| `--field <key=value>` | 表格/图片型库的任意字段可重复key 为 Excel 列头);与 content/title/image 互斥 |
Notes
@@ -41,9 +41,9 @@ bl knowledge chunk add --index-id idx-xxx --field 列A=v1 --field 列B=v2
Usage: bl knowledge chunk list --index-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--doc-id <id>` | 只看该文档的 chunk |
| Flag | 说明 |
| --------------------------------------- | ------------------------------- |
| `--doc-id <id>` | 只看该文档的 chunk |
| `--page-number <n>` / `--page-size <n>` | 分页(服务端默认 20上限 100 |
Notes
@@ -62,14 +62,14 @@ bl knowledge chunk list --index-id idx-xxx --doc-id file-xxx --page-size 50
Usage: bl knowledge chunk update --index-id <id> --chunk-id <id> --doc-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--chunk-id <id>` | Chunk ID`chunk list``metadata._id` |
| `--doc-id <id>` | 归属文档 ID`chunk list``metadata.doc_id` |
| `--content <text>` | 新内容10-6000 字符;与 `--content-file` 二选一 |
| `--content-file <path>` | 从 UTF-8 纯文本文件读新内容 |
| `--title <text>` | 标题0-50 字符(空串清除;省略保持不变) |
| `--exclude` / `--include` | 从检索中排除 / 恢复(默认 include |
| Flag | 说明 |
| ------------------------- | ------------------------------------------------ |
| `--chunk-id <id>` | Chunk ID`chunk list``metadata._id` |
| `--doc-id <id>` | 归属文档 ID`chunk list``metadata.doc_id` |
| `--content <text>` | 新内容10-6000 字符;与 `--content-file` 二选一 |
| `--content-file <path>` | 从 UTF-8 纯文本文件读新内容 |
| `--title <text>` | 标题0-50 字符(空串清除;省略保持不变) |
| `--exclude` / `--include` | 从检索中排除 / 恢复(默认 include |
Notes
@@ -90,10 +90,10 @@ bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-
Usage: bl knowledge chunk delete --index-id <id> --chunk-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ----------------- | ------------------------------------------------- |
| `--chunk-id <id>` | 要删的 chunk ID可重复超过 10 个自动分批发送) |
| `--yes` | 跳过交互确认 |
| `--yes` | 跳过交互确认 |
```bash
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --yes
@@ -67,8 +67,8 @@ bl auth status
Usage: bl update [--to <version>]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ---------------- | ------------------------ |
| `--to <version>` | 安装该精确版本而非最新版 |
```bash
@@ -11,13 +11,13 @@
Usage: bl knowledge category list [flags]
```
| Flag | 说明 |
| --- | --- |
| `--collection-id <id>` | 按集合 ID 精确过滤 |
| `--parent-id <id>` | 列出该父类目下的子类目 |
| `--name <text>` | 按名称过滤(**精确匹配**,与 knowledge list 的模糊匹配不同) |
| `--next-token <token>` | 游标分页(取自上一页输出) |
| `--max-result <n>` | 每页条数(默认 20 |
| Flag | 说明 |
| ---------------------- | ------------------------------------------------------------ |
| `--collection-id <id>` | 按集合 ID 精确过滤 |
| `--parent-id <id>` | 列出该父类目下的子类目 |
| `--name <text>` | 按名称过滤(**精确匹配**,与 knowledge list 的模糊匹配不同) |
| `--next-token <token>` | 游标分页(取自上一页输出) |
| `--max-result <n>` | 每页条数(默认 20 |
Notes
@@ -36,10 +36,10 @@ bl knowledge category list --name my-category
Usage: bl knowledge category add --name <text> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 类目名1-20 字符) |
| `--parent-id <id>` | 作为该类目的子类目创建 |
| Flag | 说明 |
| ---------------------- | ---------------------------- |
| `--name <text>` | 类目名1-20 字符) |
| `--parent-id <id>` | 作为该类目的子类目创建 |
| `--collection-id <id>` | 建在该集合下(默认平台集合) |
```bash
@@ -55,8 +55,8 @@ bl knowledge category add --name sub --parent-id cate-xxx
Usage: bl knowledge category delete --category-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ------- | ------------ |
| `--yes` | 跳过交互确认 |
Notes
@@ -75,12 +75,12 @@ bl knowledge category delete --category-id cate-xxx --yes
Usage: bl knowledge file list --category-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--category-id <id>` | 必须是真实类目 id通过 `category list` 查);精确匹配 |
| `--name <text>` | 按**不含扩展名的完整文件名**精确过滤a.md → 传 a部分关键词查不到 |
| `--file-id <id>` | 按文件 ID 精确过滤(可重复) |
| `--next-token <token>` / `--max-result <n>` | 游标分页 |
| Flag | 说明 |
| ------------------------------------------- | --------------------------------------------------------------------- |
| `--category-id <id>` | 必须是真实类目 id通过 `category list` 查);精确匹配 |
| `--name <text>` | 按**不含扩展名的完整文件名**精确过滤a.md → 传 a部分关键词查不到 |
| `--file-id <id>` | 按文件 ID 精确过滤(可重复) |
| `--next-token <token>` / `--max-result <n>` | 游标分页 |
```bash
bl knowledge file list --category-id cate-xxx --workspace-id ws-xxx
@@ -107,8 +107,8 @@ bl knowledge file get --file-id file-xxx --workspace-id ws-xxx
Usage: bl knowledge file delete --file-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ------- | ------------ |
| `--yes` | 跳过交互确认 |
Notes
@@ -128,12 +128,12 @@ bl knowledge file delete --file-id file-xxx --yes
Usage: bl knowledge collection create --name <text> --description <text> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 集合名 |
| `--description <text>` | 描述(服务端必填) |
| `--store-type <type>` | 存储platform托管默认或 custom自有 OSS bucket |
| `--oss-region <id>` / `--oss-bucket <name>` | `--store-type custom` 时必填 |
| Flag | 说明 |
| ------------------------------------------- | -------------------------------------------------------- |
| `--name <text>` | 集合名 |
| `--description <text>` | 描述(服务端必填) |
| `--store-type <type>` | 存储platform托管默认或 custom自有 OSS bucket |
| `--oss-region <id>` / `--oss-bucket <name>` | `--store-type custom` 时必填 |
Notes
@@ -10,14 +10,14 @@
Usage: bl knowledge doc upload --file <path> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--file <path>` | 本地文件或目录(可重复)。目录递归扫描,不支持的格式自动跳过 |
| `--index-id <id>` | 注册后同时导入该知识库(所有文件合并为一个导入任务) |
| `--category-id <id>` | 目标数据中心类目;默认 workspace 默认类目 |
| `--tag <text>` | 文件标签(可重复),应用到每个上传文件 |
| `--wait` | 轮询导入任务到终态(需配合 `--index-id` |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
| Flag | 说明 |
| --------------------------- | ------------------------------------------------------------ |
| `--file <path>` | 本地文件或目录(可重复)。目录递归扫描,不支持的格式自动跳过 |
| `--index-id <id>` | 注册后同时导入该知识库(所有文件合并为一个导入任务) |
| `--category-id <id>` | 目标数据中心类目;默认 workspace 默认类目 |
| `--tag <text>` | 文件标签(可重复),应用到每个上传文件 |
| `--wait` | 轮询导入任务到终态(需配合 `--index-id` |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
Notes
@@ -39,10 +39,10 @@ bl knowledge doc upload --file ./docs/ --dry-run --verbose
Usage: bl knowledge doc list --index-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--page-number <n>` | 页码(默认 1 |
| `--page-size <n>` | 每页条数(服务端默认 10上限 100 |
| Flag | 说明 |
| ------------------- | ----------------------------------- |
| `--page-number <n>` | 页码(默认 1 |
| `--page-size <n>` | 每页条数(服务端默认 10上限 100 |
Notes
@@ -62,11 +62,11 @@ bl knowledge doc list --index-id idx-xxx --page-size 100
Usage: bl knowledge doc status --index-id <id> --job-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--job-id <id>` | 导入任务 ID导入命令返回的 ingestionId |
| `--wait` | 轮询到终态 |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
| Flag | 说明 |
| --------------------------- | ----------------------------------------- |
| `--job-id <id>` | 导入任务 ID导入命令返回的 ingestionId |
| `--wait` | 轮询到终态 |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
Notes
@@ -85,10 +85,10 @@ bl knowledge doc status --index-id idx-xxx --job-id job-xxx --wait --poll-interv
Usage: bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| --------------- | ------------------------- |
| `--doc-id <id>` | 要删除的文档 ID可重复 |
| `--yes` | 跳过交互确认 |
| `--yes` | 跳过交互确认 |
Notes
@@ -108,11 +108,11 @@ bl knowledge doc delete --index-id idx-xxx --doc-id file-a --doc-id file-b --yes
Usage: bl knowledge doc tag --doc-id <id> --tag <text> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--doc-id <id>` | 数据中心文件 ID可重复每次 1-20 个) |
| `--tag <text>` | 应用到每个 `--doc-id` 的标签(可重复,单个 ≤32 字符) |
| `--mode <mode>` | 更新模式append默认或 overwrite |
| Flag | 说明 |
| --------------- | ----------------------------------------------------- |
| `--doc-id <id>` | 数据中心文件 ID可重复每次 1-20 个) |
| `--tag <text>` | 应用到每个 `--doc-id` 的标签(可重复,单个 ≤32 字符) |
| `--mode <mode>` | 更新模式append默认或 overwrite |
Notes
@@ -132,12 +132,12 @@ bl knowledge doc tag --doc-id file-a --doc-id file-b --tag final --mode overwrit
Usage: bl knowledge doc import-oss --bucket <name> --region <id> --oss-key <key> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--bucket <name>` | 已授权的 OSS bucket |
| `--region <id>` | OSS region如 cn-beijing |
| Flag | 说明 |
| ----------------- | ----------------------------------------------- |
| `--bucket <name>` | 已授权的 OSS bucket |
| `--region <id>` | OSS region如 cn-beijing |
| `--oss-key <key>` | 要导入的 OSS object key可重复每次 1-10 个) |
| `--overwrite` | 覆盖之前导入的同名文件 |
| `--overwrite` | 覆盖之前导入的同名文件 |
Notes
@@ -0,0 +1,76 @@
# `bl` 知识库命令参考索引
> 由 `bailian-cli`1.16.0)各命令 `--help` 输出整理。
> 命令详情在同目录 `<group>.md`;本索引只放速查表、全局 flag 与鉴权说明。
> 版本更新后以 `bl <命令> --help` 为准。
## 速查表
| 命令 | 鉴权 | 说明 | 详情 |
| -------------------------------- | ------- | ------------------------------------------------------------ | ------------------------------ |
| `bl knowledge list` | API Key | 列出 workspace 内知识库 | [kb.md](kb.md) |
| `bl knowledge info` | API Key | 查看知识库配置详情 | [kb.md](kb.md) |
| `bl knowledge create` | API Key | 建库并导入数据中心文件或类目 | [kb.md](kb.md) |
| `bl knowledge update` | API Key | 改名、描述或 rerank 阈值 | [kb.md](kb.md) |
| `bl knowledge delete` | API Key | 删库(含全部文档与 chunk不可逆 | [kb.md](kb.md) |
| `bl knowledge stats` | API Key | 存储量与 QPS 监控数据 | [kb.md](kb.md) |
| `bl knowledge doc list` | API Key | 列出库内文档及解析/索引状态 | [doc.md](doc.md) |
| `bl knowledge doc status` | API Key | 查看导入任务状态 | [doc.md](doc.md) |
| `bl knowledge doc upload` | API Key | 上传本地文件/目录,可选同时入库 | [doc.md](doc.md) |
| `bl knowledge doc delete` | API Key | 从库中删除文档及其 chunk | [doc.md](doc.md) |
| `bl knowledge doc tag` | API Key | 批量更新数据中心文件标签 | [doc.md](doc.md) |
| `bl knowledge doc import-oss` | API Key | 从已授权 OSS bucket 批量导入 | [doc.md](doc.md) |
| `bl knowledge service list` | API Key | 列出检索/问答服务 | [service.md](service.md) |
| `bl knowledge service get` | API Key | 查看服务各版本配置 | [service.md](service.md) |
| `bl knowledge service create` | API Key | 创建服务(初始为 draft/beta | [service.md](service.md) |
| `bl knowledge service update` | API Key | 更新名称、描述或草稿配置 | [service.md](service.md) |
| `bl knowledge service deploy` | API Key | 把 beta 草稿发布为新版本 | [service.md](service.md) |
| `bl knowledge service delete` | API Key | 删除服务(软删、幂等) | [service.md](service.md) |
| `bl knowledge service copy` | API Key | 复制服务为新草稿(名称加 copy\_ 前缀) | [service.md](service.md) |
| `bl knowledge chunk add` | API Key | 直接向库内添加 chunk | [chunk.md](chunk.md) |
| `bl knowledge chunk list` | API Key | 列出 chunk 内容与状态 | [chunk.md](chunk.md) |
| `bl knowledge chunk update` | API Key | 改 chunk 内容或切换检索可见性 | [chunk.md](chunk.md) |
| `bl knowledge chunk delete` | API Key | 删除 chunk不可逆 | [chunk.md](chunk.md) |
| `bl knowledge category list` | API Key | 列出数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge category add` | API Key | 创建数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge category delete` | API Key | 删除数据中心类目 | [datacenter.md](datacenter.md) |
| `bl knowledge file list` | API Key | 列出类目下的文件 | [datacenter.md](datacenter.md) |
| `bl knowledge file get` | API Key | 查看文件详情(大小/MD5/标签/时间) | [datacenter.md](datacenter.md) |
| `bl knowledge file delete` | API Key | 永久删除数据中心文件 | [datacenter.md](datacenter.md) |
| `bl knowledge collection create` | API Key | 创建 FILE 数据集合(无删除 API | [datacenter.md](datacenter.md) |
| `bl knowledge collection get` | API Key | 查看数据集合详情 | [datacenter.md](datacenter.md) |
| `bl config show` | 无需 | 显示当前配置 | [config.md](config.md) |
| `bl config set` | 无需 | 设置配置项 | [config.md](config.md) |
| `bl config list` / `use` | 无需 | 列出 / 切换配置 profile | [config.md](config.md) |
| `bl auth login` | 无需 | 存储 API key 等凭据 | [config.md](config.md) |
| `bl update` | 无需 | 升级 CLI | [config.md](config.md) |
| `bl knowledge search` | API Key | RAG 语义检索(部署验证用;日常检索走原生工具 kb_search | [query.md](query.md) |
| `bl knowledge chat` | API Key | RAG 问答SSE 流式(部署验证用;日常问答走原生工具 kb_chat | [query.md](query.md) |
| `bl knowledge retrieve` | API Key | 已废弃,改用 `search` | [query.md](query.md) |
## 全局 flag所有命令可用
| Flag | 说明 |
| ---------------------- | ---------------------------- |
| `--output <format>` | 输出格式text、json |
| `--timeout <seconds>` | 请求超时 |
| `--quiet` | 抑制非必要输出 |
| `--verbose` | 打印 HTTP 请求/响应详情 |
| `--dry-run` | 只预览请求不执行 |
| `--config <name>` | 本次命令使用指定配置 profile |
| `--help` / `--version` | 帮助 / 版本 |
## 鉴权 flagAPI Key 类命令可用)
| Flag | 说明 |
| --------------------- | --------------------------------------------------------------------------- |
| `--api-key <key>` | API key优先于环境变量 `DASHSCOPE_API_KEY` 与 config |
| `--base-url <url>` | API base URL |
| `--workspace-id <id>` | Workspace ID或环境变量 `BAILIAN_WORKSPACE_ID`,或 config `workspace_id` |
## 说明
- 所有知识库管理命令使用 DashScope API KeyBearer token鉴权无需 console 登录态API key 也可用 `bl auth login --api-key sk-xxx` 存储。
- 除知识库外bl 还有模型调用/应用/用量等其他命令组,不在本 skill 范围,见 `bl --help`
- 默认输出为 textagent 解析结果时建议显式加 `--output json`
- 分页有两种风格knowledge/doc/service/chunk 用 `--page-number/--page-size`page-size 上限 100category/file 用游标 `--next-token/--max-result`
@@ -10,11 +10,11 @@
Usage: bl knowledge list [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 按名称过滤模糊匹配1-20 字符) |
| `--page-number <n>` | 页码(默认 1 |
| `--page-size <n>` | 每页条数 |
| Flag | 说明 |
| ------------------- | --------------------------------- |
| `--name <text>` | 按名称过滤模糊匹配1-20 字符) |
| `--page-number <n>` | 页码(默认 1 |
| `--page-size <n>` | 每页条数 |
Notes
@@ -49,15 +49,15 @@ bl knowledge info --index-id idx-xxx --workspace-id ws-xxx
Usage: bl knowledge create --name <text> (--doc-id <id> | --category-id <id>) [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 库名1-20 字符workspace 内唯一) |
| `--doc-id <id>` | 数据中心文件 id可重复`--category-id` 互斥 |
| `--category-id <id>` | 导入该类目下所有文件(可重复);与 `--doc-id` 互斥 |
| `--embedding-model <name>` | Embedding 模型(默认 text-embedding-v4 |
| `--chunk-size <n>` | Chunk 大小(默认 600建议 300-800 |
| `--wait` | 轮询首次导入任务到终态 |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
| Flag | 说明 |
| --------------------------- | -------------------------------------------------- |
| `--name <text>` | 库名1-20 字符workspace 内唯一) |
| `--doc-id <id>` | 数据中心文件 id可重复`--category-id` 互斥 |
| `--category-id <id>` | 导入该类目下所有文件(可重复);与 `--doc-id` 互斥 |
| `--embedding-model <name>` | Embedding 模型(默认 text-embedding-v4 |
| `--chunk-size <n>` | Chunk 大小(默认 600建议 300-800 |
| `--wait` | 轮询首次导入任务到终态 |
| `--poll-interval <seconds>` | 轮询间隔(默认 5 |
Notes
@@ -77,10 +77,10 @@ bl knowledge create --name demo --category-id cate-xxx --wait
Usage: bl knowledge update --index-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--name <text>` | 新库名1-20 字符) |
| `--description <text>` | 新描述 |
| Flag | 说明 |
| ---------------------------- | ------------------------------------------------- |
| `--name <text>` | 新库名1-20 字符) |
| `--description <text>` | 新描述 |
| `--rerank-min-score <score>` | Rerank 最低分阈值0-1低于该分的 chunk 被过滤) |
Notes
@@ -100,8 +100,8 @@ bl knowledge update --index-id idx-xxx --rerank-min-score 0.3
Usage: bl knowledge delete --index-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ------- | ------------ |
| `--yes` | 跳过交互确认 |
Notes
@@ -122,10 +122,10 @@ bl knowledge delete --index-id idx-xxx --yes
Usage: bl knowledge stats --index-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| Flag | 说明 |
| ---------------- | ------------------------------------------------------------ |
| `--start <time>` | 区间起点Unix 秒或 ISO 日期,须为过去时间(默认 24 小时前) |
| `--end <time>` | 区间终点(默认现在) |
| `--end <time>` | 区间终点(默认现在) |
Notes
@@ -12,12 +12,12 @@ RAG 语义检索。
Usage: bl knowledge search --query <text> --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--query <text>` | 查询文本(必填,不能为空) |
| `--agent-id <id>` | 检索服务 ID |
| Flag | 说明 |
| --------------------------- | -------------------------------------------------------- |
| `--query <text>` | 查询文本(必填,不能为空) |
| `--agent-id <id>` | 检索服务 ID |
| `--agent-version <version>` | 调用版本beta草稿调试或已发布版本号默认最新发布版 |
| `--image <url>` | 多模态检索图片 URL可重复 |
| `--image <url>` | 多模态检索图片 URL可重复 |
Notes
@@ -36,12 +36,12 @@ RAG 问答SSE 流式)。
Usage: bl knowledge chat --message <text> --agent-id <id> [flags]
```
| Flag | 说明 |
| --- | --- |
| `--message <text>` | 消息(可重复)。支持 `role:content` 前缀设角色(如 `user:hello`),默认 user遵循 OpenAI 消息格式 |
| `--agent-id <id>` | 问答服务 ID |
| `--agent-version <version>` | beta 或已发布版本号;默认最新发布版 |
| `--image <url>` | 图片 URL可重复作为多模态内容附加到最后一条 user 消息 |
| Flag | 说明 |
| --------------------------- | -------------------------------------------------------------------------------------------------- |
| `--message <text>` | 消息(可重复)。支持 `role:content` 前缀设角色(如 `user:hello`),默认 user遵循 OpenAI 消息格式 |
| `--agent-id <id>` | 问答服务 ID |
| `--agent-version <version>` | beta 或已发布版本号;默认最新发布版 |
| `--image <url>` | 图片 URL可重复作为多模态内容附加到最后一条 user 消息 |
Notes
@@ -0,0 +1,159 @@
# `bl knowledge service` — 检索 / 问答服务agent
> 通用鉴权/全局 flag 见 [index.md](index.md)。以下 Flags 只列命令专属项。
> 服务状态机create → draftbeta 草稿,用 `--agent-version beta` 调试)→ deploy → deployed版本号自增可被默认版本调用
## `bl knowledge service list`
列出检索/问答服务。
```
Usage: bl knowledge service list --scene <chat|search> [flags]
```
| Flag | 说明 |
| --------------------------------------- | ------------------------------------------------------- |
| `--scene <scene>` | 服务场景chat问答或 search检索。**服务端必填** |
| `--status <status>` | 按状态过滤draft、deployed含 edited或 deleted |
| `--name <text>` | 按名称过滤(模糊匹配) |
| `--agent-id <id>` | 按 agent ID 精确过滤 |
| `--index-id <id>` | 按关联知识库 ID 精确过滤 |
| `--page-number <n>` / `--page-size <n>` | 分页 |
Notes
- 场景必填——要看全两类服务需分别执行两次。
- 返回的 agent_id 用于 search/chat 调用及 service 管理命令。
```bash
bl knowledge service list --scene chat --workspace-id ws-xxx
bl knowledge service list --scene search --status deployed
```
## `bl knowledge service get`
查看服务各版本配置。
```
Usage: bl knowledge service get --agent-id <id> [flags]
```
| Flag | 说明 |
| --------------------------- | ----------------------------------------------------- |
| `--agent-version <version>` | 只看指定版本beta 或已发布版本号);缺省返回全部版本 |
```bash
bl knowledge service get --agent-id aid-xxx --workspace-id ws-xxx
bl knowledge service get --agent-id aid-xxx --agent-version beta
```
## `bl knowledge service create`
创建检索/问答服务(初始 status: draftversion: beta
```
Usage: bl knowledge service create --name <text> --scene <chat|search> [flags]
```
| Flag | 说明 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name <text>` | 服务名≤200 字符,同场景内唯一)。**名称是模型判断该服务能查什么的主要依据**(描述暂未随 `service list` 返回),写清楚覆盖内容,避开 `检索服务1` / `test-xxx` |
| `--scene <scene>` | chat问答或 search检索 |
| `--description <text>` | 描述≤1000 字符)。建议始终填写:说明覆盖什么内容、适合回答什么问题 |
| `--index-id <id>` | 绑定知识库;其余配置用服务端默认值 |
Notes
- 草稿beta版可在 deploy 前用 search/chat 的 `--agent-version beta` 测试。
- 需要 workspace 的知识库创建权限。
```bash
bl knowledge service create --name my-qa --scene chat --workspace-id ws-xxx
bl knowledge service create --name my-search --scene search --index-id idx-xxx
```
## `bl knowledge service update`
更新名称、描述或草稿配置。
```
Usage: bl knowledge service update --agent-id <id> [flags]
```
| Flag | 说明 |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `--name <text>` / `--description <text>` | 新名称 / 描述 |
| `--agent-version <version>` | 目标版本(默认 beta 草稿)。已发布版本只接受 `--version-desc` |
| `--version-desc <text>` | 版本描述 |
| `--policy <policy>` | Agent 策略turbo或 agentic多轮 |
| `--model <name>` | 生成模型 code须在平台白名单内 |
| `--temperature <n>` | 采样温度0-2 |
| `--max-llm-calls <n>` | 单请求最大 LLM 调用次数1-30 |
| `--enable-session-file/-refusal/-anti-leak/-rich-text/-citation <bool>` | 功能开关true/false |
| `--config-file <path>` | JSON 文件整体替换 agent_config含 kb_search_configs 等嵌套配置);与标量配置 flag 互斥 |
Notes
- 配置变更只作用于 beta 草稿;已发布版本只能改 `--version-desc`
- 要改已发布版本的配置:先改 beta 草稿 → `--agent-version beta` 验证 → `service deploy` 发新版本。
- 标量 flag 合并进当前草稿配置(读-合-写);`--config-file` 整体替换,二者互斥。
- 需要 workspace 的知识库修改权限。
```bash
bl knowledge service update --agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx
bl knowledge service update --agent-id aid-xxx --config-file ./agent-config.json
bl knowledge service update --agent-id aid-xxx --agent-version 1 --version-desc 'first stable release'
```
## `bl knowledge service deploy`
把 beta 草稿发布为新版本。**发布影响线上调用方,执行前须向用户确认。**
```
Usage: bl knowledge service deploy --agent-id <id> [flags]
```
| Flag | 说明 |
| ----------------------- | ------------ |
| `--version-desc <text>` | 新版本描述 |
| `--yes` | 跳过交互确认 |
Notes
- 版本号自增;状态变为 deployed。需要 workspace 的知识库修改权限。
```bash
bl knowledge service deploy --agent-id aid-xxx --version-desc 'tuned rerank params' --yes
```
## `bl knowledge service delete`
删除服务(软删、幂等)。**删除后 agent_id 不可再用于 search/chat执行前须向用户确认。**
```
Usage: bl knowledge service delete --agent-id <id> [flags]
```
| Flag | 说明 |
| ------- | ------------ |
| `--yes` | 跳过交互确认 |
```bash
bl knowledge service delete --agent-id aid-xxx --yes
```
## `bl knowledge service copy`
复制服务为新草稿(名称加 copy\_ 前缀)。
```
Usage: bl knowledge service copy --agent-id <id> [flags]
```
Notes
- 副本以 beta 草稿开始;用 `--agent-version beta` 测试后 deploy 发布。需要知识库创建权限。
```bash
bl knowledge service copy --agent-id aid-xxx --workspace-id ws-xxx
```
@@ -1,19 +1,19 @@
/** Request/response fields of the DashScope search and chat endpoints, mirrored from the verified bl CLI types. */
/** Retrieval-service scenes; the server requires one per list query. */
export type ServiceScene = 'chat' | 'search'
export type ServiceScene = "chat" | "search";
export interface ServiceListRequest {
agent_scene: ServiceScene
agent_scene: ServiceScene;
/**
* Verified to be honored by the server, and to mean "deployed or edited"
* (matching the CLI's documented `--status deployed含 edited`). The
* spellings `status` and `agent_status_list` are silently ignored.
*/
agent_status?: 'deployed'
agent_name?: string
page_number: number
page_size: number
agent_status?: "deployed";
agent_name?: string;
page_number: number;
page_size: number;
}
/**
@@ -29,49 +29,49 @@ export interface ServiceListRequest {
* 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 }[]
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[] }
code?: string;
message?: string;
data?: { total_count?: number; rows?: ServiceListRow[] };
}
export interface SearchRequest {
query: string
agent_id: string
agent_version?: string
images?: string[]
query: string;
agent_id: string;
agent_version?: string;
images?: string[];
}
export interface SearchResponse {
request_id?: string
request_id?: string;
data?: {
total?: number
nodes?: { score: number; text: string; metadata?: Record<string, unknown> }[]
}
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
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
message?: { content?: string; extra?: { step_change?: string } };
finish_reason?: string;
}[];
};
request_id?: string;
}
@@ -12,21 +12,21 @@
* directly and always asks for one.
*/
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
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
apiKey?: string;
/** Bailian workspace id (`workspace_id`), present when the console login callback carried one. */
workspaceId?: string
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')
return join(homedir(), ".bailian", "config.json");
}
/**
@@ -38,16 +38,22 @@ export function blCliConfigPath(): string {
*/
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
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 {}
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 };
}
@@ -1,28 +1,31 @@
/** Shared HTTP client for the knowledge endpoints: per-call Bearer auth, JSON/SSE POST, and error translation. */
import { kbEndpoint } from './endpoints.js'
import { kbEndpoint } from "./endpoints.js";
/** Maximum error-body characters kept in a translated message. */
const ERROR_BODY_LIMIT = 500
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'
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
resolveWorkspaceId: () => Promise<string>;
endpointHost: string;
/** Service version forwarded on search/chat when set (deployment debug choice). */
agentVersion?: string
agentVersion?: string;
/** Resolves the current DASHSCOPE_API_KEY per call; throws with guidance when unconfigured. */
resolveApiKey: () => Promise<string>
resolveApiKey: () => Promise<string>;
/** Test seam; defaults to global fetch. */
fetchImpl?: typeof fetch
fetchImpl?: typeof fetch;
}
export class KbClient {
@@ -30,29 +33,47 @@ export class KbClient {
/** The deployment's configured service version, exposed for request builders. */
get agentVersion(): string | undefined {
return this.opts.agentVersion
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)
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 },
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
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)
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
return res;
}
/**
@@ -63,8 +84,8 @@ export class KbClient {
* @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
const res = await this.post(path, body, "application/json", signal);
return (await res.json()) as T;
}
/**
@@ -75,6 +96,6 @@ export class KbClient {
* @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)
return await this.post(path, body, "text/event-stream", signal);
}
}
@@ -17,28 +17,28 @@
* value (separated by `?`), not a sibling query parameter.
*/
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import http from 'node:http'
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',
}
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
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
/** Upper bound on a callback body, matching the CLI's limit. */
const MAX_CALLBACK_BODY = 65536
const MAX_CALLBACK_BODY = 65536;
/** Credentials the console callback can carry. */
export interface ConsoleLoginCredentials {
/** Freshly issued DashScope api key (`needapikey=true` asks for one). */
apiKey?: string
apiKey?: string;
/** Workspace id of the account that signed in. */
workspaceId?: string
workspaceId?: string;
}
/**
@@ -47,42 +47,50 @@ export interface ConsoleLoginCredentials {
* 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 }
| { 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' }
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
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()
const value = source[key];
if (typeof value === "string" && value.trim() !== "") return value.trim();
}
return undefined
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('') })
})
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("");
});
});
}
/**
@@ -93,31 +101,33 @@ function readBody(req: http.IncomingMessage): Promise<string> {
* @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
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 */ }
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)) {
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 { ...(inner as Record<string, unknown>), ...record };
}
return record
return record;
}
try {
return Object.fromEntries(new URLSearchParams(text))
return Object.fromEntries(new URLSearchParams(text));
} catch (_notForm) {
return {}
return {};
}
}
@@ -132,55 +142,66 @@ 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')
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)
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]
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 => { err ? reject(err) : resolve() })
})
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
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)
})
})
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 }
| { 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
@@ -194,98 +215,102 @@ export type ConsoleLoginStart =
* @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[]>
site?: string;
onComplete: (credentials: ConsoleLoginCredentials) => Promise<string[]>;
}): Promise<ConsoleLoginStart> {
if (active !== undefined) {
return {
status: 'already-running',
loginUrl: state.phase === 'waiting' ? state.loginUrl : '',
}
status: "already-running",
loginUrl: state.phase === "waiting" ? state.loginUrl : "",
};
}
const expectedState = randomBytes(16).toString('hex')
let settled = false
const expectedState = randomBytes(16).toString("hex");
let settled = false;
const server = http.createServer((req, res) => {
void (async () => {
if (req.method === 'OPTIONS') {
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
"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) {
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
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("bad state\n");
return;
}
const credentials = await extractCredentials(req, url)
const credentials = await extractCredentials(req, url);
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Access-Control-Allow-Origin': '*',
})
res.end('OK\n')
"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
return;
}
settled = true
settled = true;
try {
const fields = await opts.onComplete(credentials)
state = { phase: 'done', fields }
const fields = await opts.onComplete(credentials);
state = { phase: "done", fields };
} catch (err) {
state = { phase: 'failed', reason: err instanceof Error ? err.message : 'persist failed' }
state = { phase: "failed", reason: err instanceof Error ? err.message : "persist failed" };
}
server.close()
server.close();
})().catch(() => {
res.statusCode = 500
res.end()
})
})
res.statusCode = 500;
res.end();
});
});
let port: number
let port: number;
try {
port = await listenLoopback(server)
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 }
const reason = err instanceof Error ? err.message : "could not bind a local port";
state = { phase: "failed", reason };
return { status: "failed", reason };
}
// `state` rides inside the `notice` value, and `needapikey=true` is the whole
// point: it makes the console issue a key for the account signing in.
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`
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' }
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)
await openInBrowser(loginUrl);
} catch (_browserRefused) {
// Headless or locked-down host: the panel shows `loginUrl` to open by hand.
}
return { status: 'started', loginUrl }
return { status: "started", loginUrl };
}
/** Abandon an in-flight login (closes the listener). */
export function cancelConsoleLogin(): void {
active?.server.close()
active = undefined
state = { phase: 'idle' }
active?.server.close();
active = undefined;
state = { phase: "idle" };
}
@@ -7,10 +7,10 @@
* 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
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.
@@ -20,5 +20,5 @@ export const KB_PATHS = {
* @returns the absolute endpoint URL.
*/
export function kbEndpoint(endpointHost: string, workspaceId: string, path: string): string {
return `https://${workspaceId}.${endpointHost}${path}`
return `https://${workspaceId}.${endpointHost}${path}`;
}
+628
View File
@@ -0,0 +1,628 @@
/**
* 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 { CATALOG_ENTRY_LIMIT } 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 three 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 optional 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.
describeServicesAfterRefresh: async (scene) => {
await serviceCache.refresh();
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) return undefined;
const forScene =
serviceCache.peek(workspaceId)?.entries.filter((entry) => entry.scene === scene) ?? [];
if (forScene.length === 0) return undefined;
const lines = forScene
.slice(0, CATALOG_ENTRY_LIMIT)
.map(
(entry) =>
`- ${entry.agent_id}${entry.agent_name === "" ? "(unnamed)" : entry.agent_name}`,
);
const more = forScene.length - lines.length;
return [
`Deployed ${scene} services in this workspace, re-read just now:`,
...lines,
...(more > 0
? [`(and ${more} more — \`bl knowledge service list --scene ${scene}\`)`]
: []),
].join("\n");
},
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 with `needapikey=true`, 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);
});
}
@@ -20,17 +20,17 @@
* 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'
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
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
export const CACHE_TTL_MS = 30 * 60 * 1000;
/**
* Refresh interval applied when the cached list is EMPTY.
@@ -42,25 +42,25 @@ export const CACHE_TTL_MS = 30 * 60 * 1000
* 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
export const EMPTY_CACHE_TTL_MS = 60 * 1000;
/** The stored document. */
export interface ServiceCacheDocument {
version: number
version: number;
/** Epoch millis of the fetch that produced `entries`. */
fetchedAt: number
fetchedAt: number;
/** Guards against reading a file written for another account or region. */
workspaceId: string
endpointHost: string
entries: ServiceEntry[]
total: number
truncated: boolean
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')
const fromEnv = process.env.DSH_HOME;
return fromEnv !== undefined && fromEnv !== "" ? fromEnv : join(homedir(), ".dsh");
}
/**
@@ -70,7 +70,7 @@ function dshHome(): string {
* @returns the absolute file path.
*/
export function serviceCachePath(workspaceId: string, home: string = dshHome()): string {
return join(home, 'cache', 'bailian-kb', `services-${workspaceId}.json`)
return join(home, "cache", "bailian-kb", `services-${workspaceId}.json`);
}
/**
@@ -85,26 +85,26 @@ export function readServiceCache(
workspaceId: string,
endpointHost: string,
): ServiceCacheDocument | undefined {
let parsed: unknown
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (_unreadableOrMalformed) {
return undefined
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
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,
total: typeof doc.total === "number" ? doc.total : doc.entries.length,
truncated: doc.truncated === true,
}
};
}
/**
@@ -114,36 +114,36 @@ export function readServiceCache(
* @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)
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
workspaceId: string;
/** Epoch millis of the last successful fetch; absent when nothing is cached. */
fetchedAt?: number
searchCount: number
chatCount: number
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
total: number;
truncated: boolean;
stale: boolean;
}
export interface ServiceCacheOptions {
client: KbClient
client: KbClient;
/** Resolves the current workspace id; a failure means "not configured yet". */
resolveWorkspaceId: () => Promise<string>
endpointHost: string
resolveWorkspaceId: () => Promise<string>;
endpointHost: string;
/** Reports refresh failures without escalating them. */
warn: (message: string) => void
warn: (message: string) => void;
/** Test seams. */
home?: string
now?: () => number
home?: string;
now?: () => number;
}
/**
@@ -152,16 +152,16 @@ export interface ServiceCacheOptions {
*/
export class ServiceCache {
/** Last document read or written; undefined until one is available. */
private document: ServiceCacheDocument | undefined
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
private inFlight: Promise<void> | undefined;
/** Workspace of {@link document}, so a workspace switch invalidates in memory too. */
private loadedFor: string | undefined
private loadedFor: string | undefined;
constructor(private readonly opts: ServiceCacheOptions) {}
private get now(): number {
return (this.opts.now ?? Date.now)()
return (this.opts.now ?? Date.now)();
}
/**
@@ -176,10 +176,10 @@ export class ServiceCache {
serviceCachePath(workspaceId, this.opts.home ?? dshHome()),
workspaceId,
this.opts.endpointHost,
)
this.loadedFor = workspaceId
);
this.loadedFor = workspaceId;
}
return this.document
return this.document;
}
/**
@@ -189,16 +189,16 @@ export class ServiceCache {
* @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
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
this.document = undefined;
this.loadedFor = undefined;
}
/**
@@ -212,19 +212,19 @@ export class ServiceCache {
* @returns the snapshot; `fetchedAt` is undefined when nothing is cached.
*/
status(workspaceId: string): ServiceCacheStatus {
const doc = this.peek(workspaceId)
const doc = this.peek(workspaceId);
if (doc === undefined) {
return { workspaceId, searchCount: 0, chatCount: 0, total: 0, truncated: false, stale: true }
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,
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),
}
};
}
/**
@@ -235,12 +235,12 @@ export class ServiceCache {
* @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 []
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 ?? ''))
.filter((entry) => entry.scene === scene)
.sort((left, right) => (right.modify_time ?? "").localeCompare(left.modify_time ?? ""));
}
/**
@@ -253,22 +253,24 @@ export class ServiceCache {
// 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
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)
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
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(', ')}`)
this.opts.warn(
`bailian-kb service cache refreshed without scene(s): ${list.failedScenes.join(", ")}`,
);
}
const doc: ServiceCacheDocument = {
version: CACHE_VERSION,
@@ -278,12 +280,12 @@ export class ServiceCache {
entries: list.entries,
total: list.total,
truncated: list.truncated,
}
writeServiceCache(serviceCachePath(workspaceId, this.opts.home ?? dshHome()), doc)
this.document = doc
this.loadedFor = workspaceId
};
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)}`)
this.opts.warn(`bailian-kb service cache refresh failed: ${String(failed)}`);
}
}
}
@@ -15,49 +15,50 @@
* invites the model to handle a case that does not exist.
*/
import type { ServiceScene } from './api-types.js'
import type { ServiceEntry } from './services.js'
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
export const CATALOG_ENTRY_LIMIT = 10;
/** Truncation applied to a service description once the backend returns one. */
const DESCRIPTION_LIMIT = 200
const DESCRIPTION_LIMIT = 200;
export interface CatalogInput {
entries: readonly ServiceEntry[]
entries: readonly ServiceEntry[];
/** Server-reported total, which may exceed `entries` when the fetch itself was capped. */
total: number
total: number;
/** True when the fetch stopped before the server ran out of rows. */
truncated: boolean
defaultRetrieveAgentId?: string
defaultChatAgentId?: string
truncated: boolean;
defaultRetrieveAgentId?: string;
defaultChatAgentId?: string;
}
const SCENE_LABEL: Record<ServiceScene, string> = {
search: 'kb_search (retrieval)',
chat: 'kb_chat (grounded Q&A)',
}
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}`}`
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
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;
}
/**
@@ -74,43 +75,44 @@ function renderScene(
defaultAgentId: string | undefined,
truncatedFetch: boolean,
): string[] | undefined {
const forScene = entries.filter(entry => entry.scene === scene)
if (forScene.length === 0) return undefined
const lines = [`${SCENE_LABEL[scene]}:`]
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)
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
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)`,
)
` (default service; ${others} other${others === 1 ? "" : "s"} exist — ` +
`run \`bl knowledge service list --scene ${scene}\` to see them)`,
);
}
return lines
return lines;
}
if (forScene.length <= CATALOG_ENTRY_LIMIT && !truncatedFetch) {
lines.push(...forScene.map(renderEntry))
return lines
lines.push(...forScene.map(renderEntry));
return lines;
}
const shown = [...forScene].sort(byRecency).slice(0, CATALOG_ENTRY_LIMIT)
lines.push(...shown.map(renderEntry))
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)
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
` (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;
}
/**
@@ -119,24 +121,29 @@ function renderScene(
* @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 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;
return [
'<system-reminder>',
"<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.',
'',
"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 ?? []),
...(search !== undefined && chat !== undefined ? [''] : []),
...(search !== undefined && chat !== undefined ? [""] : []),
...(chat ?? []),
'',
'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.',
'</system-reminder>',
].join('\n')
"",
"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.",
"</system-reminder>",
].join("\n");
}
@@ -0,0 +1,146 @@
/**
* 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 { 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);
if (document === undefined) return decision;
const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([
opts.resolveDefaultRetrieveAgentId(),
opts.resolveDefaultChatAgentId(),
]);
if (signal.aborted) return decision;
const text = buildServiceCatalog({
entries: document.entries,
total: document.total,
truncated: document.truncated,
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
});
if (text === undefined) return decision;
// 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 },
);
}
@@ -13,40 +13,40 @@
* model.
*/
import type { ServiceListResponse, ServiceScene } from './api-types.js'
import type { KbClient } from './client.js'
import { KB_PATHS } from './endpoints.js'
import type { ServiceListResponse, ServiceScene } from "./api-types.js";
import type { KbClient } from "./client.js";
import { KB_PATHS } from "./endpoints.js";
/** Server page-size maximum; larger requests are silently clamped to this. */
const PAGE_SIZE = 100
const PAGE_SIZE = 100;
/** Pages fetched per scene before reporting truncation (200 rows is far past the useful range). */
const MAX_PAGES = 2
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
agent_id: string;
agent_name: string;
scene: ServiceScene;
/** `deployed` or `edited` — both are callable by the default version. */
status: string
status: string;
/** Last modification timestamp; the only signal for "which of these is in use". */
modify_time?: string
modify_time?: string;
/** Absent until the backend adds a description to the list response. */
description?: string
description?: string;
}
export interface ServiceList {
entries: ServiceEntry[]
entries: ServiceEntry[];
/** Server-reported total across the queried scenes, including rows never fetched. */
total: number
total: number;
/** True when a scene reported more rows than {@link MAX_PAGES} pages returned. */
truncated: boolean
truncated: boolean;
/** Scenes whose query failed; a partial list stays usable. */
failedScenes: ServiceScene[]
failedScenes: ServiceScene[];
}
const SCENES: readonly ServiceScene[] = ['search', 'chat']
const SCENES: readonly ServiceScene[] = ["search", "chat"];
/**
* Fetch the deployed services of one scene, stopping at the page cap.
@@ -58,33 +58,33 @@ async function listScene(
client: KbClient,
scene: ServiceScene,
): Promise<{ entries: ServiceEntry[]; total: number; truncated: boolean }> {
const entries: ServiceEntry[] = []
let total = 0
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',
agent_status: "deployed",
page_number: page,
page_size: PAGE_SIZE,
})
total = res.data?.total_count ?? total
const rows = res.data?.rows ?? []
});
total = res.data?.total_count ?? total;
const rows = res.data?.rows ?? [];
for (const row of rows) {
const agentId = row.agent_id ?? ''
const agentId = row.agent_id ?? "";
// A row without an id cannot be called, so it has no reason to exist here.
if (agentId === '') continue
if (agentId === "") continue;
entries.push({
agent_id: agentId,
agent_name: row.agent_name ?? '',
agent_name: row.agent_name ?? "",
scene,
status: row.agent_status ?? '',
...(typeof row.modify_time === 'string' ? { modify_time: row.modify_time } : {}),
})
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 }
if (rows.length < PAGE_SIZE) return { entries, total, truncated: false };
}
return { entries, total, truncated: total > entries.length }
return { entries, total, truncated: total > entries.length };
}
/**
@@ -95,19 +95,19 @@ async function listScene(
* @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
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
const result = await listScene(client, scene);
entries.push(...result.entries);
total += result.total;
truncated = truncated || result.truncated;
} catch (_sceneFailed) {
failedScenes.push(scene)
failedScenes.push(scene);
}
}
return { entries, total, truncated, failedScenes }
return { entries, total, truncated, failedScenes };
}
@@ -11,28 +11,28 @@
* 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'
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'
import type {} from "@deepseek-ai/dsh-skill";
const SKILL_DIR = fileURLToPath(new URL('../skills/bailian-kb/', import.meta.url))
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
name: string;
/** Routing description from frontmatter (the catalog truncates at 500 chars). */
description: string
description: string;
/** Optional extra routing guidance. */
whenToUse?: string
whenToUse?: string;
/** Optional frontmatter `metadata` object. */
metadata?: Record<string, unknown>
metadata?: Record<string, unknown>;
/** Markdown body with the frontmatter block removed. */
content: string
content: string;
}
/**
@@ -42,24 +42,24 @@ export interface ParsedSkillFile {
* @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
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$/, '') === '---') {
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
if (nextNewline < 0) return undefined;
lineStart = nextNewline + 1;
}
return undefined
return undefined;
}
/**
@@ -68,32 +68,35 @@ function splitFrontmatter(raw: string): { yaml: string; body: string } | undefin
* @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
const split = splitFrontmatter(raw);
if (split === undefined) return undefined;
let data: unknown;
try {
data = parseYaml(split.yaml)
data = parseYaml(split.yaml);
} catch (_invalidYaml) {
return undefined
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() : ''
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
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 } : {}),
...(whenToUse !== "" ? { whenToUse } : {}),
...(metadata !== undefined ? { metadata } : {}),
content: split.body,
}
};
}
/**
@@ -103,21 +106,23 @@ export function parseSkillFile(raw: string): ParsedSkillFile | undefined {
* @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
ctx.inject(["skills"], (skillCtx) => {
const path = join(SKILL_DIR, "SKILL.md");
let raw: string;
try {
raw = readFileSync(path, 'utf8')
raw = readFileSync(path, "utf8");
} catch (unreadable) {
skillCtx.logger.warn(`bailian-kb skill not registered: cannot read ${path}: ${String(unreadable)}`)
return
skillCtx.logger.warn(
`bailian-kb skill not registered: cannot read ${path}: ${String(unreadable)}`,
);
return;
}
const parsed = parseSkillFile(raw)
const parsed = parseSkillFile(raw);
if (parsed === undefined) {
skillCtx.logger.warn(
`bailian-kb skill not registered: ${path} needs YAML frontmatter carrying name and description`,
)
return
);
return;
}
skillCtx.skills.register({
name: parsed.name,
@@ -125,9 +130,9 @@ export function registerSkill(ctx: Context): void {
...(parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}),
...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
content: parsed.content,
source: 'bundled',
source: "bundled",
path,
resourceBase: { kind: 'directory', path: SKILL_DIR },
})
})
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;
}
}
}
+287
View File
@@ -0,0 +1,287 @@
/**
* 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.
*/
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. The deployed services of this workspace, " +
"with their ids, are listed in a context message in this conversation; take the id from the " +
"section matching the tool you are calling. If that list is absent or none of its services " +
"covers the question, run `bl knowledge service list --scene search --name <keyword>` to look " +
"(workspaceId resolves automatically from DSH settings: bailian-kb.workspaceId in " +
"~/.dsh/settings.yaml).",
};
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];
}
@@ -236,7 +236,7 @@
}
.advancedToggle::after {
content: '';
content: "";
display: inline-block;
width: 6px;
height: 6px;
@@ -246,7 +246,7 @@
transition: transform 0.2s ease;
}
.advancedToggle[aria-expanded='true']::after {
.advancedToggle[aria-expanded="true"]::after {
transform: translateY(1px) rotate(-135deg);
}
@@ -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>
);
}
@@ -12,65 +12,65 @@
* 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'
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
"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]
export type BailianFieldKey = (typeof BAILIAN_CARD_REFS)[number];
/** Settings-section field names of the echoing controls. */
export type BailianSettingsField = 'workspaceId' | 'defaultRetrieveAgentId' | 'defaultChatAgentId'
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',
}
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
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
configured: boolean;
/** Whether `credentials.set` can affect it; false disables the control. */
writable: boolean
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'
status: "loading" | "ready" | "unavailable";
/** Whether the Host settings document accepts writes. */
writable: boolean
writable: boolean;
/** Resolved section values (entry base + user layer) for the two hybrid controls. */
values: BailianKbSection
values: BailianKbSection;
}
/** Where the autofill flow (adopt a Bailian console login) currently stands. */
export type BailianAutofillStatus = 'idle' | 'running' | 'awaitingLogin' | 'done' | 'failed'
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
agent_id: string;
agent_name: string;
scene: "search" | "chat";
status: string;
modify_time?: string;
}
/**
@@ -83,103 +83,103 @@ export interface BailianServiceEntry {
*/
export interface BailianCacheView {
/** `unconfigured` = no workspace id yet; `unavailable` = the bridge route failed. */
status: 'loading' | 'ready' | 'unconfigured' | 'unavailable'
status: "loading" | "ready" | "unconfigured" | "unavailable";
/** Epoch millis of the last successful fetch; absent when nothing is cached. */
fetchedAt?: number
searchCount: number
chatCount: number
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[]
total: number;
truncated: boolean;
stale: boolean;
search: BailianServiceEntry[];
chat: BailianServiceEntry[];
/** Whether a forced refresh is in flight. */
refreshing: boolean
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>
drafts: Record<BailianFieldKey, string | undefined>;
/** Last credentials-domain answer per reference; unknown refs read as writable. */
credentials: Record<BailianFieldKey, BailianCredentialView>
credentials: Record<BailianFieldKey, BailianCredentialView>;
/** Settings-scope echo state for the id fields. */
settings: BailianSettingsView
settings: BailianSettingsView;
/** Whether a save is in flight. */
saving: boolean
saving: boolean;
/** Whether a default-service clear is in flight. */
clearing: boolean
clearing: boolean;
/** Whether the last save or clear was refused; drafts are kept for correction. */
failed: boolean
failed: boolean;
/** The autofill flow's state; feeds the button label and its result notice. */
autofill: BailianAutofillStatus
autofill: BailianAutofillStatus;
/** Console login URL while `awaitingLogin`, shown in case the host could not open a browser. */
autofillLoginUrl?: string
autofillLoginUrl?: string;
/** Service cache diagnostics and the pickable services. */
cache: BailianCacheView
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>
}
bailianCard: SnapshotStore<BailianCardState>;
};
/** Stage one draft. */
edit: (key: BailianFieldKey, text: string) => void
edit: (key: BailianFieldKey, text: string) => void;
/** Write every staged draft through its domain, then re-read. */
save: () => Promise<void>
save: () => Promise<void>;
/** Drop every staged draft. */
discard: () => void
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>
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>
autofill: () => Promise<void>;
/** Force a service-cache refresh, bypassing the TTL. */
refreshServices: () => Promise<void>
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>
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] ?? ''
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)
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 !== ''
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))
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>
private readonly store: SnapshotStore<BailianCardState>;
/**
* @param api - wire face used for the three credential references.
*/
constructor(
private readonly api: Pick<IApiClient, 'credentials'>,
) {
constructor(private readonly api: Pick<IApiClient, "credentials">) {
this.store = createSnapshotStore<BailianCardState>({
drafts: {
DASHSCOPE_API_KEY: undefined,
@@ -193,14 +193,14 @@ export class BailianCardController {
BAILIAN_DEFAULT_RETRIEVE_AGENT_ID: { configured: false, writable: true },
BAILIAN_DEFAULT_CHAT_AGENT_ID: { configured: false, writable: true },
},
settings: { status: 'loading', writable: false, values: {} },
settings: { status: "loading", writable: false, values: {} },
saving: false,
clearing: false,
failed: false,
autofill: 'idle',
autofill: "idle",
autofillLoginUrl: undefined,
cache: {
status: 'loading',
status: "loading",
searchCount: 0,
chatCount: 0,
total: 0,
@@ -210,10 +210,10 @@ export class BailianCardController {
chat: [],
refreshing: false,
},
})
void this.fetchSettings()
void this.fetchServices()
void this.read()
});
void this.fetchSettings();
void this.fetchServices();
void this.read();
}
/**
@@ -222,24 +222,28 @@ export class BailianCardController {
*/
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 => {
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',
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 } : {}),
...(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: {} }
})
this.store.update((draft) => {
draft.settings = { status: "unavailable", writable: false, values: {} };
});
}
}
@@ -248,53 +252,56 @@ export class BailianCardController {
* @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 })
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
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
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 = "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 ?? []
})
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'
})
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)
if (this.store.getSnapshot().cache.refreshing) return;
await this.fetchServices(true);
}
/**
@@ -306,27 +313,37 @@ export class BailianCardController {
* @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'
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
await this.clearDefaultAgent(key);
return;
}
const field: BailianSettingsField = scene === 'search' ? 'defaultRetrieveAgentId' : 'defaultChatAgentId'
this.store.update(draft => { draft.saving = true })
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' },
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 })
});
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 })
this.store.update((draft) => {
draft.failed = true;
});
} finally {
this.store.update(draft => { draft.saving = false })
await this.fetchSettings()
this.store.update((draft) => {
draft.saving = false;
});
await this.fetchSettings();
}
}
@@ -337,10 +354,10 @@ export class BailianCardController {
* @param text - the staged text.
*/
edit(key: BailianFieldKey, text: string): void {
this.store.update(draft => {
draft.drafts[key] = text
draft.failed = false
})
this.store.update((draft) => {
draft.drafts[key] = text;
draft.failed = false;
});
}
/**
@@ -351,57 +368,61 @@ export class BailianCardController {
* 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[] = []
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
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
}
})())
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)
await this.saveSettings(settingsPatch);
} catch (_settingsWriteFailure) {
failed = true
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()
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
})
this.store.update((draft) => {
for (const ref of BAILIAN_CARD_REFS) draft.drafts[ref] = undefined;
draft.failed = false;
});
}
/**
@@ -417,15 +438,15 @@ export class BailianCardController {
* 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()
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();
}
/**
@@ -437,22 +458,26 @@ export class BailianCardController {
* account's key with a new account's workspace.)
*/
private async runConsoleLogin(): Promise<void> {
let started: { status?: string, loginUrl?: string }
let started: { status?: string; loginUrl?: string };
try {
started = await this.postAutofill('login') as { status?: string, loginUrl?: string }
started = (await this.postAutofill("login")) as { status?: string; loginUrl?: string };
} catch (_routeFailure) {
this.store.update(draft => { draft.autofill = 'failed' })
return
this.store.update((draft) => {
draft.autofill = "failed";
});
return;
}
if (started.status !== 'started' && started.status !== 'already-running') {
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()
this.store.update((draft) => {
draft.autofill = "awaitingLogin";
draft.autofillLoginUrl = started.loginUrl;
});
await this.pollConsoleLogin();
}
/**
@@ -462,31 +487,33 @@ export class BailianCardController {
* on the next page read.
*/
private async pollConsoleLogin(): Promise<void> {
const deadline = Date.now() + 5 * 60 * 1000
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 2000))
let phase: string | undefined
await new Promise((resolve) => setTimeout(resolve, 2000));
let phase: string | undefined;
try {
phase = (await this.postAutofill('loginStatus') as { phase?: string }).phase
phase = ((await this.postAutofill("loginStatus")) as { phase?: string }).phase;
} catch (_pollFailure) {
continue
continue;
}
if (phase === 'done') {
this.store.update(draft => {
draft.autofill = 'done'
draft.autofillLoginUrl = undefined
})
return
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
if (phase === "failed") {
this.store.update((draft) => {
draft.autofill = "failed";
draft.autofillLoginUrl = undefined;
});
return;
}
}
this.store.update(draft => { draft.autofill = 'failed' })
this.store.update((draft) => {
draft.autofill = "failed";
});
}
/**
@@ -496,34 +523,39 @@ export class BailianCardController {
* 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') {
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 })
await this.saveSettings({ [settingsField]: null });
} catch (_settingsWriteFailure) {
failed = true
failed = true;
}
}
if (state.credentials[key].configured) {
try {
const response = await this.api.credentials.unset({ ref: key })
if (!response.result.ok) failed = true
const response = await this.api.credentials.unset({ ref: key });
if (!response.result.ok) failed = true;
} catch (_credentialWriteFailure) {
failed = true
failed = true;
}
}
this.store.update(draft => {
draft.clearing = false
draft.failed = failed
draft.drafts[key] = undefined
})
await this.fetchSettings()
await this.read()
this.store.update((draft) => {
draft.clearing = false;
draft.failed = failed;
draft.drafts[key] = undefined;
});
await this.fetchSettings();
await this.read();
}
/**
@@ -536,8 +568,8 @@ export class BailianCardController {
* @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()
if (!(BAILIAN_CARD_REFS as readonly string[]).includes(ref)) return;
void this.read();
}
/**
@@ -547,14 +579,18 @@ export class BailianCardController {
inject(): BailianCardFace {
return {
hooks: { bailianCard: this.store },
edit: (key, text) => { this.edit(key, text) },
edit: (key, text) => {
this.edit(key, text);
},
save: () => this.save(),
discard: () => { this.discard() },
discard: () => {
this.discard();
},
clearDefaultAgent: (key) => this.clearDefaultAgent(key),
autofill: () => this.autofill(),
refreshServices: () => this.refreshServices(),
selectDefaultAgent: (scene, agentId) => this.selectDefaultAgent(scene, agentId),
}
};
}
/**
@@ -563,14 +599,14 @@ export class BailianCardController {
* 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' },
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()
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
/**
@@ -580,14 +616,14 @@ export class BailianCardController {
* @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' },
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}`)
const body = (await resp.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${resp.status}`);
}
}
@@ -597,23 +633,23 @@ export class BailianCardController {
* and a write still reaches the Host.
*/
private async read(): Promise<void> {
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
let response: Awaited<ReturnType<IApiClient["credentials"]["describe"]>>;
try {
response = await this.api.credentials.describe({ refs: [...BAILIAN_CARD_REFS] })
response = await this.api.credentials.describe({ refs: [...BAILIAN_CARD_REFS] });
} catch (_credentialReadFailure) {
return
return;
}
if (!response.result.ok) return
const view = response.result.value.credentials
this.store.update(draft => {
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,
}
};
}
})
});
}
}
@@ -3,7 +3,7 @@
* `*.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
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
@@ -8,63 +8,71 @@
* and never echoes.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
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'
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'
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' {
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
"tool-bailian-kb": BailianKbLocaleKey;
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'tool-bailian-kb'
const NS = "tool-bailian-kb";
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote']
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')
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)
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)
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.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))
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,122 @@
import { describe, expect, it } from "vite-plus/test";
import { CATALOG_ENTRY_LIMIT, 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 nothing worth injecting", () => {
// No services at all: the tool descriptions alone keep the tools usable.
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("…");
});
});
@@ -0,0 +1,196 @@
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>;
}
/** 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" },
];
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 nothing when the cache holds no services", async () => {
const h = harness({ entries: [] });
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");
});
});
@@ -1,12 +1,17 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"declaration": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": 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"]
}
@@ -1,6 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"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"],
@@ -7,50 +7,54 @@
* 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'
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 = '@ali/bailian-kb-dsh'
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',
]
"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'
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]
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'
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 lib/web subdir: the tsc node half owns
// lib/ directly, and a shared outDir would clobber lib/client.js (the KbClient
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
// lib/web/client.js; the host serves it at /plugins/<id>/client.js via
// 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: 'lib/web',
format: 'cjs',
platform: 'browser',
outDir: "dist/web",
format: "cjs",
platform: "browser",
dts: false,
sourcemap: true,
clean: false,
@@ -60,9 +64,9 @@ export default defineConfig({
// 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' }),
"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: [
{
@@ -72,58 +76,58 @@ export default defineConfig({
// 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',
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
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)',
)
`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',
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
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)
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)
this.addWatchFile(fileId);
const source = await readFile(fileId);
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: '[hash]_[local]' },
cssModules: { pattern: "[hash]_[local]" },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
});
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\');',
"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);',
'}',
" tag.dataset.pluginCss = tagId;",
" tag.textContent = css;",
" document.head.appendChild(tag);",
"}",
`export default ${JSON.stringify(classMap)};`,
].join('\n')
].join("\n");
},
},
],
outputOptions: {
entryFileNames: 'client.js',
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;',
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: {},
});
-5
View File
@@ -1,5 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { include: ['packages/*/tests/**/*.test.ts'] },
})
+3569
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -16,6 +16,13 @@ export const PACKAGES = [
export const KSCLI_PACKAGE = { key: "kscli", dir: "packages/kscli", name: "knowledge-studio-cli" };
export const ALL_PACKAGES = [...PACKAGES, KSCLI_PACKAGE];
// Deliberately absent from every list above: packages/bailian-kb-dsh (bailian-kb-dsh).
// It is a dsh plugin — a downstream host adapter, not part of the bl release closure:
// its version tracks the dsh rc cadence instead of the locked core/runtime/commands/cli/kscli
// version, and it ships through .github/workflows/publish-kb-dsh.yml. So it is exempt from
// loadAndValidatePackages' version-consistency check and from packAndScan. Not an oversight;
// see docs/agents/dsh-plugin.md before adding it here.
export function readJson(path) {
return JSON.parse(readFileSync(path, "utf-8"));
}
+25
View File
@@ -87,6 +87,31 @@ export default defineConfig({
"no-restricted-properties": restrictCommandCapabilities("exportApiCredential"),
},
},
{
// dsh 插件(下游宿主适配层)的 `_` 前缀是“有意不用”的声明:被忽略的 catch
// 绑定用 `_原因` 命名解释为何可以忽略,测试 mock 的 `_path` 类参数则是为了
// 钉住被 mock 函数的签名(删了就不再约束调用形状)。
files: ["packages/bailian-kb-dsh/**/*.{ts,tsx}"],
rules: {
"no-unused-vars": ["error", { caughtErrorsIgnorePattern: "^_", argsIgnorePattern: "^_" }],
},
},
{
// web 半会被 tsdown 打成浏览器 bundle,由 host 的 frozen module table 解析 require:
// node 内置模块和本仓 CLI 包在那里根本不存在,import 到就是运行时必崩。
// @deepseek-ai/* 的 platform module 白名单仍由 tsdown 的 dsh-client-bundle-purity
// 插件在构建期把关(名单即 CLIENT_EXTERNALS,见 tsdown.config.ts
files: ["packages/bailian-kb-dsh/src/web/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: ["bailian-cli-core", "bailian-cli-runtime", "bailian-cli-commands"],
patterns: ["node:*"],
},
],
},
},
],
},
run: {