mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -14,6 +14,7 @@ dist-bin
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
*.tsbuildinfo
|
||||
|
||||
*.local
|
||||
|
||||
|
||||
@@ -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 发布到 npm(CI 驱动) | [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 发布到 npm(CI 驱动) | [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 不翻译服务端错误
|
||||
|
||||
@@ -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 import(type-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)
|
||||
+44
-44
@@ -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 | 检索服务 id(scene=search 的服务);检索范围与策略(多库加权、路由、重排)由服务端配置决定 |
|
||||
| `top_k` | integer | 否,默认 5 | 返回片段数上限。服务端 search API 无此参数(条数由检索服务配置决定),插件对按 score 降序的 `nodes` 做客户端截断;description 写明该语义 |
|
||||
| `images` | string[] | 否 | 多模态检索的图片 URL |
|
||||
| 参数 | 类型 | 必填 | 语义 |
|
||||
| ---------- | -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `query` | string | 是 | 检索文本 |
|
||||
| `agent_id` | string | 见 §5 | 检索服务 id(scene=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 | 问答服务 id(scene=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,部署必须可调。默认 300000(5 分钟)。 */
|
||||
chatTimeoutMs: number
|
||||
chatTimeoutMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -169,11 +169,11 @@ interface Config {
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
| 层 | 内容 |
|
||||
|---|---|
|
||||
| 单元测试 | endpoint 拼接、请求体构造(scene 合并、分页内化、`defaultAgentId` 解析)、错误翻译 |
|
||||
| 层 | 内容 |
|
||||
| -------- | --------------------------------------------------------------------------------------------- |
|
||||
| 单元测试 | endpoint 拼接、请求体构造(scene 合并、分页内化、`defaultAgentId` 解析)、错误翻译 |
|
||||
| snapshot | mock HTTP fixture 的可重放 keyless snapshot,macOS/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 过滤,非页码 |
|
||||
+635
-491
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
lib/
|
||||
*.tsbuildinfo
|
||||
@@ -1 +0,0 @@
|
||||
@deepseek-ai:registry=https://registry.npmjs.org
|
||||
@@ -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 CLI(bailian-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 bundle):Config、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` checkout(npm 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 namespace,patch 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 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
|
||||
|
||||
降级:远程浏览器(非 loopback,settings 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? | — | 百炼工作空间 id;API 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?` | chunks(text/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` 回调) | 下一步 |
|
||||
| 调用撞 4xx(agent_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 loop,UI 只有 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` 并在清单里告知。
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -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 CLI(bailian-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 namespace,patch 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 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
|
||||
|
||||
降级:远程浏览器(非 loopback,settings 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? | — | 百炼工作空间 id;API 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?` | chunks(text/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` 回调) | 下一步 |
|
||||
| 调用撞 4xx(agent_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` | 帮助 / 版本 |
|
||||
|
||||
## 鉴权 flag(API 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 Key(Bearer token)鉴权,无需 console 登录态;API key 也可用 `bl auth login --api-key sk-xxx` 存储。
|
||||
- 除知识库外,bl 还有模型调用/应用/用量等其他命令组,不在本 skill 范围,见 `bl --help`。
|
||||
- 默认输出为 text;agent 解析结果时建议显式加 `--output json`。
|
||||
- 分页有两种风格:knowledge/doc/service/chunk 用 `--page-number/--page-size`(page-size 上限 100);category/file 用游标 `--next-token/--max-result`。
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
# `bl knowledge service` — 检索 / 问答服务(agent)
|
||||
|
||||
> 通用鉴权/全局 flag 见 [index.md](index.md)。以下 Flags 只列命令专属项。
|
||||
> 服务状态机:create → draft(beta 草稿,用 `--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: draft,version: 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 account’s 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 account’s 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"]
|
||||
}
|
||||
Generated
-3926
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
packages:
|
||||
- packages/*
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
+19
-19
@@ -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` |
|
||||
|
||||
## 命令参考(权威)
|
||||
|
||||
+22
-22
@@ -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
|
||||
+2
-2
@@ -67,8 +67,8 @@ bl auth status
|
||||
Usage: bl update [--to <version>]
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
| --- | --- |
|
||||
| Flag | 说明 |
|
||||
| ---------------- | ------------------------ |
|
||||
| `--to <version>` | 安装该精确版本而非最新版 |
|
||||
|
||||
```bash
|
||||
+27
-27
@@ -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:
|
||||
|
||||
+30
-30
@@ -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` | 帮助 / 版本 |
|
||||
|
||||
## 鉴权 flag(API 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 Key(Bearer token)鉴权,无需 console 登录态;API key 也可用 `bl auth login --api-key sk-xxx` 存储。
|
||||
- 除知识库外,bl 还有模型调用/应用/用量等其他命令组,不在本 skill 范围,见 `bl --help`。
|
||||
- 默认输出为 text;agent 解析结果时建议显式加 `--output json`。
|
||||
- 分页有两种风格:knowledge/doc/service/chunk 用 `--page-number/--page-size`(page-size 上限 100);category/file 用游标 `--next-token/--max-result`。
|
||||
+23
-23
@@ -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:
|
||||
|
||||
+11
-11
@@ -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 → draft(beta 草稿,用 `--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: draft,version: 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
|
||||
```
|
||||
+33
-33
@@ -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;
|
||||
}
|
||||
+19
-13
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+49
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
+155
-130
@@ -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" };
|
||||
}
|
||||
+5
-5
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+84
-82
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-61
@@ -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 },
|
||||
);
|
||||
}
|
||||
+40
-40
@@ -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 };
|
||||
}
|
||||
+58
-53
@@ -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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
+302
-266
@@ -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
-3
@@ -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;
|
||||
}
|
||||
+36
-28
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 account’s 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 account’s 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);
|
||||
});
|
||||
});
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+8
-3
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
+7
-1
@@ -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"],
|
||||
+60
-56
@@ -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;",
|
||||
},
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite-plus";
|
||||
|
||||
// 本包不用 `vp pack`:产物是 tsc 出的 node 半(dist/)+ tsdown 出的浏览器 bundle
|
||||
// (dist/web/client.js,带 __ModuleLoader__ banner 与 lightningcss CSS Modules 内联),
|
||||
// 由包内 `build` script 负责。这里只接管 lint / fmt / test。
|
||||
export default defineConfig({
|
||||
lint: {
|
||||
options: {
|
||||
typeAware: true,
|
||||
typeCheck: true,
|
||||
},
|
||||
},
|
||||
fmt: {},
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ['packages/*/tests/**/*.test.ts'] },
|
||||
})
|
||||
Generated
+3569
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user