mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(knowledge): 新增知识库管理及用户旅程端到端测试支持
- 增加test:journey脚本,覆盖知识库跨命令全链路用户旅程测试 - 在文档中新增Journey E2E章节,详细说明用户旅程测试定位及断言机制 - 完善commands模块,新增知识库相关命令包括知识库列表、信息、创建、更新、删除 - 新增知识库文档相关命令,如文档列表、状态、上传、删除、打标签及OSS导入 - 添加知识服务管理命令,支持列表、创建、更新、部署、删除及复制 - 支持知识块增删查改命令,完善知识点的灵活操作能力 - 实现数据中心分类管理命令,支持分类增删查操作 - 优化knowledge chat命令,增加workspace-id统一解析及agent-version版本控制 - 重构与知识库相关命令的导出与注册,完善CLI整体能力覆盖 - 新增命令详尽的帮助文档,包含参数说明、使用示例及错误边界 - 实现批量删除知识块的自动分批处理逻辑,易于操作大规模数据 - 添加必要的输入校验与安全提示,确保操作安全且符合规范
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) |
|
||||
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 |
|
||||
| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated;见 `journeys/README.md` |
|
||||
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
|
||||
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) |
|
||||
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
|
||||
@@ -27,7 +28,7 @@
|
||||
|
||||
### commands E2E
|
||||
|
||||
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
|
||||
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`;knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里)
|
||||
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射)
|
||||
- fixtures:`packages/commands/tests/e2e/fixtures/`
|
||||
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map)
|
||||
@@ -78,6 +79,14 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
|
||||
4. **真实集成**:放在 skip 块**末尾**
|
||||
|
||||
## Journey 层(用户旅程全链路)
|
||||
|
||||
- **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复
|
||||
- **闭环断言**:fixture 埋独特标记词,以“标记词能否被召回”判定回路闭合;硬断言 fail,软断言 `recordSoft` 落报告人工复核
|
||||
- **日志产物**:`createJourneyReporter` 在 `test/output/<session>/` 落盘 `journey-report.md`、分步 stdout/stderr、`resources.json`(未清理资源警示)
|
||||
- **入口**:`pnpm run test:journey`;旅程清单与约定见 [journeys/README.md](../../packages/commands/tests/e2e/knowledge/journeys/README.md)
|
||||
- **新增命令时**:评估是否属于某条旅程的环节,是则纳入对应 journey 并更新 README 映射表
|
||||
|
||||
## 增删命令同步
|
||||
|
||||
- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"bl": "pnpm -F bailian-cli dev",
|
||||
"kscli": "pnpm -F knowledge-studio-cli dev",
|
||||
"test": "vp test",
|
||||
"test:journey": "vp test packages/commands/tests/e2e/knowledge/journeys",
|
||||
"release:check": "node tools/release/check.mjs",
|
||||
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
|
||||
"test:stress": "node packages/cli/tests/stress/run.mjs"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 迭代一设计 · doc 组命令
|
||||
|
||||
> 命令:`doc upload` / `doc list` / `doc status` / `doc delete` / `doc tag` / `doc import-oss`
|
||||
> 公共约定见 [README.md](README.md)。
|
||||
|
||||
## doc upload — 上传本地文件入库(编排命令)
|
||||
|
||||
**说明**:本迭代最复杂命令。把"本地文件 → 数据中心 →(可选)导入知识库"封装为一条命令,替代构建期最高频的控制台操作(S2.2 痛点:高)。对标竞品 add-file。
|
||||
|
||||
**编排四步**:
|
||||
|
||||
| 步 | API | 输入 | 输出 |
|
||||
| ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| 1 申请租约 | `POST /api/v1/connector/dash/applyFileUploadLease` | `category`(类目ID) + `fileName` + `sizeBytes`(字符串!) + `contentMd5`(Base64) | `leaseId` + `param.url/method/headers` |
|
||||
| 2 OSS 上传 | `PUT {param.url}` | 文件二进制 + `param.headers`(含 `x-bailian-extra`、`Content-Type`) | HTTP 200 |
|
||||
| 3 注册文件 | `POST /api/v1/connector/dash/addFile` | `leaseId` + `category` + `parser: "AUTO_SELECT"` + `tags?` | `fileId` |
|
||||
| 4 导入(可选,传 `--index-id` 时) | `POST /api/v1/indices/rag/index/job/create` | `indexId` + `dataSource: { sourceType: "DATA_CENTER_FILE", fileIds }` | `ingestionId` |
|
||||
|
||||
坑位(实现注释必须标注):
|
||||
|
||||
- `sizeBytes` 必须字符串;`contentMd5` = `crypto.createHash("md5").update(buf).digest("base64")`
|
||||
- 租约/注册的类目参数名是 `category`,不是 `categoryId`
|
||||
- 第 4 步 body 是嵌套 `dataSource: { sourceType, fileIds }`(实测;公开文档的平铺 `documentIds` 会报 `Index.InvalidParameter`)
|
||||
- **第 4 步必须显式传 `sourceType`,不传会导入整个数据中心(API 文档明示的默认行为)**
|
||||
- 步骤 2 走 OSS 域名不走 DashScope 网关,用原生 fetch 而非 ctx.client(无 Bearer 头);失败归类 NETWORK
|
||||
|
||||
**Flags**:
|
||||
|
||||
| flag | 类型 | 必填 | 说明 |
|
||||
| -------------------------------------------------- | ------ | ---- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--file <path>` | array | 是 | 本地文件路径,可重复;扩展名与大小按产品支持范围预校验(见下方格式白名单) |
|
||||
| `--index-id <id>` | string | 否 | 注册后立即导入该知识库(触发第 4 步,多文件合并为一个 job) |
|
||||
| `--category-id <id>` | string | 否 | 目标类目;缺省自动解析默认类目(listCategory 取 `isDefault: true`),解析失败报 GENERAL + hint 显式传 `--category-id` |
|
||||
| `--tag <text>` | array | 否 | addFile tags,可重复 |
|
||||
| `--wait` / `--poll-interval <s>` / `--timeout <s>` | — | 否 | 与 `--index-id` 联用,轮询 job status 至终态 |
|
||||
|
||||
**validate**:`--wait` 无 `--index-id` → USAGE;文件不存在/不可读 → GENERAL + errno hint(沿用错误边界规范)。
|
||||
|
||||
**格式白名单与大小预校验**(依据 data/documents.md「支持的格式」,读文件前拦截,避免白传 OSS):
|
||||
|
||||
| 类型 | 扩展名 | 硬限(超限 USAGE) |
|
||||
| ------ | -------------------------- | ----------------------------------------------------- |
|
||||
| 文档 | .doc .docx .ppt .pptx .pdf | 150 MB |
|
||||
| 表格 | .xls .xlsx | 10 MB(产品为“建议值”,超限降级为 stderr 警告不拦截) |
|
||||
| 图片 | .png .jpg .jpeg .bmp .gif | 20 MB(尺寸约束不做客户端校验,留服务端) |
|
||||
| 纯文本 | .md .txt .html | 10 MB(同表格,警告不拦截) |
|
||||
|
||||
- 扩展名不在白名单 → USAGE,错误信息列出支持格式;白名单常量独立导出便于后续随产品更新
|
||||
- 开放问题:create-kb.md 提及 .csv 但 documents.md 格式表未列——文档口径不一致,实现前向产品确认;确认前 .csv 暂入白名单(服务端拒绝会透传)
|
||||
|
||||
**输出**:
|
||||
|
||||
- text:每文件一行 `<fileName> <fileId> registered`;有导入时追加 `job: <ingestionId>`;--wait 结束追加终态
|
||||
- json:`{ files: [{path, fileId}], index_id?, ingestion_id?, final_status? }`(编排命令无单一响应可透传,输出自定义稳定结构)
|
||||
- quiet:仅 fileId 每行一个
|
||||
|
||||
**实现方案**:
|
||||
|
||||
- 文件 `doc-upload.ts`;多文件串行执行 1-3 步(首版不并发,避免 OSS 限流复杂化),全部注册成功后合并执行第 4 步
|
||||
- 部分失败语义:任一文件步骤 1-3 失败即中止并报错,已成功的 fileId 列入错误 hint(幂等重传代价低)
|
||||
- 默认类目解析结果进程内缓存(多文件只查一次)
|
||||
- dry-run:不读文件内容(size/md5 以占位符表示),输出四步编排计划 `{ steps: [{step, endpoint, request}] }`
|
||||
|
||||
**测试方案**:
|
||||
|
||||
- help / 缺 `--file` exitCode 2 / `--wait` 无 `--index-id` exitCode 2
|
||||
- 文件不存在 → 非零退出 + ENOENT hint;`.zip` 扩展名 → USAGE 列出支持格式
|
||||
- dry-run:断言 steps 长度(带/不带 --index-id 为 4/3)、lease 请求 `sizeBytes` 为字符串类型、job 请求含 `sourceType: "DATA_CENTER_FILE"`
|
||||
- live:上传 1KB 临时 md 文件 → 断言 fileId 前缀 `file_` → afterAll doc delete + 数据中心 deleteFile 清理
|
||||
|
||||
## doc list — 查询知识库文档列表
|
||||
|
||||
**说明**:列出库内文档及解析/索引状态,含 FAILED 发现(S2.3 / S5.2)。
|
||||
|
||||
**API**:`GET /api/v1/indices/rag/index/files`,query string:`index_id` + `page_num`(注意本接口是 page_num)+ `page_size`(默认 10,最大 100)。
|
||||
|
||||
**Flags**:`--index-id` 必填;`--page-number` / `--page-size`。
|
||||
|
||||
**输出**:
|
||||
|
||||
- text:每行 `doc_id status doc_name doc_type size`;status=FAILED 行红色高亮(TTY);尾行 `total: N`
|
||||
- json 透传;quiet 仅 doc_id
|
||||
|
||||
**实现/测试**:单 API 直映射(`doc-list.ts`);dry-run 断言 query 参数名为 `page_num`;live 断言 rows 结构与 doc_id 前缀。
|
||||
|
||||
## doc status — 查询导入任务状态
|
||||
|
||||
**说明**:查导入任务进度,`--wait` 阻塞至终态供脚本串行(S2.3 痛点:高,L3 验收:FAILED 时非零 exit code)。
|
||||
|
||||
**API**:`GET /api/v1/indices/rag/index_job/status`,query string:`index_id` + `job_id`(**双必填,仅传其一服务端返回 SystemError,客户端前置双校验拦截**)+ 分页参数。
|
||||
|
||||
**Flags**:
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
| -------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- |
|
||||
| `--index-id <id>` | 是 | 知识库 ID |
|
||||
| `--job-id <id>` | 是 | 导入任务 ID(kb create / doc upload 返回的 ingestionId;也见 doc list 的 ingestion_id) |
|
||||
| `--page-number` / `--page-size` | 否 | 任务含大量文档时分页 |
|
||||
| `--wait` / `--poll-interval <s>`(默认 5) / `--timeout <s>`(默认 600) | 否 | 轮询至终态 |
|
||||
|
||||
**行为**:
|
||||
|
||||
- 终态 FINISH → exit 0;FAILED → `BailianError(GENERAL)` 透传服务端 message(含文档级失败明细摘要),exit 1
|
||||
- `--wait` 超时 → TIMEOUT(5)
|
||||
- 已知行为:库无进行中任务时接口可能返回 SystemError——hint 引导 "check ingestion_id via doc list"
|
||||
|
||||
**输出**:text 顶部任务总状态 + 文档级状态列表(FAILED 高亮);json 透传。
|
||||
|
||||
**测试方案**:help / 缺任一必填(两条用例)/ dry-run 断言 query 含两个 id / live:配合 upload 用例拿真实 job 轮询到 FINISH;`--wait --timeout 1` 对慢任务断言 exitCode 5(若不稳定则仅静态覆盖超时路径,live 标记 skip 原因)。
|
||||
|
||||
## doc delete — 删除文档【危险操作】
|
||||
|
||||
**说明**:从知识库删除文档及其全部切片(S5.1 内容更新循环)。
|
||||
|
||||
**API**:`POST /api/v1/indices/rag/index/delete_file`,body `{ index_id, doc_ids }`(snake_case)。响应 `data.deleted[]` 为实际删除列表。
|
||||
|
||||
**Flags**:`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`。
|
||||
|
||||
**实现方案**:`doc-delete.ts`;确认摘要含 index_id + doc_id 列表(≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
|
||||
|
||||
**测试方案**:help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY 无 `--yes` exitCode 2 / live 配合 upload 清理链。
|
||||
|
||||
## doc tag — 批量更新文档标签
|
||||
|
||||
**说明**:批量打标,支撑标签过滤检索(S2.4)。
|
||||
|
||||
**API**:`POST /api/v1/connector/dash/batchUpdateFileTag`。`fileInfos`(1-20 项,每项 `fileId` + `tags`,单标签 ≤32 字符、单文件 ≤100 个、总长 ≤700)+ `updateMode`(OVERWRITE/APPEND)。
|
||||
|
||||
**Flags**:
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
| --------------- | ---- | ------------------------------------------------------------------------- |
|
||||
| `--doc-id <id>` | 是 | 可重复,1-20 个(客户端预校验),映射 fileInfos[].fileId |
|
||||
| `--tag <text>` | 是 | 可重复,应用到所有 `--doc-id`(首版同一组标签批量打;异构标签用多次调用) |
|
||||
| `--mode <m>` | 否 | choices: `overwrite`/`append`,默认 `append`(追加比覆盖安全,作为缺省) |
|
||||
|
||||
**实现/测试**:`doc-tag.ts` 单 API 直映射;客户端预校验标签长度约束(USAGE 前置拦截);dry-run 断言 `updateMode: "APPEND"` 大写映射与 fileInfos 结构;live 打标后 listFile/describeFile 验证回读。
|
||||
|
||||
## doc import-oss — 从授权 OSS 批量导入
|
||||
|
||||
**说明**:从已 SLR 授权的 OSS Bucket 批量导入数据中心(大客户批量场景)。
|
||||
|
||||
**API**:`POST /api/v1/connector/dash/addFilesFromAuthorizedOss`。必填 `categoryId/categoryType/ossBucket/ossRegionId/fileDetails`(1-10 项,每项 `fileName+ossKey`)。返回 `data.fileIds`。
|
||||
|
||||
**Flags**:
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
| -------------------- | ---- | -------------------------------------------- |
|
||||
| `--bucket <name>` | 是 | 映射 ossBucket |
|
||||
| `--region <id>` | 是 | 映射 ossRegionId(如 cn-beijing) |
|
||||
| `--oss-key <key>` | 是 | 可重复,1-10 个;fileName 取 key 的 basename |
|
||||
| `--category-id <id>` | 否 | 缺省走默认类目解析(复用 upload 的解析函数) |
|
||||
| `--tag <text>` | 否 | 可重复,≤10 |
|
||||
| `--overwrite` | 否 | switch,映射 overWriteFileByOssKey |
|
||||
|
||||
固定值:`categoryType: "UNSTRUCTURED"`;`parser` 不暴露(默认 AUTO_SELECT,审慎原则——DASH_QWEN_VL_PARSER 等需配 parserConfig,使用方式未验证)。
|
||||
|
||||
**错误边界**:SLR 未授权的服务端权限错误原样透传,hint 附 RAM 控制台确认 `AliyunServiceRoleForBailian` 的指引(该指引来自 API 文档 Note,属可权威解释范围)。
|
||||
|
||||
**实现/测试**:`doc-import-oss.ts` 单 API 直映射;dry-run 断言 fileDetails 结构与 fileName 派生逻辑;live 依赖 OSS 授权环境,gating 追加 `BAILIAN_E2E_OSS_BUCKET` 环境变量,无则 skip。
|
||||
@@ -33,6 +33,37 @@ import {
|
||||
knowledgeRetrieve,
|
||||
knowledgeSearch,
|
||||
knowledgeChat,
|
||||
knowledgeKbList,
|
||||
knowledgeKbInfo,
|
||||
knowledgeDocList,
|
||||
knowledgeDocStatus,
|
||||
knowledgeDocUpload,
|
||||
knowledgeKbCreate,
|
||||
knowledgeKbUpdate,
|
||||
knowledgeKbDelete,
|
||||
knowledgeDocDelete,
|
||||
knowledgeDocTag,
|
||||
knowledgeServiceList,
|
||||
knowledgeServiceGet,
|
||||
knowledgeServiceCreate,
|
||||
knowledgeServiceUpdate,
|
||||
knowledgeServiceDeploy,
|
||||
knowledgeServiceDelete,
|
||||
knowledgeServiceCopy,
|
||||
knowledgeChunkAdd,
|
||||
knowledgeChunkList,
|
||||
knowledgeChunkUpdate,
|
||||
knowledgeChunkDelete,
|
||||
knowledgeKbStats,
|
||||
knowledgeCategoryList,
|
||||
knowledgeCategoryAdd,
|
||||
knowledgeCategoryDelete,
|
||||
knowledgeFileList,
|
||||
knowledgeFileGet,
|
||||
knowledgeFileDelete,
|
||||
knowledgeCollectionCreate,
|
||||
knowledgeCollectionGet,
|
||||
knowledgeDocImportOss,
|
||||
mcpCall,
|
||||
mcpList,
|
||||
mcpTools,
|
||||
@@ -151,6 +182,39 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"knowledge retrieve": knowledgeRetrieve,
|
||||
"knowledge search": knowledgeSearch,
|
||||
"knowledge chat": knowledgeChat,
|
||||
"knowledge list": knowledgeKbList,
|
||||
"knowledge info": knowledgeKbInfo,
|
||||
"knowledge create": knowledgeKbCreate,
|
||||
"knowledge update": knowledgeKbUpdate,
|
||||
"knowledge delete": knowledgeKbDelete,
|
||||
"knowledge doc list": knowledgeDocList,
|
||||
"knowledge doc status": knowledgeDocStatus,
|
||||
"knowledge doc upload": knowledgeDocUpload,
|
||||
"knowledge doc delete": knowledgeDocDelete,
|
||||
"knowledge doc tag": knowledgeDocTag,
|
||||
"knowledge service list": knowledgeServiceList,
|
||||
"knowledge service get": knowledgeServiceGet,
|
||||
"knowledge service create": knowledgeServiceCreate,
|
||||
"knowledge service update": knowledgeServiceUpdate,
|
||||
"knowledge service deploy": knowledgeServiceDeploy,
|
||||
"knowledge service delete": knowledgeServiceDelete,
|
||||
"knowledge service copy": knowledgeServiceCopy,
|
||||
"knowledge chunk add": knowledgeChunkAdd,
|
||||
"knowledge chunk list": knowledgeChunkList,
|
||||
"knowledge chunk update": knowledgeChunkUpdate,
|
||||
"knowledge chunk delete": knowledgeChunkDelete,
|
||||
"knowledge stats": knowledgeKbStats,
|
||||
"knowledge doc import-oss": knowledgeDocImportOss,
|
||||
// Data-center commands live under knowledge (no separate connector namespace);
|
||||
// the user-facing term for connector is "collection".
|
||||
"knowledge collection create": knowledgeCollectionCreate,
|
||||
"knowledge collection get": knowledgeCollectionGet,
|
||||
"knowledge category list": knowledgeCategoryList,
|
||||
"knowledge category add": knowledgeCategoryAdd,
|
||||
"knowledge category delete": knowledgeCategoryDelete,
|
||||
"knowledge file list": knowledgeFileList,
|
||||
"knowledge file get": knowledgeFileGet,
|
||||
"knowledge file delete": knowledgeFileDelete,
|
||||
"mcp call": mcpCall,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAddCategoryResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CATEGORY_ADD_FLAGS = {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Category name (1-20 chars)",
|
||||
required: true,
|
||||
},
|
||||
parentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Create as a sub-category of this category",
|
||||
},
|
||||
collectionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Create under this collection (defaults to the platform collection)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a data-center category",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--name <text> [flags]",
|
||||
flags: CATEGORY_ADD_FLAGS,
|
||||
notes: ["Use categories to organize data-center files by business domain."],
|
||||
exampleArgs: ["--name product-docs --workspace-id ws-xxx", "--name sub --parent-id cate-xxx"],
|
||||
validate(flags) {
|
||||
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// categoryType fixed to UNSTRUCTURED (the only valid value for knowledge-base creation today)
|
||||
const body = {
|
||||
categoryName: flags.name,
|
||||
categoryType: "UNSTRUCTURED",
|
||||
...(flags.parentId ? { parentCategoryId: flags.parentId } : {}),
|
||||
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addCategory);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAddCategoryResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const categoryId = response.data?.categoryId;
|
||||
if (settings.quiet) {
|
||||
emitBare(categoryId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`created: ${categoryId ?? "-"} (${flags.name})`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagConnectorResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CATEGORY_DELETE_FLAGS = {
|
||||
categoryId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Category ID to delete",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a data-center category",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--category-id <id> [flags]",
|
||||
flags: CATEGORY_DELETE_FLAGS,
|
||||
notes: [
|
||||
"Behavior for categories containing files or sub-categories is server-defined — the server error is passed through as-is.",
|
||||
],
|
||||
exampleArgs: ["--category-id cate-xxx --workspace-id ws-xxx", "--category-id cate-xxx --yes"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = { categoryId: flags.categoryId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteCategory);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmDangerousAction(
|
||||
`Delete category ${flags.categoryId}\nThis cannot be undone.`,
|
||||
flags.yes ?? false,
|
||||
);
|
||||
|
||||
const response = await ctx.client.requestJson<
|
||||
RagConnectorResponse<Record<string, unknown> | undefined>
|
||||
>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`deleted: ${flags.categoryId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagListCategoryResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CATEGORY_LIST_FLAGS = {
|
||||
collectionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Filter by collection ID",
|
||||
},
|
||||
parentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "List sub-categories of this category",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Filter by category name (exact match, unlike the knowledge base list)",
|
||||
},
|
||||
nextToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Cursor for the next page (from previous output)",
|
||||
},
|
||||
maxResult: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Items per page (default: 20)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List data-center categories",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[flags]",
|
||||
flags: CATEGORY_LIST_FLAGS,
|
||||
notes: [
|
||||
"Categories marked [default] are where files land when no category is specified.",
|
||||
"Pagination is cursor-based: reuse the printed next token to continue.",
|
||||
],
|
||||
exampleArgs: ["--workspace-id ws-xxx", "--name my-category", "--next-token <token>"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// type fixed to UNSTRUCTURED, not exposed as a flag (the only valid value today); note: maxResult is singular
|
||||
const body = {
|
||||
type: "UNSTRUCTURED",
|
||||
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
|
||||
...(flags.parentId ? { parentId: flags.parentId } : {}),
|
||||
...(flags.name ? { categoryName: flags.name } : {}),
|
||||
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
|
||||
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listCategory);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagListCategoryResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const categories = response.data?.categoryList ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const category of categories) emitBare(category.categoryId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
if (categories.length === 0) {
|
||||
emitBare("No categories found.");
|
||||
} else {
|
||||
for (const category of categories) {
|
||||
const defaultMark = category.isDefault ? " [default]" : "";
|
||||
emitBare(truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`));
|
||||
}
|
||||
}
|
||||
const nextToken = response.data?.nextToken;
|
||||
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type KnowledgeChatStreamChunk,
|
||||
} from "bailian-cli-core";
|
||||
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CHAT_FLAGS = {
|
||||
message: {
|
||||
@@ -27,11 +28,15 @@ const CHAT_FLAGS = {
|
||||
description: "Q&A service ID (find in console knowledge Q&A page)",
|
||||
required: true,
|
||||
},
|
||||
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
|
||||
workspaceId: {
|
||||
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
|
||||
// flag here (the console credential scope does not apply).
|
||||
...WORKSPACE_FLAG,
|
||||
// Named to avoid the runtime-reserved global --version flag
|
||||
agentVersion: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
|
||||
valueHint: "<version>",
|
||||
description:
|
||||
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
|
||||
},
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -146,6 +151,7 @@ export default defineCommand({
|
||||
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
|
||||
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
|
||||
'Multi-turn: use --message "user:..." and --message "assistant:..." to pass conversation history.',
|
||||
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
|
||||
],
|
||||
exampleArgs: [
|
||||
'--message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
|
||||
@@ -168,14 +174,7 @@ export default defineCommand({
|
||||
messages = [{ role: "user", content: "" }];
|
||||
}
|
||||
|
||||
const workspaceId = flags.workspaceId || settings.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Workspace ID is required.",
|
||||
ExitCode.USAGE,
|
||||
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
|
||||
);
|
||||
}
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
// API only supports SSE; streamOutput controls whether to print tokens in real-time
|
||||
@@ -199,6 +198,9 @@ export default defineCommand({
|
||||
parameters: {
|
||||
agent_options: {
|
||||
agent_id: flags.agentId,
|
||||
// Omitted flag → field not sent (default behavior unchanged); the value is
|
||||
// not validated — the set of versions is server-side state
|
||||
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
|
||||
},
|
||||
},
|
||||
stream: true,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
type RagMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
import { readUtf8TextFile } from "./upload-support.ts";
|
||||
|
||||
const CHUNK_ADD_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
docId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Attach the chunk to this document (document-type knowledge bases)",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Chunk body text, up to 6000 chars (document-type); alternative to --content-file",
|
||||
},
|
||||
contentFile: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Read chunk body from a UTF-8 plain text file (.md/.txt etc.)",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Chunk title, up to 50 chars (document-type)",
|
||||
},
|
||||
imageUrl: {
|
||||
type: "array",
|
||||
valueHint: "<url>",
|
||||
description: "Chunk image URL (repeatable, up to 10; document-type)",
|
||||
},
|
||||
field: {
|
||||
type: "array",
|
||||
valueHint: "<key=value>",
|
||||
description:
|
||||
"Arbitrary field entry (repeatable) for table/image knowledge bases where keys are Excel column headers; mutually exclusive with content/title/image flags",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Parse --field key=value: split on the first =, value may contain = */
|
||||
export function parseFieldEntries(entries: string[]): Record<string, string> {
|
||||
const field: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const separatorIndex = entry.indexOf("=");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new BailianError(`--field must be key=value, got: ${entry}`, ExitCode.USAGE);
|
||||
}
|
||||
field[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Add a chunk directly to a knowledge base",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> (--content <text> | --field <k=v>) [flags]",
|
||||
flags: CHUNK_ADD_FLAGS,
|
||||
notes: [
|
||||
"Document / table / image knowledge bases are supported; audio-video ones are not.",
|
||||
"The API is idempotent but rate-limited to 10 calls per second — throttle batch scripts.",
|
||||
"The response carries no chunk id; list chunks afterwards to find the new one.",
|
||||
"For table/image knowledge bases use --field with Excel column headers as keys; values are passed through as strings.",
|
||||
],
|
||||
exampleArgs: [
|
||||
'--index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx',
|
||||
"--index-id idx-xxx --field 列A=v1 --field 列B=v2",
|
||||
],
|
||||
validate(flags) {
|
||||
const hasConvenience =
|
||||
flags.content !== undefined ||
|
||||
flags.contentFile !== undefined ||
|
||||
flags.title !== undefined ||
|
||||
!!flags.imageUrl?.length;
|
||||
const hasField = !!flags.field?.length;
|
||||
if (hasConvenience && hasField) {
|
||||
return "--field is mutually exclusive with --content/--content-file/--title/--image-url";
|
||||
}
|
||||
if (!hasConvenience && !hasField) {
|
||||
return "Provide chunk content via --content/--content-file or --field entries";
|
||||
}
|
||||
if (flags.content !== undefined && flags.contentFile !== undefined) {
|
||||
return "Use either --content or --content-file, not both";
|
||||
}
|
||||
if (flags.content !== undefined && flags.content.length > 6000) {
|
||||
return "--content must be at most 6000 characters";
|
||||
}
|
||||
if (flags.title !== undefined && flags.title.length > 50) {
|
||||
return "--title must be at most 50 characters";
|
||||
}
|
||||
if (flags.imageUrl !== undefined && flags.imageUrl.length > 10) {
|
||||
return "--image-url accepts at most 10 entries";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// dry-run also reads the file and parses --field (rehearsal semantics)
|
||||
let field: Record<string, unknown>;
|
||||
if (flags.field?.length) {
|
||||
field = parseFieldEntries(flags.field);
|
||||
} else {
|
||||
const content =
|
||||
flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content;
|
||||
if (typeof content === "string" && content.length > 6000) {
|
||||
throw new BailianError("Chunk content must be at most 6000 characters", ExitCode.USAGE);
|
||||
}
|
||||
field = {
|
||||
...(content !== undefined ? { content } : {}),
|
||||
...(flags.title !== undefined ? { title: flags.title } : {}),
|
||||
...(flags.imageUrl?.length ? { image_urls: flags.imageUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const body = {
|
||||
pipelineId: flags.indexId,
|
||||
...(flags.docId ? { dataId: flags.docId } : {}),
|
||||
field,
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkCreate);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
// The response carries no chunk_id — quiet mode exits 0 silently on success
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`chunk created (pipeline: ${flags.indexId})`);
|
||||
emitBare("List chunks to find the new chunk id.");
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
type FlagsDef,
|
||||
type RagMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CHUNK_DELETE_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
chunkId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description: "Chunk ID to delete (repeatable; batches of 10 are sent automatically)",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** The server caps each request at 10 chunk ids — the client batches automatically (bulk delete is where the CLI beats the console) */
|
||||
export function splitIntoBatches(chunkIds: string[], batchSize = 10): string[][] {
|
||||
const batches: string[][] = [];
|
||||
for (let batchStart = 0; batchStart < chunkIds.length; batchStart += batchSize) {
|
||||
batches.push(chunkIds.slice(batchStart, batchStart + batchSize));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete chunks from a knowledge base (irreversible)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> --chunk-id <id> [flags]",
|
||||
flags: CHUNK_DELETE_FLAGS,
|
||||
notes: [
|
||||
"The server accepts at most 10 ids per call; larger sets are split into sequential batches automatically.",
|
||||
"If a batch fails, the operation stops and already-deleted batches are listed in the error.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --chunk-id chunk-a --yes",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const batches = splitIntoBatches(flags.chunkId);
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkDelete);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
endpoint,
|
||||
batches: batches.map((batchIds) => ({
|
||||
request: { pipelineId: flags.indexId, chunkIds: batchIds },
|
||||
})),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmDangerousAction(
|
||||
`Delete ${flags.chunkId.length} chunk(s) from knowledge base ${flags.indexId} in ${batches.length} batch(es).\nChunks are permanently removed. This cannot be undone.`,
|
||||
flags.yes ?? false,
|
||||
);
|
||||
|
||||
// Sequential batches; any batch failure aborts, listing already-deleted batches in the error
|
||||
let deletedCount = 0;
|
||||
for (const batchIds of batches) {
|
||||
try {
|
||||
await ctx.client.requestJson<RagMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body: { pipelineId: flags.indexId, chunkIds: batchIds },
|
||||
});
|
||||
deletedCount += batchIds.length;
|
||||
} catch (error) {
|
||||
if (deletedCount > 0 && error instanceof BailianError && !error.hint) {
|
||||
throw new BailianError(
|
||||
error.message,
|
||||
error.exitCode,
|
||||
`${deletedCount} chunk(s) in earlier batches were already deleted.`,
|
||||
{ cause: error, api: error.api, rawResponse: error.rawResponse },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`);
|
||||
return;
|
||||
}
|
||||
emitResult({ deleted_count: deletedCount, batches: batches.length }, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagChunkListResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const CHUNK_LIST_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
docId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Only show chunks belonging to this document",
|
||||
},
|
||||
...PAGE_FLAGS,
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List chunks in a knowledge base with content and status",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: CHUNK_LIST_FLAGS,
|
||||
notes: [
|
||||
"Use metadata._id as the chunk id and metadata.doc_id as the document id in chunk update/delete commands.",
|
||||
"Page size defaults to 20 (server default), max 100.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --doc-id file-xxx --page-size 50",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
|
||||
return "--page-size must be between 1 and 100";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Gotcha: this endpoint's pagination keys are pageNum/pageSize (camelCase, in the body)
|
||||
const body = {
|
||||
indexId: flags.indexId,
|
||||
pageNum: flags.pageNumber ?? 1,
|
||||
pageSize: flags.pageSize ?? 20,
|
||||
...(flags.docId ? { docId: flags.docId } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkList);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagChunkListResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const nodes = response.data?.nodes ?? [];
|
||||
if (settings.quiet) {
|
||||
// chunk ids only, for piping into chunk update/delete
|
||||
for (const node of nodes) emitBare(node.metadata?._id ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
if (nodes.length === 0) {
|
||||
emitBare("No chunks found.");
|
||||
} else {
|
||||
for (const node of nodes) {
|
||||
const metadata = node.metadata ?? {};
|
||||
const statusPart = metadata._chunk_status_message
|
||||
? ` status: ${metadata._chunk_status_message}`
|
||||
: "";
|
||||
const excludedPart =
|
||||
metadata.is_displayed_chunk_content === false ? " [excluded from retrieval]" : "";
|
||||
emitBare(
|
||||
`[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`,
|
||||
);
|
||||
const contentText = metadata.content ?? node.text ?? "";
|
||||
emitBare(` ${contentText.length > 200 ? `${contentText.slice(0, 200)}…` : contentText}`);
|
||||
}
|
||||
}
|
||||
emitBare(`total: ${response.data?.total ?? nodes.length}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagChunkListResponse,
|
||||
type RagMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
import { readUtf8TextFile } from "./upload-support.ts";
|
||||
|
||||
const CHUNK_UPDATE_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
chunkId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Chunk ID (metadata._id from the chunk list output)",
|
||||
required: true,
|
||||
},
|
||||
docId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Document ID owning the chunk (metadata.doc_id from the chunk list output)",
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "New chunk content, 10-6000 chars; alternative to --content-file",
|
||||
},
|
||||
contentFile: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Read new content from a UTF-8 plain text file (.md/.txt etc.)",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Chunk title, 0-50 chars (empty string clears it; omit to keep unchanged)",
|
||||
},
|
||||
exclude: { type: "switch", description: "Exclude this chunk from retrieval" },
|
||||
include: { type: "switch", description: "Include this chunk in retrieval (default)" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** When only toggling include/exclude, read back the current content first (the API requires content — hide that quirk from users) */
|
||||
async function fetchChunkContent(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
indexId: string,
|
||||
chunkId: string,
|
||||
docId: string,
|
||||
): Promise<string> {
|
||||
const maxPages = 10;
|
||||
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
||||
const response = await client.requestJson<RagChunkListResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.chunkList),
|
||||
method: "POST",
|
||||
body: { indexId, docId, pageNum, pageSize: 100 },
|
||||
});
|
||||
const nodes = response.data?.nodes ?? [];
|
||||
const match = nodes.find((node) => node.metadata?._id === chunkId);
|
||||
const matchContent = match?.metadata?.content ?? match?.text;
|
||||
if (typeof matchContent === "string") return matchContent;
|
||||
if (nodes.length < 100) break;
|
||||
}
|
||||
throw new BailianError(
|
||||
`Chunk not found: ${chunkId}`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the chunk id via the chunk list command.",
|
||||
);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update chunk content or toggle its retrieval visibility",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> --chunk-id <id> --doc-id <id> [flags]",
|
||||
flags: CHUNK_UPDATE_FLAGS,
|
||||
notes: [
|
||||
"Content must be 10-6000 characters and within the knowledge base's max chunk size.",
|
||||
"--content-file expects a UTF-8 plain text file; document formats (.docx/.pdf) are not parsed here.",
|
||||
"Toggling --exclude/--include without new content re-submits the existing content automatically.",
|
||||
],
|
||||
exampleArgs: [
|
||||
'--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text"',
|
||||
"--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.content !== undefined && flags.contentFile !== undefined) {
|
||||
return "Use either --content or --content-file, not both";
|
||||
}
|
||||
if (flags.exclude && flags.include) return "--exclude and --include are mutually exclusive";
|
||||
const hasContent = flags.content !== undefined || flags.contentFile !== undefined;
|
||||
if (!hasContent && !flags.exclude && !flags.include && flags.title === undefined) {
|
||||
return "Nothing to update — pass --content/--content-file, --title, --exclude or --include";
|
||||
}
|
||||
// Content lower-bound is enforced here (not deferred to run) so dry-run and
|
||||
// missing-flag diagnostics surface the same error as the live request.
|
||||
if (flags.content !== undefined && (flags.content.length < 10 || flags.content.length > 6000)) {
|
||||
return "--content must be 10-6000 characters";
|
||||
}
|
||||
if (flags.title !== undefined && flags.title.length > 50) {
|
||||
return "--title must be at most 50 characters";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// dry-run also reads the file and validates (rehearsal semantics); the read-back
|
||||
// request is only made outside dry-run and when no new content is given
|
||||
let content =
|
||||
flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content;
|
||||
if (content !== undefined && (content.length < 10 || content.length > 6000)) {
|
||||
throw new BailianError("Chunk content must be 10-6000 characters", ExitCode.USAGE);
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
if (settings.dryRun) {
|
||||
content = "<current-content (fetched at run time)>";
|
||||
} else {
|
||||
content = await fetchChunkContent(
|
||||
ctx.client,
|
||||
workspaceId,
|
||||
flags.indexId,
|
||||
flags.chunkId,
|
||||
flags.docId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
pipelineId: flags.indexId,
|
||||
chunkId: flags.chunkId,
|
||||
dataId: flags.docId,
|
||||
content,
|
||||
// Without exclude/include the chunk stays retrievable (safe default)
|
||||
isDisplayedChunkContent: !flags.exclude,
|
||||
...(flags.title !== undefined ? { title: flags.title } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkUpdate);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`updated: ${flags.chunkId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAddConnectorResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const COLLECTION_CREATE_FLAGS = {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Collection name",
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Collection description (required by the server)",
|
||||
required: true,
|
||||
},
|
||||
storeType: {
|
||||
type: "string",
|
||||
valueHint: "<type>",
|
||||
description: "Storage: platform (managed) or custom (your own OSS bucket)",
|
||||
},
|
||||
ossRegion: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "OSS region id (required with --store-type custom)",
|
||||
},
|
||||
ossBucket: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "OSS bucket name (required with --store-type custom)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a FILE data collection",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--name <text> --description <text> [flags]",
|
||||
flags: COLLECTION_CREATE_FLAGS,
|
||||
notes: [
|
||||
"Store type defaults to platform (managed storage); custom uses your authorized OSS bucket.",
|
||||
"There is no collection delete API — create collections deliberately.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--name my-collection --description 'team docs' --workspace-id ws-xxx",
|
||||
"--name oss-coll --description 'own bucket' --store-type custom --oss-region cn-beijing --oss-bucket my-bucket",
|
||||
],
|
||||
validate(flags) {
|
||||
// Server rejects names longer than 20 characters ("Connector name is longer than 20")
|
||||
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
|
||||
const storeType = flags.storeType ?? "platform";
|
||||
if (storeType !== "platform" && storeType !== "custom") {
|
||||
return "--store-type must be platform or custom";
|
||||
}
|
||||
if (storeType === "custom" && (!flags.ossRegion || !flags.ossBucket)) {
|
||||
return "--store-type custom requires --oss-region and --oss-bucket";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const storeType = (flags.storeType ?? "platform").toUpperCase();
|
||||
// The server contract still uses connector* fields; only the CLI-facing term is collection
|
||||
const body = {
|
||||
connectorType: "FILE",
|
||||
connectorName: flags.name,
|
||||
description: flags.description,
|
||||
fileConnectorConfig: {
|
||||
storeType,
|
||||
...(storeType === "CUSTOM"
|
||||
? { ossRegionId: flags.ossRegion, ossBucket: flags.ossBucket }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addConnector);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAddConnectorResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const collectionId = response.data?.connectorId;
|
||||
if (settings.quiet) {
|
||||
emitBare(collectionId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`created: ${collectionId ?? "-"} (${flags.name}, ${storeType})`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagGetConnectorResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const COLLECTION_GET_FLAGS = {
|
||||
collectionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Collection ID; alternative to --name",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Collection name; alternative to --collection-id",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show data collection details",
|
||||
auth: "apiKey",
|
||||
usageArgs: "(--collection-id <id> | --name <text>) [flags]",
|
||||
flags: COLLECTION_GET_FLAGS,
|
||||
exampleArgs: ["--collection-id conn-xxx --workspace-id ws-xxx", "--name my-collection"],
|
||||
validate(flags) {
|
||||
if (!flags.collectionId && !flags.name) return "Pass --collection-id or --name";
|
||||
if (flags.collectionId && flags.name) return "Use either --collection-id or --name, not both";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// The server contract still uses connector* fields; only the CLI-facing term is collection
|
||||
const body = {
|
||||
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
|
||||
...(flags.name ? { connectorName: flags.name } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.getConnector);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagGetConnectorResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const collection = response.data;
|
||||
if (settings.quiet) {
|
||||
emitBare(collection?.connectorId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`id: ${collection?.connectorId ?? "-"}`);
|
||||
emitBare(`name: ${collection?.connectorName ?? "-"}`);
|
||||
emitBare(`description: ${collection?.description ?? "-"}`);
|
||||
const storeType = collection?.fileConnectorConfig?.storeType;
|
||||
emitBare(`storeType: ${storeType ?? "-"}`);
|
||||
if (storeType === "CUSTOM") {
|
||||
emitBare(` ossRegion: ${collection?.fileConnectorConfig?.ossRegionId ?? "-"}`);
|
||||
emitBare(` ossBucket: ${collection?.fileConnectorConfig?.ossBucket ?? "-"}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagDeleteFileResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const DOC_DELETE_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
docId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description: "Document ID to delete (repeatable)",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */
|
||||
function buildDeleteSummary(indexId: string, docIds: string[]): string {
|
||||
const listed =
|
||||
docIds.length <= 5
|
||||
? docIds.join("\n ")
|
||||
: `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`;
|
||||
return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete documents and their chunks from a knowledge base",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> --doc-id <id> [flags]",
|
||||
flags: DOC_DELETE_FLAGS,
|
||||
notes: [
|
||||
"Removes documents from the knowledge base index only; the source files remain in the data center.",
|
||||
"The output lists the ids actually deleted as reported by the server.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --doc-id file-a --doc-id file-b --yes",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// snake_case: body { index_id, doc_ids }
|
||||
const body = { index_id: flags.indexId, doc_ids: flags.docId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDeleteFile);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmDangerousAction(
|
||||
buildDeleteSummary(flags.indexId, flags.docId),
|
||||
flags.yes ?? false,
|
||||
);
|
||||
|
||||
const response = await ctx.client.requestJson<RagDeleteFileResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
// Output follows the server's data.deleted list
|
||||
const deleted = response.data?.deleted ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const docId of deleted) emitBare(docId);
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`deleted: ${deleted.length} document(s)`);
|
||||
for (const docId of deleted) emitBare(` ${docId}`);
|
||||
if (deleted.length !== flags.docId.length) {
|
||||
process.stderr.write(
|
||||
`Warning: requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagOssImportResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const DOC_IMPORT_OSS_FLAGS = {
|
||||
bucket: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Authorized OSS bucket name",
|
||||
required: true,
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "OSS region id (e.g. cn-beijing)",
|
||||
required: true,
|
||||
},
|
||||
ossKey: {
|
||||
type: "array",
|
||||
valueHint: "<key>",
|
||||
description: "OSS object key to import (repeatable, 1-10 per call)",
|
||||
required: true,
|
||||
},
|
||||
categoryId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Target data-center category (default: the default category)",
|
||||
},
|
||||
tag: {
|
||||
type: "array",
|
||||
valueHint: "<text>",
|
||||
description: "File tag applied to every imported file (repeatable, up to 10)",
|
||||
},
|
||||
overwrite: {
|
||||
type: "switch",
|
||||
description: "Overwrite files previously imported from the same OSS keys",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Batch import files from an authorized OSS bucket into the data center",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--bucket <name> --region <id> --oss-key <key> [flags]",
|
||||
flags: DOC_IMPORT_OSS_FLAGS,
|
||||
notes: [
|
||||
"The bucket must be authorized to the platform service role beforehand; permission errors from the server are passed through with a pointer to check AliyunServiceRoleForBailian in the RAM console.",
|
||||
"File names are derived from the OSS key basename.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx",
|
||||
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.ossKey.length > 10) return "--oss-key accepts at most 10 entries per call";
|
||||
if (flags.tag !== undefined && flags.tag.length > 10) {
|
||||
return "--tag accepts at most 10 entries";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// categoryType fixed to UNSTRUCTURED; parser not exposed as a flag (defaults to AUTO_SELECT)
|
||||
const body = {
|
||||
categoryId: flags.categoryId ?? "default",
|
||||
categoryType: "UNSTRUCTURED",
|
||||
ossBucket: flags.bucket,
|
||||
ossRegionId: flags.region,
|
||||
fileDetails: flags.ossKey.map((ossKey) => ({ fileName: basename(ossKey), ossKey })),
|
||||
...(flags.tag?.length ? { tags: flags.tag } : {}),
|
||||
...(flags.overwrite ? { overWriteFileByOssKey: true } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addFilesFromAuthorizedOss);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagOssImportResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const fileIds = response.data?.fileIds ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const fileId of fileIds) emitBare(fileId);
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`imported: ${fileIds.length} file(s)`);
|
||||
for (const fileId of fileIds) emitBare(` ${fileId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagIndexFilesResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const DOC_LIST_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
...PAGE_FLAGS,
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List documents in a knowledge base with parse/index status",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: DOC_LIST_FLAGS,
|
||||
notes: [
|
||||
"Documents with status FAILED are highlighted in text mode — use the import job status command to inspect failures.",
|
||||
"Page size defaults to 10 (server default), max 100.",
|
||||
],
|
||||
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --page-size 100"],
|
||||
validate(flags) {
|
||||
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
|
||||
return "--page-size must be between 1 and 100";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Gotcha: this endpoint's page parameter is page_num (not page_number)
|
||||
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
|
||||
url.searchParams.set("index_id", flags.indexId);
|
||||
url.searchParams.set("page_num", String(flags.pageNumber ?? 1));
|
||||
url.searchParams.set("page_size", String(flags.pageSize ?? 10));
|
||||
const endpoint = url.toString();
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: null }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagIndexFilesResponse>({
|
||||
path: endpoint,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const rows = response.data?.rows ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const row of rows) emitBare(row.doc_id ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
const styles = ansi(process.stdout);
|
||||
if (rows.length === 0) {
|
||||
emitBare("No documents found.");
|
||||
} else {
|
||||
for (const row of rows) {
|
||||
const line = truncateLine(
|
||||
[row.doc_id, row.status, row.doc_name, row.doc_type ?? "-", row.size ?? "-"].join(" "),
|
||||
);
|
||||
emitBare(row.status === "FAILED" ? styles.red(line) : line);
|
||||
}
|
||||
}
|
||||
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
type RagIndexJobStatusResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveWorkspaceId,
|
||||
PAGE_FLAGS,
|
||||
WORKSPACE_FLAG,
|
||||
failedImportDocs,
|
||||
importJobFailureMessage,
|
||||
pollImportJob,
|
||||
} from "./shared.ts";
|
||||
|
||||
const DOC_STATUS_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
jobId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Import job ID (ingestionId returned by import commands)",
|
||||
required: true,
|
||||
},
|
||||
...PAGE_FLAGS,
|
||||
wait: { type: "switch", description: "Poll until the job reaches a terminal state" },
|
||||
pollInterval: {
|
||||
type: "number",
|
||||
valueHint: "<seconds>",
|
||||
description: "Polling interval when waiting (default: 5)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
function printStatus(response: RagIndexJobStatusResponse): void {
|
||||
const styles = ansi(process.stdout);
|
||||
emitBare(`status: ${response.data?.ingestion_status ?? "UNKNOWN"}`);
|
||||
for (const doc of response.data?.rows ?? []) {
|
||||
const docState = doc.code ?? doc.status ?? "?";
|
||||
const line = ` ${doc.doc_id ?? "?"} ${docState} ${doc.doc_name ?? ""}`;
|
||||
emitBare(docState.includes("FAILED") ? styles.red(line) : line);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Check knowledge base import job status",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> --job-id <id> [flags]",
|
||||
flags: DOC_STATUS_FLAGS,
|
||||
notes: [
|
||||
"Both --index-id and --job-id are required by the server (passing only one returns SystemError).",
|
||||
"If the server returns SystemError for an idle knowledge base, the job may not exist — check the ingestion id in the document list output.",
|
||||
"Overall job states are PENDING / RUNNING / COMPLETED; per-document failures (for example PARSE_FAILED) exit non-zero with the server message passed through.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --job-id job-xxx --wait --poll-interval 10",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Both required flags are enforced by the parser up front; parameters go in
|
||||
// the query string (they are ignored in the body)
|
||||
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
|
||||
url.searchParams.set("index_id", flags.indexId);
|
||||
url.searchParams.set("job_id", flags.jobId);
|
||||
if (flags.pageNumber !== undefined) {
|
||||
url.searchParams.set("page_number", String(flags.pageNumber));
|
||||
}
|
||||
if (flags.pageSize !== undefined) {
|
||||
url.searchParams.set("page_size", String(flags.pageSize));
|
||||
}
|
||||
const endpoint = url.toString();
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: null }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: RagIndexJobStatusResponse;
|
||||
if (flags.wait) {
|
||||
// Reuse the shared polling (timeout → TIMEOUT(5)); failure detection happens
|
||||
// uniformly after return, based on per-document status
|
||||
response = await pollImportJob(ctx.client, settings, {
|
||||
statusUrl: endpoint,
|
||||
intervalSec: flags.pollInterval ?? 5,
|
||||
});
|
||||
} else {
|
||||
response = await ctx.client.requestJson<RagIndexJobStatusResponse>({
|
||||
path: endpoint,
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
// Any per-document failure means a non-zero exit; the server message is passed through verbatim
|
||||
if (failedImportDocs(response).length > 0) {
|
||||
throw new BailianError(
|
||||
importJobFailureMessage(response, "Import job reported document failures."),
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(response.data?.ingestion_status ?? "UNKNOWN");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
printStatus(response);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagBatchUpdateTagResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const DOC_TAG_FLAGS = {
|
||||
docId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description: "Data-center file ID to tag (repeatable, 1-20 per call)",
|
||||
required: true,
|
||||
},
|
||||
tag: {
|
||||
type: "array",
|
||||
valueHint: "<text>",
|
||||
description: "Tag applied to every --doc-id (repeatable, each up to 32 chars)",
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
valueHint: "<mode>",
|
||||
description: "Update mode: append (default) or overwrite",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Batch update tags on data-center files",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--doc-id <id> --tag <text> [flags]",
|
||||
flags: DOC_TAG_FLAGS,
|
||||
notes: [
|
||||
"The same tag set is applied to every --doc-id; run the command multiple times for different tag sets.",
|
||||
"Server limits: up to 100 tags per file, total tag length up to 700 chars, tag up to 32 chars.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx",
|
||||
"--doc-id file-a --doc-id file-b --tag final --mode overwrite",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.docId.length > 20) return "--doc-id accepts at most 20 ids per call";
|
||||
if (flags.mode !== undefined && flags.mode !== "append" && flags.mode !== "overwrite") {
|
||||
return "--mode must be append or overwrite";
|
||||
}
|
||||
// Hard limits stated by the API contract: each tag ≤32 chars; ≤100 tags per file; total length ≤700
|
||||
if (flags.tag.length > 100) return "At most 100 tags per file";
|
||||
const overlongTag = flags.tag.find((tag) => tag.length > 32);
|
||||
if (overlongTag) return `Tag exceeds 32 characters: ${overlongTag}`;
|
||||
const totalLength = flags.tag.reduce((sum, tag) => sum + tag.length, 0);
|
||||
if (totalLength > 700) return "Total tag length exceeds 700 characters";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = {
|
||||
fileInfos: flags.docId.map((fileId) => ({ fileId, tags: flags.tag })),
|
||||
updateMode: (flags.mode ?? "append").toUpperCase(),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.batchUpdateFileTag);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagBatchUpdateTagResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
// Orchestration command: local file → data center → (optional) import into a knowledge base.
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
type RagUploadLeaseResponse,
|
||||
type RagAddFileResponse,
|
||||
type RagJobCreateResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveWorkspaceId,
|
||||
WORKSPACE_FLAG,
|
||||
failedImportDocs,
|
||||
importJobFailureMessage,
|
||||
importJobStatus,
|
||||
importJobStatusUrl,
|
||||
pollImportJob,
|
||||
withPartialSuccessHint,
|
||||
} from "./shared.ts";
|
||||
import { checkUploadFile } from "./upload-support.ts";
|
||||
|
||||
const DOC_UPLOAD_FLAGS = {
|
||||
file: {
|
||||
type: "array",
|
||||
valueHint: "<path>",
|
||||
description: "Local file path (repeatable). Extension and size validated before upload",
|
||||
required: true,
|
||||
},
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Import into this knowledge base after registration (one job for all files)",
|
||||
},
|
||||
categoryId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Target data-center category; defaults to the workspace default category",
|
||||
},
|
||||
tag: {
|
||||
type: "array",
|
||||
valueHint: "<text>",
|
||||
description: "File tag (repeatable), applied to every uploaded file",
|
||||
},
|
||||
wait: {
|
||||
type: "switch",
|
||||
description: "Poll the import job to a terminal state (needs --index-id)",
|
||||
},
|
||||
pollInterval: {
|
||||
type: "number",
|
||||
valueHint: "<seconds>",
|
||||
description: "Polling interval when waiting (default: 5)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
interface UploadedFile {
|
||||
path: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Upload local files to the data center and optionally import into a knowledge base",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--file <path> [flags]",
|
||||
flags: DOC_UPLOAD_FLAGS,
|
||||
notes: [
|
||||
"Pipeline: apply upload lease → PUT to OSS → register file → (with --index-id) create import job.",
|
||||
"Without --category-id the workspace default category is resolved automatically.",
|
||||
"Multiple files are processed sequentially; on failure, already-registered file ids are listed in the error hint.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--file ./a.md --workspace-id ws-xxx",
|
||||
"--file ./a.md --file ./b.pdf --index-id idx-xxx --wait",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.wait && !flags.indexId) return "--wait requires --index-id";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Local pre-flight validation also runs in dry-run (rehearsal semantics: surface
|
||||
// file problems early); exceeding a soft limit only warns
|
||||
const checkedFiles = flags.file.map((filePath) => {
|
||||
const checked = checkUploadFile(filePath);
|
||||
if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`);
|
||||
return { filePath, sizeBytes: checked.sizeBytes };
|
||||
});
|
||||
|
||||
if (settings.dryRun) {
|
||||
// dry-run does not read file contents (md5 shown as a placeholder)
|
||||
const categoryPlaceholder = flags.categoryId ?? "default";
|
||||
const steps = checkedFiles.flatMap((checkedFile) => [
|
||||
{
|
||||
step: "applyFileUploadLease",
|
||||
endpoint: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
|
||||
request: {
|
||||
category: categoryPlaceholder,
|
||||
fileName: basename(checkedFile.filePath),
|
||||
sizeBytes: String(checkedFile.sizeBytes), // gotcha: must be a string
|
||||
contentMd5: "<md5-base64>",
|
||||
} as unknown,
|
||||
},
|
||||
{
|
||||
step: "ossPut",
|
||||
endpoint: "<lease.param.url>",
|
||||
request: { method: "PUT", headers: "<lease.param.headers>" } as unknown,
|
||||
},
|
||||
{
|
||||
step: "addFile",
|
||||
endpoint: ragEndpoint(workspaceId, RAG_PATHS.addFile),
|
||||
request: {
|
||||
leaseId: "<leaseId>",
|
||||
category: categoryPlaceholder,
|
||||
parser: "AUTO_SELECT",
|
||||
...(flags.tag?.length ? { tags: flags.tag } : {}),
|
||||
} as unknown,
|
||||
},
|
||||
]);
|
||||
if (flags.indexId) {
|
||||
steps.push({
|
||||
step: "createImportJob",
|
||||
endpoint: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
|
||||
request: {
|
||||
indexId: flags.indexId,
|
||||
// Gotcha (live-verified): job/create requires the nested dataSource shape;
|
||||
// the public docs' flat documentIds body returns Index.InvalidParameter.
|
||||
// Omitting sourceType would import the entire data center.
|
||||
dataSource: { sourceType: "DATA_CENTER_FILE", fileIds: ["<fileId>"] },
|
||||
} as unknown,
|
||||
});
|
||||
}
|
||||
emitResult({ steps }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default category: the literal "default" is accepted by lease/addFile
|
||||
// (verified against the live API), so no listCategory resolution is needed
|
||||
const categoryId = flags.categoryId ?? "default";
|
||||
|
||||
// Multiple files run steps 1-3 sequentially (no concurrency in this version,
|
||||
// to avoid OSS rate-limit complexity)
|
||||
const uploaded: UploadedFile[] = [];
|
||||
for (const checkedFile of checkedFiles) {
|
||||
try {
|
||||
const fileBuffer = readFileSync(checkedFile.filePath);
|
||||
const contentMd5 = createHash("md5").update(fileBuffer).digest("base64");
|
||||
|
||||
// 1) Apply for an upload lease (gotcha: the category parameter is named
|
||||
// category, not categoryId; sizeBytes must be a string)
|
||||
const lease = await ctx.client.requestJson<RagUploadLeaseResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
|
||||
method: "POST",
|
||||
body: {
|
||||
category: categoryId,
|
||||
fileName: basename(checkedFile.filePath),
|
||||
sizeBytes: String(checkedFile.sizeBytes),
|
||||
contentMd5,
|
||||
},
|
||||
});
|
||||
const leaseId = lease.data?.leaseId;
|
||||
const leaseParam = lease.data?.param;
|
||||
if (!leaseId || !leaseParam?.url) {
|
||||
throw new BailianError(
|
||||
`Upload lease response missing leaseId/url for ${checkedFile.filePath}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) OSS upload: goes to the OSS host, not the DashScope gateway — native fetch without a Bearer header
|
||||
let ossResponse: Response;
|
||||
try {
|
||||
ossResponse = await fetch(leaseParam.url, {
|
||||
method: leaseParam.method ?? "PUT",
|
||||
headers: leaseParam.headers,
|
||||
body: fileBuffer,
|
||||
});
|
||||
} catch (error) {
|
||||
const causeCode = (error as { cause?: { code?: string } }).cause?.code;
|
||||
throw new BailianError(
|
||||
`OSS upload failed for ${basename(checkedFile.filePath)}`,
|
||||
ExitCode.NETWORK,
|
||||
causeCode ? `Network error (${causeCode}).` : undefined,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (!ossResponse.ok) {
|
||||
const ossBody = await ossResponse.text().catch(() => "");
|
||||
throw new BailianError(
|
||||
`OSS upload rejected (HTTP ${ossResponse.status}) for ${basename(checkedFile.filePath)}${ossBody ? `: ${ossBody.slice(0, 300)}` : ""}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Register the file
|
||||
const added = await ctx.client.requestJson<RagAddFileResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.addFile),
|
||||
method: "POST",
|
||||
body: {
|
||||
leaseId,
|
||||
category: categoryId,
|
||||
parser: "AUTO_SELECT",
|
||||
...(flags.tag?.length ? { tags: flags.tag } : {}),
|
||||
},
|
||||
});
|
||||
const fileId = added.data?.fileId;
|
||||
if (!fileId) {
|
||||
throw new BailianError(
|
||||
`addFile response missing fileId for ${checkedFile.filePath}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
uploaded.push({ path: checkedFile.filePath, fileId });
|
||||
} catch (error) {
|
||||
// Partial-failure semantics: abort with an error, listing already-registered
|
||||
// fileIds in the hint (re-uploading is cheap and idempotent)
|
||||
if (uploaded.length > 0) {
|
||||
throw withPartialSuccessHint(
|
||||
error,
|
||||
`Already registered: ${uploaded.map((item) => item.fileId).join(", ")}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Optional import (merged into a single job after all files are registered)
|
||||
let ingestionId: string | undefined;
|
||||
let finalStatus: string | undefined;
|
||||
if (flags.indexId) {
|
||||
const job = await ctx.client.requestJson<RagJobCreateResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
|
||||
method: "POST",
|
||||
body: {
|
||||
indexId: flags.indexId,
|
||||
// Live-verified shape: nested dataSource (the docs' flat documentIds is rejected)
|
||||
dataSource: {
|
||||
sourceType: "DATA_CENTER_FILE",
|
||||
fileIds: uploaded.map((item) => item.fileId),
|
||||
},
|
||||
},
|
||||
});
|
||||
ingestionId = job.data?.ingestionId;
|
||||
if (flags.wait && ingestionId) {
|
||||
const statusResponse = await pollImportJob(ctx.client, settings, {
|
||||
statusUrl: importJobStatusUrl(workspaceId, flags.indexId, ingestionId).toString(),
|
||||
intervalSec: flags.pollInterval ?? 5,
|
||||
});
|
||||
finalStatus = importJobStatus(statusResponse);
|
||||
// Job finished but some documents failed to parse → non-zero exit, server message passed through verbatim
|
||||
if (failedImportDocs(statusResponse).length > 0) {
|
||||
throw new BailianError(
|
||||
importJobFailureMessage(statusResponse, "Import job reported document failures."),
|
||||
ExitCode.GENERAL,
|
||||
`Registered file ids: ${uploaded.map((item) => item.fileId).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.quiet) {
|
||||
for (const item of uploaded) emitBare(item.fileId);
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
for (const item of uploaded) {
|
||||
emitBare(`${basename(item.path)} ${item.fileId} registered`);
|
||||
}
|
||||
if (ingestionId) emitBare(`job: ${ingestionId}`);
|
||||
if (finalStatus) emitBare(`status: ${finalStatus}`);
|
||||
return;
|
||||
}
|
||||
// An orchestration command has no single response to pass through — emit a custom stable shape
|
||||
emitResult(
|
||||
{
|
||||
files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })),
|
||||
...(flags.indexId ? { index_id: flags.indexId } : {}),
|
||||
...(ingestionId ? { ingestion_id: ingestionId } : {}),
|
||||
...(finalStatus ? { final_status: finalStatus } : {}),
|
||||
},
|
||||
format,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagConnectorResponse,
|
||||
type RagDescribeFileResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const FILE_DELETE_FLAGS = {
|
||||
fileId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Data-center file ID to delete",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary lookup (file name/size); failure degrades to id-only */
|
||||
async function buildDeleteSummary(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
): Promise<string> {
|
||||
let infoPart = "";
|
||||
try {
|
||||
const detail = await client.requestJson<RagDescribeFileResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.describeFile),
|
||||
method: "POST",
|
||||
body: { fileId },
|
||||
});
|
||||
if (detail.data?.fileName) infoPart = ` name: ${detail.data.fileName}`;
|
||||
} catch {
|
||||
// Degrade gracefully: a failed lookup does not block confirmation
|
||||
}
|
||||
return `Delete data-center file ${fileId}${infoPart}\nPERMANENT: if the file is referenced by knowledge bases, their document indexes break too. This differs from removing a document from one knowledge base.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Permanently delete a file from the data center",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--file-id <id> [flags]",
|
||||
flags: FILE_DELETE_FLAGS,
|
||||
notes: [
|
||||
"Irreversible. If knowledge bases reference this file, their related document indexes become invalid.",
|
||||
"To remove a document from a single knowledge base only, use the document delete command instead.",
|
||||
],
|
||||
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx", "--file-id file-xxx --yes"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = { fileId: flags.fileId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteFile);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = flags.yes
|
||||
? ""
|
||||
: await buildDeleteSummary(ctx.client, workspaceId, flags.fileId);
|
||||
await confirmDangerousAction(summary, flags.yes ?? false);
|
||||
|
||||
const response = await ctx.client.requestJson<
|
||||
RagConnectorResponse<Record<string, unknown> | undefined>
|
||||
>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`deleted: ${flags.fileId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagDescribeFileResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const FILE_GET_FLAGS = {
|
||||
fileId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Data-center file ID",
|
||||
required: true,
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show data-center file details (size, MD5, tags, timestamps)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--file-id <id> [flags]",
|
||||
flags: FILE_GET_FLAGS,
|
||||
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = { fileId: flags.fileId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.describeFile);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagDescribeFileResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const file = response.data;
|
||||
if (settings.quiet || format !== "text") {
|
||||
emitResult(response, format === "text" ? "json" : format);
|
||||
return;
|
||||
}
|
||||
emitBare(`id: ${file?.fileId ?? "-"}`);
|
||||
emitBare(`name: ${file?.fileName ?? "-"}`);
|
||||
emitBare(`type: ${file?.fileType ?? "-"}`);
|
||||
emitBare(`size: ${file?.sizeBytes ?? "-"}`);
|
||||
emitBare(`status: ${file?.status ?? "-"}`);
|
||||
emitBare(`parser: ${file?.parser ?? "-"}`);
|
||||
emitBare(`category: ${file?.category ?? "-"}`);
|
||||
emitBare(`uploaded: ${file?.uploadTime ?? "-"}`);
|
||||
const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-");
|
||||
emitBare(`tags: ${tags || "-"}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagListFileResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const FILE_LIST_FLAGS = {
|
||||
categoryId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Category to list (find ids via the category list command)",
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Filter by file name",
|
||||
},
|
||||
fileId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description: "Filter by file ID (repeatable)",
|
||||
},
|
||||
nextToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Cursor for the next page (from previous output)",
|
||||
},
|
||||
maxResult: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Items per page",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List files in a data-center category",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--category-id <id> [flags]",
|
||||
flags: FILE_LIST_FLAGS,
|
||||
notes: [
|
||||
"The server requires a real category id here — unlike upload APIs, the literal default is NOT resolved (returns an empty list). Find the id via file details of any uploaded file, or the category list command.",
|
||||
"Pagination is cursor-based: reuse the printed next token to continue.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--category-id cate-xxx --workspace-id ws-xxx",
|
||||
"--category-id cate-xxx --name report",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = {
|
||||
categoryId: flags.categoryId,
|
||||
...(flags.name ? { fileName: flags.name } : {}),
|
||||
...(flags.fileId?.length ? { fileIds: flags.fileId } : {}),
|
||||
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
|
||||
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listFile);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagListFileResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const files = response.data?.fileList ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const file of files) emitBare(file.fileId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
if (files.length === 0) {
|
||||
emitBare("No files found.");
|
||||
} else {
|
||||
for (const file of files) {
|
||||
emitBare(
|
||||
truncateLine(
|
||||
[file.fileId, file.status ?? "-", file.fileName, file.sizeBytes ?? "-"].join(" "),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const nextToken = response.data?.nextToken;
|
||||
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
type RagCreateIndexV2Response,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveWorkspaceId,
|
||||
WORKSPACE_FLAG,
|
||||
failedImportDocs,
|
||||
importJobFailureMessage,
|
||||
importJobStatus,
|
||||
importJobStatusUrl,
|
||||
pollImportJob,
|
||||
} from "./shared.ts";
|
||||
|
||||
const KB_CREATE_FLAGS = {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Knowledge base name (1-20 chars, unique in workspace)",
|
||||
required: true,
|
||||
},
|
||||
docId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description:
|
||||
"Data-center file id to import (repeatable); mutually exclusive with --category-id",
|
||||
},
|
||||
categoryId: {
|
||||
type: "array",
|
||||
valueHint: "<id>",
|
||||
description:
|
||||
"Import every file under this category (repeatable); mutually exclusive with --doc-id",
|
||||
},
|
||||
embeddingModel: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Embedding model name (default: text-embedding-v4)",
|
||||
},
|
||||
chunkSize: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Chunk size in characters (default: 600, recommended 300-800)",
|
||||
},
|
||||
wait: { type: "switch", description: "Poll the initial import job to a terminal state" },
|
||||
pollInterval: {
|
||||
type: "number",
|
||||
valueHint: "<seconds>",
|
||||
description: "Polling interval when waiting (default: 5)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** sourceType/docIds/categoryIds derivation, centralized for unit testing (gotcha: the parameter is docIds, not fileIds) */
|
||||
export function buildDataSourceFields(flags: { docId?: string[]; categoryId?: string[] }): {
|
||||
sourceType: string;
|
||||
docIds?: string[];
|
||||
categoryIds?: string[];
|
||||
dataSources: Array<{ sourceType: string }>;
|
||||
} {
|
||||
if (flags.docId?.length) {
|
||||
return {
|
||||
sourceType: "DATA_CENTER_FILE",
|
||||
docIds: flags.docId,
|
||||
dataSources: [{ sourceType: "DATA_CENTER_FILE" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceType: "DATA_CENTER_CATEGORY",
|
||||
categoryIds: flags.categoryId,
|
||||
dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }],
|
||||
};
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a knowledge base and import data-center files or categories",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--name <text> (--doc-id <id> | --category-id <id>) [flags]",
|
||||
flags: KB_CREATE_FLAGS,
|
||||
notes: [
|
||||
"Structure/sink types are fixed to the default document knowledge base (unstructured, BUILT_IN storage).",
|
||||
"Returns the knowledge base id (pipelineId) and the initial import job id (ingestionId).",
|
||||
"Use the import job status command (or --wait) to track the initial import.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--name demo --doc-id file-xxx --workspace-id ws-xxx",
|
||||
"--name demo --category-id cate-xxx --wait",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
|
||||
const hasDocIds = !!flags.docId?.length;
|
||||
const hasCategoryIds = !!flags.categoryId?.length;
|
||||
if (hasDocIds && hasCategoryIds) return "Use either --doc-id or --category-id, not both";
|
||||
if (!hasDocIds && !hasCategoryIds)
|
||||
return "Provide --doc-id or --category-id as the data source";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Fixed values, not exposed as flags in this version: structureType unstructured, sinkType BUILT_IN.
|
||||
// Note: the public docs' example uses sinkType DEFAULT, but BUILT_IN is what works against the live API.
|
||||
const body = {
|
||||
name: flags.name,
|
||||
structureType: "unstructured",
|
||||
sinkType: "BUILT_IN",
|
||||
embeddingModelName: flags.embeddingModel ?? "text-embedding-v4",
|
||||
chunkSize: flags.chunkSize ?? 600,
|
||||
...buildDataSourceFields(flags),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexCreateV2);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagCreateIndexV2Response>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const pipelineId = response.data?.pipelineId;
|
||||
const ingestionId = response.data?.ingestionId;
|
||||
|
||||
let finalStatus: string | undefined;
|
||||
if (flags.wait && pipelineId && ingestionId) {
|
||||
const statusResponse = await pollImportJob(ctx.client, settings, {
|
||||
statusUrl: importJobStatusUrl(workspaceId, pipelineId, ingestionId).toString(),
|
||||
intervalSec: flags.pollInterval ?? 5,
|
||||
});
|
||||
finalStatus = importJobStatus(statusResponse);
|
||||
// Job finished but some documents failed to parse → non-zero exit, server message
|
||||
// passed through verbatim (the knowledge base was created; its id goes in the hint)
|
||||
if (failedImportDocs(statusResponse).length > 0) {
|
||||
throw new BailianError(
|
||||
importJobFailureMessage(statusResponse, "Initial import reported document failures."),
|
||||
ExitCode.GENERAL,
|
||||
`Knowledge base created: ${pipelineId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(pipelineId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`index_id: ${pipelineId ?? "-"}`);
|
||||
if (ingestionId) emitBare(`ingestion_id: ${ingestionId}`);
|
||||
if (finalStatus) emitBare(`status: ${finalStatus}`);
|
||||
emitBare("Next: check the import job status, then search against this knowledge base.");
|
||||
return;
|
||||
}
|
||||
emitResult(finalStatus ? { ...response, final_status: finalStatus } : response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagIndexFilesResponse,
|
||||
type RagMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
import { fetchIndexDetail } from "./kb-info.ts";
|
||||
|
||||
const KB_DELETE_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary lookup: name + document count; any lookup failure degrades to id-only (never blocks deletion) */
|
||||
async function buildDeleteSummary(
|
||||
ctx: { client: Parameters<typeof fetchIndexDetail>[0] },
|
||||
workspaceId: string,
|
||||
indexId: string,
|
||||
): Promise<string> {
|
||||
let namePart = "";
|
||||
let docCountPart = "";
|
||||
try {
|
||||
const detail = await fetchIndexDetail(ctx.client, workspaceId, indexId);
|
||||
namePart = ` name: ${detail.name}`;
|
||||
} catch {
|
||||
// Degrade gracefully: a missing name does not block confirmation
|
||||
}
|
||||
try {
|
||||
const filesUrl = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
|
||||
filesUrl.searchParams.set("index_id", indexId);
|
||||
filesUrl.searchParams.set("page_num", "1");
|
||||
filesUrl.searchParams.set("page_size", "1");
|
||||
const files = await ctx.client.requestJson<RagIndexFilesResponse>({
|
||||
path: filesUrl.toString(),
|
||||
method: "GET",
|
||||
});
|
||||
const totalCount = files.data?.total_count;
|
||||
if (typeof totalCount === "number") docCountPart = ` documents: ${totalCount}`;
|
||||
} catch {
|
||||
// Same graceful degradation as above
|
||||
}
|
||||
return `Delete knowledge base ${indexId}${namePart}${docCountPart}\nThis permanently removes the knowledge base with all documents and chunks. It cannot be undone.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a knowledge base with all its documents and chunks",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: KB_DELETE_FLAGS,
|
||||
notes: [
|
||||
"Irreversible — the knowledge base and all indexed content are permanently removed.",
|
||||
"Files in the data center are not affected; only the knowledge base index is deleted.",
|
||||
],
|
||||
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --yes"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// This endpoint is back to snake_case: body { index_id }
|
||||
const body = { index_id: flags.indexId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDelete);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = flags.yes
|
||||
? "" // --yes bypasses the prompt, so skip the summary lookups
|
||||
: await buildDeleteSummary(ctx, workspaceId, flags.indexId);
|
||||
await confirmDangerousAction(summary, flags.yes ?? false);
|
||||
|
||||
const response = await ctx.client.requestJson<RagMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`deleted: ${flags.indexId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagIndexListResponse,
|
||||
type RagIndexRow,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const KB_INFO_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
function indexListUrl(workspaceId: string, pageNumber: number): string {
|
||||
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
|
||||
url.searchParams.set("page_number", String(pageNumber));
|
||||
url.searchParams.set("page_size", "100");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no dedicated detail API on the server, so fall back to paging
|
||||
* through index/list. If a detail API ships, only this function changes.
|
||||
* Also reused by kb delete for its confirmation summary.
|
||||
*/
|
||||
export async function fetchIndexDetail(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
indexId: string,
|
||||
): Promise<RagIndexRow> {
|
||||
const maxPages = 10;
|
||||
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
|
||||
const response = await client.requestJson<RagIndexListResponse>({
|
||||
path: indexListUrl(workspaceId, pageNumber),
|
||||
method: "GET",
|
||||
});
|
||||
const rows = response.data?.rows ?? [];
|
||||
const match = rows.find((row) => row.id === indexId);
|
||||
if (match) return match;
|
||||
if (rows.length < 100) break; // reached the last page
|
||||
}
|
||||
throw new BailianError(
|
||||
`Knowledge base not found: ${indexId}`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the id — list knowledge bases in this workspace to verify it.",
|
||||
);
|
||||
}
|
||||
|
||||
function formatField(label: string, value: string | number | boolean | null | undefined): string {
|
||||
return ` ${label}: ${value ?? "-"}`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show knowledge base configuration details",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: KB_INFO_FLAGS,
|
||||
notes: [
|
||||
"No dedicated detail API on the server yet — falls back to paginating the index list (up to 10 pages of 100).",
|
||||
"Indexing settings are immutable; changing them requires recreating the knowledge base.",
|
||||
],
|
||||
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
endpoint: indexListUrl(workspaceId, 1),
|
||||
request: null,
|
||||
strategy:
|
||||
"paginate index/list (page_size=100, up to 10 pages) to find --index-id; no dedicated detail API",
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = await fetchIndexDetail(ctx.client, workspaceId, flags.indexId);
|
||||
|
||||
if (settings.quiet || format !== "text") {
|
||||
emitResult(row, format === "text" ? "json" : format);
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped by diagnostic concern; the immutable annotation on Indexing tells
|
||||
// users which settings require recreating the knowledge base
|
||||
emitBare("Basic:");
|
||||
emitBare(formatField("id", row.id));
|
||||
emitBare(formatField("name", row.name));
|
||||
emitBare(formatField("description", row.description));
|
||||
emitBare(formatField("dataType", row.dataType));
|
||||
emitBare("Indexing: [immutable — recreate required to change]");
|
||||
emitBare(formatField("embeddingModelName", row.embeddingModelName));
|
||||
emitBare(formatField("embeddingDimension", row.embeddingDimension));
|
||||
emitBare(formatField("chunkSize", row.chunkSize));
|
||||
emitBare(formatField("overlapSize", row.overlapSize));
|
||||
emitBare(formatField("chunkMode", row.chunkMode));
|
||||
emitBare(formatField("separator", row.separator));
|
||||
emitBare("Retrieval:");
|
||||
emitBare(formatField("rerankModelName", row.rerankModelName));
|
||||
emitBare(formatField("rerankMinScore", row.rerankMinScore));
|
||||
emitBare(formatField("rerankTopN", row.rerankTopN));
|
||||
emitBare(formatField("rerankMode", row.rerankMode));
|
||||
emitBare(formatField("enableRewrite", row.enableRewrite));
|
||||
emitBare(formatField("denseSimilarityTopK", row.denseSimilarityTopK));
|
||||
emitBare(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK));
|
||||
emitBare("Data:");
|
||||
emitBare(formatField("sourceType", row.sourceType));
|
||||
emitBare(formatField("connectorId", row.connectorId));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagIndexListResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const KB_LIST_FLAGS = {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Filter by knowledge base name (fuzzy match, 1-20 chars)",
|
||||
},
|
||||
...PAGE_FLAGS,
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List knowledge bases in the workspace",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[flags]",
|
||||
flags: KB_LIST_FLAGS,
|
||||
notes: [
|
||||
"Auth: uses DashScope API Key (Bearer token).",
|
||||
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.",
|
||||
"Use the returned id as --index-id in knowledge base / document management commands.",
|
||||
],
|
||||
exampleArgs: ["--workspace-id ws-xxx", "--name demo --page-number 2 --page-size 50"],
|
||||
validate(flags) {
|
||||
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
|
||||
return "--name must be 1-20 characters";
|
||||
}
|
||||
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
|
||||
return "--page-size must be between 1 and 100";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Pagination and filter parameters must go in the query string — the server ignores them in the body
|
||||
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
|
||||
if (flags.name) url.searchParams.set("pipeline_name", flags.name);
|
||||
url.searchParams.set("page_number", String(flags.pageNumber ?? 1));
|
||||
url.searchParams.set("page_size", String(flags.pageSize ?? 20));
|
||||
const endpoint = url.toString();
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: null }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagIndexListResponse>({
|
||||
path: endpoint,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const rows = response.data?.rows ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const row of rows) emitBare(row.id);
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
if (rows.length === 0) {
|
||||
emitBare("No knowledge bases found.");
|
||||
} else {
|
||||
for (const row of rows) {
|
||||
emitBare(
|
||||
truncateLine(
|
||||
[
|
||||
row.id,
|
||||
row.name,
|
||||
row.embeddingModelName ?? "-",
|
||||
row.chunkSize ?? "-",
|
||||
row.description ?? "",
|
||||
].join(" "),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
emitBare(`total: ${response.data?.total ?? rows.length}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
type RagMonitorResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const KB_STATS_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
type: "string",
|
||||
valueHint: "<time>",
|
||||
description: "Range start: Unix seconds or ISO date (default: 24 hours ago)",
|
||||
},
|
||||
end: {
|
||||
type: "string",
|
||||
valueHint: "<time>",
|
||||
description: "Range end: Unix seconds or ISO date (default: now)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Normalize time input to a second-precision string (the API requires seconds as a string). Accepts Unix seconds or an ISO date. */
|
||||
export function toEpochSecondsString(input: string): string {
|
||||
if (/^\d+$/.test(input)) {
|
||||
// Digits-only input is treated as Unix seconds; 13-digit millisecond timestamps are reduced to seconds
|
||||
return input.length >= 13 ? String(Math.floor(Number(input) / 1000)) : input;
|
||||
}
|
||||
const parsedMs = Date.parse(input);
|
||||
if (Number.isNaN(parsedMs)) {
|
||||
throw new BailianError(
|
||||
`Invalid time value: ${input}`,
|
||||
ExitCode.USAGE,
|
||||
"Pass Unix seconds (e.g. 1780900000) or an ISO date (e.g. 2026-07-30T00:00:00Z).",
|
||||
);
|
||||
}
|
||||
return String(Math.floor(parsedMs / 1000));
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show knowledge base storage and QPS monitoring data",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: KB_STATS_FLAGS,
|
||||
notes: [
|
||||
"Defaults to the last 24 hours when --start/--end are omitted.",
|
||||
"Timestamps are normalized to epoch seconds as required by the server.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --start 2026-07-30 --end 2026-07-31",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const startTimestamp = flags.start
|
||||
? toEpochSecondsString(flags.start)
|
||||
: String(nowSeconds - 24 * 3600);
|
||||
const endTimestamp = flags.end ? toEpochSecondsString(flags.end) : String(nowSeconds);
|
||||
|
||||
const body = { indexId: flags.indexId, startTimestamp, endTimestamp };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexMonitor);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagMonitorResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet || format !== "text") {
|
||||
emitResult(response, format === "text" ? "json" : format);
|
||||
return;
|
||||
}
|
||||
// Shape verified against the live API: the monitor fields are objects, not arrays
|
||||
const storage = response.data?.storageMonitorData;
|
||||
const qps = response.data?.qpsMonitorData;
|
||||
emitBare(`plan: ${response.data?.pipelineCommercialType ?? "-"}`);
|
||||
emitBare(
|
||||
`storage: ${storage?.indexStorageUsage ?? "-"} / ${storage?.indexStorageLimit ?? "-"}`,
|
||||
);
|
||||
emitBare(`peak qps: ${qps?.peakQps ?? "-"}`);
|
||||
emitBare(`qps windows: ${qps?.monitorData?.length ?? 0} data point(s)`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const KB_UPDATE_FLAGS = {
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Knowledge base ID",
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "New knowledge base name (1-20 chars)",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "New knowledge base description",
|
||||
},
|
||||
rerankMinScore: {
|
||||
type: "number",
|
||||
valueHint: "<score>",
|
||||
description: "Rerank minimum score threshold, range 0-1 (chunks below are filtered)",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update knowledge base name, description or rerank threshold",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--index-id <id> [flags]",
|
||||
flags: KB_UPDATE_FLAGS,
|
||||
notes: [
|
||||
"Indexing settings (embedding model, chunk size, etc.) are immutable — recreate the knowledge base to change them.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --description 'product docs v2' --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --rerank-min-score 0.3",
|
||||
],
|
||||
validate(flags) {
|
||||
if (
|
||||
flags.name === undefined &&
|
||||
flags.description === undefined &&
|
||||
flags.rerankMinScore === undefined
|
||||
) {
|
||||
return "Nothing to update — pass --name, --description or --rerank-min-score";
|
||||
}
|
||||
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
|
||||
return "--name must be 1-20 characters";
|
||||
}
|
||||
if (
|
||||
flags.rerankMinScore !== undefined &&
|
||||
(flags.rerankMinScore < 0 || flags.rerankMinScore > 1)
|
||||
) {
|
||||
return "--rerank-min-score must be between 0 and 1";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Gotcha: this endpoint names the knowledge base ID parameter `id` (not index_id/indexId)
|
||||
const body = {
|
||||
id: flags.indexId,
|
||||
...(flags.name !== undefined ? { name: flags.name } : {}),
|
||||
...(flags.description !== undefined ? { description: flags.description } : {}),
|
||||
...(flags.rerankMinScore !== undefined ? { rerankMinScore: flags.rerankMinScore } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexUpdate);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`updated: ${flags.indexId}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type KnowledgeSearchResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SEARCH_FLAGS = {
|
||||
query: {
|
||||
@@ -23,11 +24,15 @@ const SEARCH_FLAGS = {
|
||||
description: "Retrieval service ID (find in console knowledge retrieval page)",
|
||||
required: true,
|
||||
},
|
||||
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
|
||||
workspaceId: {
|
||||
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
|
||||
// flag here (the console credential scope does not apply).
|
||||
...WORKSPACE_FLAG,
|
||||
// Named to avoid the runtime-reserved global --version flag
|
||||
agentVersion: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
|
||||
valueHint: "<version>",
|
||||
description:
|
||||
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
|
||||
},
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -52,6 +57,7 @@ export default defineCommand({
|
||||
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
|
||||
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
|
||||
"`--query-history` passes prior conversation turns; the server rewrites the query based on context to improve retrieval relevance.",
|
||||
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
|
||||
],
|
||||
exampleArgs: [
|
||||
'--query "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
|
||||
@@ -61,14 +67,7 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
|
||||
const workspaceId = flags.workspaceId || settings.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Workspace ID is required.",
|
||||
ExitCode.USAGE,
|
||||
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
|
||||
);
|
||||
}
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -77,6 +76,12 @@ export default defineCommand({
|
||||
agent_id: flags.agentId,
|
||||
};
|
||||
|
||||
// Omitted flag → field not sent (default behavior unchanged: latest published
|
||||
// version); the value is not validated — the set of versions is server-side state
|
||||
if (flags.agentVersion) {
|
||||
body.agent_version = flags.agentVersion;
|
||||
}
|
||||
|
||||
if (flags.image && flags.image.length > 0) {
|
||||
body.images = flags.image;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAgentMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_COPY_FLAGS = {
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Source service (agent) ID to copy",
|
||||
required: true,
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Copy a service into a new draft (name gets a copy_ prefix)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--agent-id <id> [flags]",
|
||||
flags: SERVICE_COPY_FLAGS,
|
||||
notes: [
|
||||
"The copy starts as a beta draft; test it with --agent-version beta, then deploy to publish.",
|
||||
"Requires the knowledge-base create permission in the workspace.",
|
||||
],
|
||||
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = { agent_id: flags.agentId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCopy);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const newAgentId = agentMutationField(response, "agent_id");
|
||||
if (settings.quiet) {
|
||||
emitBare(newAgentId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(
|
||||
`new agent_id: ${newAgentId ?? "-"} (name: ${agentMutationField(response, "agent_name") ?? "-"}, status: ${agentMutationField(response, "agent_status") ?? "draft"})`,
|
||||
);
|
||||
emitBare(
|
||||
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAgentMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_CREATE_FLAGS = {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Service name (up to 200 chars, unique per scene in the workspace)",
|
||||
required: true,
|
||||
},
|
||||
scene: {
|
||||
type: "string",
|
||||
valueHint: "<scene>",
|
||||
description: "Service scene: chat (Q&A) or search (retrieval)",
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Service description (up to 1000 chars)",
|
||||
},
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Bind this knowledge base; other settings use server defaults",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a retrieval / Q&A service (initial status: draft, version: beta)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--name <text> --scene <chat|search> [flags]",
|
||||
flags: SERVICE_CREATE_FLAGS,
|
||||
notes: [
|
||||
"Without an explicit configuration the server applies its default agent settings.",
|
||||
"The draft (beta) version can be tested via --agent-version beta on search/chat before deploying.",
|
||||
"Requires the knowledge-base create permission in the workspace.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--name my-qa --scene chat --workspace-id ws-xxx",
|
||||
"--name my-search --scene search --index-id idx-xxx",
|
||||
],
|
||||
validate(flags) {
|
||||
if (flags.name.length > 200) return "--name must be at most 200 characters";
|
||||
if (flags.scene !== "chat" && flags.scene !== "search") {
|
||||
return "--scene must be chat or search";
|
||||
}
|
||||
if (flags.description !== undefined && flags.description.length > 1000) {
|
||||
return "--description must be at most 1000 characters";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// agent_config is optional — omit to use server defaults; with --index-id build
|
||||
// the minimal kb_search_configs (name/desc etc. are backfilled by the system from
|
||||
// the knowledge base, so they are not sent)
|
||||
const body = {
|
||||
agent_name: flags.name,
|
||||
agent_scene: flags.scene,
|
||||
...(flags.description ? { agent_desc: flags.description } : {}),
|
||||
...(flags.indexId ? { agent_config: { kb_search_configs: [{ id: flags.indexId }] } } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCreate);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const agentId = agentMutationField(response, "agent_id");
|
||||
if (settings.quiet) {
|
||||
emitBare(agentId ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(
|
||||
`created: ${agentId ?? "-"} (status: ${agentMutationField(response, "agent_status") ?? "draft"}, version: ${agentMutationField(response, "agent_version") ?? "beta"})`,
|
||||
);
|
||||
emitBare(
|
||||
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagAgentGetResponse,
|
||||
type RagAgentMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_DELETE_FLAGS = {
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Service (agent) ID",
|
||||
required: true,
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary lookup (name/status); failure degrades to id-only */
|
||||
async function buildDeleteSummary(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): Promise<string> {
|
||||
let infoPart = "";
|
||||
let liveWarning = "";
|
||||
try {
|
||||
const detail = await client.requestJson<RagAgentGetResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
|
||||
method: "POST",
|
||||
body: { agent_id: agentId },
|
||||
});
|
||||
const name = detail.data?.agent_name;
|
||||
const status = detail.data?.agent_status;
|
||||
if (name) infoPart += ` name: ${name}`;
|
||||
if (status) {
|
||||
infoPart += ` status: ${status}`;
|
||||
if (status === "deployed" || status === "edited") {
|
||||
liveWarning = "\nWARNING: this service is LIVE — deleting it breaks existing callers.";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Degrade gracefully: a failed lookup does not block confirmation
|
||||
}
|
||||
return `Delete service ${agentId}${infoPart}${liveWarning}\nDeletion cannot be undone; the agent_id can no longer be used for search or chat calls.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a retrieval / Q&A service (soft delete, idempotent)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--agent-id <id> [flags]",
|
||||
flags: SERVICE_DELETE_FLAGS,
|
||||
notes: [
|
||||
"Deletion cannot be undone; the agent_id becomes unusable for search and chat calls.",
|
||||
"The API is idempotent — deleting an already-deleted service does not fail.",
|
||||
"Requires the knowledge-base delete permission in the workspace.",
|
||||
],
|
||||
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx", "--agent-id aid-xxx --yes"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = { agent_id: flags.agentId };
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDelete);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = flags.yes
|
||||
? ""
|
||||
: await buildDeleteSummary(ctx.client, workspaceId, flags.agentId);
|
||||
await confirmDangerousAction(summary, flags.yes ?? false);
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(
|
||||
`deleted: ${flags.agentId} (status: ${agentMutationField(response, "agent_status") ?? "deleted"})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagAgentGetResponse,
|
||||
type RagAgentMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_DEPLOY_FLAGS = {
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Service (agent) ID",
|
||||
required: true,
|
||||
},
|
||||
versionDesc: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Description for the newly published version",
|
||||
},
|
||||
yes: { type: "switch", description: "Skip the confirmation prompt" },
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary lookup (name/status); warns that deploying an edited draft overwrites live behavior; failure degrades to id-only */
|
||||
async function buildDeploySummary(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): Promise<string> {
|
||||
let infoPart = "";
|
||||
let editedWarning = "";
|
||||
try {
|
||||
const detail = await client.requestJson<RagAgentGetResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
|
||||
method: "POST",
|
||||
body: { agent_id: agentId },
|
||||
});
|
||||
const name = detail.data?.agent_name;
|
||||
const status = detail.data?.agent_status;
|
||||
if (name) infoPart += ` name: ${name}`;
|
||||
if (status) {
|
||||
infoPart += ` status: ${status}`;
|
||||
if (status === "edited") {
|
||||
editedWarning =
|
||||
"\nWARNING: a published version is live — deploying replaces its behavior with the current draft.";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Degrade gracefully: a failed lookup does not block confirmation
|
||||
}
|
||||
return `Deploy service ${agentId}${infoPart}${editedWarning}\nPublishing changes what live callers get from this service.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Publish the beta draft of a service as a new version",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--agent-id <id> [flags]",
|
||||
flags: SERVICE_DEPLOY_FLAGS,
|
||||
notes: [
|
||||
"The version number auto-increments; status becomes deployed.",
|
||||
"Publishing affects live callers — the confirmation prompt guards against accidents.",
|
||||
"Requires the knowledge-base modify permission in the workspace.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--agent-id aid-xxx --workspace-id ws-xxx",
|
||||
"--agent-id aid-xxx --version-desc 'tuned rerank params' --yes",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = {
|
||||
agent_id: flags.agentId,
|
||||
...(flags.versionDesc ? { agent_version_desc: flags.versionDesc } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDeploy);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = flags.yes
|
||||
? ""
|
||||
: await buildDeploySummary(ctx.client, workspaceId, flags.agentId);
|
||||
await confirmDangerousAction(summary, flags.yes ?? false);
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const newVersion = agentMutationField(response, "agent_version");
|
||||
if (settings.quiet) {
|
||||
emitBare(newVersion ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
emitBare(`deployed: ${flags.agentId} version ${newVersion ?? "-"}`);
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAgentDetail,
|
||||
type RagAgentGetResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_GET_FLAGS = {
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Service (agent) ID",
|
||||
required: true,
|
||||
},
|
||||
agentVersion: {
|
||||
type: "string",
|
||||
valueHint: "<version>",
|
||||
description: "Specific version to inspect (beta or a published number); omit for all versions",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
function printDetail(detail: RagAgentDetail): void {
|
||||
emitBare(`Version ${detail.agent_version ?? "?"}:`);
|
||||
if (detail.agent_version_desc) emitBare(` desc: ${detail.agent_version_desc}`);
|
||||
if (detail.publish_time !== undefined) emitBare(` published: ${detail.publish_time}`);
|
||||
const config = detail.agent_config;
|
||||
if (!config) return;
|
||||
if (config.agent_policy) emitBare(` policy: ${config.agent_policy}`);
|
||||
if (config.agent_model) emitBare(` model: ${config.agent_model}`);
|
||||
if (config.temperature !== undefined) emitBare(` temperature: ${config.temperature}`);
|
||||
for (const kbConfig of config.kb_search_configs ?? []) {
|
||||
const kbId = typeof kbConfig.id === "string" ? kbConfig.id : "?";
|
||||
const kbName = typeof kbConfig.name === "string" ? ` (${kbConfig.name})` : "";
|
||||
emitBare(` kb: ${kbId}${kbName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show service (agent) details including per-version configuration",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--agent-id <id> [flags]",
|
||||
flags: SERVICE_GET_FLAGS,
|
||||
notes: [
|
||||
"Without --agent-version all versions are returned (beta draft plus published numbers).",
|
||||
"The version value is passed through as-is; the valid set is server-side state.",
|
||||
],
|
||||
exampleArgs: [
|
||||
"--agent-id aid-xxx --workspace-id ws-xxx",
|
||||
"--agent-id aid-xxx --agent-version beta",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body = {
|
||||
agent_id: flags.agentId,
|
||||
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentGet);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentGetResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (settings.quiet || format !== "text") {
|
||||
emitResult(response, format === "text" ? "json" : format);
|
||||
return;
|
||||
}
|
||||
emitBare("Basic:");
|
||||
emitBare(` id: ${data?.agent_id ?? "-"}`);
|
||||
emitBare(` name: ${data?.agent_name ?? "-"}`);
|
||||
emitBare(` desc: ${data?.agent_desc ?? "-"}`);
|
||||
emitBare(` scene: ${data?.agent_scene ?? "-"}`);
|
||||
emitBare(` status: ${data?.agent_status ?? "-"}`);
|
||||
for (const detail of data?.agent_details ?? []) {
|
||||
printDetail(detail);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
type FlagsDef,
|
||||
type RagAgentListResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const SERVICE_LIST_FLAGS = {
|
||||
scene: {
|
||||
type: "string",
|
||||
valueHint: "<scene>",
|
||||
description: "Service scene: chat (Q&A) or search (retrieval). Required by the server",
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
valueHint: "<status>",
|
||||
description: "Filter by status: draft, deployed (includes edited) or deleted",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Filter by service name (fuzzy match)",
|
||||
},
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Filter by exact agent ID",
|
||||
},
|
||||
indexId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Filter by linked knowledge base (pipeline) ID",
|
||||
},
|
||||
...PAGE_FLAGS,
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const SCENES = ["chat", "search"];
|
||||
const STATUSES = ["draft", "deployed", "deleted"];
|
||||
|
||||
export default defineCommand({
|
||||
description: "List retrieval / Q&A services (agents) in the workspace",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--scene <chat|search> [flags]",
|
||||
flags: SERVICE_LIST_FLAGS,
|
||||
notes: [
|
||||
"The server requires a scene — run once per scene to see both chat and search services.",
|
||||
"Use the returned agent_id with the search or chat commands, or with service management commands.",
|
||||
],
|
||||
exampleArgs: ["--scene chat --workspace-id ws-xxx", "--scene search --status deployed"],
|
||||
validate(flags) {
|
||||
if (!SCENES.includes(flags.scene)) return "--scene must be chat or search";
|
||||
if (flags.status !== undefined && !STATUSES.includes(flags.status)) {
|
||||
return "--status must be draft, deployed or deleted";
|
||||
}
|
||||
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
|
||||
return "--page-size must be between 1 and 100";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// agent/list pagination goes in the body
|
||||
const body = {
|
||||
agent_scene: flags.scene,
|
||||
...(flags.status ? { agent_status: flags.status } : {}),
|
||||
...(flags.name ? { agent_name: flags.name } : {}),
|
||||
...(flags.agentId ? { agent_id: flags.agentId } : {}),
|
||||
...(flags.indexId ? { pipeline_id: flags.indexId } : {}),
|
||||
page_number: flags.pageNumber ?? 1,
|
||||
page_size: flags.pageSize ?? 10,
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentList);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ endpoint, request: body }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentListResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const rows = response.data?.rows ?? [];
|
||||
if (settings.quiet) {
|
||||
for (const row of rows) emitBare(row.agent_id ?? "");
|
||||
return;
|
||||
}
|
||||
if (format === "text") {
|
||||
if (rows.length === 0) {
|
||||
emitBare("No services found.");
|
||||
} else {
|
||||
for (const row of rows) {
|
||||
const kbNames = (row.pipeline_list ?? [])
|
||||
.map((pipeline) => pipeline.pipeline_name)
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
emitBare(
|
||||
truncateLine(
|
||||
[
|
||||
row.agent_id,
|
||||
row.agent_status,
|
||||
row.agent_version,
|
||||
row.agent_name,
|
||||
kbNames ? `(kb: ${kbNames})` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
|
||||
// Connect finding an ID with using it (capability wording, no hardcoded product path)
|
||||
emitBare(
|
||||
flags.scene === "search"
|
||||
? "Use an agent_id above with the knowledge search command."
|
||||
: "Use an agent_id above with the knowledge chat command.",
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
defineCommand,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagAgentConfig,
|
||||
type RagAgentGetResponse,
|
||||
type RagAgentMutationResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const BOOL_CHOICES = ["true", "false"];
|
||||
|
||||
const SERVICE_UPDATE_FLAGS = {
|
||||
agentId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Service (agent) ID",
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "New service name (up to 200 chars)",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "New service description (up to 1000 chars)",
|
||||
},
|
||||
agentVersion: {
|
||||
type: "string",
|
||||
valueHint: "<version>",
|
||||
description:
|
||||
"Target version (default: beta draft). Published versions only accept --version-desc",
|
||||
},
|
||||
versionDesc: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Version description",
|
||||
},
|
||||
policy: {
|
||||
type: "string",
|
||||
valueHint: "<policy>",
|
||||
description: "Agent policy: turbo (fast) or agentic (multi-turn)",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Generation model code (must be in the platform allowlist)",
|
||||
},
|
||||
temperature: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Sampling temperature, range 0-2",
|
||||
},
|
||||
maxLlmCalls: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Max LLM calls per request, range 1-30",
|
||||
},
|
||||
enableSessionFile: {
|
||||
type: "string",
|
||||
valueHint: "<bool>",
|
||||
description: "Enable session files: true or false",
|
||||
},
|
||||
enableRefusal: {
|
||||
type: "string",
|
||||
valueHint: "<bool>",
|
||||
description: "Enable refusal answers: true or false",
|
||||
},
|
||||
enableAntiLeak: {
|
||||
type: "string",
|
||||
valueHint: "<bool>",
|
||||
description: "Enable anti prompt-leak: true or false",
|
||||
},
|
||||
enableRichText: {
|
||||
type: "string",
|
||||
valueHint: "<bool>",
|
||||
description: "Enable rich text output: true or false",
|
||||
},
|
||||
enableCitation: {
|
||||
type: "string",
|
||||
valueHint: "<bool>",
|
||||
description: "Enable citations: true or false",
|
||||
},
|
||||
configFile: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description:
|
||||
"JSON file replacing the whole agent_config (for nested settings like kb_search_configs); mutually exclusive with scalar config flags",
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
type UpdateFlags = {
|
||||
policy?: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxLlmCalls?: number;
|
||||
enableSessionFile?: string;
|
||||
enableRefusal?: string;
|
||||
enableAntiLeak?: string;
|
||||
enableRichText?: string;
|
||||
enableCitation?: string;
|
||||
};
|
||||
|
||||
/** Scalar config flag → agent_config field mapping (centralized for validation and merge) */
|
||||
function collectScalarConfig(flags: UpdateFlags): Partial<RagAgentConfig> {
|
||||
const scalar: Partial<RagAgentConfig> = {};
|
||||
if (flags.policy !== undefined) scalar.agent_policy = flags.policy;
|
||||
if (flags.model !== undefined) scalar.agent_model = flags.model;
|
||||
if (flags.temperature !== undefined) scalar.temperature = flags.temperature;
|
||||
if (flags.maxLlmCalls !== undefined) scalar.max_num_llm_calls = flags.maxLlmCalls;
|
||||
if (flags.enableSessionFile !== undefined) scalar.enable_session_file = flags.enableSessionFile;
|
||||
if (flags.enableRefusal !== undefined) scalar.enable_refusal = flags.enableRefusal;
|
||||
if (flags.enableAntiLeak !== undefined) scalar.enable_anti_leak = flags.enableAntiLeak;
|
||||
if (flags.enableRichText !== undefined) scalar.enable_rich_text = flags.enableRichText;
|
||||
if (flags.enableCitation !== undefined) scalar.enable_citation = flags.enableCitation;
|
||||
return scalar;
|
||||
}
|
||||
|
||||
/** --config-file: read JSON + structural/enum/range validation; unknown fields warn but pass through */
|
||||
export function parseConfigFile(filePath: string): RagAgentConfig {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf-8");
|
||||
} catch (error) {
|
||||
const errno = (error as { code?: string }).code ?? "unknown";
|
||||
throw new BailianError(
|
||||
`Cannot read config file: ${filePath}`,
|
||||
ExitCode.GENERAL,
|
||||
`File system error (${errno}) — check the path and permissions.`,
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new BailianError("--config-file must contain valid JSON.", ExitCode.USAGE);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new BailianError("--config-file must contain a JSON object.", ExitCode.USAGE);
|
||||
}
|
||||
const config = parsed as RagAgentConfig;
|
||||
validateConfigValues(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
"agent_policy",
|
||||
"agent_model",
|
||||
"enable_session_file",
|
||||
"enable_refusal",
|
||||
"enable_anti_leak",
|
||||
"enable_rich_text",
|
||||
"enable_citation",
|
||||
"temperature",
|
||||
"max_num_llm_calls",
|
||||
"max_completion_tokens",
|
||||
"session_file_max_parse_length",
|
||||
"enable_kb_router",
|
||||
"kb_router_model",
|
||||
"rerank_top_n",
|
||||
"hybrid_rerank",
|
||||
"kb_search_configs",
|
||||
]);
|
||||
|
||||
/** Enum/range validation + behavioral warnings, per the agent_config contract */
|
||||
export function validateConfigValues(config: RagAgentConfig): void {
|
||||
if (config.agent_policy !== undefined && !["turbo", "agentic"].includes(config.agent_policy)) {
|
||||
throw new BailianError("agent_policy must be turbo or agentic", ExitCode.USAGE);
|
||||
}
|
||||
if (config.temperature !== undefined && (config.temperature < 0 || config.temperature > 2)) {
|
||||
throw new BailianError("temperature must be between 0 and 2", ExitCode.USAGE);
|
||||
}
|
||||
if (
|
||||
config.max_num_llm_calls !== undefined &&
|
||||
(config.max_num_llm_calls < 1 || config.max_num_llm_calls > 30)
|
||||
) {
|
||||
throw new BailianError("max_num_llm_calls must be between 1 and 30", ExitCode.USAGE);
|
||||
}
|
||||
if (config.rerank_top_n !== undefined && (config.rerank_top_n < 1 || config.rerank_top_n > 20)) {
|
||||
throw new BailianError("rerank_top_n must be between 1 and 20", ExitCode.USAGE);
|
||||
}
|
||||
// Behavioral warning: the server clears rerank_instruct unless rerank_mode is custom
|
||||
for (const kbConfig of config.kb_search_configs ?? []) {
|
||||
const rerank = kbConfig.rerank as
|
||||
| { rerank_mode?: string; rerank_instruct?: string }
|
||||
| undefined;
|
||||
if (rerank?.rerank_instruct && rerank.rerank_mode !== "custom") {
|
||||
process.stderr.write(
|
||||
"Warning: rerank_instruct is only effective with rerank_mode=custom; the server clears it otherwise.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Unknown fields: warn but pass through (the server is the source of truth)
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!KNOWN_CONFIG_KEYS.has(key)) {
|
||||
process.stderr.write(`Warning: unknown agent_config field passed through: ${key}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** read-merge-write: fetch the full beta-draft config and merge scalar changes (the API has whole-replace semantics) */
|
||||
async function fetchBetaConfig(
|
||||
client: Client,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): Promise<RagAgentConfig> {
|
||||
const response = await client.requestJson<RagAgentGetResponse>({
|
||||
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
|
||||
method: "POST",
|
||||
body: { agent_id: agentId, agent_version: "beta" },
|
||||
});
|
||||
const betaDetail = (response.data?.agent_details ?? []).find(
|
||||
(detail) => detail.agent_version === "beta",
|
||||
);
|
||||
return betaDetail?.agent_config ?? {};
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update service name, description or draft configuration",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--agent-id <id> [flags]",
|
||||
flags: SERVICE_UPDATE_FLAGS,
|
||||
notes: [
|
||||
"Configuration changes only apply to the beta draft; published versions accept --version-desc only.",
|
||||
"To change the configuration of a published version, first update the beta draft (this command without --agent-version or with --agent-version beta), then run service deploy to publish a new version.",
|
||||
"Scalar config flags merge into the current draft config (read-merge-write); --config-file replaces the whole config and is mutually exclusive with them.",
|
||||
"After updating the draft, verify with --agent-version beta on search/chat, then deploy.",
|
||||
"Requires the knowledge-base modify permission in the workspace.",
|
||||
],
|
||||
// Note on `agent_version` in the request body: this is the agent-management
|
||||
// domain's "target version" parameter (beta draft or a published version
|
||||
// number). It is NOT the search/chat debug-draft field that the project-wide
|
||||
// agent_version cleanup removes — the two share a name but live in different
|
||||
// API surfaces and have different semantics.
|
||||
exampleArgs: [
|
||||
"--agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx",
|
||||
"--agent-id aid-xxx --config-file ./agent-config.json",
|
||||
"--agent-id aid-xxx --agent-version 1 --version-desc 'first stable release'",
|
||||
],
|
||||
validate(flags) {
|
||||
const scalarTouched = Object.keys(collectScalarConfig(flags)).length > 0;
|
||||
const anyChange =
|
||||
flags.name !== undefined ||
|
||||
flags.description !== undefined ||
|
||||
flags.versionDesc !== undefined ||
|
||||
flags.configFile !== undefined ||
|
||||
scalarTouched;
|
||||
if (!anyChange)
|
||||
return "Nothing to update — pass a name/description/version-desc or config flags";
|
||||
if (flags.configFile !== undefined && scalarTouched) {
|
||||
return "--config-file is mutually exclusive with scalar config flags";
|
||||
}
|
||||
// Version check up front: published version + config change → USAGE (the server
|
||||
// would also reject it, but failing early is faster)
|
||||
const publishedVersion = flags.agentVersion !== undefined && flags.agentVersion !== "beta";
|
||||
if (publishedVersion && (scalarTouched || flags.configFile !== undefined)) {
|
||||
return "Published versions only accept --version-desc; update the beta draft to change config";
|
||||
}
|
||||
if (flags.name !== undefined && flags.name.length > 200) {
|
||||
return "--name must be at most 200 characters";
|
||||
}
|
||||
if (flags.description !== undefined && flags.description.length > 1000) {
|
||||
return "--description must be at most 1000 characters";
|
||||
}
|
||||
if (flags.policy !== undefined && !["turbo", "agentic"].includes(flags.policy)) {
|
||||
return "--policy must be turbo or agentic";
|
||||
}
|
||||
if (flags.temperature !== undefined && (flags.temperature < 0 || flags.temperature > 2)) {
|
||||
return "--temperature must be between 0 and 2";
|
||||
}
|
||||
if (flags.maxLlmCalls !== undefined && (flags.maxLlmCalls < 1 || flags.maxLlmCalls > 30)) {
|
||||
return "--max-llm-calls must be between 1 and 30";
|
||||
}
|
||||
for (const [flagName, value] of [
|
||||
["--enable-session-file", flags.enableSessionFile],
|
||||
["--enable-refusal", flags.enableRefusal],
|
||||
["--enable-anti-leak", flags.enableAntiLeak],
|
||||
["--enable-rich-text", flags.enableRichText],
|
||||
["--enable-citation", flags.enableCitation],
|
||||
] as const) {
|
||||
if (value !== undefined && !BOOL_CHOICES.includes(value)) {
|
||||
return `${flagName} must be true or false`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const workspaceId = resolveWorkspaceId(ctx);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const scalarConfig = collectScalarConfig(flags);
|
||||
const hasConfigChange = flags.configFile !== undefined || Object.keys(scalarConfig).length > 0;
|
||||
|
||||
let agentConfig: RagAgentConfig | undefined;
|
||||
if (flags.configFile !== undefined) {
|
||||
// dry-run also reads the file and validates (rehearsal semantics)
|
||||
agentConfig = parseConfigFile(flags.configFile);
|
||||
} else if (Object.keys(scalarConfig).length > 0) {
|
||||
if (settings.dryRun) {
|
||||
// dry-run does not issue the read-merge request; show the scalar delta with a placeholder note
|
||||
agentConfig = { ...scalarConfig };
|
||||
} else {
|
||||
// The API has whole-replace semantics — read the full beta config first,
|
||||
// then merge (hidden from the user)
|
||||
const currentConfig = await fetchBetaConfig(ctx.client, workspaceId, flags.agentId);
|
||||
agentConfig = { ...currentConfig, ...scalarConfig };
|
||||
validateConfigValues(agentConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
agent_id: flags.agentId,
|
||||
...(flags.name !== undefined ? { agent_name: flags.name } : {}),
|
||||
...(flags.description !== undefined ? { agent_desc: flags.description } : {}),
|
||||
...(flags.agentVersion !== undefined ? { agent_version: flags.agentVersion } : {}),
|
||||
...(flags.versionDesc !== undefined ? { agent_version_desc: flags.versionDesc } : {}),
|
||||
...(agentConfig !== undefined ? { agent_config: agentConfig } : {}),
|
||||
};
|
||||
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentUpdate);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
endpoint,
|
||||
request: body,
|
||||
...(hasConfigChange && flags.configFile === undefined
|
||||
? { note: "scalar config flags are merged into the current beta config at run time" }
|
||||
: {}),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (settings.quiet) return;
|
||||
if (format === "text") {
|
||||
emitBare(`updated: ${flags.agentId}`);
|
||||
if (hasConfigChange) {
|
||||
emitBare("Draft config changed — verify with --agent-version beta, then deploy.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
emitResult(response, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Shared building blocks for the knowledge admin commands.
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type RagIndexJobDoc,
|
||||
type RagIndexJobStatusResponse,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
|
||||
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
|
||||
// flag here (the console credential scope does not apply).
|
||||
export const WORKSPACE_FLAG = {
|
||||
workspaceId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
// Unified pagination flags for admin list commands. The server-side page/size
|
||||
// parameter names differ per endpoint (page_number/page_num/pageNum/pageNumber…)
|
||||
// and the body-vs-query-string placement also varies — each command maps these
|
||||
// flags to its own API contract. Never pass the flag names through verbatim;
|
||||
// the CLI-facing flag vocabulary is stable even when the backend is not.
|
||||
export const PAGE_FLAGS = {
|
||||
pageNumber: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
pageSize: { type: "number", valueHint: "<n>", description: "Page size per request" },
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Three-level fallback: flag > BAILIAN_WORKSPACE_ID env > config (env/config are merged into settings); missing → USAGE. */
|
||||
export function resolveWorkspaceId(ctx: {
|
||||
flags: { workspaceId?: string };
|
||||
settings: { workspaceId?: string };
|
||||
identity: { binName: string };
|
||||
}): string {
|
||||
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new BailianError(
|
||||
"Workspace ID is required.",
|
||||
ExitCode.USAGE,
|
||||
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
|
||||
);
|
||||
}
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
/** Truncate text-mode table rows to the terminal width; no truncation when not a TTY (pipe/redirect). */
|
||||
export function truncateLine(line: string): string {
|
||||
if (!process.stdout.isTTY) return line;
|
||||
const width = process.stdout.columns ?? 120;
|
||||
return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line;
|
||||
}
|
||||
|
||||
// ---- Shared import-job (index_job/status) logic ----
|
||||
// Verified against the live API: the overall job state lives in `ingestion_status`
|
||||
// (PENDING/RUNNING/COMPLETED, no FAILED value); the per-document list is `rows[]`
|
||||
// and failures surface via `rows[].code` (e.g. PARSE_FAILED).
|
||||
// Shared by doc status / doc upload / kb create — contract changes only touch this file.
|
||||
|
||||
/** Overall job state (ingestion_status) */
|
||||
export function importJobStatus(response: unknown): string {
|
||||
return (response as RagIndexJobStatusResponse).data?.ingestion_status ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
/** Per-document failures: rows[].code / status containing FAILED */
|
||||
export function failedImportDocs(response: RagIndexJobStatusResponse): RagIndexJobDoc[] {
|
||||
return (response.data?.rows ?? []).filter((doc) =>
|
||||
[doc.code, doc.status].some((value) => typeof value === "string" && value.includes("FAILED")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-document failure summary: server message passed through verbatim + per-document detail */
|
||||
export function importJobFailureMessage(
|
||||
response: RagIndexJobStatusResponse,
|
||||
fallbackMessage: string,
|
||||
): string {
|
||||
const detail = failedImportDocs(response)
|
||||
.map((doc) => `${doc.doc_name ?? doc.doc_id ?? "?"}: ${doc.message ?? doc.code ?? "unknown"}`)
|
||||
.join("; ");
|
||||
const base =
|
||||
typeof response.message === "string" && response.message ? response.message : fallbackMessage;
|
||||
return detail ? `${base} (${detail})` : base;
|
||||
}
|
||||
|
||||
/** Build the index_job/status query string (both index_id and job_id are required) */
|
||||
export function importJobStatusUrl(workspaceId: string, indexId: string, jobId: string): URL {
|
||||
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
|
||||
url.searchParams.set("index_id", indexId);
|
||||
url.searchParams.set("job_id", jobId);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the import job until the overall state is COMPLETED.
|
||||
* The overall state has no FAILED value, so isFailed is always false — failures
|
||||
* are determined by the caller after return via `failedImportDocs` (semantics:
|
||||
* the job finished, but some documents failed to parse).
|
||||
*/
|
||||
export async function pollImportJob(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
options: { statusUrl: string; intervalSec: number },
|
||||
): Promise<RagIndexJobStatusResponse> {
|
||||
return poll<RagIndexJobStatusResponse>(client, settings, {
|
||||
url: options.statusUrl,
|
||||
intervalSec: options.intervalSec,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (data) => importJobStatus(data) === "COMPLETED",
|
||||
isFailed: () => false,
|
||||
getStatus: (data) => importJobStatus(data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Attach a hint for partial-success cases while preserving server context (api/rawResponse kept) */
|
||||
export function withPartialSuccessHint(error: unknown, hint: string): unknown {
|
||||
if (!(error instanceof BailianError) || error.hint) return error;
|
||||
return new BailianError(error.message, error.exitCode, hint, {
|
||||
cause: error,
|
||||
api: error.api,
|
||||
rawResponse: error.rawResponse,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read response fields from agent-domain mutation endpoints. Gotcha verified
|
||||
* against the live API: create/copy return agent_id etc. inside data, but
|
||||
* deploy returns agent_version/agent_status at the top level next to code —
|
||||
* the server envelope is inconsistent, so read both locations defensively.
|
||||
*/
|
||||
export function agentMutationField(
|
||||
response: { data?: Record<string, unknown>; [key: string]: unknown },
|
||||
field: "agent_id" | "agent_name" | "agent_version" | "agent_status",
|
||||
): string | undefined {
|
||||
const nested = response.data?.[field];
|
||||
if (typeof nested === "string") return nested;
|
||||
const topLevel = response[field];
|
||||
return typeof topLevel === "string" ? topLevel : undefined;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Local pre-flight validation for file uploads (doc upload).
|
||||
// Default category: the lease/addFile `category` parameter accepts the literal
|
||||
// "default" (verified against the live API), so no listCategory resolution is needed.
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { basename, extname } from "node:path";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export interface UploadFormatRule {
|
||||
maxBytes: number;
|
||||
/** block: exceeding the limit throws USAGE; warn: only a stderr warning (documented as a recommended limit) */
|
||||
enforce: "block" | "warn";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format allowlist based on the officially supported document formats.
|
||||
* .csv is inconsistent across the public docs — kept in the allowlist for now;
|
||||
* a server-side rejection would be passed through verbatim.
|
||||
*/
|
||||
export const UPLOAD_FORMAT_RULES: Record<string, UploadFormatRule> = {
|
||||
".doc": { maxBytes: 150 * MB, enforce: "block" },
|
||||
".docx": { maxBytes: 150 * MB, enforce: "block" },
|
||||
".ppt": { maxBytes: 150 * MB, enforce: "block" },
|
||||
".pptx": { maxBytes: 150 * MB, enforce: "block" },
|
||||
".pdf": { maxBytes: 150 * MB, enforce: "block" },
|
||||
".png": { maxBytes: 20 * MB, enforce: "block" },
|
||||
".jpg": { maxBytes: 20 * MB, enforce: "block" },
|
||||
".jpeg": { maxBytes: 20 * MB, enforce: "block" },
|
||||
".bmp": { maxBytes: 20 * MB, enforce: "block" },
|
||||
".gif": { maxBytes: 20 * MB, enforce: "block" },
|
||||
".xls": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
".xlsx": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
".csv": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
".md": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
".txt": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
".html": { maxBytes: 10 * MB, enforce: "warn" },
|
||||
};
|
||||
|
||||
/** Local pre-flight check before reading the file: extension allowlist + hard/soft size limits. File I/O failure → GENERAL + errno hint. */
|
||||
export function checkUploadFile(filePath: string): { sizeBytes: number; warning?: string } {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const rule = UPLOAD_FORMAT_RULES[extension];
|
||||
if (!rule) {
|
||||
throw new BailianError(
|
||||
`Unsupported file type: ${extension || basename(filePath)}`,
|
||||
ExitCode.USAGE,
|
||||
`Supported formats: ${Object.keys(UPLOAD_FORMAT_RULES).join(" ")}`,
|
||||
);
|
||||
}
|
||||
let sizeBytes: number;
|
||||
try {
|
||||
sizeBytes = statSync(filePath).size;
|
||||
} catch (error) {
|
||||
const errno = (error as { code?: string }).code ?? "unknown";
|
||||
throw new BailianError(
|
||||
`Cannot read file: ${filePath}`,
|
||||
ExitCode.GENERAL,
|
||||
`File system error (${errno}) — check the path and permissions.`,
|
||||
);
|
||||
}
|
||||
if (sizeBytes > rule.maxBytes) {
|
||||
const limitMb = rule.maxBytes / MB;
|
||||
if (rule.enforce === "block") {
|
||||
throw new BailianError(
|
||||
`File exceeds the ${limitMb} MB limit for ${extension}: ${basename(filePath)}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sizeBytes,
|
||||
warning: `${basename(filePath)} exceeds the recommended 10 MB for ${extension}; the server may reject or truncate it.`,
|
||||
};
|
||||
}
|
||||
return { sizeBytes };
|
||||
}
|
||||
|
||||
// Known document-format extensions — used to tailor the hint on decode failure
|
||||
// (pointing users to the document upload flow instead)
|
||||
const DOCUMENT_EXTENSIONS = new Set([
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".pdf",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".bmp",
|
||||
".gif",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Read a UTF-8 plain-text file (--content-file for chunk add/update).
|
||||
* Support is defined by content, not extension: any UTF-8 text is valid.
|
||||
* Strict decode failure → USAGE, with a hint pointing to the document upload
|
||||
* flow when the extension is a document format; file I/O failure → GENERAL + errno.
|
||||
*/
|
||||
export function readUtf8TextFile(filePath: string): string {
|
||||
let fileBuffer: Buffer;
|
||||
try {
|
||||
fileBuffer = readFileSync(filePath);
|
||||
} catch (error) {
|
||||
const errno = (error as { code?: string }).code ?? "unknown";
|
||||
throw new BailianError(
|
||||
`Cannot read file: ${filePath}`,
|
||||
ExitCode.GENERAL,
|
||||
`File system error (${errno}) — check the path and permissions.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(fileBuffer);
|
||||
} catch {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const hint = DOCUMENT_EXTENSIONS.has(extension)
|
||||
? "Document formats cannot be used as chunk content. Upload documents via the document upload command; to edit a chunk, save the text as .md/.txt first."
|
||||
: "Convert the file to UTF-8 encoding and retry.";
|
||||
throw new BailianError(
|
||||
`File is not valid UTF-8 plain text: ${basename(filePath)}`,
|
||||
ExitCode.USAGE,
|
||||
hint,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,37 @@ export { default as memoryProfileGet } from "./commands/memory/profile-get.ts";
|
||||
export { default as knowledgeRetrieve } from "./commands/knowledge/retrieve.ts";
|
||||
export { default as knowledgeSearch } from "./commands/knowledge/search.ts";
|
||||
export { default as knowledgeChat } from "./commands/knowledge/chat.ts";
|
||||
export { default as knowledgeKbList } from "./commands/knowledge/kb-list.ts";
|
||||
export { default as knowledgeKbInfo } from "./commands/knowledge/kb-info.ts";
|
||||
export { default as knowledgeDocList } from "./commands/knowledge/doc-list.ts";
|
||||
export { default as knowledgeDocStatus } from "./commands/knowledge/doc-status.ts";
|
||||
export { default as knowledgeDocUpload } from "./commands/knowledge/doc-upload.ts";
|
||||
export { default as knowledgeKbCreate } from "./commands/knowledge/kb-create.ts";
|
||||
export { default as knowledgeKbUpdate } from "./commands/knowledge/kb-update.ts";
|
||||
export { default as knowledgeKbDelete } from "./commands/knowledge/kb-delete.ts";
|
||||
export { default as knowledgeDocDelete } from "./commands/knowledge/doc-delete.ts";
|
||||
export { default as knowledgeDocTag } from "./commands/knowledge/doc-tag.ts";
|
||||
export { default as knowledgeServiceList } from "./commands/knowledge/service-list.ts";
|
||||
export { default as knowledgeServiceGet } from "./commands/knowledge/service-get.ts";
|
||||
export { default as knowledgeServiceCreate } from "./commands/knowledge/service-create.ts";
|
||||
export { default as knowledgeServiceUpdate } from "./commands/knowledge/service-update.ts";
|
||||
export { default as knowledgeServiceDeploy } from "./commands/knowledge/service-deploy.ts";
|
||||
export { default as knowledgeServiceDelete } from "./commands/knowledge/service-delete.ts";
|
||||
export { default as knowledgeServiceCopy } from "./commands/knowledge/service-copy.ts";
|
||||
export { default as knowledgeChunkAdd } from "./commands/knowledge/chunk-add.ts";
|
||||
export { default as knowledgeChunkList } from "./commands/knowledge/chunk-list.ts";
|
||||
export { default as knowledgeChunkUpdate } from "./commands/knowledge/chunk-update.ts";
|
||||
export { default as knowledgeChunkDelete } from "./commands/knowledge/chunk-delete.ts";
|
||||
export { default as knowledgeKbStats } from "./commands/knowledge/kb-stats.ts";
|
||||
export { default as knowledgeCategoryList } from "./commands/knowledge/category-list.ts";
|
||||
export { default as knowledgeCategoryAdd } from "./commands/knowledge/category-add.ts";
|
||||
export { default as knowledgeCategoryDelete } from "./commands/knowledge/category-delete.ts";
|
||||
export { default as knowledgeFileList } from "./commands/knowledge/file-list.ts";
|
||||
export { default as knowledgeFileGet } from "./commands/knowledge/file-get.ts";
|
||||
export { default as knowledgeFileDelete } from "./commands/knowledge/file-delete.ts";
|
||||
export { default as knowledgeCollectionCreate } from "./commands/knowledge/collection-create.ts";
|
||||
export { default as knowledgeCollectionGet } from "./commands/knowledge/collection-get.ts";
|
||||
export { default as knowledgeDocImportOss } from "./commands/knowledge/doc-import-oss.ts";
|
||||
export { default as mcpCall } from "./commands/mcp/call.ts";
|
||||
export { default as mcpList } from "./commands/mcp/list.ts";
|
||||
export { default as mcpTools } from "./commands/mcp/tools.ts";
|
||||
|
||||
@@ -26,8 +26,10 @@ export {
|
||||
isBailianE2EMediaEnabled,
|
||||
isBailianE2EVideoEnabled,
|
||||
isChatE2EReady,
|
||||
isConnectorE2EReady,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isKbAdminE2EReady,
|
||||
isOpenApiE2EReady,
|
||||
isSearchE2EReady,
|
||||
} from "e2e/gating";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Journey E2E(用户旅程链路测试)
|
||||
|
||||
从用户 case 出发验证关键流程可用性:每条 journey = 用户带着一个目标跨命令走完整回路,
|
||||
以「fixture 标记词能否被召回」判定回路闭合(区别于 `../*.e2e.test.ts` 的单命令契约测试)。
|
||||
|
||||
## 旅程映射
|
||||
|
||||
| # | 用户 Case | 文件 | 闭环断言 |
|
||||
| --- | ------------------------------------ | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| J1 | 冷启动:建库并获得首个答案 | `j1-cold-start.e2e.test.ts` | retrieve/search(beta) 召回标记词(硬);chat 回答引用标记词(软) |
|
||||
| J2 | 内容运维:文档增删的可见性 | `j2-content-ops.e2e.test.ts` | 双标记词命中 → 删除其一后 markerB 消失且 markerA 仍在(硬) |
|
||||
| J3 | 检索精修:chunk 排除生效 | `j3-chunk-tuning.e2e.test.ts` | exclude 后排除标志生效(硬);include 恢复(软)。retrieve 不过滤被排除 chunk,以 `is_displayed_chunk_content` 标志为准 |
|
||||
| J4 | 服务调优:草稿→修改→发布 | `j4-service-tuning.e2e.test.ts` | beta 草稿可用、update 落库、发布后正式版可用(硬) |
|
||||
| J5 | 数据面治理:collection/category/file | `j5-data-plane.e2e.test.ts` | 自建类目内文件可见/可删,类目删后消失(硬) |
|
||||
| J6 | 退场清理:删库验证消失 | 复用 [`../knowledge-kb-delete.e2e.test.ts`](../knowledge-kb-delete.e2e.test.ts) live 链 | delete 后 list 不再包含(硬) |
|
||||
|
||||
## 约定
|
||||
|
||||
- **gating**:J1–J4 `isKbAdminE2EReady()`;J5 `isConnectorE2EReady()`(collection 无删除 API,仅手动开启;gating 函数/环境变量保留 CONNECTOR 旧名)。
|
||||
- **自建自清**:所有资源自建 + `try/finally` 清理;kb 删除走 `deleteKbWithRetry`(IndexStatusError 重试);
|
||||
数据中心文件用 `knowledge file delete` 回收;清理失败不掩盖,落 `resources.json` 供人工回收。
|
||||
- **软/硬断言**:可用性关键路径硬断言(fail);依赖服务端语义/延迟波动的信号软断言
|
||||
(`recordSoft`,只落报告不 fail,人工复核)。
|
||||
- **日志产物**:每次 live 运行在 `test/output/<session>/e2e-vp-<journey>-<ts>/` 生成
|
||||
`journey-report.md`(步骤表 + 软断言区 + 未清理资源警示)、分步 stdout/stderr、`resources.json`、`journey.log`。
|
||||
|
||||
## 运行
|
||||
|
||||
```sh
|
||||
pnpm run test:journey # 全部 journey(无凭证时全部 skip)
|
||||
vp test packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts
|
||||
```
|
||||
|
||||
live 运行需 `.env`:`BAILIAN_E2E=1` + DashScope API key + `BAILIAN_WORKSPACE_ID`;J5 另需 `BAILIAN_E2E_CONNECTOR=1`。
|
||||
@@ -0,0 +1,177 @@
|
||||
// J1 cold-start first answer: from zero to the first citable answer.
|
||||
// upload → kb create --wait → retrieve recalls the marker → service create (search/chat, draft)
|
||||
// → search --agent-version beta recalls → chat --agent-version beta first answer.
|
||||
// Closure assertions: the fixture marker is recalled by retrieve/search (hard);
|
||||
// the chat answer cites the marker (soft).
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
|
||||
import { JOURNEY_J1_ROUTES } from "../../topic-routes.ts";
|
||||
import {
|
||||
cleanupKbFixture,
|
||||
createJourneyReporter,
|
||||
createKbWithDocs,
|
||||
patchSearchServiceRetrievalConfig,
|
||||
pollUntil,
|
||||
uniqueMarker,
|
||||
type KbFixture,
|
||||
} from "./journey-helpers.ts";
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("journey J1: 冷启动首答 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("upload → create → retrieve → search(beta) → chat(beta) 回路闭合", async () => {
|
||||
const reporter = createJourneyReporter(import.meta.url);
|
||||
const marker = uniqueMarker("j1");
|
||||
const fixture: Partial<KbFixture> = {};
|
||||
const serviceAgentIds: string[] = [];
|
||||
try {
|
||||
// 1) Upload + create the knowledge base (--wait for the initial import)
|
||||
const kb = await createKbWithDocs(reporter, JOURNEY_J1_ROUTES, "j1", [marker], workspaceId);
|
||||
Object.assign(fixture, kb);
|
||||
|
||||
// 2) retrieve directly against the base recalls the marker (hard assertion,
|
||||
// polling to absorb index-visibility lag)
|
||||
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
|
||||
const retrievePoll = await pollUntil(
|
||||
() =>
|
||||
reporter.runStep("retrieve marker", JOURNEY_J1_ROUTES, [
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--query",
|
||||
marker,
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`retrieve 轮询 ${retrievePoll.attempts} 次`);
|
||||
expect(retrievePoll.satisfied, `retrieve 未召回标记词 ${marker}`).toBe(true);
|
||||
|
||||
// 3) Create a retrieval service (initial draft/beta); search on the draft recalls (hard assertion)
|
||||
const searchServiceRun = await reporter.runStep(
|
||||
"service create (search)",
|
||||
JOURNEY_J1_ROUTES,
|
||||
[
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
`e2e-j1-s-${Date.now() % 100000000}`,
|
||||
"--scene",
|
||||
"search",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
],
|
||||
);
|
||||
expect(searchServiceRun.exitCode, searchServiceRun.stderr).toBe(0);
|
||||
const searchAgentId = searchServiceRun.stdout.trim().split("\n").pop()!;
|
||||
expect(searchAgentId).toMatch(/^aid-/);
|
||||
reporter.trackResource("service", searchAgentId);
|
||||
serviceAgentIds.push(searchAgentId);
|
||||
|
||||
// Server gotcha: a minimally created search service is missing required
|
||||
// retrieval parameters — backfill before calling (see journey-helpers)
|
||||
await patchSearchServiceRetrievalConfig(
|
||||
reporter,
|
||||
JOURNEY_J1_ROUTES,
|
||||
searchAgentId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const searchPoll = await pollUntil(
|
||||
() =>
|
||||
reporter.runStep("search marker (beta)", JOURNEY_J1_ROUTES, [
|
||||
"knowledge",
|
||||
"search",
|
||||
"--query",
|
||||
marker,
|
||||
"--agent-id",
|
||||
searchAgentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 120_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`search 轮询 ${searchPoll.attempts} 次`);
|
||||
expect(searchPoll.satisfied, `search(beta) 未召回标记词 ${marker}`).toBe(true);
|
||||
|
||||
// 4) Create a chat service; first answer on the draft (non-empty answer is a
|
||||
// hard assertion; citing the marker is a soft assertion for manual review)
|
||||
const chatServiceRun = await reporter.runStep("service create (chat)", JOURNEY_J1_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
`e2e-j1-c-${Date.now() % 100000000}`,
|
||||
"--scene",
|
||||
"chat",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(chatServiceRun.exitCode, chatServiceRun.stderr).toBe(0);
|
||||
const chatAgentId = chatServiceRun.stdout.trim().split("\n").pop()!;
|
||||
expect(chatAgentId).toMatch(/^aid-/);
|
||||
reporter.trackResource("service", chatAgentId);
|
||||
serviceAgentIds.push(chatAgentId);
|
||||
|
||||
const chatRun = await reporter.runStep("chat first answer (beta)", JOURNEY_J1_ROUTES, [
|
||||
"knowledge",
|
||||
"chat",
|
||||
"--message",
|
||||
`What is ${marker}? Answer strictly based on the knowledge base.`,
|
||||
"--agent-id",
|
||||
chatAgentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(chatRun.exitCode, chatRun.stderr).toBe(0);
|
||||
const chatData = parseStdoutJson<{ answer?: string }>(chatRun.stdout);
|
||||
const answerText = chatData.answer?.trim() ?? "";
|
||||
expect(answerText.length, "chat 回答为空").toBeGreaterThan(0);
|
||||
reporter.recordSoft(
|
||||
"chat 回答引用 fixture 标记词",
|
||||
answerText.includes(marker),
|
||||
answerText || "(empty)",
|
||||
);
|
||||
} finally {
|
||||
for (const agentId of serviceAgentIds) {
|
||||
const deleteRun = await reporter.runStep(
|
||||
`cleanup: service delete ${agentId}`,
|
||||
JOURNEY_J1_ROUTES,
|
||||
[
|
||||
"knowledge",
|
||||
"service",
|
||||
"delete",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
if (deleteRun.exitCode === 0) reporter.markCleaned(agentId);
|
||||
}
|
||||
await cleanupKbFixture(reporter, JOURNEY_J1_ROUTES, fixture, workspaceId);
|
||||
reporter.finalize();
|
||||
}
|
||||
// Observed create times vary widely plus three polling phases — timeout sized for the worst path
|
||||
}, 900_000);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// J2 content operations: routine document add/remove and the visibility loop.
|
||||
// create (doc1+doc2) → doc list/status checks → doc tag → retrieve hits both markers
|
||||
// → doc delete doc2 → retrieve verifies markerB is gone and markerA remains.
|
||||
// Note: there is no "append documents to an existing base" command (kb update takes
|
||||
// no doc-id), so incremental semantics are expressed as a two-document create + deleting one.
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady } from "../../helpers.ts";
|
||||
import { JOURNEY_J2_ROUTES } from "../../topic-routes.ts";
|
||||
import {
|
||||
cleanupKbFixture,
|
||||
createJourneyReporter,
|
||||
createKbWithDocs,
|
||||
pollUntil,
|
||||
uniqueMarker,
|
||||
type KbFixture,
|
||||
} from "./journey-helpers.ts";
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("journey J2: 内容运维 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("建库(双文档) → list/status/tag → 删除其一 → 召回可见性闭环", async () => {
|
||||
const reporter = createJourneyReporter(import.meta.url);
|
||||
const markerA = uniqueMarker("j2a");
|
||||
const markerB = uniqueMarker("j2b");
|
||||
const fixture: Partial<KbFixture> = {};
|
||||
try {
|
||||
// 1) Create the base with two documents
|
||||
const kb = await createKbWithDocs(
|
||||
reporter,
|
||||
JOURNEY_J2_ROUTES,
|
||||
"j2",
|
||||
[markerA, markerB],
|
||||
workspaceId,
|
||||
);
|
||||
Object.assign(fixture, kb);
|
||||
const [fileIdA, fileIdB] = kb.fileIds as [string, string];
|
||||
|
||||
// 2) doc list contains both documents (hard); doc status terminal state COMPLETED (hard)
|
||||
const docListRun = await reporter.runStep("doc list", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(docListRun.exitCode, docListRun.stderr).toBe(0);
|
||||
const listedDocIds = docListRun.stdout.trim().split("\n");
|
||||
expect(listedDocIds).toContain(fileIdA);
|
||||
expect(listedDocIds).toContain(fileIdB);
|
||||
|
||||
const docStatusRun = await reporter.runStep("doc status", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--job-id",
|
||||
kb.jobId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(docStatusRun.exitCode, docStatusRun.stderr).toBe(0);
|
||||
expect(docStatusRun.stdout.trim()).toBe("COMPLETED");
|
||||
|
||||
// 3) doc tag on doc2 (tagging succeeds: hard; tag read-back: soft — server-side
|
||||
// display lag or the API not echoing tags are both tolerable)
|
||||
const tagValue = `e2e-j2-${Date.now() % 100000000}`;
|
||||
const docTagRun = await reporter.runStep("doc tag doc2", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
fileIdB,
|
||||
"--tag",
|
||||
tagValue,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(docTagRun.exitCode, docTagRun.stderr).toBe(0);
|
||||
const tagReadbackRun = await reporter.runStep("doc list (tag readback)", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
reporter.recordSoft(
|
||||
"doc list 回读可见新标签",
|
||||
tagReadbackRun.exitCode === 0 && tagReadbackRun.stdout.includes(tagValue),
|
||||
`tag=${tagValue} exit=${tagReadbackRun.exitCode}`,
|
||||
);
|
||||
|
||||
// 4) retrieve hits both markers (hard, polling to absorb indexing lag)
|
||||
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
|
||||
const retrieveMarker = (marker: string, stepName: string) =>
|
||||
pollUntil(
|
||||
() =>
|
||||
reporter.runStep(stepName, JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--query",
|
||||
marker,
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
const pollA = await retrieveMarker(markerA, "retrieve markerA");
|
||||
expect(pollA.satisfied, `retrieve 未召回 ${markerA}`).toBe(true);
|
||||
const pollB = await retrieveMarker(markerB, "retrieve markerB");
|
||||
expect(pollB.satisfied, `retrieve 未召回 ${markerB}`).toBe(true);
|
||||
|
||||
// 5) Delete doc2 → markerB no longer recalled while markerA remains (hard)
|
||||
const docDeleteRun = await reporter.runStep("doc delete doc2", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--doc-id",
|
||||
fileIdB,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(docDeleteRun.exitCode, docDeleteRun.stderr).toBe(0);
|
||||
|
||||
const goneB = await pollUntil(
|
||||
() =>
|
||||
reporter.runStep("retrieve markerB (expect miss)", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--query",
|
||||
markerB,
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
(run) => run.exitCode === 0 && !run.stdout.includes(markerB),
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`doc delete 后 markerB 消失轮询 ${goneB.attempts} 次`);
|
||||
expect(goneB.satisfied, `doc delete 后 ${markerB} 仍可召回`).toBe(true);
|
||||
|
||||
const stillA = await reporter.runStep("retrieve markerA (still hit)", JOURNEY_J2_ROUTES, [
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--query",
|
||||
markerA,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(stillA.exitCode, stillA.stderr).toBe(0);
|
||||
expect(stillA.stdout, `doc delete 误伤: ${markerA} 不再召回`).toContain(markerA);
|
||||
} finally {
|
||||
await cleanupKbFixture(reporter, JOURNEY_J2_ROUTES, fixture, workspaceId);
|
||||
reporter.finalize();
|
||||
}
|
||||
// Base creation plus several polling phases — timeout sized for the worst path
|
||||
}, 900_000);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// J3 retrieval tuning: the real effect of chunk-level exclusion on recall.
|
||||
// create → chunk list locates the marker chunk → chunk update --exclude → retrieve
|
||||
// asserts the exclusion flag (hard) → chunk update --include restores → retrieve
|
||||
// asserts restoration (soft, index-refresh lag is tolerable).
|
||||
// Semantic gotcha (verified live): the legacy retrieve endpoint does not filter
|
||||
// excluded chunks; it only sets metadata.is_displayed_chunk_content to false —
|
||||
// that flag is the closure signal.
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
|
||||
import { JOURNEY_J3_ROUTES } from "../../topic-routes.ts";
|
||||
import {
|
||||
cleanupKbFixture,
|
||||
createJourneyReporter,
|
||||
createKbWithDocs,
|
||||
pollUntil,
|
||||
uniqueMarker,
|
||||
type KbFixture,
|
||||
} from "./journey-helpers.ts";
|
||||
|
||||
interface ChunkListResponse {
|
||||
data?: {
|
||||
nodes?: Array<{
|
||||
text?: string;
|
||||
metadata?: { _id?: string; doc_id?: string; content?: string };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RetrieveResponse {
|
||||
data?: {
|
||||
nodes?: Array<{
|
||||
text?: string;
|
||||
metadata?: { content?: string; is_displayed_chunk_content?: boolean };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Exclusion flag of the node containing the marker; undefined when the marker is not found */
|
||||
function markerNodeDisplayFlag(stdout: string, marker: string): boolean | undefined {
|
||||
let response: RetrieveResponse;
|
||||
try {
|
||||
response = parseStdoutJson<RetrieveResponse>(stdout);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const markedNode = (response.data?.nodes ?? []).find((node) =>
|
||||
(node.metadata?.content ?? node.text ?? "").includes(marker),
|
||||
);
|
||||
if (!markedNode) return undefined;
|
||||
return markedNode.metadata?.is_displayed_chunk_content !== false;
|
||||
}
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("journey J3: 检索精修 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("chunk exclude → 排除标志生效 → include 恢复", async () => {
|
||||
const reporter = createJourneyReporter(import.meta.url);
|
||||
const marker = uniqueMarker("j3");
|
||||
const fixture: Partial<KbFixture> = {};
|
||||
try {
|
||||
// 1) Create the base and confirm the marker is recallable (baseline for judging exclude)
|
||||
const kb = await createKbWithDocs(reporter, JOURNEY_J3_ROUTES, "j3", [marker], workspaceId);
|
||||
Object.assign(fixture, kb);
|
||||
|
||||
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
|
||||
const retrieveArgs = [
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--query",
|
||||
marker,
|
||||
"--output",
|
||||
"json",
|
||||
];
|
||||
const baseline = await pollUntil(
|
||||
() => reporter.runStep("retrieve baseline", JOURNEY_J3_ROUTES, retrieveArgs),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
expect(baseline.satisfied, `基线未召回标记词 ${marker}`).toBe(true);
|
||||
|
||||
// 2) chunk list locates the chunk containing the marker (metadata._id / doc_id feed update)
|
||||
const chunkListRun = await reporter.runStep("chunk list", JOURNEY_J3_ROUTES, [
|
||||
"knowledge",
|
||||
"chunk",
|
||||
"list",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(chunkListRun.exitCode, chunkListRun.stderr).toBe(0);
|
||||
const chunkData = parseStdoutJson<ChunkListResponse>(chunkListRun.stdout);
|
||||
const markedNode = (chunkData.data?.nodes ?? []).find((node) =>
|
||||
(node.metadata?.content ?? node.text ?? "").includes(marker),
|
||||
);
|
||||
expect(markedNode, `chunk list 中未找到含 ${marker} 的切片`).toBeTruthy();
|
||||
const chunkId = markedNode!.metadata?._id ?? "";
|
||||
const chunkDocId = markedNode!.metadata?.doc_id ?? "";
|
||||
expect(chunkId).toBeTruthy();
|
||||
expect(chunkDocId).toBeTruthy();
|
||||
reporter.recordNote(`目标 chunk=${chunkId} doc=${chunkDocId}`);
|
||||
|
||||
// 3) exclude → the exclusion flag takes effect on the marker node (hard)
|
||||
const excludeRun = await reporter.runStep("chunk update --exclude", JOURNEY_J3_ROUTES, [
|
||||
"knowledge",
|
||||
"chunk",
|
||||
"update",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--chunk-id",
|
||||
chunkId,
|
||||
"--doc-id",
|
||||
chunkDocId,
|
||||
"--exclude",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(excludeRun.exitCode, excludeRun.stderr).toBe(0);
|
||||
|
||||
const excluded = await pollUntil(
|
||||
() => reporter.runStep("retrieve after exclude", JOURNEY_J3_ROUTES, retrieveArgs),
|
||||
(run) => run.exitCode === 0 && markerNodeDisplayFlag(run.stdout, marker) === false,
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`exclude 生效轮询 ${excluded.attempts} 次`);
|
||||
expect(excluded.satisfied, `exclude 后 ${marker} 所在 chunk 未标记为排除`).toBe(true);
|
||||
|
||||
// 4) include restores → exclusion flag cleared (soft: index-refresh lag on the
|
||||
// restore path varies a lot; result goes to the report for manual review)
|
||||
const includeRun = await reporter.runStep("chunk update --include", JOURNEY_J3_ROUTES, [
|
||||
"knowledge",
|
||||
"chunk",
|
||||
"update",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--chunk-id",
|
||||
chunkId,
|
||||
"--doc-id",
|
||||
chunkDocId,
|
||||
"--include",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(includeRun.exitCode, includeRun.stderr).toBe(0);
|
||||
|
||||
const restored = await pollUntil(
|
||||
() => reporter.runStep("retrieve after include", JOURNEY_J3_ROUTES, retrieveArgs),
|
||||
(run) => run.exitCode === 0 && markerNodeDisplayFlag(run.stdout, marker) === true,
|
||||
{ timeoutMs: 120_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordSoft(
|
||||
"include 恢复后排除标志解除",
|
||||
restored.satisfied,
|
||||
`attempts=${restored.attempts} exit=${restored.value.exitCode}`,
|
||||
);
|
||||
} finally {
|
||||
await cleanupKbFixture(reporter, JOURNEY_J3_ROUTES, fixture, workspaceId);
|
||||
reporter.finalize();
|
||||
}
|
||||
// Base creation plus three polling phases — timeout sized for the worst path
|
||||
}, 900_000);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// J4 service tuning: draft (beta) usable → config change persisted → released version usable after deploy.
|
||||
// create + service create (search, initial draft/beta) → search --agent-version beta recalls (hard)
|
||||
// → service update --description → service get asserts the change (hard) → deploy → released search recalls (hard).
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
|
||||
import { JOURNEY_J4_ROUTES } from "../../topic-routes.ts";
|
||||
import {
|
||||
cleanupKbFixture,
|
||||
createJourneyReporter,
|
||||
createKbWithDocs,
|
||||
patchSearchServiceRetrievalConfig,
|
||||
pollUntil,
|
||||
uniqueMarker,
|
||||
type KbFixture,
|
||||
} from "./journey-helpers.ts";
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("journey J4: 问答服务调优 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("draft 可用 → update 落库 → deploy → 正式版可用", async () => {
|
||||
const reporter = createJourneyReporter(import.meta.url);
|
||||
const marker = uniqueMarker("j4");
|
||||
const fixture: Partial<KbFixture> = {};
|
||||
let agentId = "";
|
||||
try {
|
||||
// 1) Create the base + a retrieval service bound to it (initial draft/beta)
|
||||
const kb = await createKbWithDocs(reporter, JOURNEY_J4_ROUTES, "j4", [marker], workspaceId);
|
||||
Object.assign(fixture, kb);
|
||||
|
||||
const createServiceRun = await reporter.runStep(
|
||||
"service create (search)",
|
||||
JOURNEY_J4_ROUTES,
|
||||
[
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
`e2e-j4-${Date.now() % 100000000}`,
|
||||
"--scene",
|
||||
"search",
|
||||
"--index-id",
|
||||
kb.indexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
],
|
||||
);
|
||||
expect(createServiceRun.exitCode, createServiceRun.stderr).toBe(0);
|
||||
agentId = createServiceRun.stdout.trim().split("\n").pop()!;
|
||||
expect(agentId).toMatch(/^aid-/);
|
||||
reporter.trackResource("service", agentId);
|
||||
|
||||
// Server gotcha: a minimally created search service is missing required
|
||||
// retrieval parameters — backfill before calling (see journey-helpers)
|
||||
await patchSearchServiceRetrievalConfig(reporter, JOURNEY_J4_ROUTES, agentId, workspaceId);
|
||||
|
||||
// 2) Draft search recalls (hard, polling to absorb index/service propagation lag)
|
||||
const betaSearchArgs = [
|
||||
"knowledge",
|
||||
"search",
|
||||
"--query",
|
||||
marker,
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
];
|
||||
const betaPoll = await pollUntil(
|
||||
() => reporter.runStep("search (beta)", JOURNEY_J4_ROUTES, betaSearchArgs),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 180_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`beta search 轮询 ${betaPoll.attempts} 次`);
|
||||
expect(betaPoll.satisfied, `search(beta) 未召回标记词 ${marker}`).toBe(true);
|
||||
|
||||
// 3) update the description (top-level scalar, valid for the search scene too) → get asserts persistence (hard)
|
||||
const newDescription = `journey j4 tuned at ${Date.now()}`;
|
||||
const updateRun = await reporter.runStep("service update --description", JOURNEY_J4_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--description",
|
||||
newDescription,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
|
||||
|
||||
const getRun = await reporter.runStep("service get (beta)", JOURNEY_J4_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(getRun.exitCode, getRun.stderr).toBe(0);
|
||||
const getData = parseStdoutJson<{ data?: { agent_desc?: string } }>(getRun.stdout);
|
||||
expect(getData.data?.agent_desc).toBe(newDescription);
|
||||
|
||||
// 4) deploy → released search (without --agent-version) recalls (hard)
|
||||
const deployRun = await reporter.runStep("service deploy", JOURNEY_J4_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"deploy",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(deployRun.exitCode, deployRun.stderr).toBe(0);
|
||||
reporter.recordNote(`deploy 版本号: ${deployRun.stdout.trim().split("\n").pop() ?? "?"}`);
|
||||
|
||||
const releasedPoll = await pollUntil(
|
||||
() =>
|
||||
reporter.runStep("search (released)", JOURNEY_J4_ROUTES, [
|
||||
"knowledge",
|
||||
"search",
|
||||
"--query",
|
||||
marker,
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]),
|
||||
(run) => run.exitCode === 0 && run.stdout.includes(marker),
|
||||
{ timeoutMs: 120_000, intervalMs: 15_000 },
|
||||
);
|
||||
reporter.recordNote(`正式版 search 轮询 ${releasedPoll.attempts} 次`);
|
||||
expect(releasedPoll.satisfied, `发布后正式版 search 未召回 ${marker}`).toBe(true);
|
||||
} finally {
|
||||
if (agentId) {
|
||||
const deleteRun = await reporter.runStep("cleanup: service delete", JOURNEY_J4_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"delete",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
if (deleteRun.exitCode === 0) reporter.markCleaned(agentId);
|
||||
}
|
||||
await cleanupKbFixture(reporter, JOURNEY_J4_ROUTES, fixture, workspaceId);
|
||||
reporter.finalize();
|
||||
}
|
||||
// Base creation plus two polling phases plus deploy — timeout sized for the worst path
|
||||
}, 900_000);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// J5 data-plane governance: the collection/category/file read-write loop (decoupled from the index plane).
|
||||
// collection reuse-or-create (no delete API, fixed-name idempotent) → category add → visible in list
|
||||
// → upload a file into the self-created category → file list/get → file delete
|
||||
// → category delete → list verifies removal.
|
||||
// gating: collection artifacts cannot be cleaned up — only enable explicitly for a
|
||||
// full manual regression (BAILIAN_E2E_CONNECTOR=1).
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isConnectorE2EReady, parseStdoutJson } from "../../helpers.ts";
|
||||
import { JOURNEY_J5_ROUTES } from "../../topic-routes.ts";
|
||||
import { createJourneyReporter, uniqueMarker, writeMarkerFixture } from "./journey-helpers.ts";
|
||||
|
||||
describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("collection → category → file 读写 → 类目清理闭环", async () => {
|
||||
const reporter = createJourneyReporter(import.meta.url);
|
||||
let categoryId = "";
|
||||
let categoryName = "";
|
||||
try {
|
||||
// 1) collection reuse-or-create (fixed-name idempotent — avoid accumulating undeletable collections)
|
||||
// Server rejects names longer than 20 characters ("Connector name is longer than 20")
|
||||
const collectionName = "e2e-journey-coll";
|
||||
const collectionGetRun = await reporter.runStep("collection get by name", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"collection",
|
||||
"get",
|
||||
"--name",
|
||||
collectionName,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
let collectionId = collectionGetRun.exitCode === 0 ? collectionGetRun.stdout.trim() : "";
|
||||
if (!collectionId) {
|
||||
const collectionCreateRun = await reporter.runStep("collection create", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"collection",
|
||||
"create",
|
||||
"--name",
|
||||
collectionName,
|
||||
"--description",
|
||||
"journey J5 fixture collection (PLATFORM, reused across runs)",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(collectionCreateRun.exitCode, collectionCreateRun.stderr).toBe(0);
|
||||
collectionId = collectionCreateRun.stdout.trim();
|
||||
}
|
||||
expect(collectionId).toBeTruthy();
|
||||
reporter.trackResource("collection", collectionId, { persistent: true });
|
||||
|
||||
// 2) Self-created category → visible via exact-name filtering in list (hard)
|
||||
categoryName = `e2e-j5-${Date.now() % 100000000}`;
|
||||
const categoryAddRun = await reporter.runStep("category add", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"category",
|
||||
"add",
|
||||
"--name",
|
||||
categoryName,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0);
|
||||
categoryId = categoryAddRun.stdout.trim();
|
||||
expect(categoryId).toMatch(/^cate_/);
|
||||
reporter.trackResource("category", categoryId);
|
||||
|
||||
const categoryListRun = await reporter.runStep("category list (filter)", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"category",
|
||||
"list",
|
||||
"--name",
|
||||
categoryName,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(categoryListRun.exitCode, categoryListRun.stderr).toBe(0);
|
||||
expect(categoryListRun.stdout.trim().split("\n")).toContain(categoryId);
|
||||
|
||||
// 3) Upload a file into the self-created category → file list/get → file delete
|
||||
// (only touch self-created artifacts)
|
||||
const marker = uniqueMarker("j5");
|
||||
const filePath = writeMarkerFixture("j5", marker);
|
||||
const uploadRun = await reporter.runStep("doc upload to category", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
filePath,
|
||||
"--category-id",
|
||||
categoryId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
|
||||
const fileId = uploadRun.stdout.trim();
|
||||
expect(fileId).toMatch(/^file_/);
|
||||
reporter.trackResource("data-center-file", fileId);
|
||||
|
||||
const fileListRun = await reporter.runStep("file list", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"list",
|
||||
"--category-id",
|
||||
categoryId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(fileListRun.exitCode, fileListRun.stderr).toBe(0);
|
||||
expect(fileListRun.stdout.trim().split("\n")).toContain(fileId);
|
||||
|
||||
const fileGetRun = await reporter.runStep("file get", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"get",
|
||||
"--file-id",
|
||||
fileId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(fileGetRun.exitCode, fileGetRun.stderr).toBe(0);
|
||||
const fileDetail = parseStdoutJson<{ data?: { category?: string } }>(fileGetRun.stdout);
|
||||
expect(fileDetail.data?.category).toBe(categoryId);
|
||||
|
||||
const fileDeleteRun = await reporter.runStep("file delete", JOURNEY_J5_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
fileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
|
||||
reporter.markCleaned(fileId);
|
||||
} finally {
|
||||
if (categoryId) {
|
||||
// 4) Clean up the self-created category + list verifies removal
|
||||
// (doubles as live coverage of category delete)
|
||||
const categoryDeleteRun = await reporter.runStep(
|
||||
"cleanup: category delete",
|
||||
JOURNEY_J5_ROUTES,
|
||||
[
|
||||
"knowledge",
|
||||
"category",
|
||||
"delete",
|
||||
"--category-id",
|
||||
categoryId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
if (categoryDeleteRun.exitCode === 0) {
|
||||
reporter.markCleaned(categoryId);
|
||||
const goneListRun = await reporter.runStep(
|
||||
"category list (verify gone)",
|
||||
JOURNEY_J5_ROUTES,
|
||||
[
|
||||
"knowledge",
|
||||
"category",
|
||||
"list",
|
||||
"--name",
|
||||
categoryName,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
],
|
||||
);
|
||||
if (goneListRun.exitCode === 0) {
|
||||
expect(goneListRun.stdout.trim().split("\n")).not.toContain(categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
reporter.finalize();
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
// Shared journey-level infrastructure: marker-word fixtures, eventual-consistency
|
||||
// polling, IndexStatusError retrying deletes, and an on-disk reporter (per-step
|
||||
// stdout/stderr, soft assertions, resource inventory, human-readable report).
|
||||
import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
e2eLabelFromMetaUrl,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCommandE2e,
|
||||
type RunCliResult,
|
||||
} from "../../helpers.ts";
|
||||
import type { E2eRouteExports } from "../../topic-routes.ts";
|
||||
|
||||
// ---- Marker words and fixtures ----
|
||||
|
||||
/** Unique marker word: journey closure is judged by whether it can be recalled */
|
||||
export function uniqueMarker(journeyId: string): string {
|
||||
return `E2E_MARKER_${journeyId.toUpperCase()}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
|
||||
/** Write a temporary md fixture containing the marker word; returns the file path */
|
||||
export function writeMarkerFixture(journeyId: string, marker: string): string {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), `journey-${journeyId}-`));
|
||||
const filePath = join(fixtureDir, `${journeyId}-${Date.now()}.md`);
|
||||
writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
`# Journey fixture for ${journeyId}`,
|
||||
"",
|
||||
`The secret code word of this document is ${marker}.`,
|
||||
`${marker} is a fictional internal project name used only by CLI journey tests.`,
|
||||
`When asked about ${marker}, this document is the authoritative source.`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ---- Eventual-consistency polling ----
|
||||
|
||||
export interface PollOutcome<T> {
|
||||
value: T;
|
||||
satisfied: boolean;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export async function sleep(milliseconds: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until the predicate holds or the timeout is reached. Never throws —
|
||||
* returns satisfied and lets the caller decide hard vs soft assertion.
|
||||
*/
|
||||
export async function pollUntil<T>(
|
||||
action: () => Promise<T>,
|
||||
predicate: (value: T) => boolean,
|
||||
options: { timeoutMs?: number; intervalMs?: number } = {},
|
||||
): Promise<PollOutcome<T>> {
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
const intervalMs = options.intervalMs ?? 10_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let attempts = 0;
|
||||
let value = await action();
|
||||
attempts += 1;
|
||||
while (!predicate(value) && Date.now() < deadline) {
|
||||
await sleep(intervalMs);
|
||||
value = await action();
|
||||
attempts += 1;
|
||||
}
|
||||
return { value, satisfied: predicate(value), attempts };
|
||||
}
|
||||
|
||||
// ---- Delete with IndexStatusError retry (server gotcha: briefly undeletable right after import) ----
|
||||
|
||||
export async function deleteKbWithRetry(
|
||||
runner: (args: string[]) => Promise<RunCliResult>,
|
||||
indexId: string,
|
||||
workspaceId: string,
|
||||
): Promise<RunCliResult> {
|
||||
const args = [
|
||||
"knowledge",
|
||||
"delete",
|
||||
"--index-id",
|
||||
indexId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
];
|
||||
let deleteRun = await runner(args);
|
||||
for (let retry = 0; retry < 5 && deleteRun.exitCode !== 0; retry++) {
|
||||
if (!deleteRun.stderr.includes("IndexStatusError")) break; // other errors are not retried
|
||||
await sleep(10_000);
|
||||
deleteRun = await runner(args);
|
||||
}
|
||||
return deleteRun;
|
||||
}
|
||||
|
||||
// ---- Journey reporter: artifacts for manual review ----
|
||||
|
||||
interface JourneyStepRecord {
|
||||
index: number;
|
||||
name: string;
|
||||
args: string[];
|
||||
exitCode: number | null;
|
||||
durationMs: number;
|
||||
stdoutFile: string;
|
||||
stderrFile: string;
|
||||
}
|
||||
|
||||
interface SoftAssertionRecord {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface TrackedResource {
|
||||
type: string;
|
||||
id: string;
|
||||
cleaned: boolean;
|
||||
/** Resources expected to persist (e.g. no delete API); excluded from the uncleaned warning */
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
export interface JourneyReporter {
|
||||
outputDir: string;
|
||||
runStep(
|
||||
name: string,
|
||||
routes: E2eRouteExports,
|
||||
args: string[],
|
||||
envOverrides?: NodeJS.ProcessEnv,
|
||||
): Promise<RunCliResult>;
|
||||
recordSoft(name: string, passed: boolean, detail: string): void;
|
||||
recordNote(text: string): void;
|
||||
trackResource(type: string, id: string, options?: { persistent?: boolean }): void;
|
||||
markCleaned(id: string): void;
|
||||
/** Inventory of uncleaned resources (queryable before finalize, for afterAll fallbacks) */
|
||||
uncleanedResources(): TrackedResource[];
|
||||
finalize(): void;
|
||||
}
|
||||
|
||||
function sanitizeStepName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 60);
|
||||
}
|
||||
|
||||
export function createJourneyReporter(metaUrl: string): JourneyReporter {
|
||||
const outputDir = makeE2eOutputDir(e2eLabelFromMetaUrl(metaUrl));
|
||||
const logFile = join(outputDir, "journey.log");
|
||||
const steps: JourneyStepRecord[] = [];
|
||||
const softAssertions: SoftAssertionRecord[] = [];
|
||||
const notes: string[] = [];
|
||||
const resources: TrackedResource[] = [];
|
||||
|
||||
function appendLog(line: string): void {
|
||||
appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`);
|
||||
}
|
||||
|
||||
function flushResources(): void {
|
||||
writeFileSync(join(outputDir, "resources.json"), `${JSON.stringify(resources, null, 2)}\n`);
|
||||
}
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
|
||||
async runStep(name, routes, args, envOverrides = {}) {
|
||||
const index = steps.length + 1;
|
||||
const prefix = `${String(index).padStart(2, "0")}-${sanitizeStepName(name)}`;
|
||||
const startedAt = Date.now();
|
||||
// Redaction convention: only CLI args are persisted (credentials travel via
|
||||
// env/config, never in args); env is not persisted
|
||||
const result = await runCommandE2e(routes, args, envOverrides);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const stdoutFile = `${prefix}.stdout.txt`;
|
||||
const stderrFile = `${prefix}.stderr.txt`;
|
||||
writeFileSync(join(outputDir, stdoutFile), result.stdout);
|
||||
writeFileSync(join(outputDir, stderrFile), result.stderr);
|
||||
steps.push({
|
||||
index,
|
||||
name,
|
||||
args,
|
||||
exitCode: result.exitCode,
|
||||
durationMs,
|
||||
stdoutFile,
|
||||
stderrFile,
|
||||
});
|
||||
appendLog(`[${prefix}] exit=${result.exitCode} ${durationMs}ms args: ${args.join(" ")}`);
|
||||
return result;
|
||||
},
|
||||
|
||||
recordSoft(name, passed, detail) {
|
||||
softAssertions.push({ name, passed, detail });
|
||||
appendLog(`[soft] ${passed ? "PASS" : "MISS"} ${name}`);
|
||||
},
|
||||
|
||||
recordNote(text) {
|
||||
notes.push(text);
|
||||
appendLog(`[note] ${text}`);
|
||||
},
|
||||
|
||||
trackResource(type, id, options = {}) {
|
||||
resources.push({ type, id, cleaned: false, persistent: options.persistent ?? false });
|
||||
flushResources();
|
||||
},
|
||||
|
||||
markCleaned(id) {
|
||||
const resource = resources.find((candidate) => candidate.id === id && !candidate.cleaned);
|
||||
if (resource) resource.cleaned = true;
|
||||
flushResources();
|
||||
},
|
||||
|
||||
uncleanedResources() {
|
||||
return resources.filter((resource) => !resource.cleaned && !resource.persistent);
|
||||
},
|
||||
|
||||
finalize() {
|
||||
flushResources();
|
||||
const leaked = resources.filter((resource) => !resource.cleaned && !resource.persistent);
|
||||
const lines: string[] = [
|
||||
`# Journey report`,
|
||||
"",
|
||||
`- generated: ${new Date().toISOString()}`,
|
||||
`- output dir: ${outputDir}`,
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"| # | step | exit | duration | stdout |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
...steps.map(
|
||||
(step) =>
|
||||
`| ${step.index} | ${step.name} | ${step.exitCode} | ${step.durationMs}ms | ${step.stdoutFile} |`,
|
||||
),
|
||||
"",
|
||||
"## Soft assertions (manual review)",
|
||||
"",
|
||||
];
|
||||
if (softAssertions.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
for (const softAssertion of softAssertions) {
|
||||
lines.push(
|
||||
`### ${softAssertion.passed ? "✅" : "⚠️"} ${softAssertion.name}`,
|
||||
"",
|
||||
"```",
|
||||
softAssertion.detail,
|
||||
"```",
|
||||
"",
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("", "## Notes", "");
|
||||
lines.push(...(notes.length === 0 ? ["(none)"] : notes.map((note) => `- ${note}`)));
|
||||
lines.push("", "## Resources", "");
|
||||
lines.push("| type | id | cleaned |", "| --- | --- | --- |");
|
||||
lines.push(
|
||||
...resources.map(
|
||||
(resource) =>
|
||||
`| ${resource.type} | ${resource.id} | ${resource.persistent ? "retained (persistent)" : String(resource.cleaned)} |`,
|
||||
),
|
||||
);
|
||||
if (leaked.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"## ⚠️ Uncleaned resources (manual cleanup required)",
|
||||
"",
|
||||
...leaked.map((resource) => `- ${resource.type}: ${resource.id}`),
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
writeFileSync(join(outputDir, "journey-report.md"), lines.join("\n"));
|
||||
appendLog(
|
||||
`[finalize] steps=${steps.length} soft=${softAssertions.length} leaked=${leaked.length}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Shared J1–J4 setup: upload marker documents + create a knowledge base ----
|
||||
|
||||
export interface KbFixture {
|
||||
indexId: string;
|
||||
jobId: string;
|
||||
fileIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload N marker documents and run `kb create --wait`. Any failure throws
|
||||
* (message includes stderr). Artifacts (data-center file / kb) are tracked via
|
||||
* trackResource; cleanup is the caller's responsibility.
|
||||
*/
|
||||
export async function createKbWithDocs(
|
||||
reporter: JourneyReporter,
|
||||
routes: E2eRouteExports,
|
||||
journeyId: string,
|
||||
markers: string[],
|
||||
workspaceId: string,
|
||||
): Promise<KbFixture> {
|
||||
const fileIds: string[] = [];
|
||||
for (const [markerIndex, marker] of markers.entries()) {
|
||||
const filePath = writeMarkerFixture(journeyId, marker);
|
||||
const uploadRun = await reporter.runStep(`upload doc ${markerIndex + 1}`, routes, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
filePath,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
if (uploadRun.exitCode !== 0) {
|
||||
throw new Error(`doc upload failed (marker ${marker}): ${uploadRun.stderr}`);
|
||||
}
|
||||
const fileId = uploadRun.stdout.trim();
|
||||
reporter.trackResource("data-center-file", fileId);
|
||||
fileIds.push(fileId);
|
||||
}
|
||||
|
||||
const kbName = `e2e-${journeyId}-${Date.now() % 100000000}`;
|
||||
const createRun = await reporter.runStep("kb create --wait", routes, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
kbName,
|
||||
...fileIds.flatMap((fileId) => ["--doc-id", fileId]),
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--wait",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (createRun.exitCode !== 0) {
|
||||
throw new Error(`kb create failed: ${createRun.stderr}`);
|
||||
}
|
||||
const createData = parseStdoutJson<{
|
||||
data?: { pipelineId?: string; ingestionId?: string };
|
||||
final_status?: string;
|
||||
}>(createRun.stdout);
|
||||
const indexId = createData.data?.pipelineId ?? "";
|
||||
const jobId = createData.data?.ingestionId ?? "";
|
||||
if (!indexId) throw new Error(`kb create returned no pipelineId: ${createRun.stdout}`);
|
||||
reporter.trackResource("kb", indexId);
|
||||
reporter.recordNote(
|
||||
`kb ${indexId} (${kbName}) import final_status=${createData.final_status ?? "?"}`,
|
||||
);
|
||||
return { indexId, jobId, fileIds };
|
||||
}
|
||||
|
||||
/** Unified cleanup: kb delete (with retry) + data-center file delete. Never throws — failures surface in the report. */
|
||||
export async function cleanupKbFixture(
|
||||
reporter: JourneyReporter,
|
||||
routes: E2eRouteExports,
|
||||
fixture: Partial<KbFixture>,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
if (fixture.indexId) {
|
||||
const deleteRun = await deleteKbWithRetry(
|
||||
(args) => reporter.runStep("cleanup: kb delete", routes, args),
|
||||
fixture.indexId,
|
||||
workspaceId,
|
||||
);
|
||||
if (deleteRun.exitCode === 0) reporter.markCleaned(fixture.indexId);
|
||||
}
|
||||
for (const fileId of fixture.fileIds ?? []) {
|
||||
const fileDeleteRun = await reporter.runStep(`cleanup: file delete ${fileId}`, routes, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
fileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
if (fileDeleteRun.exitCode === 0) reporter.markCleaned(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Backfill required retrieval parameters for search services (server gotcha) ----
|
||||
// The minimal kb_search_configs created by service create --index-id is missing
|
||||
// required fields like rerank_min_score / dense_similarity_top_k, so the search
|
||||
// endpoint rejects it (InvalidParameter). The chat scene is unaffected.
|
||||
// Workaround: get the beta config → backfill the fields → update --config-file.
|
||||
|
||||
interface AgentGetPayload {
|
||||
data?: {
|
||||
agent_details?: Array<{
|
||||
agent_config?: { kb_search_configs?: Array<Record<string, unknown>> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function patchSearchServiceRetrievalConfig(
|
||||
reporter: JourneyReporter,
|
||||
routes: E2eRouteExports,
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const getRun = await reporter.runStep("service get (patch retrieval config)", routes, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
if (getRun.exitCode !== 0) {
|
||||
throw new Error(`service get failed while patching retrieval config: ${getRun.stderr}`);
|
||||
}
|
||||
const agentConfig = parseStdoutJson<AgentGetPayload>(getRun.stdout).data?.agent_details?.[0]
|
||||
?.agent_config;
|
||||
if (!agentConfig) throw new Error(`service get returned no agent_config: ${getRun.stdout}`);
|
||||
for (const kbConfig of agentConfig.kb_search_configs ?? []) {
|
||||
Object.assign(kbConfig, {
|
||||
rerank_min_score: 0.01,
|
||||
dense_similarity_top_k: 100,
|
||||
sparse_similarity_top_k: 50,
|
||||
enable_reranking: true,
|
||||
rerank_top_n: 5,
|
||||
});
|
||||
}
|
||||
const configDir = mkdtempSync(join(tmpdir(), "journey-svc-cfg-"));
|
||||
const configFile = join(configDir, "agent-config.json");
|
||||
writeFileSync(configFile, JSON.stringify(agentConfig));
|
||||
const updateRun = await reporter.runStep("service update (patch retrieval config)", routes, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--config-file",
|
||||
configFile,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
if (updateRun.exitCode !== 0) {
|
||||
throw new Error(`service update failed while patching retrieval config: ${updateRun.stderr}`);
|
||||
}
|
||||
reporter.recordNote(
|
||||
`service ${agentId} kb_search_configs patched with required retrieval parameters`,
|
||||
);
|
||||
}
|
||||
+68
-3
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { KNOWLEDGE_CHAT_ROUTES } from "./topic-routes.ts";
|
||||
import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_CHAT_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
interface ContentPart {
|
||||
type: string;
|
||||
@@ -17,6 +17,7 @@ interface DryRunBody {
|
||||
parameters?: {
|
||||
agent_options?: {
|
||||
agent_id?: string;
|
||||
agent_version?: string;
|
||||
};
|
||||
};
|
||||
stream?: boolean;
|
||||
@@ -61,7 +62,7 @@ describe("e2e: knowledge chat", () => {
|
||||
test("缺少 --workspace-id 时非零退出并提示", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
KNOWLEDGE_CHAT_ROUTES,
|
||||
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
|
||||
// Fake key + isolated config dir: keep the local config's workspace_id/api_key from leaking in
|
||||
[
|
||||
"knowledge",
|
||||
"chat",
|
||||
@@ -101,6 +102,29 @@ describe("e2e: knowledge chat", () => {
|
||||
expect(data.request?.input?.messages?.[0]?.role).toBe("user");
|
||||
expect(data.request?.input?.messages?.[0]?.content).toBe("什么是RAG");
|
||||
expect(data.request?.parameters?.agent_options?.agent_id).toBe("aid_test");
|
||||
// Without --agent-version the field is not sent (default behavior unchanged)
|
||||
expect(data.request?.parameters?.agent_options).not.toHaveProperty("agent_version");
|
||||
});
|
||||
|
||||
test("--dry-run + --agent-version 落在 agent_options 内", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
|
||||
"knowledge",
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"什么是RAG",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--agent-version",
|
||||
"2",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.parameters?.agent_options?.agent_version).toBe("2");
|
||||
});
|
||||
|
||||
test("--dry-run 多轮消息解析 role:content 前缀", async () => {
|
||||
@@ -193,6 +217,47 @@ describe("e2e: knowledge chat", () => {
|
||||
image_url: { url: "https://example.com/b.png" },
|
||||
});
|
||||
});
|
||||
|
||||
test("--dry-run JSON 对象消息通道解析结构化 content", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
|
||||
"knowledge",
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
'{"role":"user","content":[{"type":"text","text":"结构化消息"}]}',
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
const firstMsg = data.request?.input?.messages?.[0];
|
||||
expect(firstMsg?.role).toBe("user");
|
||||
expect(Array.isArray(firstMsg?.content)).toBe(true);
|
||||
const parts = firstMsg?.content as ContentPart[];
|
||||
expect(parts[0]).toEqual({ type: "text", text: "结构化消息" });
|
||||
});
|
||||
|
||||
test("--image 与消息内嵌 image_url 冲突报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
|
||||
"knowledge",
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
'{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/e.png"}}]}',
|
||||
"--image",
|
||||
"https://example.com/x.png",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/embedded/i);
|
||||
});
|
||||
});
|
||||
|
||||
interface ChatJsonResult {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_DOC_DELETE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge doc delete", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--doc-id/i);
|
||||
expect(stderr).toMatch(/--yes/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("缺 --doc-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 doc_ids 数组 (snake_case)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--doc-id",
|
||||
"file_a",
|
||||
"--doc-id",
|
||||
"file_b",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/delete_file/);
|
||||
expect(data.request?.index_id).toBe("idx_test");
|
||||
expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]);
|
||||
});
|
||||
|
||||
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"delete",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--api-key",
|
||||
"sk-fake",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--yes/);
|
||||
});
|
||||
});
|
||||
|
||||
// Live coverage comes from the kb delete chain (deleting a knowledge base deletes
|
||||
// its documents); a standalone doc delete live case needs a "delete documents
|
||||
// without deleting the base" scenario, covered by the chunk/category/file live chain.
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_DOC_LIST_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge doc list", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--page-number/i);
|
||||
expect(stderr).toMatch(/--page-size/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--page-size 101 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--page-size",
|
||||
"101",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 query 参数名为 page_num (本接口坑位)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--page-number",
|
||||
"3",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/files/);
|
||||
expect(data.endpoint).toMatch(/index_id=idx_test/);
|
||||
expect(data.endpoint).toMatch(/page_num=3/);
|
||||
expect(data.endpoint).not.toMatch(/page_number/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc list (live)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("对真实库列出文档 (可为空)", async () => {
|
||||
const listRun = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
const firstIndexId = listRun.stdout.trim().split("\n")[0];
|
||||
if (!firstIndexId) return;
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"list",
|
||||
"--index-id",
|
||||
firstIndexId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ code: string }>(stdout);
|
||||
expect(data.code).toBe("Success");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_DOC_STATUS_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
// Live coverage depends on a real job_id produced by doc upload; it is exercised
|
||||
// as part of the upload live chain.
|
||||
|
||||
describe("e2e: knowledge doc status", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--job-id/i);
|
||||
expect(stderr).toMatch(/--wait/i);
|
||||
expect(stderr).toMatch(/--poll-interval/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--job-id",
|
||||
"job_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("缺 --job-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 query 同时含两个 id (双必填坑位)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--dry-run",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--job-id",
|
||||
"job_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index_job\/status/);
|
||||
expect(data.endpoint).toMatch(/index_id=idx_test/);
|
||||
expect(data.endpoint).toMatch(/job_id=job_test/);
|
||||
});
|
||||
|
||||
test("--dry-run 断言分页参数 query 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"status",
|
||||
"--dry-run",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--job-id",
|
||||
"job_test",
|
||||
"--page-number",
|
||||
"2",
|
||||
"--page-size",
|
||||
"50",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
|
||||
expect(data.endpoint).toMatch(/page_number=2/);
|
||||
expect(data.endpoint).toMatch(/page_size=50/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_DOC_TAG_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge doc tag", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--doc-id/i);
|
||||
expect(stderr).toMatch(/--tag/i);
|
||||
expect(stderr).toMatch(/--mode/i);
|
||||
});
|
||||
|
||||
test("缺 --doc-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--tag",
|
||||
"demo",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("缺 --tag 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("21 个 --doc-id 报 USAGE (2)", async () => {
|
||||
const docIdArgs = Array.from({ length: 21 }, (_, docIndex) => [
|
||||
"--doc-id",
|
||||
`file_${docIndex}`,
|
||||
]).flat();
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
...docIdArgs,
|
||||
"--tag",
|
||||
"demo",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("33 字符标签报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--tag",
|
||||
"x".repeat(33),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("101 个标签报 USAGE (2)", async () => {
|
||||
const tagArgs = Array.from({ length: 101 }, (_, tagIndex) => ["--tag", `t${tagIndex}`]).flat();
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
...tagArgs,
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("标签总长超 700 字符报 USAGE (2)", async () => {
|
||||
// 25 tags × 32 chars = 800 > 700, while each tag and the count stay within limits
|
||||
const tagArgs = Array.from({ length: 25 }, () => ["--tag", "x".repeat(32)]).flat();
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
...tagArgs,
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--mode 非法值报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--tag",
|
||||
"demo",
|
||||
"--mode",
|
||||
"replace",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 updateMode 大写映射与 fileInfos 结构", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_a",
|
||||
"--doc-id",
|
||||
"file_b",
|
||||
"--tag",
|
||||
"alpha",
|
||||
"--tag",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
endpoint?: string;
|
||||
request?: { fileInfos?: Array<{ fileId: string; tags: string[] }>; updateMode?: string };
|
||||
}>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/connector\/dash\/batchUpdateFileTag/);
|
||||
expect(data.request?.updateMode).toBe("APPEND");
|
||||
expect(data.request?.fileInfos).toEqual([
|
||||
{ fileId: "file_a", tags: ["alpha", "beta"] },
|
||||
{ fileId: "file_b", tags: ["alpha", "beta"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 --mode overwrite 大写映射 OVERWRITE", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--tag",
|
||||
"final",
|
||||
"--mode",
|
||||
"overwrite",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { updateMode?: string } }>(stdout);
|
||||
expect(data.request?.updateMode).toBe("OVERWRITE");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc tag (live)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("对新上传文件打标断言 Success", async () => {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "doc-tag-e2e-"));
|
||||
const filePath = join(fixtureDir, `tag-${Date.now()}.md`);
|
||||
writeFileSync(filePath, "# doc tag e2e fixture\n");
|
||||
const uploadRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
filePath,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
|
||||
const fileId = uploadRun.stdout.trim();
|
||||
|
||||
const tagRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
fileId,
|
||||
"--tag",
|
||||
"e2e-tag",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(tagRun.exitCode, tagRun.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ code: string }>(tagRun.stdout);
|
||||
expect(data.code).toBe("Success");
|
||||
|
||||
// overwrite mode replaces the tag set live (append above covered the default)
|
||||
const overwriteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"tag",
|
||||
"--doc-id",
|
||||
fileId,
|
||||
"--tag",
|
||||
"e2e-tag-final",
|
||||
"--mode",
|
||||
"overwrite",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(overwriteRun.exitCode, overwriteRun.stderr).toBe(0);
|
||||
const overwriteData = parseStdoutJson<{ code: string }>(overwriteRun.stdout);
|
||||
expect(overwriteData.code).toBe("Success");
|
||||
|
||||
// Clean up the uploaded data-center file
|
||||
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
fileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_DOC_UPLOAD_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "doc-upload-e2e-"));
|
||||
const smallMd = join(fixtureDir, "small.md");
|
||||
writeFileSync(smallMd, "# e2e fixture\n");
|
||||
|
||||
interface DryRunStep {
|
||||
step: string;
|
||||
endpoint: string;
|
||||
request: unknown;
|
||||
}
|
||||
|
||||
describe("e2e: knowledge doc upload", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--file/i);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--category-id/i);
|
||||
expect(stderr).toMatch(/--tag/i);
|
||||
expect(stderr).toMatch(/--wait/i);
|
||||
});
|
||||
|
||||
test("缺 --file 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--wait 无 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
smallMd,
|
||||
"--wait",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("文件不存在非零退出且 hint 含 errno", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
join(fixtureDir, "missing.md"),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/ENOENT|Cannot read file/i);
|
||||
});
|
||||
|
||||
test(".zip 扩展名报 USAGE 并列出支持格式", async () => {
|
||||
const zipPath = join(fixtureDir, "bad.zip");
|
||||
writeFileSync(zipPath, "PK");
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
zipPath,
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/\.pdf/);
|
||||
});
|
||||
|
||||
test("--dry-run 不带 --index-id 输出 3 步编排计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
smallMd,
|
||||
"--category-id",
|
||||
"cate_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
|
||||
expect(data.steps).toHaveLength(3);
|
||||
const leaseRequest = data.steps[0]!.request as { sizeBytes?: unknown; category?: string };
|
||||
expect(typeof leaseRequest.sizeBytes).toBe("string"); // gotcha: sizeBytes must be a string
|
||||
expect(leaseRequest.category).toBe("cate_test");
|
||||
});
|
||||
|
||||
test("--dry-run 带 --index-id 输出 4 步且 job 请求含显式 sourceType", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
smallMd,
|
||||
"--category-id",
|
||||
"cate_test",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
|
||||
expect(data.steps).toHaveLength(4);
|
||||
const jobRequest = data.steps[3]!.request as {
|
||||
indexId?: string;
|
||||
dataSource?: { sourceType?: string; fileIds?: string[] };
|
||||
};
|
||||
// Live-verified gotcha: job/create requires the nested dataSource shape, and
|
||||
// omitting sourceType would import the entire data center
|
||||
expect(jobRequest.dataSource?.sourceType).toBe("DATA_CENTER_FILE");
|
||||
expect(jobRequest.indexId).toBe("idx_test");
|
||||
expect(jobRequest).not.toHaveProperty("documentIds");
|
||||
});
|
||||
});
|
||||
|
||||
// Live write artifacts (data-center files) are cleaned up in place via the file delete command.
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc upload (live)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("上传 2 个 md 返回各自 file_ 前缀 fileId (多文件 happy path)", async () => {
|
||||
const livePathA = join(fixtureDir, `live-a-${Date.now()}.md`);
|
||||
const livePathB = join(fixtureDir, `live-b-${Date.now()}.md`);
|
||||
writeFileSync(livePathA, "# e2e live upload fixture A\n");
|
||||
writeFileSync(livePathB, "# e2e live upload fixture B\n");
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
livePathA,
|
||||
"--file",
|
||||
livePathB,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const fileIds = stdout.trim().split("\n").filter(Boolean);
|
||||
expect(fileIds).toHaveLength(2);
|
||||
for (const fileId of fileIds) {
|
||||
expect(fileId).toMatch(/^file_/);
|
||||
}
|
||||
|
||||
// Clean up both uploaded data-center files
|
||||
for (const fileId of fileIds) {
|
||||
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
fileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_KB_CREATE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
request?: {
|
||||
sourceType?: string;
|
||||
sinkType?: string;
|
||||
docIds?: string[];
|
||||
categoryIds?: string[];
|
||||
embeddingModelName?: string;
|
||||
chunkSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe("e2e: knowledge kb create", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--name/i);
|
||||
expect(stderr).toMatch(/--doc-id/i);
|
||||
expect(stderr).toMatch(/--category-id/i);
|
||||
expect(stderr).toMatch(/--embedding-model/i);
|
||||
expect(stderr).toMatch(/--chunk-size/i);
|
||||
});
|
||||
|
||||
test("缺 --name 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("无数据源 flag 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("两数据源同传报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--category-id",
|
||||
"cate_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--name 21 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"x".repeat(21),
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run + --doc-id 断言 DATA_CENTER_FILE / docIds / BUILT_IN", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/create_v2/);
|
||||
expect(data.request?.sourceType).toBe("DATA_CENTER_FILE");
|
||||
expect(data.request?.docIds).toEqual(["file_test"]);
|
||||
expect(data.request?.sinkType).toBe("BUILT_IN");
|
||||
// Defaults are part of the contract — the server applies no fallback of its own
|
||||
expect(data.request?.embeddingModelName).toBe("text-embedding-v4");
|
||||
expect(data.request?.chunkSize).toBe(600);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 --embedding-model/--chunk-size 在 body 中的映射", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--doc-id",
|
||||
"file_test",
|
||||
"--embedding-model",
|
||||
"text-embedding-v3",
|
||||
"--chunk-size",
|
||||
"300",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.embeddingModelName).toBe("text-embedding-v3");
|
||||
expect(data.request?.chunkSize).toBe(300);
|
||||
});
|
||||
|
||||
test("--dry-run + --category-id 断言 DATA_CENTER_CATEGORY / categoryIds", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--category-id",
|
||||
"cate_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.sourceType).toBe("DATA_CENTER_CATEGORY");
|
||||
expect(data.request?.categoryIds).toEqual(["cate_test"]);
|
||||
});
|
||||
});
|
||||
|
||||
// The live self-cleaning chain lives in knowledge-kb-delete.e2e.test.ts (upload → create → delete).
|
||||
@@ -0,0 +1,220 @@
|
||||
// The live group chains the full lifecycle: upload → create → update → upload --index-id
|
||||
// (import orchestration) → delete → list to verify removal.
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { deleteKbWithRetry } from "./journeys/journey-helpers.ts";
|
||||
import { KNOWLEDGE_KB_DELETE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge kb delete", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"delete",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--yes/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"delete",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 body index_id (snake_case)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"delete",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/delete/);
|
||||
expect(data.request?.index_id).toBe("idx_test");
|
||||
});
|
||||
|
||||
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"delete",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--api-key",
|
||||
"sk-fake",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--yes/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("upload → create → update → upload --index-id → delete → list 验证消失", async () => {
|
||||
// Mid-chain failures must not leak the base / data-center files — track created
|
||||
// resources and best-effort clean them in finally (only when the chain aborted)
|
||||
const fixtureFileIds: string[] = [];
|
||||
let indexId = "";
|
||||
let chainCompleted = false;
|
||||
try {
|
||||
// 1) Upload a file
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "kb-chain-e2e-"));
|
||||
const filePath = join(fixtureDir, `chain-${Date.now()}.md`);
|
||||
writeFileSync(filePath, "# kb chain e2e fixture\n");
|
||||
const uploadRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
filePath,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
|
||||
const fileId = uploadRun.stdout.trim();
|
||||
expect(fileId).toMatch(/^file_/);
|
||||
fixtureFileIds.push(fileId);
|
||||
|
||||
// 2) Create the knowledge base (--wait for the import; server gotcha: a freshly
|
||||
// created base cannot be deleted immediately — Index.IndexStatusError)
|
||||
const kbName = `e2e-del-${Date.now() % 100000000}`;
|
||||
const createRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"create",
|
||||
"--name",
|
||||
kbName,
|
||||
"--doc-id",
|
||||
fileId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--wait",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(createRun.exitCode, createRun.stderr).toBe(0);
|
||||
indexId = createRun.stdout.trim().split("\n")[0]!;
|
||||
expect(indexId).toBeTruthy();
|
||||
|
||||
// 2.5) Live update coverage: name / description / rerank threshold in one call
|
||||
const updateRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
indexId,
|
||||
"--name",
|
||||
`e2e-upd-${Date.now() % 100000000}`,
|
||||
"--description",
|
||||
"e2e chain updated description",
|
||||
"--rerank-min-score",
|
||||
"0.3",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
|
||||
expect(updateRun.stdout).toMatch(/updated/);
|
||||
|
||||
// 2.7) Live coverage of the upload → import orchestration (doc upload --index-id --wait):
|
||||
// the journeys always upload bare files and import via kb create, so this is the
|
||||
// only place the createImportJob step runs live
|
||||
const importFilePath = join(fixtureDir, `chain-import-${Date.now()}.md`);
|
||||
writeFileSync(importFilePath, "# kb chain e2e import fixture\n");
|
||||
const importUploadRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
"upload",
|
||||
"--file",
|
||||
importFilePath,
|
||||
"--index-id",
|
||||
indexId,
|
||||
"--wait",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(importUploadRun.exitCode, importUploadRun.stderr).toBe(0);
|
||||
const importedFileId = importUploadRun.stdout.trim();
|
||||
expect(importedFileId).toMatch(/^file_/);
|
||||
fixtureFileIds.push(importedFileId);
|
||||
|
||||
// 3) Delete the base (--yes non-interactive; deleteKbWithRetry retries on
|
||||
// IndexStatusError: readiness can lag briefly even after the import completes)
|
||||
const deleteRun = await deleteKbWithRetry(
|
||||
(args) => runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, args),
|
||||
indexId,
|
||||
workspaceId,
|
||||
);
|
||||
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
|
||||
expect(deleteRun.stdout).toMatch(/deleted/);
|
||||
|
||||
// 3.5) Clean up the data-center files (kb delete removes them from the base but
|
||||
// the file artifacts persist in the data center)
|
||||
for (const cleanupFileId of fixtureFileIds) {
|
||||
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
cleanupFileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
|
||||
}
|
||||
|
||||
// 4) list verifies the base is gone
|
||||
const listRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(listRun.exitCode, listRun.stderr).toBe(0);
|
||||
const remainingIds = listRun.stdout.trim().split("\n");
|
||||
expect(remainingIds).not.toContain(indexId);
|
||||
chainCompleted = true;
|
||||
} finally {
|
||||
if (!chainCompleted) {
|
||||
// Best-effort cleanup on abort — do not assert, the original failure matters more
|
||||
if (indexId) {
|
||||
await deleteKbWithRetry(
|
||||
(args) => runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, args),
|
||||
indexId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
for (const cleanupFileId of fixtureFileIds) {
|
||||
await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"file",
|
||||
"delete",
|
||||
"--file-id",
|
||||
cleanupFileId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two --wait import phases in the chain — timeout sized for slow server-side parsing
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_KB_INFO_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge kb info", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"info",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--workspace-id/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"info",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--index-id|Usage:/i);
|
||||
});
|
||||
|
||||
test("--dry-run 输出 list endpoint 与兜底策略说明", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"info",
|
||||
"--dry-run",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string; strategy?: string }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/list/);
|
||||
expect(data.endpoint).toMatch(/page_size=100/);
|
||||
expect(data.strategy).toMatch(/paginate/i);
|
||||
});
|
||||
});
|
||||
|
||||
interface KbListQuietResult {
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb info (live)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("先 list 取 id 再 info 断言分组字段", async () => {
|
||||
const listRun: KbListQuietResult = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
const firstId = listRun.stdout.trim().split("\n")[0];
|
||||
if (!firstId) return; // skip assertions when the workspace has no knowledge bases
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"info",
|
||||
"--index-id",
|
||||
firstId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/Basic:/);
|
||||
expect(stdout).toMatch(/Indexing:/);
|
||||
expect(stdout).toMatch(/immutable/);
|
||||
expect(stdout).toMatch(/Retrieval:/);
|
||||
});
|
||||
|
||||
test("不存在的 id 非零退出并给 hint", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
|
||||
"knowledge",
|
||||
"info",
|
||||
"--index-id",
|
||||
"idx-not-exist-e2e",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_KB_LIST_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
request?: unknown;
|
||||
}
|
||||
|
||||
describe("e2e: knowledge kb list", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--name/i);
|
||||
expect(stderr).toMatch(/--page-number/i);
|
||||
expect(stderr).toMatch(/--page-size/i);
|
||||
expect(stderr).toMatch(/--workspace-id/i);
|
||||
});
|
||||
|
||||
test("缺 workspace 时报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
KNOWLEDGE_KB_LIST_ROUTES,
|
||||
["knowledge", "list", "--api-key", "sk-fake", "--output", "json"],
|
||||
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
|
||||
);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/workspace.*required/i);
|
||||
});
|
||||
|
||||
test("--dry-run 输出 endpoint (query string 含分页/过滤参数)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--name",
|
||||
"demo",
|
||||
"--page-number",
|
||||
"2",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/list/);
|
||||
expect(data.endpoint).toMatch(/page_number=2/);
|
||||
expect(data.endpoint).toMatch(/pipeline_name=demo/);
|
||||
expect(data.request).toBeNull();
|
||||
});
|
||||
|
||||
test("--name 21 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--name",
|
||||
"x".repeat(21),
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--page-size 101 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--page-size",
|
||||
"101",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
interface KbListResponse {
|
||||
code: string;
|
||||
data: { rows: Array<{ id: string; name: string }> };
|
||||
}
|
||||
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb list (live)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("list 返回 rows 数组", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<KbListResponse>(stdout);
|
||||
expect(data.code).toBe("Success");
|
||||
expect(Array.isArray(data.data.rows)).toBe(true);
|
||||
});
|
||||
|
||||
test("--quiet 输出行数与 rows 数一致", async () => {
|
||||
const jsonRun = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
const rows = parseStdoutJson<KbListResponse>(jsonRun.stdout).data.rows;
|
||||
const quietRun = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
|
||||
"knowledge",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
const lines = quietRun.stdout.trim().split("\n").filter(Boolean);
|
||||
expect(lines.length).toBe(rows.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_KB_UPDATE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
describe("e2e: knowledge kb update", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
expect(stderr).toMatch(/--name/i);
|
||||
expect(stderr).toMatch(/--description/i);
|
||||
expect(stderr).toMatch(/--rerank-min-score/i);
|
||||
});
|
||||
|
||||
test("缺 --index-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--description",
|
||||
"x",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("无修改项报 USAGE (2) 'Nothing to update'", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/nothing to update/i);
|
||||
});
|
||||
|
||||
test("--rerank-min-score 1.5 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--rerank-min-score",
|
||||
"1.5",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--name 21 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--name",
|
||||
"x".repeat(21),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 body 键名为 id (本接口坑位)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--description",
|
||||
"new desc",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/update/);
|
||||
expect(data.request?.id).toBe("idx_test");
|
||||
expect(data.request).not.toHaveProperty("index_id");
|
||||
expect(data.request?.description).toBe("new desc");
|
||||
});
|
||||
|
||||
test("--dry-run 断言 rerankMinScore body 键名映射", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
|
||||
"knowledge",
|
||||
"update",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--rerank-min-score",
|
||||
"0.3",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: Record<string, unknown> }>(stdout);
|
||||
expect(data.request?.rerankMinScore).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
// Live coverage: the kb delete self-cleaning chain (knowledge-kb-delete.e2e.test.ts)
|
||||
// runs a live update step between create and delete.
|
||||
+27
-3
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { KNOWLEDGE_SEARCH_ROUTES } from "./topic-routes.ts";
|
||||
import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_SEARCH_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
request?: {
|
||||
query?: string;
|
||||
agent_id?: string;
|
||||
agent_version?: string;
|
||||
images?: string[];
|
||||
query_history?: Array<{ role: string; content: string }>;
|
||||
};
|
||||
@@ -52,7 +53,7 @@ describe("e2e: knowledge search", () => {
|
||||
test("缺少 --workspace-id 时非零退出并提示", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
KNOWLEDGE_SEARCH_ROUTES,
|
||||
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
|
||||
// Fake key + isolated config dir: keep the local config's workspace_id/api_key from leaking in
|
||||
[
|
||||
"knowledge",
|
||||
"search",
|
||||
@@ -91,6 +92,29 @@ describe("e2e: knowledge search", () => {
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
|
||||
expect(data.request?.query).toBe("什么是RAG");
|
||||
expect(data.request?.agent_id).toBe("aid_test");
|
||||
// Without --agent-version the field is not sent (default behavior unchanged: latest published version)
|
||||
expect(data.request).not.toHaveProperty("agent_version");
|
||||
});
|
||||
|
||||
test("--dry-run + --agent-version 落在 body 顶层", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
|
||||
"knowledge",
|
||||
"search",
|
||||
"--dry-run",
|
||||
"--query",
|
||||
"什么是RAG",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.agent_version).toBe("beta");
|
||||
});
|
||||
|
||||
test("--dry-run + --image 输出 images", async () => {
|
||||
@@ -0,0 +1,651 @@
|
||||
// service group, 7 commands: the static group covers help/missing-args/dry-run per
|
||||
// command; the live group runs the full lifecycle (create → scalar update → get
|
||||
// assertion → search --agent-version beta smoke → deploy → copy → delete×2).
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_SERVICE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
interface DryRunBody {
|
||||
endpoint?: string;
|
||||
request?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("e2e: knowledge service list", () => {
|
||||
test("--help 展示 flags", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--scene/i);
|
||||
expect(stderr).toMatch(/--status/i);
|
||||
expect(stderr).toMatch(/--index-id/i);
|
||||
});
|
||||
|
||||
test("缺 --scene 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--scene 非法值报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"qa",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--status 非法值报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"chat",
|
||||
"--status",
|
||||
"online",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--page-size 101 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"chat",
|
||||
"--page-size",
|
||||
"101",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 body agent_scene 与分页走 body", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"chat",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/app\/list/);
|
||||
expect(data.request?.agent_scene).toBe("chat");
|
||||
expect(data.request?.pipeline_id).toBe("idx_test");
|
||||
expect(data.request?.page_number).toBe(1);
|
||||
});
|
||||
|
||||
test("--dry-run 断言 --status/--name/--agent-id 的 snake_case body 映射", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"search",
|
||||
"--status",
|
||||
"deployed",
|
||||
"--name",
|
||||
"demo",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.request?.agent_status).toBe("deployed");
|
||||
expect(data.request?.agent_name).toBe("demo");
|
||||
expect(data.request?.agent_id).toBe("aid_test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: knowledge service get / create / copy", () => {
|
||||
test("get: 缺 --agent-id 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("get: --dry-run 断言 agent_version 透传", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/rag\/app\/get/);
|
||||
expect(data.request?.agent_version).toBe("beta");
|
||||
});
|
||||
|
||||
test("create: 缺 --scene 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("create: --scene 非法值报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--scene",
|
||||
"qa",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("create: --name 201 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
"x".repeat(201),
|
||||
"--scene",
|
||||
"chat",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("create: --description 1001 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--scene",
|
||||
"chat",
|
||||
"--description",
|
||||
"x".repeat(1001),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("create: --dry-run + --index-id 断言最简 kb_search_configs", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
"demo",
|
||||
"--scene",
|
||||
"search",
|
||||
"--index-id",
|
||||
"idx_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
const agentConfig = data.request?.agent_config as
|
||||
| { kb_search_configs?: Array<{ id: string }> }
|
||||
| undefined;
|
||||
expect(agentConfig?.kb_search_configs).toEqual([{ id: "idx_test" }]);
|
||||
});
|
||||
|
||||
test("copy: --dry-run 断言 body agent_id", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"copy",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/rag\/app\/copy/);
|
||||
expect(data.request?.agent_id).toBe("aid_test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: knowledge service update", () => {
|
||||
test("无修改项报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/nothing to update/i);
|
||||
});
|
||||
|
||||
test("--config-file 与标量 config flag 互斥 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--temperature",
|
||||
"0.5",
|
||||
"--config-file",
|
||||
"/tmp/whatever.json",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("已发布版本 + config 修改前置拦截 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--agent-version",
|
||||
"1",
|
||||
"--temperature",
|
||||
"0.5",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/beta draft/i);
|
||||
});
|
||||
|
||||
test("--temperature 2.5 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--temperature",
|
||||
"2.5",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--policy 非法值报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--policy",
|
||||
"fast",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--max-llm-calls 31 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--max-llm-calls",
|
||||
"31",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--enable-session-file 非 true/false 报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--enable-session-file",
|
||||
"maybe",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--name 201 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--name",
|
||||
"x".repeat(201),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--description 1001 字符报 USAGE (2)", async () => {
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--description",
|
||||
"x".repeat(1001),
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--config-file 非法 JSON 报 USAGE (2)", async () => {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "svc-update-e2e-"));
|
||||
const badJson = join(fixtureDir, "bad.json");
|
||||
writeFileSync(badJson, "{not json");
|
||||
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--config-file",
|
||||
badJson,
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
test("--dry-run + --version-desc 断言 body 与标量合并说明", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--temperature",
|
||||
"0.7",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody & { note?: string }>(stdout);
|
||||
expect(data.endpoint).toMatch(/rag\/app\/update/);
|
||||
const agentConfig = data.request?.agent_config as { temperature?: number } | undefined;
|
||||
expect(agentConfig?.temperature).toBe(0.7);
|
||||
expect(data.note).toMatch(/merged/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: knowledge service deploy / delete (危险)", () => {
|
||||
test("deploy: 非 TTY 无 --yes 报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"deploy",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--api-key",
|
||||
"sk-fake",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--yes/);
|
||||
});
|
||||
|
||||
test("deploy: --dry-run 断言 body", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"deploy",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--version-desc",
|
||||
"v1 desc",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<DryRunBody>(stdout);
|
||||
expect(data.endpoint).toMatch(/rag\/app\/deploy/);
|
||||
expect(data.request?.agent_version_desc).toBe("v1 desc");
|
||||
});
|
||||
|
||||
test("delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"delete",
|
||||
"--agent-id",
|
||||
"aid_test",
|
||||
"--api-key",
|
||||
"sk-fake",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--yes/);
|
||||
});
|
||||
});
|
||||
|
||||
// Path gotcha: the actual gateway prefix is rag/app/; the rag/agent/ prefix from the
|
||||
// public API docs is not routed (it returns console HTML).
|
||||
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live, 自清理)", () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
|
||||
|
||||
test("create → update → get 断言 → deploy → copy → delete×2", async () => {
|
||||
const serviceName = `e2e-svc-${Date.now() % 100000000}`;
|
||||
|
||||
// 1) create (chat scene, no knowledge base bound — verifies server defaults)
|
||||
const createRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"create",
|
||||
"--name",
|
||||
serviceName,
|
||||
"--scene",
|
||||
"chat",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(createRun.exitCode, createRun.stderr).toBe(0);
|
||||
const agentId = createRun.stdout.trim().split("\n").pop()!;
|
||||
expect(agentId).toMatch(/^aid-/);
|
||||
|
||||
// 1.5) list asserts the new agent shows up (filtered by agent_id)
|
||||
const listRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"list",
|
||||
"--scene",
|
||||
"chat",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(listRun.exitCode, listRun.stderr).toBe(0);
|
||||
expect(listRun.stdout.trim().split("\n")).toContain(agentId);
|
||||
|
||||
// 2) scalar update (read-merge-write onto the beta draft)
|
||||
const updateRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"update",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--temperature",
|
||||
"0.55",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
|
||||
|
||||
// 3) get asserts the scalars landed on beta
|
||||
const getRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--agent-version",
|
||||
"beta",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(getRun.exitCode, getRun.stderr).toBe(0);
|
||||
const getData = parseStdoutJson<{
|
||||
data: { agent_details: Array<{ agent_config?: { temperature?: number } }> };
|
||||
}>(getRun.stdout);
|
||||
expect(getData.data.agent_details[0]?.agent_config?.temperature).toBe(0.55);
|
||||
|
||||
// 4) deploy (--yes non-interactive)
|
||||
const deployRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"deploy",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(deployRun.exitCode, deployRun.stderr).toBe(0);
|
||||
expect(deployRun.stdout.trim().split("\n").pop()).toBe("1");
|
||||
|
||||
// 4.5) bare get (no --agent-version) returns all versions: beta draft + published 1
|
||||
const allVersionsRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"get",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(allVersionsRun.exitCode, allVersionsRun.stderr).toBe(0);
|
||||
const allVersionsData = parseStdoutJson<{
|
||||
data: { agent_details: Array<{ agent_version?: string }> };
|
||||
}>(allVersionsRun.stdout);
|
||||
const versions = allVersionsData.data.agent_details.map((detail) => detail.agent_version);
|
||||
expect(versions).toContain("beta");
|
||||
expect(versions).toContain("1");
|
||||
|
||||
// 5) copy
|
||||
const copyRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"copy",
|
||||
"--agent-id",
|
||||
agentId,
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
"--quiet",
|
||||
]);
|
||||
expect(copyRun.exitCode, copyRun.stderr).toBe(0);
|
||||
const copiedAgentId = copyRun.stdout.trim().split("\n").pop()!;
|
||||
expect(copiedAgentId).toMatch(/^aid-/);
|
||||
expect(copiedAgentId).not.toBe(agentId);
|
||||
|
||||
// 6) delete both (idempotent soft delete)
|
||||
for (const idToDelete of [copiedAgentId, agentId]) {
|
||||
const deleteRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
|
||||
"knowledge",
|
||||
"service",
|
||||
"delete",
|
||||
"--agent-id",
|
||||
idToDelete,
|
||||
"--yes",
|
||||
"--workspace-id",
|
||||
workspaceId,
|
||||
]);
|
||||
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { KNOWLEDGE_ROUTES } from "./topic-routes.ts";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
|
||||
import { KNOWLEDGE_ROUTES } from "../topic-routes.ts";
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -183,3 +183,142 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
|
||||
"managed-agent session send": "managedAgentSessionSend",
|
||||
"managed-agent skill-list": "managedAgentSkillList",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_KB_LIST_ROUTES: E2eRouteExports = {
|
||||
"knowledge list": "knowledgeKbList",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_KB_INFO_ROUTES: E2eRouteExports = {
|
||||
"knowledge info": "knowledgeKbInfo",
|
||||
"knowledge list": "knowledgeKbList", // live cases list first to grab a real id
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_DOC_LIST_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc list": "knowledgeDocList",
|
||||
"knowledge list": "knowledgeKbList", // live cases grab a real index id first
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_DOC_STATUS_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc status": "knowledgeDocStatus",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_DOC_UPLOAD_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc upload": "knowledgeDocUpload",
|
||||
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_KB_CREATE_ROUTES: E2eRouteExports = {
|
||||
"knowledge create": "knowledgeKbCreate",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_KB_UPDATE_ROUTES: E2eRouteExports = {
|
||||
"knowledge update": "knowledgeKbUpdate",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_KB_DELETE_ROUTES: E2eRouteExports = {
|
||||
"knowledge delete": "knowledgeKbDelete",
|
||||
"knowledge create": "knowledgeKbCreate", // live self-cleaning chain
|
||||
"knowledge update": "knowledgeKbUpdate", // live update step in the chain
|
||||
"knowledge list": "knowledgeKbList",
|
||||
"knowledge doc upload": "knowledgeDocUpload",
|
||||
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_DOC_DELETE_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc delete": "knowledgeDocDelete",
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_DOC_TAG_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc tag": "knowledgeDocTag",
|
||||
"knowledge doc upload": "knowledgeDocUpload", // live uploads first to grab a real fileId
|
||||
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_SERVICE_ROUTES: E2eRouteExports = {
|
||||
"knowledge service list": "knowledgeServiceList",
|
||||
"knowledge service get": "knowledgeServiceGet",
|
||||
"knowledge service create": "knowledgeServiceCreate",
|
||||
"knowledge service update": "knowledgeServiceUpdate",
|
||||
"knowledge service deploy": "knowledgeServiceDeploy",
|
||||
"knowledge service delete": "knowledgeServiceDelete",
|
||||
"knowledge service copy": "knowledgeServiceCopy",
|
||||
"knowledge search": "knowledgeSearch", // beta verification chain smoke
|
||||
};
|
||||
|
||||
export const KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES: E2eRouteExports = {
|
||||
"knowledge chunk add": "knowledgeChunkAdd",
|
||||
"knowledge chunk list": "knowledgeChunkList",
|
||||
"knowledge chunk update": "knowledgeChunkUpdate",
|
||||
"knowledge chunk delete": "knowledgeChunkDelete",
|
||||
"knowledge stats": "knowledgeKbStats",
|
||||
"knowledge retrieve": "knowledgeRetrieve", // live rerank retrieval on the throwaway base
|
||||
"knowledge category list": "knowledgeCategoryList",
|
||||
"knowledge category add": "knowledgeCategoryAdd",
|
||||
"knowledge category delete": "knowledgeCategoryDelete",
|
||||
"knowledge file list": "knowledgeFileList",
|
||||
"knowledge file get": "knowledgeFileGet",
|
||||
"knowledge file delete": "knowledgeFileDelete",
|
||||
"knowledge collection create": "knowledgeCollectionCreate",
|
||||
"knowledge collection get": "knowledgeCollectionGet",
|
||||
"knowledge doc import-oss": "knowledgeDocImportOss",
|
||||
"knowledge list": "knowledgeKbList", // live grabs a real index id
|
||||
"knowledge doc upload": "knowledgeDocUpload", // live produces a fileId
|
||||
"knowledge doc delete": "knowledgeDocDelete", // live verifies document-level delete semantics
|
||||
};
|
||||
|
||||
// ---- Journey-level routes (full user-journey chains, see journeys/README.md) ----
|
||||
|
||||
/** Minimal create/cleanup routes shared by J1–J4 */
|
||||
const JOURNEY_KB_BASE_ROUTES: E2eRouteExports = {
|
||||
"knowledge doc upload": "knowledgeDocUpload",
|
||||
"knowledge create": "knowledgeKbCreate",
|
||||
"knowledge retrieve": "knowledgeRetrieve",
|
||||
"knowledge delete": "knowledgeKbDelete",
|
||||
"knowledge file delete": "knowledgeFileDelete", // clean up data-center files
|
||||
};
|
||||
|
||||
export const JOURNEY_J1_ROUTES: E2eRouteExports = {
|
||||
...JOURNEY_KB_BASE_ROUTES,
|
||||
"knowledge service create": "knowledgeServiceCreate",
|
||||
"knowledge service get": "knowledgeServiceGet", // search-service retrieval-parameter backfill chain
|
||||
"knowledge service update": "knowledgeServiceUpdate",
|
||||
"knowledge service delete": "knowledgeServiceDelete",
|
||||
"knowledge search": "knowledgeSearch",
|
||||
"knowledge chat": "knowledgeChat",
|
||||
};
|
||||
|
||||
export const JOURNEY_J2_ROUTES: E2eRouteExports = {
|
||||
...JOURNEY_KB_BASE_ROUTES,
|
||||
"knowledge doc list": "knowledgeDocList",
|
||||
"knowledge doc status": "knowledgeDocStatus",
|
||||
"knowledge doc tag": "knowledgeDocTag",
|
||||
"knowledge doc delete": "knowledgeDocDelete",
|
||||
};
|
||||
|
||||
export const JOURNEY_J3_ROUTES: E2eRouteExports = {
|
||||
...JOURNEY_KB_BASE_ROUTES,
|
||||
"knowledge chunk list": "knowledgeChunkList",
|
||||
"knowledge chunk update": "knowledgeChunkUpdate",
|
||||
};
|
||||
|
||||
export const JOURNEY_J4_ROUTES: E2eRouteExports = {
|
||||
...JOURNEY_KB_BASE_ROUTES,
|
||||
"knowledge service create": "knowledgeServiceCreate",
|
||||
"knowledge service update": "knowledgeServiceUpdate",
|
||||
"knowledge service get": "knowledgeServiceGet",
|
||||
"knowledge service deploy": "knowledgeServiceDeploy",
|
||||
"knowledge service delete": "knowledgeServiceDelete",
|
||||
"knowledge search": "knowledgeSearch",
|
||||
};
|
||||
|
||||
export const JOURNEY_J5_ROUTES: E2eRouteExports = {
|
||||
"knowledge collection create": "knowledgeCollectionCreate",
|
||||
"knowledge collection get": "knowledgeCollectionGet",
|
||||
"knowledge category add": "knowledgeCategoryAdd",
|
||||
"knowledge category list": "knowledgeCategoryList",
|
||||
"knowledge category delete": "knowledgeCategoryDelete",
|
||||
"knowledge file list": "knowledgeFileList",
|
||||
"knowledge file get": "knowledgeFileGet",
|
||||
"knowledge file delete": "knowledgeFileDelete",
|
||||
"knowledge doc upload": "knowledgeDocUpload",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { buildDataSourceFields } from "../../src/commands/knowledge/kb-create.ts";
|
||||
|
||||
describe("buildDataSourceFields", () => {
|
||||
test("--doc-id 推导 DATA_CENTER_FILE + docIds", () => {
|
||||
const fields = buildDataSourceFields({ docId: ["file_1", "file_2"] });
|
||||
expect(fields).toEqual({
|
||||
sourceType: "DATA_CENTER_FILE",
|
||||
docIds: ["file_1", "file_2"],
|
||||
dataSources: [{ sourceType: "DATA_CENTER_FILE" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("--category-id 推导 DATA_CENTER_CATEGORY + categoryIds", () => {
|
||||
const fields = buildDataSourceFields({ categoryId: ["cate_1"] });
|
||||
expect(fields).toEqual({
|
||||
sourceType: "DATA_CENTER_CATEGORY",
|
||||
categoryIds: ["cate_1"],
|
||||
dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import { resolveWorkspaceId, truncateLine } from "../../src/commands/knowledge/shared.ts";
|
||||
|
||||
const identity = { binName: "kscli" };
|
||||
|
||||
describe("resolveWorkspaceId", () => {
|
||||
test("flag 优先于 settings", () => {
|
||||
const workspaceId = resolveWorkspaceId({
|
||||
flags: { workspaceId: "ws_flag" },
|
||||
settings: { workspaceId: "ws_cfg" },
|
||||
identity,
|
||||
});
|
||||
expect(workspaceId).toBe("ws_flag");
|
||||
});
|
||||
|
||||
test("无 flag 时回退 settings (env/config 已并入 settings)", () => {
|
||||
const workspaceId = resolveWorkspaceId({
|
||||
flags: {},
|
||||
settings: { workspaceId: "ws_cfg" },
|
||||
identity,
|
||||
});
|
||||
expect(workspaceId).toBe("ws_cfg");
|
||||
});
|
||||
|
||||
test("均缺失时抛 USAGE 且 hint 含 binName", () => {
|
||||
try {
|
||||
resolveWorkspaceId({ flags: {}, settings: {}, identity });
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
|
||||
expect((error as { hint?: string }).hint).toMatch(/kscli config set workspace_id/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateLine", () => {
|
||||
test("非 TTY 下不截断", () => {
|
||||
const longLine = "x".repeat(500);
|
||||
expect(truncateLine(longLine)).toBe(longLine);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import {
|
||||
checkUploadFile,
|
||||
UPLOAD_FORMAT_RULES,
|
||||
} from "../../src/commands/knowledge/upload-support.ts";
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "upload-support-"));
|
||||
|
||||
function writeFixture(fileName: string, content: string): string {
|
||||
const filePath = join(fixtureDir, fileName);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("checkUploadFile", () => {
|
||||
test("白名单内的小文件通过且无警告", () => {
|
||||
const filePath = writeFixture("note.md", "# hello");
|
||||
const result = checkUploadFile(filePath);
|
||||
expect(result.sizeBytes).toBeGreaterThan(0);
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test("白名单外扩展名 (.zip) 抛 USAGE 并列出支持格式", () => {
|
||||
const filePath = writeFixture("archive.zip", "PK");
|
||||
try {
|
||||
checkUploadFile(filePath);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
|
||||
expect((error as { hint?: string }).hint).toMatch(/\.pdf/);
|
||||
}
|
||||
});
|
||||
|
||||
test("文件不存在抛 GENERAL 且 hint 含 errno", () => {
|
||||
try {
|
||||
checkUploadFile(join(fixtureDir, "missing.md"));
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.GENERAL);
|
||||
expect((error as { hint?: string }).hint).toMatch(/ENOENT/);
|
||||
}
|
||||
});
|
||||
|
||||
test("软限类型 (.md) 超 10MB 返回警告不拦截", () => {
|
||||
const filePath = writeFixture("big.md", "x".repeat(11 * 1024 * 1024));
|
||||
const result = checkUploadFile(filePath);
|
||||
expect(result.warning).toMatch(/10 MB/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UPLOAD_FORMAT_RULES", () => {
|
||||
test("硬限/软限分类符合设计表", () => {
|
||||
expect(UPLOAD_FORMAT_RULES[".pdf"]).toEqual({ maxBytes: 150 * 1024 * 1024, enforce: "block" });
|
||||
expect(UPLOAD_FORMAT_RULES[".png"]).toEqual({ maxBytes: 20 * 1024 * 1024, enforce: "block" });
|
||||
expect(UPLOAD_FORMAT_RULES[".xlsx"]).toEqual({ maxBytes: 10 * 1024 * 1024, enforce: "warn" });
|
||||
expect(UPLOAD_FORMAT_RULES[".csv"]).toBeDefined(); // inconsistent across public docs; kept in the allowlist
|
||||
});
|
||||
});
|
||||
@@ -197,3 +197,52 @@ export function deploymentUpdatePath(deployedModel: string): string {
|
||||
export function deploymentsModelsPath(): string {
|
||||
return "/api/v1/deployments/models";
|
||||
}
|
||||
|
||||
// ---- RAG admin plane (knowledge base / data center / service management, workspace-based host) ----
|
||||
// All admin endpoints go through this factory; path constants are centralized in
|
||||
// RAG_PATHS to avoid per-endpoint boilerplate.
|
||||
|
||||
export function ragEndpoint(workspaceId: string, path: string): string {
|
||||
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`;
|
||||
}
|
||||
|
||||
export const RAG_PATHS = {
|
||||
// indices domain — knowledge bases / documents / chunks / import jobs.
|
||||
// Note: parameter naming is inconsistent across endpoints; see per-path comments.
|
||||
indexList: "/api/v1/indices/rag/index/list", // GET, pagination/filters go in the query string
|
||||
indexCreateV2: "/api/v1/indices/rag/index/create_v2", // POST
|
||||
indexUpdate: "/api/v1/indices/rag/index/update", // POST, index id parameter is named `id`
|
||||
indexDelete: "/api/v1/indices/rag/index/delete", // POST, body { index_id }
|
||||
indexMonitor: "/api/v1/indices/rag/index/monitor", // POST, second-precision string timestamps
|
||||
indexFiles: "/api/v1/indices/rag/index/files", // GET, page parameter is page_num
|
||||
indexDeleteFile: "/api/v1/indices/rag/index/delete_file", // POST, body { index_id, doc_ids }
|
||||
indexJobCreate: "/api/v1/indices/rag/index/job/create", // POST, body requires nested dataSource { sourceType, fileIds } (flat documentIds from the docs is rejected)
|
||||
indexJobStatus: "/api/v1/indices/rag/index_job/status", // GET, both index_id and job_id required
|
||||
chunkList: "/api/v1/indices/rag/index/chunklist", // POST, body pageNum/pageSize
|
||||
chunkCreate: "/api/v1/indices/rag/index/chunk/create", // POST, parameter is pipelineId; rate limit 10 req/s, no chunk_id in response
|
||||
chunkUpdate: "/api/v1/indices/rag/index/chunk/update", // POST, parameter is pipelineId
|
||||
chunkDelete: "/api/v1/indices/rag/index/chunk/delete", // POST, at most 10 per request
|
||||
// agent domain — retrieval / chat services. The actual gateway prefix is rag/app/,
|
||||
// not rag/agent/ as the public API docs state — the wrong path returns console HTML
|
||||
// instead of an API response.
|
||||
agentList: "/api/v1/indices/rag/app/list", // POST, agent_scene required
|
||||
agentGet: "/api/v1/indices/rag/app/get", // POST
|
||||
agentCreate: "/api/v1/indices/rag/app/create", // POST
|
||||
agentUpdate: "/api/v1/indices/rag/app/update", // POST, config is only mutable on the beta draft
|
||||
agentDeploy: "/api/v1/indices/rag/app/deploy", // POST
|
||||
agentDelete: "/api/v1/indices/rag/app/delete", // POST, idempotent soft delete
|
||||
agentCopy: "/api/v1/indices/rag/app/copy", // POST
|
||||
// connector domain — data center (responses use requestId; cursor pagination via nextToken/maxResult)
|
||||
applyFileUploadLease: "/api/v1/connector/dash/applyFileUploadLease", // sizeBytes must be a string
|
||||
addFile: "/api/v1/connector/dash/addFile",
|
||||
addFilesFromAuthorizedOss: "/api/v1/connector/dash/addFilesFromAuthorizedOss",
|
||||
batchUpdateFileTag: "/api/v1/connector/dash/batchUpdateFileTag",
|
||||
listFile: "/api/v1/connector/dash/listFile", // categoryId required
|
||||
describeFile: "/api/v1/connector/dash/describeFile",
|
||||
deleteFile: "/api/v1/connector/dash/deleteFile",
|
||||
addConnector: "/api/v1/connector/dash/addConnector",
|
||||
getConnector: "/api/v1/connector/dash/getConnector",
|
||||
listCategory: "/api/v1/connector/dash/listCategory", // note: maxResult is singular
|
||||
addCategory: "/api/v1/connector/dash/addCategory",
|
||||
deleteCategory: "/api/v1/connector/dash/deleteCategory",
|
||||
} as const;
|
||||
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
memorySearchPath,
|
||||
mcpWebSearchPath,
|
||||
profileSchemaPath,
|
||||
ragEndpoint,
|
||||
RAG_PATHS,
|
||||
speechRecognizePath,
|
||||
speechSynthesizePath,
|
||||
taskPath,
|
||||
|
||||
@@ -3,6 +3,7 @@ export { mapApiError, type ApiErrorBody } from "./errors/api.ts";
|
||||
export { ExitCode } from "./errors/codes.ts";
|
||||
|
||||
export type * from "./types/api.ts";
|
||||
export type * from "./types/knowledge-admin.ts";
|
||||
export * from "./auth/index.ts";
|
||||
export * from "./client/index.ts";
|
||||
export * from "./console/index.ts";
|
||||
|
||||
@@ -411,6 +411,8 @@ export interface DashScopeKnowledgeRetrieveResponse {
|
||||
export interface KnowledgeSearchRequest {
|
||||
query: string;
|
||||
agent_id: string;
|
||||
/** "beta" targets the debug draft; a numeric version targets that published version; defaults to the latest published version */
|
||||
agent_version?: string;
|
||||
images?: string[];
|
||||
query_history?: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}
|
||||
@@ -461,6 +463,8 @@ export interface KnowledgeChatRequest {
|
||||
parameters: {
|
||||
agent_options: {
|
||||
agent_id: string;
|
||||
/** "beta" targets the debug draft; a numeric version targets that published version; defaults to the latest published version */
|
||||
agent_version?: string;
|
||||
user?: {
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
|
||||
@@ -79,3 +79,4 @@ export type {
|
||||
StreamChunk,
|
||||
UserProfileResponse,
|
||||
} from "./api.ts";
|
||||
export type * from "./knowledge-admin.ts";
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// Types for the RAG admin plane (indices / agent / connector domains). Modeled
|
||||
// after the raw API field names — the mix of snake_case and camelCase reflects
|
||||
// the server as-is and is intentionally NOT normalized here; normalization only
|
||||
// happens at the CLI output layer. Kept separate from api.ts to avoid bloating it.
|
||||
|
||||
/** Common response envelope for the indices domain (top-level fields are snake_case) */
|
||||
export interface RagResponse<T> {
|
||||
code?: string;
|
||||
message?: string;
|
||||
status_code?: number;
|
||||
request_id?: string;
|
||||
data: T;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET index/list row (fields inside rows are camelCase as-is; full config field set) */
|
||||
export interface RagIndexRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
dataType?: string;
|
||||
embeddingModelName?: string;
|
||||
embeddingDimension?: number;
|
||||
chunkSize?: number;
|
||||
overlapSize?: number;
|
||||
chunkMode?: string;
|
||||
separator?: string;
|
||||
rerankModelName?: string;
|
||||
rerankMinScore?: number;
|
||||
rerankTopN?: number;
|
||||
rerankMode?: string;
|
||||
enableRewrite?: boolean;
|
||||
denseSimilarityTopK?: number;
|
||||
sparseSimilarityTopK?: number;
|
||||
sourceType?: string;
|
||||
connectorId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RagIndexListData {
|
||||
rows?: RagIndexRow[];
|
||||
total?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagIndexListResponse = RagResponse<RagIndexListData>;
|
||||
|
||||
/** GET index/files document row */
|
||||
export interface RagIndexFileRow {
|
||||
doc_id?: string;
|
||||
doc_name?: string;
|
||||
doc_type?: string;
|
||||
status?: string;
|
||||
size?: number | string;
|
||||
ingestion_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagIndexFilesData {
|
||||
rows?: RagIndexFileRow[];
|
||||
total_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagIndexFilesResponse = RagResponse<RagIndexFilesData>;
|
||||
|
||||
/**
|
||||
* GET index_job/status (verified against the live API)
|
||||
* Gotcha: the overall job state lives in `ingestion_status` (PENDING/RUNNING/COMPLETED,
|
||||
* no FAILED value); the per-document list is `rows[]` (not docs[]), and failures
|
||||
* surface via `rows[].code` (e.g. PARSE_FAILED).
|
||||
*/
|
||||
export interface RagIndexJobDoc {
|
||||
doc_id?: string;
|
||||
doc_name?: string;
|
||||
doc_type?: string;
|
||||
/** Fine-grained processing status code: FINISH / PARSE_FAILED / ... */
|
||||
code?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
size?: number | string;
|
||||
ingestion_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagIndexJobStatusData {
|
||||
/** Overall job state: PENDING / RUNNING / COMPLETED */
|
||||
ingestion_status?: string;
|
||||
ingestion_message?: string;
|
||||
rows?: RagIndexJobDoc[];
|
||||
total_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagIndexJobStatusResponse = RagResponse<RagIndexJobStatusData>;
|
||||
|
||||
/** Common response envelope for the connector domain (top-level requestId is camelCase — unlike the indices domain) */
|
||||
export interface RagConnectorResponse<T> {
|
||||
code?: string;
|
||||
message?: string;
|
||||
requestId?: string;
|
||||
success?: boolean;
|
||||
status?: number | string;
|
||||
data: T;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** POST applyFileUploadLease */
|
||||
export interface RagUploadLeaseParam {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagUploadLeaseData {
|
||||
type?: string;
|
||||
leaseId?: string;
|
||||
param?: RagUploadLeaseParam;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagUploadLeaseResponse = RagConnectorResponse<RagUploadLeaseData>;
|
||||
|
||||
/** POST addFile */
|
||||
export interface RagAddFileData {
|
||||
fileId?: string;
|
||||
parser?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAddFileResponse = RagConnectorResponse<RagAddFileData>;
|
||||
|
||||
/** POST listCategory */
|
||||
export interface RagCategory {
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
isDefault?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagListCategoryData {
|
||||
categoryList?: RagCategory[];
|
||||
nextToken?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagListCategoryResponse = RagConnectorResponse<RagListCategoryData>;
|
||||
|
||||
/** POST index/job/create (incremental import; response field names pending live verification) */
|
||||
export interface RagJobCreateData {
|
||||
ingestionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagJobCreateResponse = RagResponse<RagJobCreateData>;
|
||||
|
||||
/** POST index/create_v2 */
|
||||
export interface RagCreateIndexV2Data {
|
||||
pipelineId?: string;
|
||||
ingestionId?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagCreateIndexV2Response = RagResponse<RagCreateIndexV2Data>;
|
||||
|
||||
/** POST index/update and index/delete — data carries no details (empty object or absent) */
|
||||
export type RagMutationResponse = RagResponse<Record<string, unknown> | undefined>;
|
||||
|
||||
/** POST index/delete_file — data.deleted lists the document IDs actually deleted */
|
||||
export interface RagDeleteFileData {
|
||||
deleted?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagDeleteFileResponse = RagResponse<RagDeleteFileData>;
|
||||
|
||||
/** POST batchUpdateFileTag — connector-domain envelope, data is an empty object */
|
||||
export type RagBatchUpdateTagResponse = RagConnectorResponse<Record<string, unknown> | undefined>;
|
||||
|
||||
// ---- agent domain (retrieval / chat service management) ----
|
||||
// Parameters are snake_case; responses use the indices-domain envelope.
|
||||
// Time fields are loosely modeled as string | number (live samples show
|
||||
// millisecond timestamps; tighten once verified).
|
||||
|
||||
/** agent_config — chat and search scenes carry different field sets; modeled loosely as one type */
|
||||
export interface RagAgentConfig {
|
||||
agent_policy?: string;
|
||||
agent_model?: string;
|
||||
enable_session_file?: string;
|
||||
enable_refusal?: string;
|
||||
enable_anti_leak?: string;
|
||||
enable_rich_text?: string;
|
||||
enable_citation?: string;
|
||||
temperature?: number;
|
||||
max_num_llm_calls?: number;
|
||||
max_completion_tokens?: number;
|
||||
session_file_max_parse_length?: number;
|
||||
enable_kb_router?: string;
|
||||
kb_router_model?: string;
|
||||
rerank_top_n?: number;
|
||||
hybrid_rerank?: Record<string, unknown>;
|
||||
kb_search_configs?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** agent/list row */
|
||||
export interface RagAgentRow {
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
agent_scene?: string;
|
||||
agent_status?: string;
|
||||
agent_version?: string;
|
||||
create_time?: string | number;
|
||||
modify_time?: string | number;
|
||||
pipeline_list?: Array<{ pipeline_id?: string; pipeline_name?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagAgentListData {
|
||||
page_number?: number;
|
||||
page_size?: number;
|
||||
total_count?: number;
|
||||
rows?: RagAgentRow[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAgentListResponse = RagResponse<RagAgentListData>;
|
||||
|
||||
/** agent/get per-version detail */
|
||||
export interface RagAgentDetail {
|
||||
agent_version?: string;
|
||||
agent_version_desc?: string | null;
|
||||
publish_time?: string | number;
|
||||
agent_config?: RagAgentConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagAgentGetData {
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
agent_desc?: string;
|
||||
agent_scene?: string;
|
||||
agent_status?: string;
|
||||
create_time?: string | number;
|
||||
modify_time?: string | number;
|
||||
agent_details?: RagAgentDetail[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAgentGetResponse = RagResponse<RagAgentGetData>;
|
||||
|
||||
/** agent/create · update · deploy · delete · copy — union of the data field sets */
|
||||
export interface RagAgentMutationData {
|
||||
agent_id?: string;
|
||||
agent_name?: string;
|
||||
agent_version?: string;
|
||||
agent_status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAgentMutationResponse = RagResponse<RagAgentMutationData>;
|
||||
|
||||
// ---- chunk / monitor / category / file / connector / oss-import ----
|
||||
|
||||
/** POST index/chunklist — nodes[].metadata carries the chunk payload */
|
||||
export interface RagChunkNodeMetadata {
|
||||
_id?: string;
|
||||
doc_id?: string;
|
||||
doc_name?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
hier_title?: string;
|
||||
is_displayed_chunk_content?: boolean;
|
||||
_chunk_status_message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagChunkNode {
|
||||
score?: number;
|
||||
text?: string;
|
||||
metadata?: RagChunkNodeMetadata;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagChunkListData {
|
||||
total?: number;
|
||||
nodes?: RagChunkNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagChunkListResponse = RagResponse<RagChunkListData>;
|
||||
|
||||
/** POST index/monitor — timestamps are second-precision strings.
|
||||
* Shape verified against the live API: both monitor fields are objects,
|
||||
* not arrays as the public docs' empty-array examples suggest. */
|
||||
export interface RagStorageMonitorData {
|
||||
indexStorageLimit?: number;
|
||||
indexStorageUsage?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagQpsMonitorData {
|
||||
peakQps?: number;
|
||||
monitorData?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagMonitorData {
|
||||
pipelineCommercialType?: string;
|
||||
storageMonitorData?: RagStorageMonitorData;
|
||||
qpsMonitorData?: RagQpsMonitorData;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagMonitorResponse = RagResponse<RagMonitorData>;
|
||||
|
||||
/** POST addCategory */
|
||||
export interface RagAddCategoryData {
|
||||
categoryId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAddCategoryResponse = RagConnectorResponse<RagAddCategoryData>;
|
||||
|
||||
/** POST listFile / describeFile — field names verified against the live API:
|
||||
* sizeBytes/uploadTime/category (not sizeInBytes/createTime/categoryId as the public docs state) */
|
||||
export interface RagDataCenterFile {
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
fileType?: string;
|
||||
parser?: string;
|
||||
sizeBytes?: number | string;
|
||||
md5?: string;
|
||||
status?: string;
|
||||
tags?: string[] | string;
|
||||
category?: string;
|
||||
uploadTime?: string;
|
||||
parseErrorMessage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface RagListFileData {
|
||||
fileList?: RagDataCenterFile[];
|
||||
nextToken?: string;
|
||||
hasNext?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagListFileResponse = RagConnectorResponse<RagListFileData>;
|
||||
export type RagDescribeFileResponse = RagConnectorResponse<RagDataCenterFile>;
|
||||
|
||||
/** POST addConnector / getConnector */
|
||||
export interface RagConnectorInfo {
|
||||
connectorId?: string;
|
||||
connectorName?: string;
|
||||
description?: string;
|
||||
connectorType?: string;
|
||||
fileConnectorConfig?: {
|
||||
storeType?: string;
|
||||
ossRegionId?: string;
|
||||
ossBucket?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagAddConnectorResponse = RagConnectorResponse<RagConnectorInfo>;
|
||||
export type RagGetConnectorResponse = RagConnectorResponse<RagConnectorInfo>;
|
||||
|
||||
/** POST addFilesFromAuthorizedOss */
|
||||
export interface RagOssImportData {
|
||||
fileIds?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type RagOssImportResponse = RagConnectorResponse<RagOssImportData>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { ragEndpoint, RAG_PATHS } from "../src/client/endpoints.ts";
|
||||
|
||||
describe("ragEndpoint", () => {
|
||||
test("拼接 workspace 子域名与路径", () => {
|
||||
expect(ragEndpoint("ws_test", RAG_PATHS.indexList)).toBe(
|
||||
"https://ws_test.cn-beijing.maas.aliyuncs.com/api/v1/indices/rag/index/list",
|
||||
);
|
||||
});
|
||||
|
||||
test("RAG_PATHS 覆盖全部管理端点域", () => {
|
||||
// indices domain
|
||||
expect(RAG_PATHS.indexCreateV2).toBe("/api/v1/indices/rag/index/create_v2");
|
||||
expect(RAG_PATHS.indexJobStatus).toBe("/api/v1/indices/rag/index_job/status");
|
||||
expect(RAG_PATHS.chunkList).toBe("/api/v1/indices/rag/index/chunklist");
|
||||
expect(RAG_PATHS.chunkCreate).toBe("/api/v1/indices/rag/index/chunk/create");
|
||||
// agent domain (the actual gateway prefix is rag/app/, not rag/agent/ as the public docs state)
|
||||
expect(RAG_PATHS.agentList).toBe("/api/v1/indices/rag/app/list");
|
||||
// connector domain
|
||||
expect(RAG_PATHS.listCategory).toBe("/api/v1/connector/dash/listCategory");
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,14 @@ export function isChatE2EReady(): boolean {
|
||||
!!process.env.BAILIAN_E2E_CHAT_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** Knowledge admin commands (kb/doc/chunk/service/category/file) E2E readiness */
|
||||
export function isKbAdminE2EReady(): boolean {
|
||||
if (!isDashScopeE2EReady()) return false;
|
||||
return !!process.env.BAILIAN_WORKSPACE_ID?.trim();
|
||||
}
|
||||
|
||||
/** connector has no delete API, so live artifacts cannot be cleaned up — only enable explicitly for a full manual regression */
|
||||
export function isConnectorE2EReady(): boolean {
|
||||
return isKbAdminE2EReady() && process.env.BAILIAN_E2E_CONNECTOR === "1";
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
docs
|
||||
@@ -6,6 +6,37 @@ import {
|
||||
knowledgeRetrieve,
|
||||
knowledgeSearch,
|
||||
knowledgeChat,
|
||||
knowledgeKbList,
|
||||
knowledgeKbInfo,
|
||||
knowledgeDocList,
|
||||
knowledgeDocStatus,
|
||||
knowledgeDocUpload,
|
||||
knowledgeKbCreate,
|
||||
knowledgeKbUpdate,
|
||||
knowledgeKbDelete,
|
||||
knowledgeDocDelete,
|
||||
knowledgeDocTag,
|
||||
knowledgeServiceList,
|
||||
knowledgeServiceGet,
|
||||
knowledgeServiceCreate,
|
||||
knowledgeServiceUpdate,
|
||||
knowledgeServiceDeploy,
|
||||
knowledgeServiceDelete,
|
||||
knowledgeServiceCopy,
|
||||
knowledgeChunkAdd,
|
||||
knowledgeChunkList,
|
||||
knowledgeChunkUpdate,
|
||||
knowledgeChunkDelete,
|
||||
knowledgeKbStats,
|
||||
knowledgeCategoryList,
|
||||
knowledgeCategoryAdd,
|
||||
knowledgeCategoryDelete,
|
||||
knowledgeFileList,
|
||||
knowledgeFileGet,
|
||||
knowledgeFileDelete,
|
||||
knowledgeCollectionCreate,
|
||||
knowledgeCollectionGet,
|
||||
knowledgeDocImportOss,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update
|
||||
@@ -20,4 +51,35 @@ export const commands: Record<string, AnyCommand> = {
|
||||
retrieve: knowledgeRetrieve,
|
||||
search: knowledgeSearch,
|
||||
chat: knowledgeChat,
|
||||
"kb list": knowledgeKbList,
|
||||
"kb info": knowledgeKbInfo,
|
||||
"kb create": knowledgeKbCreate,
|
||||
"kb update": knowledgeKbUpdate,
|
||||
"kb delete": knowledgeKbDelete,
|
||||
"doc list": knowledgeDocList,
|
||||
"doc status": knowledgeDocStatus,
|
||||
"doc upload": knowledgeDocUpload,
|
||||
"doc delete": knowledgeDocDelete,
|
||||
"doc tag": knowledgeDocTag,
|
||||
"service list": knowledgeServiceList,
|
||||
"service get": knowledgeServiceGet,
|
||||
"service create": knowledgeServiceCreate,
|
||||
"service update": knowledgeServiceUpdate,
|
||||
"service deploy": knowledgeServiceDeploy,
|
||||
"service delete": knowledgeServiceDelete,
|
||||
"service copy": knowledgeServiceCopy,
|
||||
"chunk add": knowledgeChunkAdd,
|
||||
"chunk list": knowledgeChunkList,
|
||||
"chunk update": knowledgeChunkUpdate,
|
||||
"chunk delete": knowledgeChunkDelete,
|
||||
"kb stats": knowledgeKbStats,
|
||||
"category list": knowledgeCategoryList,
|
||||
"category add": knowledgeCategoryAdd,
|
||||
"category delete": knowledgeCategoryDelete,
|
||||
"file list": knowledgeFileList,
|
||||
"file get": knowledgeFileGet,
|
||||
"file delete": knowledgeFileDelete,
|
||||
"collection create": knowledgeCollectionCreate,
|
||||
"collection get": knowledgeCollectionGet,
|
||||
"doc import-oss": knowledgeDocImportOss,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Confirmation guard for dangerous operations — used by irreversible or
|
||||
// production-affecting commands (kb/doc/chunk/category/file delete, service
|
||||
// delete/deploy, ...).
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* - `yes` (the command's own --yes switch) → pass through
|
||||
* - TTY: print the summary and wait for y/yes (case-insensitive); any other
|
||||
* input cancels with exit SUCCESS (cancellation is not an error)
|
||||
* - non-TTY without --yes: throw USAGE
|
||||
*/
|
||||
export async function confirmDangerousAction(summary: string, yes: boolean): Promise<void> {
|
||||
if (yes) return;
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new BailianError(
|
||||
"Confirmation required for this destructive action.",
|
||||
ExitCode.USAGE,
|
||||
"Re-run with --yes to confirm in non-interactive mode",
|
||||
);
|
||||
}
|
||||
process.stderr.write(`${summary}\n`);
|
||||
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
const answer = (await readline.question("Proceed? [y/N] ")).trim().toLowerCase();
|
||||
if (answer !== "y" && answer !== "yes") {
|
||||
process.stderr.write("Cancelled.\n");
|
||||
// Intentional: a user-initiated cancellation is not an error, and we want
|
||||
// to exit here rather than unwind through the middleware stack (which
|
||||
// would still print a success report for an action that did not happen).
|
||||
process.exit(ExitCode.SUCCESS);
|
||||
}
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export {
|
||||
|
||||
// Utility facilities consumed by commands
|
||||
export { poll } from "./utils/polling.ts";
|
||||
export { confirmDangerousAction } from "./confirm.ts";
|
||||
export { downloadFile, formatBytes } from "./utils/download.ts";
|
||||
export { runConcurrent, getConcurrency, downloadParallel } from "./utils/concurrent.ts";
|
||||
export { resolveImageSize } from "./utils/image-size.ts";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import { confirmDangerousAction } from "../src/confirm.ts";
|
||||
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
afterEach(() => {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
});
|
||||
|
||||
describe("confirmDangerousAction", () => {
|
||||
test("--yes 时直接通过,不触碰 stdin", async () => {
|
||||
await expect(confirmDangerousAction("Delete kb idx-1", true)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("非 TTY 且无 --yes 时抛 USAGE 并引导 --yes", async () => {
|
||||
process.stdin.isTTY = false;
|
||||
try {
|
||||
await confirmDangerousAction("Delete kb idx-1", false);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
|
||||
expect((error as { hint?: string }).hint).toMatch(/--yes/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
-4
@@ -46,10 +46,6 @@ catalogs:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
|
||||
overrides:
|
||||
vite: npm:@voidzero-dev/vite-plus-core@latest
|
||||
vitest: npm:@voidzero-dev/vite-plus-test@latest
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
||||
@@ -9,89 +9,120 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
|
||||
## Quick index
|
||||
|
||||
| Command | Description | Detail |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
|
||||
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
|
||||
| `bl app list` | List Bailian applications | [app.md](app.md) |
|
||||
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
|
||||
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
|
||||
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
|
||||
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
|
||||
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
|
||||
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
|
||||
| `bl config set` | Set a config value | [config.md](config.md) |
|
||||
| `bl config show` | Display current configuration | [config.md](config.md) |
|
||||
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
|
||||
| `bl config use` | Set the active config profile | [config.md](config.md) |
|
||||
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
|
||||
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
|
||||
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
|
||||
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
|
||||
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
|
||||
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
|
||||
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
|
||||
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
|
||||
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
|
||||
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
|
||||
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
|
||||
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
|
||||
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
|
||||
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
|
||||
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
|
||||
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
|
||||
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
|
||||
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
|
||||
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
|
||||
| `bl quota history` | View quota change history | [quota.md](quota.md) |
|
||||
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
|
||||
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
|
||||
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
|
||||
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
|
||||
| Command | Description | Detail |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
|
||||
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
|
||||
| `bl app list` | List Bailian applications | [app.md](app.md) |
|
||||
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
|
||||
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
|
||||
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
|
||||
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
|
||||
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
|
||||
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
|
||||
| `bl config set` | Set a config value | [config.md](config.md) |
|
||||
| `bl config show` | Display current configuration | [config.md](config.md) |
|
||||
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
|
||||
| `bl config use` | Set the active config profile | [config.md](config.md) |
|
||||
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
|
||||
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
|
||||
| `bl knowledge category add` | Create a data-center category | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge category delete` | Delete a data-center category | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge category list` | List data-center categories | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge chunk add` | Add a chunk directly to a knowledge base | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge chunk delete` | Delete chunks from a knowledge base (irreversible) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge chunk list` | List chunks in a knowledge base with content and status | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge chunk update` | Update chunk content or toggle its retrieval visibility | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge collection create` | Create a FILE data collection | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge collection get` | Show data collection details | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge create` | Create a knowledge base and import data-center files or categories | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge delete` | Delete a knowledge base with all its documents and chunks | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc delete` | Delete documents and their chunks from a knowledge base | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc import-oss` | Batch import files from an authorized OSS bucket into the data center | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc list` | List documents in a knowledge base with parse/index status | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc status` | Check knowledge base import job status | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc tag` | Batch update tags on data-center files | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge doc upload` | Upload local files to the data center and optionally import into a knowledge base | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge file delete` | Permanently delete a file from the data center | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge file get` | Show data-center file details (size, MD5, tags, timestamps) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge file list` | List files in a data-center category | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge info` | Show knowledge base configuration details | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge list` | List knowledge bases in the workspace | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service copy` | Copy a service into a new draft (name gets a copy\_ prefix) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service create` | Create a retrieval / Q&A service (initial status: draft, version: beta) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service delete` | Delete a retrieval / Q&A service (soft delete, idempotent) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service deploy` | Publish the beta draft of a service as a new version | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service get` | Show service (agent) details including per-version configuration | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service list` | List retrieval / Q&A services (agents) in the workspace | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge service update` | Update service name, description or draft configuration | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge stats` | Show knowledge base storage and QPS monitoring data | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge update` | Update knowledge base name, description or rerank threshold | [knowledge.md](knowledge.md) |
|
||||
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
|
||||
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
|
||||
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
|
||||
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
|
||||
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
|
||||
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
|
||||
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
|
||||
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
|
||||
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
|
||||
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
|
||||
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
|
||||
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
|
||||
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
|
||||
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
|
||||
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
|
||||
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
|
||||
| `bl quota history` | View quota change history | [quota.md](quota.md) |
|
||||
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
|
||||
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
|
||||
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
|
||||
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
|
||||
|
||||
## By group
|
||||
|
||||
| Group | Commands | Reference |
|
||||
| ------------ | ---------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `advisor` | `recommend` | [advisor.md](advisor.md) |
|
||||
| `app` | `call`, `list` | [app.md](app.md) |
|
||||
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
|
||||
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
|
||||
| `console` | `call` | [console.md](console.md) |
|
||||
| `file` | `upload` | [file.md](file.md) |
|
||||
| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) |
|
||||
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
|
||||
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
|
||||
| `model` | `list` | [model.md](model.md) |
|
||||
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
|
||||
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
|
||||
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
|
||||
| `search` | `web` | [search.md](search.md) |
|
||||
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
| `update` | `(root)` | [update.md](update.md) |
|
||||
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
|
||||
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
|
||||
| Group | Commands | Reference |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `advisor` | `recommend` | [advisor.md](advisor.md) |
|
||||
| `app` | `call`, `list` | [app.md](app.md) |
|
||||
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
|
||||
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
|
||||
| `console` | `call` | [console.md](console.md) |
|
||||
| `file` | `upload` | [file.md](file.md) |
|
||||
| `knowledge` | `category add`, `category delete`, `category list`, `chat`, `chunk add`, `chunk delete`, `chunk list`, `chunk update`, `collection create`, `collection get`, `create`, `delete`, `doc delete`, `doc import-oss`, `doc list`, `doc status`, `doc tag`, `doc upload`, `file delete`, `file get`, `file list`, `info`, `list`, `retrieve`, `search`, `service copy`, `service create`, `service delete`, `service deploy`, `service get`, `service list`, `service update`, `stats`, `update` | [knowledge.md](knowledge.md) |
|
||||
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
|
||||
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
|
||||
| `model` | `list` | [model.md](model.md) |
|
||||
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
|
||||
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
|
||||
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
|
||||
| `search` | `web` | [search.md](search.md) |
|
||||
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
| `update` | `(root)` | [update.md](update.md) |
|
||||
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
|
||||
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
|
||||
|
||||
## Global flags
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user