mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad5c44d746 | |||
| 9450895a06 | |||
| e681263049 | |||
| ffc460156c | |||
| 70b50060cf | |||
| 3909a17da1 | |||
| 5ed15d3a16 | |||
| 640dd02bc5 | |||
| 6eeb8fe0cb | |||
| d0610a61dc | |||
| 57c2d98308 | |||
| 78e6993475 | |||
| 79a0d2db9a | |||
| 8e6af6c669 | |||
| f7d32504ab | |||
| cdf94a8c89 | |||
| 7461189007 | |||
| 4ccda5f929 | |||
| 4086da572f | |||
| ce4d66b736 | |||
| 196a0aa506 | |||
| a9b0a752a8 | |||
| 2b7a0c742a | |||
| e5818e103c | |||
| 7797940626 | |||
| 5f1c97940d | |||
| 94ccab0898 | |||
| b895f88abb | |||
| 7eedc05b99 | |||
| f3c7b6fb10 | |||
| eb196cb4a6 | |||
| f1b6cacd7f | |||
| 9133b6bdd1 | |||
| 98ba3279fa | |||
| 3b7c4cfabc | |||
| d5d9fcb50f | |||
| d5c4bd3572 | |||
| eb4f9af3e7 | |||
| 3ea2931152 | |||
| b402f3eacd | |||
| b7a4efe619 | |||
| bedd59df27 | |||
| 39a488181e | |||
| 4ec0f6828b | |||
| 4dcec7d075 | |||
| 30f7525d50 | |||
| daefc094ec | |||
| aa38d5c670 | |||
| cc51164c2f | |||
| ae0c2c1213 | |||
| 01a62eb85b | |||
| 798ce596f6 | |||
| bd91e9d1c2 | |||
| e244771ee9 | |||
| 94f9dbbe9e | |||
| 8a0dd70206 | |||
| 9379da7a4c | |||
| ddcd564e61 | |||
| 0e4dd4b824 | |||
| 241de61866 | |||
| 61d9a74166 | |||
| 69eb759490 | |||
| 313966d7a9 | |||
| d74d4efcd0 | |||
| e7422bd2e5 | |||
| 2d5c49b02e | |||
| 9bd8b60c22 | |||
| 0369bd36b0 | |||
| 92a978af3c | |||
| 9749a11d76 | |||
| 2965080cb7 | |||
| 81959145d7 | |||
| 4343fc87af | |||
| 1c76749ee5 | |||
| 1b568e8d37 | |||
| 5d1b7aac3a | |||
| e292b20d4b | |||
| 12e7a22195 | |||
| 219d8be80a | |||
| ab766d44d3 | |||
| 1d9852805f | |||
| 99a3dbae2d | |||
| 4d84af614b | |||
| 2389681ad6 | |||
| 9ae5dc924d | |||
| 946b7029c6 | |||
| d6cb075629 | |||
| b9ecd5c43b | |||
| 978f332fea | |||
| 4502424200 | |||
| 03839766bc | |||
| 5007b9b574 | |||
| 1f8b9ace7e | |||
| 8286a74fb6 | |||
| 9eb2acbb65 | |||
| 1d35326c86 | |||
| eb6c2b8e2a | |||
| ebd6226a9f | |||
| e25d3b0b8e | |||
| 0e33c70e65 | |||
| 3c64461cca | |||
| 24092b423c | |||
| f30fff9065 | |||
| a7245c0f62 | |||
| 752a79e442 | |||
| 80bdcb83f6 | |||
| d9e8601a50 | |||
| e3bb5a7fa0 | |||
| 54da9aa29a | |||
| ef463e8d5d | |||
| 6338df36be | |||
| cb6740965f | |||
| 8ee2c378f5 | |||
| 9fc6434a26 | |||
| 2dffee5b7a | |||
| 01ec13aad8 | |||
| b68ff45fb9 | |||
| 4990b27436 | |||
| 262681484b | |||
| 8488b251f7 | |||
| 43abf0aca5 | |||
| b1908fa879 | |||
| d64ba09bef | |||
| 8cdd54cf7a | |||
| 121fa1317f | |||
| 564e21d9f1 | |||
| 081d09863b | |||
| 17b13de162 | |||
| ca98d8a25d | |||
| 1e1f5306b3 | |||
| 13158856e8 | |||
| cf2592c07d | |||
| 3766b6d7ca | |||
| 1962758b0c | |||
| 026e250cd3 | |||
| 6d61afc1d5 | |||
| 7a870ec417 | |||
| 1c38c381e5 | |||
| 658763af2c | |||
| da2ddb7a55 | |||
| be3033baf9 | |||
| 8ad3e7b947 | |||
| 525412f566 | |||
| 75b056ba64 | |||
| f5a36b1787 | |||
| 9fb388b75d | |||
| 45d468838f | |||
| ed81178ad7 | |||
| 7e23ba00fb | |||
| 72955d66a7 | |||
| 389c932390 | |||
| 6870dc50a6 | |||
| 54b95ed122 | |||
| 5e2833569a | |||
| 434aac5b08 | |||
| e46053b93e | |||
| 30fe8182f4 | |||
| 65c0fe9604 | |||
| 2c53b0692b | |||
| adc89f635d | |||
| afa43a42b9 | |||
| bbf45a5961 | |||
| 3988e701e1 | |||
| 96744e3328 | |||
| 3b7993e854 | |||
| be6ddb6126 | |||
| 25ac5c9c84 | |||
| 8ef91fe395 | |||
| 5d9e22de8f | |||
| 20e3555b84 | |||
| 81fa5b567c | |||
| df987ad536 | |||
| 4c4e7afb83 | |||
| 17c52fb86f | |||
| fb0c4b81be | |||
| 634d7045c6 | |||
| 952f2277a4 | |||
| 871c667e97 | |||
| eadd92327f | |||
| 4c494207d6 | |||
| af3286dd00 | |||
| 6465c4a78a | |||
| 467756b319 | |||
| 5a58f56b06 | |||
| 7ad14a79b9 | |||
| 8211268bd8 | |||
| 7319f6d1ce | |||
| 2dce9fe093 | |||
| 9819eb6ddc | |||
| 58252911a8 | |||
| a03ee0c72c | |||
| 05860b3bdd | |||
| 63ee5aaec3 | |||
| 93c9149e45 | |||
| 6f9e006fef | |||
| e22058b0f7 | |||
| 0221e35803 | |||
| 7250de9228 | |||
| 36ebd63716 | |||
| dac254af86 | |||
| 8a0fb870f1 | |||
| 51ed69596e | |||
| 67b7fa30a7 | |||
| bd17c27023 | |||
| 87c37994f2 | |||
| ff469ce717 | |||
| 5f0966ec8d | |||
| c4f5bb09c6 | |||
| 9a13700390 | |||
| 32c497db63 | |||
| ebbd173b79 | |||
| 6bdc16597b | |||
| e736bab9c1 | |||
| 8dd786287f | |||
| 247bb82154 | |||
| 1e6165d7ff | |||
| 1bf4fec9e6 | |||
| d30fb2ae68 | |||
| 1d589c5178 | |||
| 9cad1994e7 | |||
| fac2b2d18b | |||
| 3e249279bc | |||
| a1a448c5d2 | |||
| 7b949d3d3c | |||
| f9012a6330 | |||
| 92ee845bdd | |||
| 4751145283 | |||
| 168e2b5ccb | |||
| 9fbd2e4ec6 | |||
| 4bd84e934c | |||
| 08bdc3be97 | |||
| 66a797203c | |||
| 64335a6201 | |||
| 90a44d7140 | |||
| 26a69a7c99 | |||
| e1caee99f2 | |||
| 1da3367de8 | |||
| 9e59b01326 | |||
| 7cbd61dd5c | |||
| 1c9dac24e9 | |||
| d6bd38a46a | |||
| 678f60be75 | |||
| d11b55b956 | |||
| e4e3f069e1 | |||
| 440cbfe6ae | |||
| 1adfe797bd | |||
| d04012b0a4 | |||
| 81539005cc | |||
| 4c566fd60e | |||
| 9ab5de8c2e | |||
| 853ce3caae | |||
| 3b779a708d | |||
| 6329427b4d | |||
| b4a2a1c42d | |||
| 4ca3e2de80 | |||
| d08edf0cd8 |
@@ -39,7 +39,7 @@ body:
|
||||
attributes:
|
||||
label: Node version
|
||||
description: "Output of node --version"
|
||||
placeholder: "v22.12.0"
|
||||
placeholder: "v18.17.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Poke the FC publish-skills flow after skills/ changes land.
|
||||
# The FC side reconciles this repo's skills/ directory against OSS
|
||||
# (bailian-wiki/skills/) using the repo HEAD snapshot as the only
|
||||
# source of truth — the request itself carries no content. Both the
|
||||
# repo and branch params are validated against FC-side whitelists
|
||||
# (PUBLISH_REPOS / PUBLISH_BRANCHES).
|
||||
#
|
||||
# feat/cli-skill-sync is temporary for end-to-end testing; remove it
|
||||
# (here and from the FC PUBLISH_BRANCHES whitelist) once the sync
|
||||
# link is verified on main.
|
||||
name: Publish skills to OSS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feat/cli-skill-sync
|
||||
paths:
|
||||
- "skills/**"
|
||||
|
||||
jobs:
|
||||
poke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger FC publish-skills
|
||||
run: |
|
||||
curl -sf -X POST "${{ vars.FC_TRIGGER_URL }}/publish-skills?repo=modelstudioai/cli&branch=${{ github.ref_name }}"
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
- channel
|
||||
- stable
|
||||
channel:
|
||||
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
|
||||
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -29,11 +29,11 @@ concurrency:
|
||||
jobs:
|
||||
publish-stable:
|
||||
if: inputs.mode == 'stable'
|
||||
name: publish stable (${{ inputs.package }}) to npm + tag
|
||||
name: publish stable (${{ inputs.package }}) to npm + binary + tag
|
||||
runs-on: ubuntu-latest
|
||||
environment: production # Required Reviewers gate
|
||||
permissions:
|
||||
contents: write # push lightweight tag to origin
|
||||
contents: write # push tag + create GitHub Release with binary assets
|
||||
id-token: write # OIDC for npm Trusted Publishing + provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -55,19 +55,47 @@ jobs:
|
||||
| sudo tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Ensure zip (per-platform binary archives)
|
||||
run: sudo apt-get update && sudo apt-get install -y zip
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
|
||||
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
|
||||
- name: publish-stable
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# OSS release channel runs fully in CI: upload + reconcile + manifest.json.
|
||||
# All values come from repo Settings → Secrets — no OSS defaults live in
|
||||
# code. Leave AK/SK unset to skip the OSS channel; once enabled,
|
||||
# bucket/region/prefix are required.
|
||||
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
|
||||
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
|
||||
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
|
||||
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
|
||||
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
|
||||
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
|
||||
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
|
||||
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
|
||||
|
||||
publish-channel:
|
||||
if: inputs.mode == 'channel'
|
||||
name: publish channel (${{ inputs.package }}) to npm
|
||||
name: publish channel (${{ inputs.package }}) to npm + binary
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # no tag, no Release; just publish
|
||||
contents: write # create prerelease GitHub Release with binary assets
|
||||
id-token: write # OIDC for npm Trusted Publishing + provenance
|
||||
steps:
|
||||
- name: Require channel input
|
||||
if: ${{ inputs.channel == '' }}
|
||||
run: |
|
||||
echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin / sync-release). Leave mode=stable if you do not need a dist-tag."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
@@ -87,7 +115,26 @@ jobs:
|
||||
| sudo tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Ensure zip (per-platform binary archives)
|
||||
run: sudo apt-get update && sudo apt-get install -y zip
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
|
||||
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
|
||||
- name: publish-channel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# OSS release channel — same Settings-injected values as stable.
|
||||
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
|
||||
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
|
||||
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
|
||||
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
|
||||
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
|
||||
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
|
||||
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
|
||||
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
# Dependencies & build output
|
||||
node_modules
|
||||
dist
|
||||
dist-bin
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
@@ -36,7 +37,9 @@ tools/generated
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.cursor/
|
||||
.qoder/
|
||||
.qwen/
|
||||
.qoder
|
||||
.playwright-mcp/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -46,3 +49,6 @@ packages/cli/scene/**/outputs/
|
||||
|
||||
# Environment variables (sensitive data)
|
||||
.env
|
||||
|
||||
# Local scratch / plan drafts (never commit)
|
||||
.scratch/
|
||||
|
||||
+10
-1
@@ -5,6 +5,15 @@ set -eu
|
||||
pnpm run sync:skill-assets
|
||||
|
||||
# Stage generator output so it is included in this commit.
|
||||
git add skills/bailian-cli/reference skills/bailian-cli/SKILL.md
|
||||
git add \
|
||||
skills/bailian-protocol/SKILL.md \
|
||||
skills/bailian-cli/SKILL.md \
|
||||
skills/bailian-cli/reference \
|
||||
skills/bailian-gen/SKILL.md \
|
||||
skills/bailian-gen/reference \
|
||||
skills/bailian-finetune/SKILL.md \
|
||||
skills/bailian-finetune/reference \
|
||||
skills/bailian-managed-agent/SKILL.md \
|
||||
skills/bailian-managed-agent/reference
|
||||
|
||||
vp staged
|
||||
|
||||
@@ -35,7 +35,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
|
||||
packages/core/src/client/ # HTTP client / endpoints / console gateway
|
||||
```
|
||||
|
||||
Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/cli` 安装。`tools/generate-reference.ts` 从 **`packages/cli/src/commands.ts`** 生成 `skills/bailian-cli/reference/`(纳入 git);`tools/sync-skill-metadata.ts` 从 `packages/cli/package.json` 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`。两者由根脚本 `pnpm run sync:skill-assets` 和 `.vite-hooks/pre-commit` 执行。
|
||||
Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill(`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts` 从 **`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts` 从 `packages/cli/package.json` 同步各 `skills/*/SKILL.md` 的 `metadata.version`。两者由根脚本 `pnpm run sync:skill-assets` 和 `.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细;SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
|
||||
|
||||
约定:
|
||||
|
||||
@@ -48,30 +48,33 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
|
||||
非代码资产:
|
||||
|
||||
- `tools/release/` — 发版自动化(CI 驱动,见 `.github/workflows/publish.yml`)
|
||||
- `tools/generate-reference.ts` — 从 `packages/cli/src/commands.ts` 生成 `skills/bailian-cli/reference/`
|
||||
- `tools/sync-skill-metadata.ts` — 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`
|
||||
- `tools/generate-reference.ts` — 从 `packages/cli/src/commands.ts` 按归属表生成各 `skills/<skill>/reference/`
|
||||
- `tools/sync-skill-metadata.ts` — 同步各 `skills/*/SKILL.md` 的 `metadata.version`(含 `bailian-protocol`)
|
||||
- `README.md` / `README.zh.md` — npm 和 GitHub 主页
|
||||
|
||||
## 业务场景索引
|
||||
|
||||
按当前任务从下表挑一条进入对应文档:
|
||||
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| ----------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
|
||||
+190
@@ -6,6 +6,196 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.16.0] - 2026-08-17
|
||||
|
||||
> Full knowledge-base lifecycle management arrives in the CLI: create and configure knowledge bases, upload documents, tune chunks, and deploy retrieval/Q&A services — all from `bl knowledge` and `kscli`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Knowledge base management** — `bl knowledge create` / `list` / `info` / `update` / `delete` manage knowledge bases end to end; `bl knowledge stats` reports document counts and usage over a past time range.
|
||||
- **Document management** — `bl knowledge doc upload` uploads local files or whole directories (recursive scan, skips unsupported formats and tool directories like `node_modules`); `doc list` / `status` / `tag` / `delete` cover the rest of the document lifecycle, and `doc import-oss` imports documents from OSS.
|
||||
- **Retrieval / Q&A service management** — `bl knowledge service list` / `get` / `create` / `update` / `deploy` / `delete` / `copy` manage retrieval and Q&A service configurations, including deploying a draft to a published version.
|
||||
- **Chunk management** — `bl knowledge chunk add` / `list` / `update` / `delete` inspect and fine-tune document chunks.
|
||||
- **Data-center management** — `bl knowledge category list` / `add` / `delete`, `bl knowledge file list` / `get` / `delete`, and `bl knowledge collection create` / `get` manage categories, raw files, and data collections.
|
||||
- **Service version selection for retrieval and chat** — `bl knowledge search` and `bl knowledge chat` accept `--agent-version` to call the beta (draft) config for debugging or a specific published version.
|
||||
- **`kscli` parity** — all new knowledge commands are also available in Knowledge Studio CLI under shorter paths, e.g. `kscli kb list`, `kscli doc upload`, `kscli service deploy`.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`bl knowledge search --query-history` removed** — the parameter never took effect; use `bl knowledge chat` with `--message` history for multi-turn scenarios.
|
||||
|
||||
### Internal
|
||||
|
||||
- Requests now carry a static OpenAPI source identification header for backend channel attribution.
|
||||
- Added knowledge-base E2E suites, including five user-journey scenarios covering cold start, content ops, chunk tuning, service tuning, and the data plane.
|
||||
|
||||
## [1.15.1] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Model permission management** — `bl permission list` shows per-model inference / fine-tune / deploy grants; `bl permission grant` and `bl permission revoke` manage them, with `--all` to one-key grant inference for every model in the workspace (including future ones).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`bl quota request` renamed to `bl quota update`** — set per-model QPM/TPM via `--rpm`/`--tpm` and clear custom limits with the new `--delete`; omitted fields keep their current values, and the old `quota request` path keeps working as an alias.
|
||||
- **`bl quota list` reworked** — now reads the model-limits API and shows per-model and workspace-level request/usage limits plus async queue/concurrency limits in a single table.
|
||||
- **`bl model list` no longer requires Console login** — the model catalog and `--enrich` parameter-schema endpoints are public.
|
||||
- **`bl skill init` output simplified** — per-skill status is now `success`/`failed` (previously `installed`) with an aggregate `success`/`partial`/`failed` result; the `publishedAt` and `agents` fields were removed.
|
||||
|
||||
## [1.15.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Responses API for `bl text chat`** — Use `--api responses` to call the DashScope Responses API with streaming, tool definitions, and structured JSON output; Chat Completions remains the default.
|
||||
- **Subscription plan usage views** — `bl usage token-plan` displays 5-hour and weekly quota usage, while `bl usage coding-plan` displays 5-hour, weekly, and monthly usage; both support text and JSON output.
|
||||
- **Authentication requirements in command help** — Help output now states whether a command requires an API Key, Console login, or Alibaba Cloud OpenAPI credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Broader speech-recognition model support** — `bl speech recognize` now routes asynchronous file-transcription and synchronous Flash ASR models to the appropriate DashScope APIs, with clear guidance for unsupported realtime models.
|
||||
- **MCP transport compatibility** — MCP commands now fall back from Streamable HTTP to classic SSE for compatible Bailian and custom endpoints.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Binary updates now refresh installed Agent Skills after a successful CLI upgrade.
|
||||
- Fixed unavailable Token Plan quota values and missing reset times.
|
||||
- Fixed Qwen3 file-transcription result handling so waiting mode and `--out` work correctly.
|
||||
- Fixed MCP SSE chunk parsing, header timeouts, abort cleanup, and fallback status matching.
|
||||
- Network failures in JSON output now preserve the errno value in `cause.code`.
|
||||
|
||||
## [1.14.3] - 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Free-tier quota compatibility** — `bl usage free` and `bl usage freetier` now use the current Bailian Commerce console APIs for quota queries, activation, and deactivation, with consistent asynchronous-task polling.
|
||||
|
||||
## [1.14.2] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl skill init`** — Install all first-party `bailian-*` skills into detected local AI Agents in one step.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skill command interface** — Skill management commands now default to JSON output for Agent workflows; `bl skill add` and `bl skill update` use explicit `--all` and `--name` selectors.
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Focused Bailian Skills** — `npx skills add modelstudioai/cli --all -g` now installs dedicated skills for media generation, fine-tuning, Managed Agent, and shared execution rules, improving task routing while reducing irrelevant context.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default image model upgraded to Qwen-Image 3.0** — image generation, image editing, pipelines, the config UI, and related documentation now default to `qwen-image-3.0` for API Key users.
|
||||
- **Broader coding-agent compatibility** — Skill installation and updates now detect more coding agents, preserve existing installation links, and automatically backfill skills into newly detected agents.
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Standalone installation without Node.js** — binary packages are available for macOS on Apple Silicon and Intel, Linux x64, and Windows x64; npm installation remains supported.
|
||||
- **Exact-version updates** — binary and npm installations can use `bl update --to <version>` to update or switch to a specified version.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Binary self-updates** — binary installations now check and download updates through a dedicated release channel. `bl update` no longer replaces the running executable, and the next invocation automatically uses the new version.
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default text model upgraded to Qwen3.8-Max** — `bl text chat`, pipelines, API key validation, the config UI, and Managed Agent init templates now default to `qwen3.8-max`; Token Plan also moves from the preview model to the stable release.
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config ui` Skills / MCP / Agents / Assets inventory** — browse installed skills, MCP servers, coding agents, and generated assets in the local Web UI with click-to-open detail drawers:
|
||||
- Skills: render `SKILL.md` as Markdown (GFM tables supported), show local vs remote origin badges, and install a skill by uploading a `.zip` archive into any supported agent's skills root.
|
||||
- MCP: view and edit JSON configuration with secret masking and mask-preserving writes; create, update, and delete MCP entries across Claude Code, Qwen Code, OpenCode, Cursor, Windsurf, Gemini, Qoder Work, OpenClaw, and Claude Desktop.
|
||||
- Agents: quick-launch coding agents directly from the UI (gated on the CLI binary being on PATH).
|
||||
- Assets: categorized, time-sorted browser with preview, open-locally, and delete.
|
||||
- **Model catalog suggestion chips** — per-category model names surfaced as click-to-fill chips under each `default_*_model` field in the config UI.
|
||||
- **Profiles tile grid** — profiles displayed as a tile grid with an add-tile and a design-consistent new-profile modal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Config UI layout: collapsible grouped sidebar with icons and persistent state, responsive breakpoint, wider main area, sticky view headers, and right-side drawers for editing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Symlinked skill directories are now correctly identified as an installed source.
|
||||
- Config file detection now supports environment-variable-based paths and legacy configuration schemes.
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent --key` / `--region`** — run commands generated by the Model Studio web console as-is: `--key` accepts the console's encoded API key and decodes it locally (use instead of `--api-key`), and `--region` derives the Token Plan endpoint from a region name (use instead of `--base-url`).
|
||||
- **`bl config agent --context-window`** — set the context window written to the OpenClaw configuration (default 256000).
|
||||
- **`bl config agent --wire-api`** — choose the wire protocol written to the Codex configuration; `chat` is kept for legacy Codex 0.80.0 and earlier (a warning is shown).
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl config agent` for Codex now writes `wire_api = "responses"` by default, matching current Codex releases that no longer accept `chat`.
|
||||
- `bl config agent` for Qwen Code now writes the `DASHSCOPE_API_KEY` environment variable instead of `BAILIAN_CLI_API_KEY`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl config agent` configurations now match each agent's official format: Claude Code honors `CLAUDE_CONFIG_DIR` and removes a stale `ANTHROPIC_API_KEY`; Qwen Code uses the v3 settings schema and writes credentials so a system-level `OPENAI_API_KEY` no longer takes precedence; OpenCode accepts JSONC config files (comments and trailing commas); OpenClaw registers the primary model in the model allowlist with complete cost metadata; Hermes uses the official flat `model.*` layout; Codex writes the official `env_key` with an `auth.json` fallback.
|
||||
- `bl config agent` now preserves existing user configuration when writing: it merges instead of overwriting, avoids duplicate provider entries, and keeps custom display names.
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- MCP tools and WebSearch now provide activation guidance and direct marketplace links when Bailian reports that the corresponding service is not activated. WebSearch also guides users with legacy SSE connections to reactivate the service using Streamable HTTP.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed text chat and API Key validation compatibility failures caused by sending unsupported `enable_thinking` values. Text chat now sends the parameter only when thinking is explicitly enabled, while validation uses a compatible model without sending it.
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- `bl image edit` now supports `--function` for specifying edit operations with Wanx image-edit models such as `wanx2.1-imageedit`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed image generation and editing failures and incorrect size parameters for some image models, improving compatibility with Qwen-Image, Wan/Wanx, Z-Image, and dated `wanx-v1` variants.
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl managed-agent`** — declaratively manage Managed Agent infrastructure through a unified CLI. The Bailian provider connects to AgentStudio, with Claude, Qoder, and Ark providers also supported:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` — initialize and validate `agents.yaml`, preview and apply resource changes, and destroy managed resources.
|
||||
- `state list` / `state show` / `state rm` / `state import` — inspect and manage local resource state, including adopting an existing remote resource or removing it from local state without destroying it remotely.
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` — manage the full session lifecycle with streaming responses and structured `--output json` output.
|
||||
- `skill-list` — browse custom and official skills; use `--source all` to return both catalogs in one call.
|
||||
|
||||
### Changed
|
||||
|
||||
- Model Base URLs are now normalized to the URL origin; paths, query parameters, and fragments supplied in the Base URL are no longer included when constructing API request paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The installation guide no longer recommends the removed `--non-interactive` flag and now documents explicit required arguments, `--output json`, and `NO_COLOR=1` for non-interactive environments.
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
|
||||
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
|
||||
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
+190
@@ -6,6 +6,196 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.16.0] - 2026-08-17
|
||||
|
||||
> CLI 迎来知识库全生命周期管理:从创建配置知识库、上传文档、调优切片,到部署检索/问答服务,均可通过 `bl knowledge` 与 `kscli` 完成。
|
||||
|
||||
### 新增
|
||||
|
||||
- **知识库管理** —— `bl knowledge create` / `list` / `info` / `update` / `delete` 覆盖知识库的完整生命周期;`bl knowledge stats` 查询指定过去时间段内的文档数量与用量统计。
|
||||
- **文档管理** —— `bl knowledge doc upload` 支持上传本地文件或整个目录(递归扫描,自动跳过不支持的格式及 `node_modules` 等工具目录);`doc list` / `status` / `tag` / `delete` 覆盖文档生命周期其余环节,`doc import-oss` 支持从 OSS 导入文档。
|
||||
- **检索 / 问答服务管理** —— `bl knowledge service list` / `get` / `create` / `update` / `deploy` / `delete` / `copy` 管理检索与问答服务配置,支持将草稿部署为正式版本。
|
||||
- **切片管理** —— `bl knowledge chunk add` / `list` / `update` / `delete` 查看并精调文档切片。
|
||||
- **数据中心管理** —— `bl knowledge category list` / `add` / `delete`、`bl knowledge file list` / `get` / `delete`、`bl knowledge collection create` / `get` 管理类目、原始文件与数据集。
|
||||
- **检索与问答支持指定服务版本** —— `bl knowledge search` 和 `bl knowledge chat` 新增 `--agent-version`,可调用 beta(草稿)配置进行调试,或指定已发布的版本号。
|
||||
- **`kscli` 同步支持** —— 全部新知识库命令在 Knowledge Studio CLI 中以更短路径提供,如 `kscli kb list`、`kscli doc upload`、`kscli service deploy`。
|
||||
|
||||
### 移除
|
||||
|
||||
- **移除 `bl knowledge search --query-history`** —— 该参数此前并未实际生效;多轮场景请改用 `bl knowledge chat` 并通过 `--message` 传入对话历史。
|
||||
|
||||
### 内部
|
||||
|
||||
- 请求现在携带静态的 OpenAPI 来源标识请求头,用于后端渠道归因。
|
||||
- 新增知识库 E2E 测试套件,含冷启动、内容运营、切片调优、服务调优、数据面五条用户旅程场景。
|
||||
|
||||
## [1.15.1] - 2026-08-17
|
||||
|
||||
### 新增
|
||||
|
||||
- **模型权限管理** —— `bl permission list` 查看各模型的推理 / 微调 / 部署授权;`bl permission grant` 与 `bl permission revoke` 负责授予和回收,支持 `--all` 一键为工作区全部模型(含后续新增模型)开启推理授权。
|
||||
|
||||
### 变更
|
||||
|
||||
- **`bl quota request` 更名为 `bl quota update`** —— 通过 `--rpm`/`--tpm` 设置单模型 QPM/TPM,新增 `--delete` 一键清除自定义限制;未指定的字段保持当前值,旧命令 `quota request` 仍作为别名可用。
|
||||
- **`bl quota list` 重构** —— 改从模型限制接口读取数据,单表展示模型级与工作区级的请求/用量限制及异步队列/并发限制。
|
||||
- **`bl model list` 不再需要控制台登录** —— 模型目录与 `--enrich` 参数结构端点均为公开接口。
|
||||
- **`bl skill init` 输出精简** —— 单技能状态改为 `success`/`failed`(原为 `installed`),新增 `success`/`partial`/`failed` 汇总结果;移除 `publishedAt` 与 `agents` 字段。
|
||||
|
||||
## [1.15.0] - 2026-08-14
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl text chat` 支持 Responses API** —— 可通过 `--api responses` 调用 DashScope Responses API,支持流式输出、工具定义和结构化 JSON 输出;默认仍使用 Chat Completions。
|
||||
- **订阅套餐用量视图** —— `bl usage token-plan` 支持查看 5 小时和每周额度,`bl usage coding-plan` 支持查看 5 小时、每周和每月额度;两者均提供文本与 JSON 输出。
|
||||
- **命令帮助展示鉴权要求** —— Help 输出现在会明确标注命令需要 API Key、控制台登录还是阿里云 OpenAPI 凭证。
|
||||
|
||||
### 变更
|
||||
|
||||
- **扩展语音识别模型支持** —— `bl speech recognize` 现在会将异步文件转写和同步 Flash ASR 模型路由至对应的 DashScope API,并为暂不支持的实时模型提供明确提示。
|
||||
- **增强 MCP 传输兼容性** —— MCP 命令现在可为兼容的百炼及自定义端点从 Streamable HTTP 自动回退至经典 SSE。
|
||||
|
||||
### 修复
|
||||
|
||||
- 二进制方式升级 CLI 成功后,现在会同步刷新已安装的 Agent Skills。
|
||||
- 修复 Token Plan 额度不可用或缺少重置时间时的展示问题。
|
||||
- 修复 Qwen3 文件转写结果处理,使等待模式和 `--out` 能够正常工作。
|
||||
- 修复 MCP SSE 分块解析、响应头超时、中止清理和回退状态匹配问题。
|
||||
- JSON 输出中的网络错误现在会在 `cause.code` 中保留 errno。
|
||||
|
||||
## [1.14.3] - 2026-08-12
|
||||
|
||||
### 修复
|
||||
|
||||
- **免费额度兼容性** —— `bl usage free` 和 `bl usage freetier` 现在使用最新的 Bailian Commerce 控制台 API 查询、开通和关闭免费额度,并统一处理异步任务轮询。
|
||||
|
||||
## [1.14.2] - 2026-08-07
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl skill init`** —— 一次性将全部官方 `bailian-*` Skill 安装到本机检测到的 AI Agent。
|
||||
|
||||
### 变更
|
||||
|
||||
- **Skill 命令接口** —— Skill 管理命令现在默认输出适合 Agent 工作流的 JSON;`bl skill add` 和 `bl skill update` 使用明确的 `--all` 与 `--name` 选择参数。
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### 新增
|
||||
|
||||
- **百炼 Skill 按领域拆分** —— 通过 `npx skills add modelstudioai/cli --all -g` 可统一安装图片与视频生成、模型微调、Managed Agent 和共享执行协议等专用 Skill,提升任务路由准确性并减少无关上下文。
|
||||
|
||||
### 变更
|
||||
|
||||
- **默认图片模型升级至 Qwen-Image 3.0** —— 普通 API Key 用户的图片生成、图片编辑、Pipeline、配置 UI 和相关文档现在默认使用 `qwen-image-3.0`。
|
||||
- **扩展 Coding Agent 兼容范围** —— Skill 安装与更新现在能够识别更多 Coding Agent,保留已有安装链接,并自动将 Skill 补充到新识别的 Agent。
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### 新增
|
||||
|
||||
- **免 Node.js 的二进制安装** — 支持 macOS Apple Silicon / Intel、Linux x64 和 Windows x64;npm 安装方式继续保留。
|
||||
- **指定版本更新** — 二进制和 npm 安装均可通过 `bl update --to <version>` 更新或切换到指定版本。
|
||||
|
||||
### 变更
|
||||
|
||||
- **二进制自更新** — 二进制安装现在通过独立的发布通道检查和下载更新;执行 `bl update` 时不会覆盖正在运行的程序,下次运行自动使用新版本。
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### 变更
|
||||
|
||||
- **默认文本模型升级至 Qwen3.8-Max** — `bl text chat`、Pipeline、API Key 登录校验、配置 UI 和 Managed Agent 初始化模板现在默认使用 `qwen3.8-max`;Token Plan 也由预览版切换至正式版。
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config ui` 技能 / MCP / 代理 / 资产清单** — 在本地 Web UI 中浏览已安装的技能、MCP 服务器、编码代理和生成的资产,点击打开右侧详情抽屉:
|
||||
- 技能:将 `SKILL.md` 渲染为 Markdown(支持 GFM 表格),展示本地/远程来源徽章,支持上传 `.zip` 压缩包将技能安装到任意受支持代理的技能目录。
|
||||
- MCP:查看和编辑 JSON 配置,支持密钥掩码与掩码保真写回;支持在 Claude Code、Qwen Code、OpenCode、Cursor、Windsurf、Gemini、Qoder Work、OpenClaw 和 Claude Desktop 中创建、更新、删除 MCP 条目。
|
||||
- 代理:从 UI 一键启动编码代理(需对应 CLI 二进制在 PATH 中)。
|
||||
- 资产:按类别分组、按时间排序的浏览器,支持预览、本地打开和删除。
|
||||
- **模型目录建议芯片** — 在配置 UI 的每个 `default_*_model` 字段下方展示按类别分组的模型名称,点击即可填入。
|
||||
- **Profile 磁贴网格** — 配置文件以磁贴网格展示,新增添加磁贴和设计一致的新建 Profile 弹窗。
|
||||
|
||||
### 变更
|
||||
|
||||
- 配置 UI 布局:可折叠分组侧边栏(带图标和持久化状态)、响应式断点、更宽的主区域、吸顶视图标题、右侧抽屉式编辑。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复软链接技能目录未被正确识别为已安装来源的问题。
|
||||
- 配置文件检测现支持基于环境变量的路径和旧版配置方案。
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent --key` / `--region`** —— 百炼控制台生成的命令可直接运行:`--key` 接收控制台编码后的 API Key 并在本地解码(与 `--api-key` 二选一);`--region` 根据地域名自动派生 Token Plan 接入地址(与 `--base-url` 二选一)。
|
||||
- **`bl config agent --context-window`** —— 设置写入 OpenClaw 配置的上下文窗口大小(默认 256000)。
|
||||
- **`bl config agent --wire-api`** —— 选择写入 Codex 配置的通信协议;`chat` 仅保留给 Codex 0.80.0 及更早版本(会显示警告)。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl config agent` 配置 Codex 时默认写入 `wire_api = "responses"`,以适配已不再支持 `chat` 的新版 Codex。
|
||||
- `bl config agent` 配置 Qwen Code 时改用 `DASHSCOPE_API_KEY` 环境变量,不再使用 `BAILIAN_CLI_API_KEY`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl config agent` 写入的配置现已与各 Agent 官方格式对齐:Claude Code 尊重 `CLAUDE_CONFIG_DIR` 并清理残留的 `ANTHROPIC_API_KEY`;Qwen Code 采用 v3 配置 schema 并正确写入凭证,避免被系统级 `OPENAI_API_KEY` 干扰;OpenCode 支持带注释和尾部逗号的 JSONC 配置文件;OpenClaw 会将主模型注册进模型白名单并补齐计费元数据;Hermes 改用官方扁平 `model.*` 结构;Codex 写入官方 `env_key` 并支持 `auth.json` 兜底。
|
||||
- `bl config agent` 写入配置时现会保留用户已有配置:合并而非覆盖,避免重复添加 provider 条目,并保留用户自定义的显示名。
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### 变更
|
||||
|
||||
- MCP 工具或 WebSearch 因对应服务未开通而不可用时,CLI 现在会提供开通指引和市场直达链接;对于使用旧版 SSE 连接的 WebSearch,还会提示重新开通以切换至 Streamable HTTP。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复文本对话与 API Key 登录校验因传递不受支持的 `enable_thinking` 参数值而产生的兼容性错误。文本对话仅在用户明确开启思考模式时传递该参数,登录校验则改用兼容模型且不再传递该参数。
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl image edit` 新增 `--function` 参数,支持为万相图片编辑模型(如 `wanx2.1-imageedit`)指定编辑功能。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复部分图片模型在图片生成与编辑时的调用失败和尺寸参数错误,并完善 Qwen-Image、Wan/Wanx、Z-Image 系列及 `wanx-v1` 日期版本的兼容性。
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl managed-agent`** —— 通过统一 CLI 声明式管理 Managed Agent 基础设施;百炼 Provider 对接 AgentStudio,并支持 Claude、Qoder 和 Ark:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` —— 基于 `agents.yaml` 初始化、校验、预览和执行资源变更,以及销毁已托管资源。
|
||||
- `state list` / `state show` / `state rm` / `state import` —— 查看和管理本地资源状态,包括纳管已有远端资源或仅解除本地跟踪。
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` —— 完整的会话生命周期操作,支持流式响应和结构化的 `--output json` 输出。
|
||||
- `skill-list` —— 浏览自定义与官方 Skill;使用 `--source all` 可一次返回两个来源。
|
||||
|
||||
### 变更
|
||||
|
||||
- 模型 Base URL 现在统一仅保留 URL Origin;传入的路径、查询参数和 Fragment 不再参与后续 API 请求路径拼接。
|
||||
|
||||
### 修复
|
||||
|
||||
- 安装指南不再推荐已移除的 `--non-interactive`,改为说明显式传入必填参数,并使用 `--output json` 或 `NO_COLOR=1` 适配非交互环境。
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
|
||||
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
|
||||
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
|
||||
|
||||
### 修复
|
||||
|
||||
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- Node.js ≥ 22.12 (required for developing this repo)
|
||||
- End users installing the published CLI only need Node.js >= 18.17
|
||||
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
|
||||
- A DashScope API key for running e2e tests
|
||||
|
||||
|
||||
+3
-2
@@ -6,8 +6,9 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 参与本仓库开发:Node.js ≥ 22.12
|
||||
- 仅安装/使用已发布的 CLI:Node.js >= 18.17
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 跑 e2e 需要一个百炼 API Key
|
||||
|
||||
## 仓库结构
|
||||
|
||||
+65
-80
@@ -1,125 +1,110 @@
|
||||
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
|
||||
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(npm 包名 `bailian-cli`,命令 `bl` / `bailian`)。用户通常通过 **npm 全局安装** 使用,**无需**访问本仓库源码。不要臆造版本号或路径;以用户环境为准。
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js(**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm,再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
|
||||
|
||||
## 1. 安装前检查
|
||||
## 1. 推荐:npm 安装(要求 **≥ 18.17.0**)
|
||||
|
||||
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求,Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
|
||||
|
||||
1. **Node.js**
|
||||
- 要求 **≥ 22.12.0**,执行 `node -v` 确认。
|
||||
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
|
||||
|
||||
2. **npm**(**唯一**允许的包管理器)
|
||||
- 执行 `npm -v` 确认可用。
|
||||
- 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`。
|
||||
- 即使本机已安装 `pnpm`、`yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`。
|
||||
|
||||
3. **网络**
|
||||
- 需能访问 npm registry(默认 **registry.npmjs.org**)。
|
||||
- 不可达:由 Agent 自行配置镜像或代理后重试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装 CLI 与 Skills
|
||||
|
||||
**仅允许以下命令**(不要用 `pnpm add -g`、`yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。
|
||||
|
||||
**2.1 安装 CLI**
|
||||
1. `node -v` 确认版本 ≥ 18.17.0。
|
||||
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。
|
||||
3. 执行:
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
```
|
||||
|
||||
安装成功后,应能在 PATH 中找到:
|
||||
4. 校验:`bl --version`。
|
||||
|
||||
- `bl`(短别名)
|
||||
- `bailian`(全名)
|
||||
|
||||
**校验**(Agent 应执行并检查退出码与输出):
|
||||
安装 skills(CLI 内置,无需 Git / npx skills):
|
||||
|
||||
```bash
|
||||
bl --version
|
||||
which bl # Windows 可用 where bl
|
||||
bl skill init
|
||||
```
|
||||
|
||||
若 `command not found`:检查全局 bin 是否在 PATH(`npm config get prefix`,其下 `bin` 目录应加入 PATH)。
|
||||
**Supported:** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。
|
||||
|
||||
**2.2 安装 Skills**
|
||||
|
||||
CLI 校验通过后,在本机终端执行:
|
||||
**Advanced / 按需子集:**
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill add --name bailian-protocol,bailian-gen
|
||||
```
|
||||
|
||||
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
|
||||
## 2. 备选:二进制安装(无需 Node)
|
||||
|
||||
当环境没有 Node / npm,或 Node 版本过低无法走 npm 时,使用二进制安装脚本。脚本安装 CLI 成功后会自动执行 `bl skill init`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证一律读 `sync-release.json`(bailian-cli 的 channel 发版都会覆盖它):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash -s -- --channel sync-release
|
||||
```
|
||||
|
||||
也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
# 正式(manifest.json)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# channel / 预发验证(sync-release.json)
|
||||
$env:BAILIAN_CHANNEL = 'sync-release'; irm 'https://bailian.aliyun.com/cli/install.ps1' | iex
|
||||
```
|
||||
|
||||
带其它参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。
|
||||
|
||||
二进制安装布局为 `versions/<ver>/` + `current` 指针;`bl update` 只切换指针并清理旧版本(保留当前与上一版)。更新进程退出后,下次执行 `bl` 即使用新版本(无需「重启应用」)。
|
||||
|
||||
校验:
|
||||
|
||||
```bash
|
||||
bl --version
|
||||
which bl # Windows: where.exe bl
|
||||
```
|
||||
|
||||
若自动 skill 安装失败,再手动执行:`bl skill init`。
|
||||
|
||||
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node,回退到上方 npm 安装。
|
||||
|
||||
---
|
||||
|
||||
## 3. 鉴权(安装后必做才能调 API)
|
||||
|
||||
### 推荐:浏览器登录(控制台会话)
|
||||
|
||||
适用于本机交互式安装,无需用户手动复制 API Key:
|
||||
|
||||
1. 执行 `bl auth status --output json`,判断是否已配置。
|
||||
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权。
|
||||
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`。
|
||||
3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。
|
||||
|
||||
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
|
||||
### 备选:API Key / Token Plan
|
||||
|
||||
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
|
||||
|
||||
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
|
||||
|
||||
- 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
|
||||
|
||||
1. 执行 `bl auth status --output json`,判断是否已配置。
|
||||
2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。
|
||||
3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
|
||||
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
|
||||
|
||||
### 备选二:使用 Token Plan API Key
|
||||
|
||||
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
|
||||
|
||||
1. 请用户从订阅详情页获取或复制 Token Plan API Key,勿要求用户发到公开渠道。
|
||||
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`。
|
||||
3. `token-plan` Profile 已内置默认 Base URL;登录命令会先测试 Key,通过后才保存并激活该 Profile,无需另行配置或重复测试。
|
||||
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
|
||||
|
||||
### 其他方式
|
||||
|
||||
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
|
||||
- **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value <key>`(`--key api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用;Agent 引导安装时仍**优先**用 `auth login`。
|
||||
- **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key <key>`,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。
|
||||
- 普通 Key:`bl auth login --api-key <Key>`
|
||||
- Token Plan:`bl auth login --config token-plan --api-key <Key>`
|
||||
|
||||
### Agent 安全约束
|
||||
|
||||
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
|
||||
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`。通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置验证
|
||||
|
||||
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
|
||||
|
||||
```bash
|
||||
bl auth status --output json
|
||||
```
|
||||
|
||||
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
## 5. 常见问题
|
||||
|
||||
---
|
||||
|
||||
## 5. 常见问题(Agent 排障清单)
|
||||
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ----------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
|
||||
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
|
||||
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ------------------------ | ---------------------------- | ------------------------------------------------ |
|
||||
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` |
|
||||
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
|
||||
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
|
||||
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI |
|
||||
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
---
|
||||
|
||||
_Chat with Qwen, generate images & videos, understand images, call agents,_
|
||||
_manage memory, search the web — all from your terminal._
|
||||
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
|
||||
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
|
||||
_every AI capability, one command away._
|
||||
|
||||
_Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
@@ -22,28 +23,16 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
## Features
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -56,120 +45,93 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
|
||||
>
|
||||
> _(Original: "帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2分钟左右的视频,尺寸是16:9")_
|
||||
|
||||
### How it works
|
||||
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
|
||||
|
||||
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
**Agent install (recommended)**
|
||||
|
||||
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
|
||||
|
||||
```text
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
**Install with NPM**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
**Install on macOS/Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
**Install on Windows**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Authenticate, recommended
|
||||
bl auth login --console
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
# Multimodal chat (text + image + audio + video)
|
||||
bl omni --message "Describe this image" --image ./photo.jpg
|
||||
|
||||
# Generate an image
|
||||
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
|
||||
|
||||
# Generate a video from local image
|
||||
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
|
||||
|
||||
# Model recommendation — find the best model for your use case
|
||||
bl advisor recommend --message "I need a visual-understanding chatbot"
|
||||
|
||||
# Compare specific models
|
||||
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| Scenario | What to say to your Agent |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
|
||||
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
|
||||
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
|
||||
| Model selection | "Recommend a model for image understanding and customer support." |
|
||||
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## Authentication
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# Option 2: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Option 3: Per-command flag
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -177,26 +139,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
### Alibaba Cloud OpenAPI AK/SK
|
||||
|
||||
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
```bash
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -205,17 +161,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# View current config
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
# List all config profiles
|
||||
bl config list
|
||||
|
||||
# Self-update to latest version
|
||||
bl update
|
||||
# Switch config profile
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
@@ -227,11 +197,3 @@ Config file location: `~/.bailian/config.json`
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
+93
-130
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -22,28 +22,16 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -53,121 +41,96 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
|
||||
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
|
||||
> _“帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9。”_
|
||||
|
||||
### 工作流程
|
||||
## 示例 2:一句话构建短片导演 Managed Agent
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
<p align="center"><i>👆 点击封面播放完整演示</i></p>
|
||||
|
||||
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _“帮我构建一个 managedagent 应用,能够实现短片拍摄,导演专家生成视频,然后也能进行设计对应的分镜图。”_
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
**Agent 安装(推荐)**
|
||||
|
||||
把下面这句话发给你的 Agent,它会自行判断环境并完成安装与校验:
|
||||
|
||||
```text
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
**NPM 安装**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
**macOS/Linux 安装**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
**Windows 安装**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
# 多模态对话(文本 + 图片 + 音频 + 视频)
|
||||
bl omni --message "描述这张图片" --image ./photo.jpg
|
||||
|
||||
# 生成图片
|
||||
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
|
||||
|
||||
# 图生视频(本地文件自动上传)
|
||||
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
|
||||
|
||||
# 模型推荐 — 根据场景推荐最适合的模型
|
||||
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
|
||||
|
||||
# 对比特定模型
|
||||
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
|
||||
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| 场景 | 可以这样对 Agent 说 |
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## 认证方式
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# 方式二:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 方式三:命令行参数
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -175,26 +138,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
### 阿里云 OpenAPI AK/SK
|
||||
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## 配置
|
||||
@@ -203,17 +160,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# 查看当前配置
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
# 查看全部配置档
|
||||
bl config list
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
# 切换配置档
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群,获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
@@ -225,11 +196,3 @@ bl update
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
@@ -25,7 +25,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
当前 command 鉴权域(`AuthRequirement`):
|
||||
|
||||
- `apiKey` — DashScope / OpenAI-compatible 模型域,用 API key 与 model base URL
|
||||
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent/workspace
|
||||
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent;`workspace_id` 是独立的 Settings 作用域,不属于 credential
|
||||
- `openapi` — 阿里云 OpenAPI 签名域,用 AccessKey ID/Secret 调用 Token Plan 等 OpenAPI
|
||||
- `none` — 本地命令、登录/配置类命令、无需 credential 的命令
|
||||
|
||||
@@ -35,7 +35,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
- `bl auth login --api-key ...` 只更新 `api_key` / `base_url`
|
||||
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
|
||||
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
|
||||
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证
|
||||
- `bl auth logout --console` 只清 `access_token`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
|
||||
- `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*`
|
||||
@@ -43,7 +43,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
解析分工:
|
||||
|
||||
- `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一去除 query、fragment、尾斜杠和已知 SDK/API Base 后缀,同时保留自定义网关前缀
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment)
|
||||
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
|
||||
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile;未传
|
||||
`--config` 时继续写当前激活项,失败和 dry-run 不切换
|
||||
@@ -53,6 +53,23 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
|
||||
|
||||
`bl managed-agent *` 按调用链分两层:
|
||||
|
||||
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
|
||||
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
|
||||
|
||||
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
|
||||
|
||||
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
|
||||
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
|
||||
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
|
||||
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
|
||||
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
|
||||
|
||||
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. core 层(类型 + 解析)
|
||||
@@ -61,6 +78,9 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
- 如新增鉴权域,扩展 `AuthRequirement`
|
||||
- 更新 `credentialFlagDefs()` 暴露该域可见的 flag
|
||||
- 必要时新增 `*_AUTH_FLAGS`
|
||||
- `workspace_id` 是作用域字段而非 credential,不要把它放进 `ConsoleCredential`;读取方式按命令 `auth` 域区分:
|
||||
- `auth: "console"` 命令通过 `CONSOLE_AUTH_FLAGS` 自动获得 `--workspace-id`,由 `buildSettings()` 解析到 `settings.workspaceId`,命令统一从 `settings.workspaceId` 读取
|
||||
- `auth: "apiKey"`/`"openapi"`/`"none"` 命令如需 `--workspace-id`,必须自声明 flag;因它不会进入 credential/global flags,命令从 `ctx.flags.workspaceId` 读取(可回退到 `settings.workspaceId`)
|
||||
- [ ] `packages/core/src/auth/types.ts`:
|
||||
- 新增 credential 类型 / source / scope 字段
|
||||
- [ ] `packages/core/src/auth/resolver.ts`:
|
||||
@@ -104,7 +124,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
### D. 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
|
||||
- [ ] `skills/bailian-cli/reference/` 通过 `pnpm run sync:skill-assets` 重建
|
||||
- [ ] 各 `skills/<skill>/reference/` 通过 `pnpm run sync:skill-assets` 重建
|
||||
|
||||
### E. 测试
|
||||
|
||||
@@ -114,6 +134,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
## 完成后自查
|
||||
|
||||
本仓库同时存在 `bl`(packages/cli) 与 `kscli`(packages/kscli) 两个入口,二者共享 core/runtime 鉴权链路,但暴露的命令不同。如果改动会影响两个入口共用的命令或错误提示,再分别验证它们各自实际暴露的路径;不要假设 `kscli` 也有 `bl auth *` 命令。
|
||||
|
||||
```sh
|
||||
# 各种凭证组合
|
||||
unset DASHSCOPE_API_KEY ALIBABA_CLOUD_ACCESS_KEY_ID ALIBABA_CLOUD_ACCESS_KEY_SECRET
|
||||
@@ -133,9 +155,11 @@ Console 登录/网关相关改动:
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli exec tsx src/main.ts auth login --console
|
||||
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json
|
||||
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
注意:`usage stats --dry-run` 仍会先校验 workspace,必须传入 `--workspace-id`(或 `BAILIAN_WORKSPACE_ID` / config `workspace_id`)。
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 加了新 token 来源但忘了改 resolver 优先级,实际不生效
|
||||
|
||||
@@ -56,7 +56,7 @@ git diff --name-only <base>...<head>
|
||||
|
||||
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
|
||||
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
|
||||
- `skills/bailian-cli/reference/` + `skills/bailian-cli/SKILL.md` 通过 `pnpm run sync:skill-assets` 更新并提交
|
||||
- 各 `skills/<skill>/reference/` + 对应 `SKILL.md` 通过 `pnpm run sync:skill-assets` 更新并提交
|
||||
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
|
||||
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
|
||||
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
|
||||
|
||||
@@ -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`)
|
||||
@@ -95,7 +104,7 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
|
||||
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
|
||||
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
|
||||
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
|
||||
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新各 `skills/<skill>/reference/` 并提交
|
||||
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
|
||||
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
|
||||
- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序
|
||||
|
||||
@@ -56,7 +56,7 @@ packages/commands/src/index.ts
|
||||
- **`packages/cli/src/commands.ts`**:`bl` 产品命令 map;新增/删除/重命名 `bl` 命令必须改这里
|
||||
- **`packages/kscli/src/main.ts`**:`kscli` 产品命令 map;只有该入口需要暴露/变更时才改
|
||||
- **`packages/runtime/src/registry.ts`**:通用 registry,从传入 map 建树;不要在这里登记业务命令
|
||||
- **`tools/generate-reference.ts`**:pre-commit / `pnpm run sync:skill-assets` 时读 `packages/cli/src/commands.ts`,写 `skills/bailian-cli/reference/index.md` + `<一级命令>.md`。该目录**纳入 git**,勿手改
|
||||
- **`tools/generate-reference.ts`**:pre-commit / `pnpm run sync:skill-assets` 时读 `packages/cli/src/commands.ts`,按 `GROUP_OWNER_SKILL` 归属表分流写到各 `skills/<skill>/reference/index.md` + `<一级命令>.md`。未显式归属的一级组默认进 `bailian-cli`。各目录**纳入 git**,勿手改。新增一级命令组若应归领域 skill,记得改归属表。
|
||||
|
||||
已删除/勿再引用:旧的 `packages/cli/src/commands/catalog.ts`、旧的 `packages/cli/src/commands/index.ts` catalog re-export、`packages/cli/src/registry.ts`、`skipDefaultApiKeySetup`、`ensureApiKey` 启动拦截、`config/export-schema.ts`。
|
||||
|
||||
@@ -87,9 +87,10 @@ packages/commands/src/index.ts
|
||||
|
||||
### C. 文档层
|
||||
|
||||
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/bailian-cli/reference/` 与 `SKILL.md` 的 `metadata.version` 并提交
|
||||
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新各 `skills/<skill>/reference/` 与 `SKILL.md` 的 `metadata.version` 并提交
|
||||
- [ ] `README.md` / `README.zh.md`:Quick Start、命令一览、认证说明(用户向,与 help 对齐)
|
||||
- [ ] `skills/bailian-cli/SKILL.md`:若安装说明或能力边界有变,同步更新
|
||||
- [ ] 相关 `skills/<skill>/SKILL.md`:若安装说明或能力边界有变,同步更新;新一级命令组若属领域 skill,同步改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL`
|
||||
- [ ] **拥有方** skill 的「When to use which command」(或等价路由表)补上新意图;hub `bailian-cli` 仅加/改 hand-off 行,**不要**把领域子命令与默认模型抄进 hub 表(约定见 [skill-change.md](skill-change.md))
|
||||
|
||||
### D. 测试层
|
||||
|
||||
@@ -105,7 +106,7 @@ packages/commands/src/index.ts
|
||||
- `packages/cli/src/commands.ts` map key
|
||||
- `packages/kscli/src/commands.ts` map key(如适用)
|
||||
- 用户可见 hint / README / tests
|
||||
- `skills/bailian-cli/reference/`(重建后检查并提交)
|
||||
- `skills/*/reference/`(重建后检查并提交)
|
||||
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
|
||||
|
||||
## 完成后自查
|
||||
@@ -127,7 +128,9 @@ pnpm -F knowledge-studio-cli exec tsx src/main.ts <command> --help
|
||||
|
||||
- ✗ 只新增 `packages/commands/src/commands/...` 文件,忘了在 `packages/commands/src/index.ts` 导出
|
||||
- ✗ 只导出了命令实现,忘了在 `packages/cli/src/commands.ts` 暴露路径 → `bl --help` 看不到
|
||||
- ✗ 手改 `skills/bailian-cli/reference/*.md` → 下次 generate 被覆盖;应改 command metadata 后重新 generate 并提交
|
||||
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖;应改 command metadata 后重新 generate 并提交
|
||||
- ✗ 新一级命令组忘改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL` → reference 会落到 hub `bailian-cli`(未必是预期)
|
||||
- ✗ 只改 reference / hub,忘改拥有方 skill 路由表;或把领域命令明细重新抄回 `bailian-cli` SKILL → 与 [skill-change.md](skill-change.md) 分层冲突
|
||||
- ✗ 在 `usageArgs` / `exampleArgs` 写死 `bl text chat` → `kscli` 等入口复用时 help 错
|
||||
- ✗ Console Gateway 命令忘设 `auth: "console"` → console flags / credential 注入都不生效
|
||||
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
### C. 文档层
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` 如果在示例里展示了相关命令,补充新 flag
|
||||
- [ ] 跑 `pnpm --filter bailian-cli run generate:reference`,让 `skills/bailian-cli/reference/` 与命令一致(勿手改;改完提交)
|
||||
- [ ] 跑 `pnpm --filter bailian-cli run generate:reference`,让各 `skills/<skill>/reference/` 与命令一致(勿手改;改完提交)
|
||||
|
||||
### D. 测试层
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
|
||||
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
|
||||
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
|
||||
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
|
||||
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`(或归属表指定的 skill reference);公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
|
||||
|
||||
验证:
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
|
||||
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
|
||||
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
@@ -62,7 +64,8 @@
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
|
||||
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 安装文档变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
|
||||
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
|
||||
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. CLI 契约
|
||||
|
||||
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
|
||||
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
|
||||
- [ ] Node.js 用户安装要求与 `packages/cli/package.json` 的 `engines.node` 一致,不使用根 `package.json` 的开发环境要求
|
||||
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
|
||||
|
||||
### B. 静态副本
|
||||
|
||||
- [ ] 将 `INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
|
||||
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
|
||||
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
|
||||
|
||||
### C. 线上验证
|
||||
|
||||
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
|
||||
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
|
||||
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli test -- tests/install-doc.test.ts
|
||||
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
|
||||
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
|
||||
- 根 `package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
|
||||
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### A. 版本一致性
|
||||
|
||||
- [ ] `package.json` 的 `engines.node` 与 README 的 Node.js 徽章一致
|
||||
- [ ] 发布包(`cli` 等)的 `engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
|
||||
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
|
||||
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
|
||||
### C. 命令手册
|
||||
|
||||
- [ ] 若 `--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/<group>.md` 并提交
|
||||
- [ ] 若 `--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新对应 `skills/<skill>/reference/<group>.md` 并提交
|
||||
- [ ] 同步**拥有该命令的领域 skill**「When to use which command」表中的 Default model(现主要是 `bailian-gen`;精调相关看 `bailian-finetune` 正文示例)。hub `bailian-cli` 已瘦身,一般**不必**再写领域默认模型(见 [skill-change.md](skill-change.md))
|
||||
|
||||
### D. 用户面文档
|
||||
|
||||
@@ -49,6 +50,7 @@ pnpm -F bailian-cli exec tsx src/main.ts <command> --model <new-model> --message
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 改了命令默认模型,但 SKILL.md frontmatter 仍写老型号 → AI agent 调用时仍按老型号宣传
|
||||
- ✗ 改了命令默认模型,但 SKILL.md frontmatter 或领域路由表 Default model 仍写老型号 → AI agent 调用时仍按老型号宣传
|
||||
- ✗ 只改了 `reference/` / flag description,忘改 `bailian-gen`(等) SKILL 路由表
|
||||
- ✗ 废弃模型时只删了代码,e2e 测试还在跑,CI 红
|
||||
- ✗ 新模型 endpoint 不一致,但只改了 default,没加 endpoint 分支判断
|
||||
|
||||
+64
-34
@@ -1,27 +1,53 @@
|
||||
# 发布(npm publish)
|
||||
# 发布(npm + GitHub Release 二进制)
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 准备发布 channel(beta/mcp/plugin 等)或正式版到 npm
|
||||
- 准备打 git tag
|
||||
- 准备发布 channel(mcp/plugin 等)或正式版到 npm **与** GitHub Releases 二进制
|
||||
- 准备打 git tag(仅 stable)
|
||||
|
||||
## 发布方式:GitHub Actions + npm OIDC
|
||||
## 发布方式:GitHub Actions 总入口
|
||||
|
||||
发版**必须**通过 CI 完成,不要本地手动 `pnpm publish`。
|
||||
|
||||
入口:GitHub Actions → **Publish** workflow(`.github/workflows/publish.yml`)→ Run workflow。
|
||||
|
||||
**编排关系(重要):**
|
||||
|
||||
```text
|
||||
publish-stable.mjs / publish-channel.mjs ← 唯一发版入口
|
||||
├─ npm(pnpm publish)
|
||||
└─ binary(lib/binary-release
|
||||
→ binary-build
|
||||
→ gh-release
|
||||
→ oss-direct-upload)
|
||||
```
|
||||
|
||||
`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。
|
||||
|
||||
两种模式:
|
||||
|
||||
| 模式 | 用途 | 触发方式 |
|
||||
| ------- | ------------------------------ | -------------------------------------------------- |
|
||||
| channel | 发 channel 版本到指定 dist-tag | 选 mode=channel,填 dist-tag 名称(如 mcp/plugin) |
|
||||
| stable | 正式发版到 latest | 选 mode=stable,需 production environment 审批 |
|
||||
| 模式 | 用途 | 触发方式 |
|
||||
| ------- | --------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| channel | npm dist-tag +(仅 bailian-cli)二进制 + CDN **一律**覆盖 `sync-release.json` | mode=channel,channel 填 **npm dist-tag** 名 |
|
||||
| stable | npm latest + GitHub Release `v<ver>` + CDN **`manifest.json`**(及 `latest.json` 别名) | mode=stable,需 production environment 审批 |
|
||||
|
||||
可选 flag:`--skip-binary`(仅发 npm,紧急逃生)。
|
||||
|
||||
### CDN 滚动指针(bailian-cli)
|
||||
|
||||
| 发布模式 | CDN 指针 | 本机安装 / 更新 |
|
||||
| -------- | ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| channel | 始终覆盖 `sync-release.json` | `BAILIAN_CHANNEL=sync-release` / `install --channel sync-release` |
|
||||
| stable | `manifest.json`(+ `latest.json`) | 默认安装 / `bl update`(无 channel) |
|
||||
|
||||
workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / `sync-release`),**不再**生成 `release-test.json` 这类旁路文件。
|
||||
|
||||
### channel 发布
|
||||
|
||||
1. 在 GitHub 触发 Publish workflow,package 选 `bailian-cli` 或 `knowledge-studio-cli`,mode 选 `channel`,channel 填 dist-tag 名(如 `mcp`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag
|
||||
1. 在 GitHub 触发 Publish workflow,mode 选 `channel`,channel 填 npm dist-tag 名:
|
||||
- **`bailian-cli`**:npm 发到该 tag;二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release`
|
||||
- **`knowledge-studio-cli`**:仅 npm(自动跳过 binary,不碰 `sync-release.json`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`(UTC 到分钟;同 commit 同分钟重跑会覆盖同号)→ 临时 bump → 自检 → **npm 发到 dist-tag** →(bailian-cli)**Bun 编二进制 + GH prerelease + 覆盖 `sync-release.json`** → 还原 package.json
|
||||
3. 对应脚本:`tools/release/publish-channel.mjs`
|
||||
|
||||
### stable 发布
|
||||
@@ -29,7 +55,7 @@
|
||||
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli`,`knowledge-studio-cli` 发布会额外包含 `packages/kscli`
|
||||
2. 在 GitHub 触发 Publish workflow,package 选目标包集合,mode 选 `stable`
|
||||
3. 需要 production environment 审批人批准
|
||||
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
|
||||
4. CI 自动:自检 → **npm 发到 latest** → **推送 git tag `v<ver>`** → **Bun 编二进制并创建/更新 GitHub Release** →(bailian-cli)维护 CDN **`manifest.json`** → 完成
|
||||
5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包
|
||||
6. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
|
||||
@@ -37,17 +63,17 @@
|
||||
|
||||
两种模式都会先跑 `check.mjs`,覆盖以下检查:
|
||||
|
||||
| 检查项 | 说明 |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `pnpm install --frozen-lockfile` | lockfile 一致性 |
|
||||
| README 同步 | `packages/cli/README.md` 与根 README 一致 |
|
||||
| 版本号一致 | `tools/release/lib/packages.mjs` 中待发布包集合 version 相同 |
|
||||
| `workspace:*` 替换 | 发布包间 workspace 依赖解析为真实版本号 |
|
||||
| 构建 | 基础发布构建 core/runtime/commands 依赖和 cli;`--knowledge` 额外构建 `knowledge-studio-cli` |
|
||||
| 生成资产 | 重建 `skills/bailian-cli/reference/`;非 channel 模式还同步 `skills/bailian-cli/SKILL.md` version |
|
||||
| pnpm pack | 打 tarball |
|
||||
| publint | 包元数据校验 |
|
||||
| gitleaks | 敏感信息扫描 |
|
||||
| 检查项 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm install --frozen-lockfile` | lockfile 一致性 |
|
||||
| README 同步 | `packages/cli/README.md` 与根 README 一致 |
|
||||
| 版本号一致 | `tools/release/lib/packages.mjs` 中待发布包集合 version 相同 |
|
||||
| `workspace:*` 替换 | 发布包间 workspace 依赖解析为真实版本号 |
|
||||
| 构建 | 基础发布构建 core/runtime/commands 依赖和 cli;`--knowledge` 额外构建 `knowledge-studio-cli` |
|
||||
| 生成资产 | 重建各 `skills/<skill>/reference/`;非 channel 模式还同步各 `skills/*/SKILL.md` version(含 `bailian-protocol`) |
|
||||
| pnpm pack | 打 tarball |
|
||||
| publint | 包元数据校验 |
|
||||
| gitleaks | 敏感信息扫描 |
|
||||
|
||||
本地可以 dry-run 验证:
|
||||
|
||||
@@ -59,7 +85,9 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
## CI 基础设施
|
||||
|
||||
- **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限
|
||||
- **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需)
|
||||
- **Node 版本**:24(npm 11.5+ 才支持 OIDC token 交换)
|
||||
- **Bun**:`oven-sh/setup-bun`,版本钉死在 workflow 中
|
||||
- **Actions 版本**:checkout/setup-node/pnpm-action 均为 v6(Node 24 兼容)
|
||||
- **npm 配置**:当前 release tooling 发布的包(`bailian-cli-core` / `bailian-cli-runtime` / `bailian-cli-commands` / `bailian-cli` / `knowledge-studio-cli`)的 Trusted Publisher 指向 `modelstudioai/cli` 的 `publish.yml`;新增发布包时同步 npm Trusted Publisher
|
||||
|
||||
@@ -93,15 +121,17 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章与 `cli/package.json.engines` 不一致(当前应为 `>=18.17`) | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
| channel job 缺少 `contents: write` | `gh release create` 失败 |
|
||||
| stable 未先推 tag 就建 Release | `--verify-tag` 失败 |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Skill 文案 / 路由 / 安装约定
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 改 `skills/*/SKILL.md` 的 description、路由表、consent、安全闸、hand-off、references 落款
|
||||
- 调整 `bailian-protocol` 与业务 skill 的关系,或业务 skill 之间的软 hand-off 约定
|
||||
- 新增 / 拆分 / 合并 `bailian-*` 业务 skill,或改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL` 归属(与命令增删改交叉时两边都看)
|
||||
- 给业务 skill 补安装说明、README,或统一「勿猜 flag → `reference/`」类约定
|
||||
|
||||
纯改生成物 `skills/*/reference/*.md`(由命令 metadata 驱动)→ 走 [command-add-remove.md](command-add-remove.md) / [command-flag-change.md](command-flag-change.md),**不要手改 reference**。
|
||||
|
||||
## 统一口径(安装)
|
||||
|
||||
1. **Supported install:** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`)
|
||||
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
|
||||
3. **不要**在 frontmatter 写 `companions`,也不要对外说「companions = 安装器硬依赖」
|
||||
4. 子集安装:`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
|
||||
5. **`bl skill add --all`:** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update` 用 `skill init`,不要用 `--all`
|
||||
|
||||
## 概念图
|
||||
|
||||
```text
|
||||
bailian-protocol ← 共享协议(consent / 鉴权 / 版本 / 错误上报)
|
||||
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
|
||||
│
|
||||
┌───────┴────────┬────────────────┬──────────────────┐
|
||||
bailian-gen bailian-finetune bailian-managed-agent
|
||||
(领域路由表) (领域工作流) (IaC 安全闸)
|
||||
│ │ │
|
||||
└────────────────┼──────────────────┘
|
||||
▼ 软 hand-off(按 skill 名)
|
||||
bailian-cli(hub)
|
||||
hub 路由表:本职命令 + 领域 hand-off 行
|
||||
细节 → 各 skill reference/(生成)
|
||||
```
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. 分层边界
|
||||
|
||||
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
|
||||
- [ ] **协议读取**:CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
|
||||
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read,未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
|
||||
- [ ] **Hub vs 领域**:`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
|
||||
- [ ] **渐进披露**:SKILL 写意图路由与领域硬规则;flags / usage / examples 以 `reference/` 或 `bl <command> --help` 为准,表后保留「勿猜 flag」指向句
|
||||
|
||||
### B. 文案与落款一致性
|
||||
|
||||
- [ ] 领域 skill(gen / finetune / managed-agent)路由或命令表后有指向 `reference/` 的句;文末 `## references`(protocol + reference)与家族对齐
|
||||
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
|
||||
- [ ] Quick examples 只演示本 skill 职责(hub 不示范 `bl image` / `bl video` 等)
|
||||
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `bl skill init` / `bl skill add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
|
||||
|
||||
### C. 归属与生成
|
||||
|
||||
- [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts` 的 `GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表;hub 最多加一行 hand-off
|
||||
- [ ] 跑 `pnpm run sync:skill-assets`(或 commit 走 pre-commit),提交生成的 `reference/` 与 version 同步结果
|
||||
- [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets
|
||||
# 已发布版本试装
|
||||
bl skill init
|
||||
```
|
||||
|
||||
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
|
||||
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
|
||||
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
|
||||
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
|
||||
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
|
||||
- ✗ 改默认模型只动 flag description / reference,忘改领域 SKILL「When to use which command」表(见 [model-add-remove.md](model-add-remove.md))
|
||||
@@ -0,0 +1,165 @@
|
||||
# 埋点变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 调整 AEM 命令事件、事件字段或参数 allowlist
|
||||
- 调整 `User-Agent`、`x-dashscope-source-config` 或其他后端渠道标识
|
||||
- 新增鉴权域、请求网关或绕开统一 Client 的网络出口
|
||||
- 排查命令量、成功率、版本、鉴权域或后端渠道数据不一致
|
||||
|
||||
## 当前数据流
|
||||
|
||||
三套鉴权对应三套请求域,但不代表三套网关使用相同的后端埋点。命令侧另有一套覆盖所有实际执行命令的 AEM 客户端事件,两者必须分开理解。
|
||||
|
||||
```text
|
||||
命令进入 run
|
||||
├─ telemetryStage
|
||||
│ ├─ ~/.bailian/telemetry.jsonl
|
||||
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
|
||||
│
|
||||
└─ authStage
|
||||
├─ apiKey → DashScope / 模型域
|
||||
├─ console → Bailian Console Gateway
|
||||
├─ openapi → 阿里云 OpenAPI
|
||||
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
|
||||
```
|
||||
|
||||
### 1. 三套鉴权与埋点标识
|
||||
|
||||
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) |
|
||||
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` |
|
||||
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 无 | 有:`pid=bailian-cli-node`、`authMethod=console` |
|
||||
| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=openapi` |
|
||||
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` |
|
||||
|
||||
`authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。
|
||||
鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录,Managed Agent 还可能访问其他 provider。
|
||||
|
||||
表中的后端埋点按该鉴权域的主要业务请求填写:
|
||||
|
||||
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
|
||||
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
|
||||
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识
|
||||
|
||||
### 2. 后端渠道参数
|
||||
|
||||
当前 `x-dashscope-source-config` 结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "bailian-cli",
|
||||
"tags": {
|
||||
"t1": "public",
|
||||
"t2": "bl 或 kscli",
|
||||
"t3": "实际 CLI 版本"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `t2` 取产品 `identity.binName`:完整 CLI 为 `bl`,Knowledge Studio CLI 为 `kscli`
|
||||
- `t3` 取产品 `identity.version`,由产品入口的 `package.json` 注入
|
||||
- `channel` 与 `t1` 是当前固定口径
|
||||
- `User-Agent` 是独立标识:`bl` 为 `bailian-cli/<version>`,`kscli` 为 `knowledge-studio-cli/<version>`
|
||||
|
||||
source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网络传输:
|
||||
|
||||
| 请求 | source-config |
|
||||
| ------------------------------------ | ------------- |
|
||||
| 模型 API、任务提交与轮询 | 有 |
|
||||
| Bailian MCP / OpenAPI | 有 |
|
||||
| DashScope 上传策略 `getPolicy` | 有 |
|
||||
| OSS 文件上传 | 无 |
|
||||
| 图片、视频、音频、转录结果下载 | 无 |
|
||||
| npm / 二进制更新检查、Skill registry | 无 |
|
||||
|
||||
当前已知例外:Pipeline runtime 自建的 `Identity.version` 为 `0.0.0-dev`,因此 Pipeline 内部模型请求的 `t3` 不代表产品包版本;现阶段不纳入本轮收敛。
|
||||
|
||||
### 3. 全命令 AEM 客户端埋点
|
||||
|
||||
`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。
|
||||
|
||||
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`:
|
||||
|
||||
- 根帮助、子命令 `--help`、`--version`
|
||||
- 未识别命令、参数解析失败、缺少必填参数
|
||||
- `defineCommand.validate` 在 dispatch 阶段拒绝的请求
|
||||
|
||||
遥测默认开启;`DO_NOT_TRACK=1` 一票否决,配置文件 `telemetry: false` 也可关闭。关闭后本地和远端均不记录。
|
||||
|
||||
单条 `TrackingEvent` 当前包含:
|
||||
|
||||
- `command`、`timestamp`、`durationMs`、`success`
|
||||
- `cliVersion`、`nodeVersion`、`os`
|
||||
- `authMethod`
|
||||
- 失败时的 `errorMessage`、`httpStatus`、`requestId`
|
||||
- 安全 allowlist 过滤后的 `params`
|
||||
|
||||
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
|
||||
|
||||
事件同时写入两处:
|
||||
|
||||
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建
|
||||
2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod`
|
||||
|
||||
底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。
|
||||
|
||||
当前 AEM 事件没有 `binName` 或 `clientName` 产品维度,并且 `bl`、`kscli` 共用 `pid=bailian-cli-node`。两边相同路径的 `config show`、`config set`、`update` 无法仅凭当前事件稳定区分产品;Knowledge 命令虽然因路径映射不同而表现为 `knowledge chat` 与 `chat`,也不应把命令路径当作长期产品标识。后端 source-config 的 `t2` 已能区分 `bl/kscli`,但这个维度尚未进入 AEM 客户端事件。
|
||||
|
||||
AEM 映射:
|
||||
|
||||
| AEM 字段 | 内容 |
|
||||
| ---------- | ----------------------------------------- |
|
||||
| event name | 命令路径 |
|
||||
| `et` | `EXP` |
|
||||
| `ext` | 除 `command`、`params` 外的结构化事件字段 |
|
||||
| `c1` | allowlist 参数 |
|
||||
| `c2` | `success` / `failure` |
|
||||
| `c3` | HTTP status |
|
||||
| `c4` | 错误文案,最多 500 字符 |
|
||||
| `c5` | request ID |
|
||||
|
||||
远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. 新增或调整命令
|
||||
|
||||
- [ ] `defineCommand({ auth })` 必须声明真实请求域;AEM 的 `authMethod` 直接读取该值
|
||||
- [ ] 新命令进入 `run` 后自动有基础事件,不得在命令内重复发送同名事件
|
||||
- [ ] 需要按产品分析 AEM 数据时,必须显式设计产品字段;不得从命令路径推断 `bl/kscli`
|
||||
- [ ] 只有可枚举、数值或布尔等低风险字段才可加入 `PARAM_ALLOWLIST`
|
||||
- [ ] 新增 console raw API flag 时只允许记录公开 API 名,不得记录请求 `data`
|
||||
|
||||
### B. 调整后端渠道参数
|
||||
|
||||
- [ ] 同时核对 `packages/core/src/client/http.ts`、`mcp.ts`、`instrumented-fetch.ts`、`client.ts` 与 `files/upload.ts`
|
||||
- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测
|
||||
- [ ] `bl` 与 `kscli` 必须分别验证 `binName`、`clientName`、`version`
|
||||
- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config
|
||||
- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单
|
||||
|
||||
### C. 调整 AEM 事件
|
||||
|
||||
- [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射
|
||||
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
|
||||
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
|
||||
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
|
||||
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
|
||||
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts'
|
||||
vp check
|
||||
vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计
|
||||
- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域
|
||||
- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config,或把它发给 OSS / npm / 第三方下载地址
|
||||
- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误
|
||||
- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware
|
||||
@@ -20,6 +20,8 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
|
||||
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
|
||||
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
@@ -49,7 +51,7 @@ grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
|
||||
|
||||
### B. 非 TS 文件(只能人工同步,无法 import)
|
||||
|
||||
- [ ] `skills/bailian-cli/reference/` 各 `<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
|
||||
- [ ] `skills/*/reference/` 各 `<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
|
||||
- [ ] `README.md` / `README.zh.md` 中所有 URL
|
||||
|
||||
### C. 渠道追踪参数
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# Chunk 管理命令手册
|
||||
|
||||
Chunk 是知识库中最小的检索单元。文档导入后自动切分为 chunk,也可以手动添加。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge chunk add`
|
||||
|
||||
直接向知识库添加 chunk。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge chunk add --index-id <id> (--content <text> | --field <k=v>) [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------------- | ------ | ---- | ------------------------------------------------------------------------------------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | string | 否² | 所属文档 ID;表格/图片知识库必填,文档型可选 |
|
||||
| `--content <text>` | string | 否¹ | Chunk 正文,最多 6000 字符(文档型);与 `--content-file` 互斥 |
|
||||
| `--content-file <path>` | string | 否¹ | 从 UTF-8 文本文件读取正文(`.md`/`.txt` 等);与 `--content` 互斥 |
|
||||
| `--title <text>` | string | 否 | Chunk 标题,最多 50 字符(文档型) |
|
||||
| `--image-url <url>` | array | 否 | Chunk 图片 URL(可重复,最多 10 个;文档型) |
|
||||
| `--field <key=value>` | array | 否¹ | 任意字段键值对(可重复),用于表格/图片知识库,键为 Excel 列名;与 content/title/image 互斥 |
|
||||
|
||||
> ¹ `--content`/`--content-file`/`--title`/`--image-url` 与 `--field` 互斥,必须提供其一。
|
||||
> ² 表格/图片知识库必须提供 `--doc-id`。文档型知识库可选。
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--field` 与 `--content`/`--content-file`/`--title`/`--image-url` 互斥
|
||||
- `--content` 与 `--content-file` 互斥
|
||||
- `--content` 最多 6000 字符
|
||||
- `--title` 最多 50 字符
|
||||
- `--image-url` 最多 10 个
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
chunk created (pipeline: idx-xxx)
|
||||
List chunks to find the new chunk id.
|
||||
```
|
||||
|
||||
quiet 模式:无输出(成功退出码 0)。
|
||||
|
||||
json 模式:返回 API 原始响应(不含 chunk ID)。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 支持文档/表格/图片知识库;音视频知识库不支持。
|
||||
- API 响应不含 chunk ID,需用 `chunk list` 查找新 chunk。
|
||||
- API 幂等但限流 10 次/秒,批量脚本需自行节流。
|
||||
- 表格/图片知识库用 `--field`,键为 Excel 列名,值为字符串。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 添加文本 chunk
|
||||
bl knowledge chunk add --index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx
|
||||
|
||||
# 添加表格行(字段方式)
|
||||
bl knowledge chunk add --index-id idx-xxx --field 列A=v1 --field 列B=v2
|
||||
|
||||
# 从文件读取内容
|
||||
bl knowledge chunk add --index-id idx-xxx --content-file ./chunk.md --doc-id doc-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge chunk list`
|
||||
|
||||
列出知识库中的 chunk,含内容和状态。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge chunk list --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------- | ------ | ---- | ------------------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | string | 否 | 只显示属于此文档的 chunk |
|
||||
| `--page-number <n>` | number | 否 | 页码(默认:1) |
|
||||
| `--page-size <n>` | number | 否 | 每页条数(默认:20,最大 100) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--page-size` 范围 1-100
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
[chunk] chunk-xxx (doc: intro.md, doc_id: file-xxx) status: COMPLETED
|
||||
chunk content preview (truncated at 200 chars)…
|
||||
total: 1
|
||||
```
|
||||
|
||||
> 如果 chunk 被排除检索,行尾会显示 `[excluded from retrieval]`。
|
||||
|
||||
quiet 模式:每行一个 `metadata._id`(chunk ID),用于管道传给 update/delete。
|
||||
|
||||
json 模式:返回 API 原始响应,`data.nodes[]` 含完整 chunk 数据。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 用 `metadata._id` 作为 chunk ID,`metadata.doc_id` 作为文档 ID,在 chunk update/delete 中使用。
|
||||
- 页大小默认 20,最大 100。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出所有 chunk
|
||||
bl knowledge chunk list --index-id idx-xxx --workspace-id ws-xxx
|
||||
|
||||
# 只看某文档的 chunk
|
||||
bl knowledge chunk list --index-id idx-xxx --doc-id file-xxx --page-size 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge chunk update`
|
||||
|
||||
更新 chunk 内容或切换其检索可见性。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge chunk update --index-id <id> --chunk-id <id> --doc-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------------- | ------ | ---- | ------------------------------------------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--chunk-id <id>` | string | 是 | Chunk ID(`metadata._id`,来自 chunk list 输出) |
|
||||
| `--doc-id <id>` | string | 是 | 所属文档 ID(`metadata.doc_id`,来自 chunk list 输出) |
|
||||
| `--content <text>` | string | 否¹ | 新内容,10-6000 字符;与 `--content-file` 互斥 |
|
||||
| `--content-file <path>` | string | 否¹ | 从 UTF-8 文本文件读取新内容 |
|
||||
| `--title <text>` | string | 否 | Chunk 标题,0-50 字符(空字符串清除标题;不传则不变) |
|
||||
| `--exclude` | switch | 否² | 将此 chunk 排除出检索 |
|
||||
| `--include` | switch | 否² | 将此 chunk 恢复检索(默认行为) |
|
||||
|
||||
> ¹ `--content` 与 `--content-file` 互斥。
|
||||
> ² `--exclude` 与 `--include` 互斥。
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--content` 与 `--content-file` 互斥
|
||||
- `--exclude` 与 `--include` 互斥
|
||||
- 至少提供一个更新项(`--content`/`--content-file`/`--title`/`--exclude`/`--include`)
|
||||
- `--content` 长度 10-6000 字符
|
||||
- `--title` 最多 50 字符
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
updated: chunk-xxx
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 内容必须 10-6000 字符,且不超过知识库的 max chunk size。
|
||||
- `--content-file` 期望 UTF-8 纯文本文件,不解析 `.docx`/`.pdf` 等文档格式。
|
||||
- 仅切换 `--exclude`/`--include` 而不提供新内容时,CLI 自动读回当前内容并重新提交(API 要求 content 字段必填,CLI 隐藏了此限制)。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 修改内容
|
||||
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text" --workspace-id ws-xxx
|
||||
|
||||
# 排除 chunk 不参与检索
|
||||
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude
|
||||
|
||||
# 恢复检索
|
||||
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --include
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge chunk delete`
|
||||
|
||||
从知识库中删除 chunk(不可逆)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge chunk delete --index-id <id> --chunk-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ------------------------------------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--chunk-id <id>` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: 2 chunk(s) in 1 batch(es)
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 `{ deleted_count, batches }`。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 服务端每次最多接受 10 个 chunk ID,CLI 自动分批。
|
||||
- 如果某批失败,操作停止,已删除的批次会在错误 hint 中列出。
|
||||
- Chunk 被永久移除,不可恢复。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除多个 chunk
|
||||
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx
|
||||
|
||||
# 跳过确认
|
||||
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,268 @@
|
||||
# 数据中心集合与分类命令手册
|
||||
|
||||
集合(collection)是数据中心的顶层容器,对应服务端的 connector。分类(category)用于组织集合内的文件,支持多级嵌套。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge collection create`
|
||||
|
||||
创建 FILE 数据集合。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge collection create --name <text> --description <text> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | ---------------------------------------------------------------- |
|
||||
| `--name <text>` | string | 是 | 集合名称(1-20 字符) |
|
||||
| `--description <text>` | string | 是 | 集合描述 |
|
||||
| `--store-type <type>` | string | 否 | 存储类型:`platform`(托管,默认)或 `custom`(自有 OSS bucket) |
|
||||
| `--oss-region <id>` | string | 否 | OSS region ID(`--store-type custom` 时必填) |
|
||||
| `--oss-bucket <name>` | string | 否 | OSS bucket 名称(`--store-type custom` 时必填) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--name` 长度 1-20 字符
|
||||
- `--store-type` 只能是 `platform` 或 `custom`
|
||||
- `--store-type custom` 时 `--oss-region` 和 `--oss-bucket` 必填
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
created: conn-xxx (my-collection, PLATFORM)
|
||||
```
|
||||
|
||||
quiet 模式:输出集合 ID。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- `platform` 使用平台托管存储;`custom` 使用已授权的 OSS bucket。
|
||||
- 自定义 bucket 必须携带标签 `bailian-connector-access=ReadAndWrite`(百炼的标签访问控制),否则服务端报 `setBucketCORS failed` 误导性错误。
|
||||
- **无集合删除 API**,创建需谨慎。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 创建平台托管的集合
|
||||
bl knowledge collection create --name my-collection --description "team docs" --workspace-id ws-xxx
|
||||
|
||||
# 创建使用自有 OSS bucket 的集合
|
||||
bl knowledge collection create --name oss-coll --description "own bucket" --store-type custom --oss-region cn-beijing --oss-bucket my-bucket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge collection get`
|
||||
|
||||
查看数据集合详情。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge collection get (--collection-id <id> | --name <text>) [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | -------- |
|
||||
| `--collection-id <id>` | string | 否¹ | 集合 ID |
|
||||
| `--name <text>` | string | 否¹ | 集合名称 |
|
||||
|
||||
> ¹ `--collection-id` 和 `--name` 二选一,必须提供其一。
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--collection-id` 和 `--name` 互斥,必须提供其一
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
id: conn-xxx
|
||||
name: my-collection
|
||||
description: team docs
|
||||
```
|
||||
|
||||
quiet 模式:输出集合 ID。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- getConnector 不返回 `fileConnectorConfig`(`storeType`/`regionId`/`bucketName`),这些字段仅在创建时通过请求体传入,查询时不可读回。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 按 ID 查询
|
||||
bl knowledge collection get --collection-id conn-xxx --workspace-id ws-xxx
|
||||
|
||||
# 按名称查询
|
||||
bl knowledge collection get --name my-collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge category list`
|
||||
|
||||
列出数据中心分类。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge category list [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | ------------------------------------------------------ |
|
||||
| `--collection-id <id>` | string | 否 | 按集合 ID 过滤 |
|
||||
| `--parent-id <id>` | string | 否 | 列出此分类的子分类 |
|
||||
| `--name <text>` | string | 否 | 按分类名称过滤(精确匹配,与知识库列表的模糊匹配不同) |
|
||||
| `--next-token <token>` | string | 否 | 游标分页令牌 |
|
||||
| `--max-result <n>` | number | 否 | 每页条数(默认:20) |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
cate-xxx product-docs
|
||||
cate-yyy system-docs [default]
|
||||
next: --next-token eyJ...
|
||||
```
|
||||
|
||||
> 标记 `[default]` 的是文件未指定分类时的默认归属。
|
||||
|
||||
quiet 模式:每行一个 `categoryId`。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 分页是游标方式:使用输出的 `next: --next-token <token>` 继续翻页。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出所有分类
|
||||
bl knowledge category list --workspace-id ws-xxx
|
||||
|
||||
# 按名称过滤
|
||||
bl knowledge category list --name my-category
|
||||
|
||||
# 翻页
|
||||
bl knowledge category list --next-token eyJ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge category add`
|
||||
|
||||
创建数据中心分类。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge category add --name <text> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | -------------------------------- |
|
||||
| `--name <text>` | string | 是 | 分类名称(1-20 字符) |
|
||||
| `--parent-id <id>` | string | 否 | 创建为指定分类的子分类 |
|
||||
| `--collection-id <id>` | string | 否 | 创建在此集合下(默认:平台集合) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--name` 长度 1-20 字符
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
created: cate-xxx (product-docs)
|
||||
```
|
||||
|
||||
quiet 模式:输出分类 ID。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 用分类按业务域组织数据中心文件。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 创建分类
|
||||
bl knowledge category add --name product-docs --workspace-id ws-xxx
|
||||
|
||||
# 创建子分类
|
||||
bl knowledge category add --name sub --parent-id cate-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge category delete`
|
||||
|
||||
删除数据中心分类。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge category delete --category-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------------------- | ------ | ---- | ------------ |
|
||||
| `--category-id <id>` | string | 是 | 分类 ID |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: cate-xxx
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 含文件或子分类的分类的删除行为由服务端定义——服务端错误原样透传。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除分类(交互确认)
|
||||
bl knowledge category delete --category-id cate-xxx --workspace-id ws-xxx
|
||||
|
||||
# 跳过确认
|
||||
bl knowledge category delete --category-id cate-xxx --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,344 @@
|
||||
# 文档管理命令手册
|
||||
|
||||
文档管理覆盖文件上传、OSS 导入、解析状态跟踪、文档删除和标签管理。文档导入知识库后自动解析为 chunk。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc list`
|
||||
|
||||
列出知识库中的文档及其解析/索引状态。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc list --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------- | ------ | ---- | ------------------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--page-number <n>` | number | 否 | 页码(默认:1) |
|
||||
| `--page-size <n>` | number | 否 | 每页条数(默认:10,最大 100) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--page-size` 范围 1-100
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:每行一个文档,`FAILED` 状态的文档红色高亮。
|
||||
|
||||
```
|
||||
doc-xxx COMPLETED intro.md md 1024
|
||||
total: 1
|
||||
```
|
||||
|
||||
quiet 模式:每行一个 `doc_id`。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- `doc_id` 与 `file_id` 的关系:通过 `knowledge create --doc-id` 导入的文档,`doc_id` 等于 `fileId`;通过 `knowledge doc upload --index-id` 导入的,`doc_id` 可能包含 workspace 后缀。
|
||||
- 页大小默认 10(服务端默认),最大 100。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出文档
|
||||
bl knowledge doc list --index-id idx-xxx --workspace-id ws-xxx
|
||||
|
||||
# 每页 100 条
|
||||
bl knowledge doc list --index-id idx-xxx --page-size 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc status`
|
||||
|
||||
查看知识库导入任务状态。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc status --index-id <id> --job-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | --------------------------------------------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--job-id <id>` | string | 是 | 导入任务 ID(`ingestionId`,由 create/upload 返回) |
|
||||
| `--page-number <n>` | number | 否 | 页码 |
|
||||
| `--page-size <n>` | number | 否 | 每页条数 |
|
||||
| `--wait` | switch | 否 | 轮询直到任务到达终态 |
|
||||
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
status: COMPLETED
|
||||
doc-xxx COMPLETED intro.md
|
||||
```
|
||||
|
||||
quiet 模式:输出任务状态(`PENDING`/`RUNNING`/`COMPLETED`)。
|
||||
|
||||
json 模式:返回 API 原始响应,`data.rows[]` 包含每个文档的状态。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- `--index-id` 和 `--job-id` 服务端均要求必传,只传一个会返回 `SystemError`。
|
||||
- 整体任务状态为 `PENDING` / `RUNNING` / `COMPLETED`(无 `FAILED` 值)。
|
||||
- 单个文档可能解析失败(如 `PARSE_FAILED`),此时 CLI 以非零退出码报错,服务端消息原样透传。
|
||||
- 如果服务端对空闲知识库返回 `SystemError`,说明该 job 可能不存在。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 查看任务状态
|
||||
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx
|
||||
|
||||
# 轮询等待完成,10 秒间隔
|
||||
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --wait --poll-interval 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc upload`
|
||||
|
||||
上传本地文件或目录到数据中心,可选导入到知识库。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc upload --file <path> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | ---------------------------------------------------------------- |
|
||||
| `--file <path>` | array | 是 | 本地文件或目录路径(可重复)。目录递归扫描,不支持的格式自动跳过 |
|
||||
| `--index-id <id>` | string | 否 | 上传后导入到此知识库(所有文件合并为一个导入任务) |
|
||||
| `--category-id <id>` | string | 否 | 目标数据中心分类(默认:工作区默认分类) |
|
||||
| `--tag <text>` | array | 否 | 文件标签(可重复),应用到每个上传的文件 |
|
||||
| `--wait` | switch | 否 | 轮询导入任务直到终态(需要 `--index-id`) |
|
||||
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--wait` 要求同时指定 `--index-id`
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
intro.md file-xxx registered
|
||||
job: job-xxx
|
||||
status: COMPLETED
|
||||
|
||||
Uploaded 1 file.
|
||||
```
|
||||
|
||||
quiet 模式:每行一个 `fileId`。
|
||||
|
||||
json 模式:返回自定义结构,包含 `files`(路径和 fileId)、`skipped`、`index_id`、`ingestion_id`、`final_status`。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 上传管道:申请 lease → PUT 到 OSS → 注册文件 →(可选)创建导入任务。
|
||||
- 目录递归扫描,`node_modules`、`.git` 等自动跳过。
|
||||
- 多文件按顺序处理(无并发),避免 OSS 限流。
|
||||
- 支持的文件格式:`.pdf .doc .docx .ppt .pptx .xls .xlsx .csv .md .txt .html .png .jpg .jpeg .bmp .gif`
|
||||
- 部分文件上传失败时,已注册的 fileId 会在错误 hint 中列出。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 上传单个文件
|
||||
bl knowledge doc upload --file ./a.md --workspace-id ws-xxx
|
||||
|
||||
# 上传多个文件并导入到知识库,等待完成
|
||||
bl knowledge doc upload --file ./a.md --file ./b.pdf --index-id idx-xxx --wait
|
||||
|
||||
# 上传整个目录
|
||||
bl knowledge doc upload --file ./docs/ --workspace-id ws-xxx
|
||||
|
||||
# 干跑预览(查看将上传和跳过的文件)
|
||||
bl knowledge doc upload --file ./docs/ --dry-run --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc delete`
|
||||
|
||||
从知识库中删除文档及其 chunk。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ----------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: 2 document(s)
|
||||
doc-a
|
||||
doc-b
|
||||
```
|
||||
|
||||
quiet 模式:每行一个已删除的 `doc_id`。
|
||||
|
||||
json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID 列表。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 只从知识库索引中移除文档,数据中心源文件不受影响(用 `file delete` 删除源文件)。
|
||||
- `doc_id` 应从 `knowledge doc list --quiet` 获取,而非 `doc upload` 返回的 `fileId`。
|
||||
- 删除是异步的:服务端立即返回 Success,但 `doc list` 中可能仍显示该文档(约 30 秒后传播完成)。
|
||||
- 输出的是服务端实际删除的 ID 列表,可能与请求的数量不一致(会在 stderr 警告)。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除单个文档
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
|
||||
|
||||
# 批量删除,跳过确认
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc tag`
|
||||
|
||||
批量更新数据中心文件的标签。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc tag --doc-id <id> --tag <text> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------- | ------ | ---- | ------------------------------------------------------ |
|
||||
| `--doc-id <id>` | array | 是 | 数据中心文件 ID(可重复,最多 20 个/次) |
|
||||
| `--tag <text>` | array | 是 | 标签(可重复),应用到每个 `--doc-id` |
|
||||
| `--mode <mode>` | string | 否 | 更新模式:`append`(默认,追加)或 `overwrite`(覆盖) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--doc-id` 最多 20 个/次
|
||||
- `--tag` 最多 100 个
|
||||
- 每个标签最多 32 字符
|
||||
- 标签总长度最多 700 字符
|
||||
- `--mode` 只能是 `append` 或 `overwrite`
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
tagged: 2 file(s) with [project-a, draft]
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 同一组标签应用到所有 `--doc-id`;不同标签集需多次执行。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 追加标签
|
||||
bl knowledge doc tag --doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx
|
||||
|
||||
# 覆盖标签
|
||||
bl knowledge doc tag --doc-id file-a --doc-id file-b --tag final --mode overwrite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge doc import-oss`
|
||||
|
||||
从已授权的 OSS bucket 批量导入文件到数据中心。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge doc import-oss --bucket <name> --region <id> --oss-key <key> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------------------- | ------ | ---- | ------------------------------------- |
|
||||
| `--bucket <name>` | string | 是 | 已授权的 OSS bucket 名称 |
|
||||
| `--region <id>` | string | 是 | OSS region ID(如 `cn-beijing`) |
|
||||
| `--oss-key <key>` | array | 是 | OSS 对象 key(可重复,最多 10 个/次) |
|
||||
| `--category-id <id>` | string | 否 | 目标数据中心分类(默认:默认分类) |
|
||||
| `--tag <text>` | array | 否 | 文件标签(可重复,最多 10 个) |
|
||||
| `--overwrite` | switch | 否 | 覆盖之前从相同 OSS key 导入的文件 |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--oss-key` 最多 10 个/次
|
||||
- `--tag` 最多 10 个
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
imported: 2 file(s)
|
||||
file-a SUCCESS docs/a.pdf
|
||||
file-b SUCCESS docs/b.docx
|
||||
```
|
||||
|
||||
quiet 模式:每行一个 `fileId`。
|
||||
|
||||
json 模式:返回 API 原始响应,`data.addFileResultList[]` 包含每个文件的 fileId、status 和 ossKey。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- bucket 必须事先授权给平台服务角色(RAM 中的 `AliyunServiceRoleForBailian`)。
|
||||
- 文件名取自 OSS key 的 basename。
|
||||
- `--overwrite` 会替换之前导入的文件并生成**新的 fileId**(旧 fileId 失效)。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 导入单个文件
|
||||
bl knowledge doc import-oss --bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx
|
||||
|
||||
# 导入多个文件并覆盖
|
||||
bl knowledge doc import-oss --bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,157 @@
|
||||
# 数据中心文件管理命令手册
|
||||
|
||||
数据中心是知识库文件的存储层。文件通过 `doc upload` 或 `doc import-oss` 进入数据中心,再导入到知识库。数据中心文件可被多个知识库引用。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge file list`
|
||||
|
||||
列出数据中心分类下的文件。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge file list --category-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | -------------------------------------------------- |
|
||||
| `--category-id <id>` | string | 是 | 分类 ID(通过 `category list` 或 `file get` 获取) |
|
||||
| `--name <text>` | string | 否 | 按文件名过滤 |
|
||||
| `--file-id <id>` | array | 否 | 按文件 ID 过滤(可重复) |
|
||||
| `--next-token <token>` | string | 否 | 游标分页令牌(从上次输出获取) |
|
||||
| `--max-result <n>` | number | 否 | 每页条数 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
file-xxx SUCCESS intro.md 1024
|
||||
next: --next-token eyJ...
|
||||
```
|
||||
|
||||
quiet 模式:每行一个 `fileId`。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- `--category-id` 必须是真实的分类 ID。与上传 API 不同,字面量 `default` 在此不被解析,传入会返回空列表。通过 `file get` 的 category 字段或 `category list` 获取真实 ID。
|
||||
- 分页是游标方式:使用输出的 `next: --next-token <token>` 继续翻页。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出分类下文件
|
||||
bl knowledge file list --category-id cate-xxx --workspace-id ws-xxx
|
||||
|
||||
# 按名称过滤
|
||||
bl knowledge file list --category-id cate-xxx --name report
|
||||
|
||||
# 翻页
|
||||
bl knowledge file list --category-id cate-xxx --next-token eyJ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge file get`
|
||||
|
||||
查看数据中心文件详情。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge file get --file-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------- | ------ | ---- | --------------- |
|
||||
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
id: file-xxx
|
||||
name: intro.md
|
||||
type: md
|
||||
size: 1024
|
||||
status: SUCCESS
|
||||
parser: AUTO_SELECT
|
||||
category: cate-xxx
|
||||
uploaded: 2026-01-01T00:00:00Z
|
||||
tags: project-a, draft
|
||||
```
|
||||
|
||||
quiet 模式:输出 JSON 格式。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 无特殊注意事项。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 查看文件详情
|
||||
bl knowledge file get --file-id file-xxx --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge file delete`
|
||||
|
||||
从数据中心永久删除文件。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge file delete --file-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------- | ------ | ---- | --------------- |
|
||||
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: file-xxx
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- **不可逆操作**:如果知识库引用了此文件,相关文档索引会失效。
|
||||
- 与 `doc delete` 的区别:`doc delete` 只从单个知识库索引中移除文档,数据中心源文件保留;`file delete` 删除源文件本身,影响所有引用它的知识库。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除文件(交互确认)
|
||||
bl knowledge file delete --file-id file-xxx --workspace-id ws-xxx
|
||||
|
||||
# 跳过确认
|
||||
bl knowledge file delete --file-id file-xxx --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,340 @@
|
||||
# 知识库管理命令手册
|
||||
|
||||
知识库(Knowledge Base / pipeline / index)是 RAG 的核心载体,存储文档解析后的向量索引。本组命令覆盖知识库的创建、查看、更新、删除和监控。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge list`
|
||||
|
||||
列出工作区中的知识库。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge list [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------- | ------ | ---- | --------------------------------- |
|
||||
| `--name <text>` | string | 否 | 按知识库名称模糊过滤(1-20 字符) |
|
||||
| `--page-number <n>` | number | 否 | 页码(默认:1) |
|
||||
| `--page-size <n>` | number | 否 | 每页条数(默认:20,最大 100) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--name` 长度 1-20 字符
|
||||
- `--page-size` 范围 1-100
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:每行一个知识库,字段以双空格分隔,末尾显示总数。
|
||||
|
||||
```
|
||||
idx-xxx my-kb text-embedding-v4 600 product docs
|
||||
total: 1
|
||||
```
|
||||
|
||||
quiet 模式:每行一个知识库 ID。
|
||||
|
||||
json 模式:返回 API 原始响应,`data.rows[]` 包含完整知识库信息。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 返回的 `id` 字段作为后续命令的 `--index-id` 使用。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出所有知识库
|
||||
bl knowledge list --workspace-id ws-xxx
|
||||
|
||||
# 按名称过滤,第二页
|
||||
bl knowledge list --name demo --page-number 2 --page-size 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge info`
|
||||
|
||||
查看知识库配置详情。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge info --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | --------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:按诊断维度分组展示。
|
||||
|
||||
```
|
||||
Basic:
|
||||
id: idx-xxx
|
||||
name: my-kb
|
||||
description: product docs
|
||||
dataType: ...
|
||||
Indexing: [immutable — recreate required to change]
|
||||
embeddingModelName: text-embedding-v4
|
||||
embeddingDimension: 1024
|
||||
chunkSize: 600
|
||||
overlapSize: ...
|
||||
chunkMode: ...
|
||||
separator: ...
|
||||
Retrieval:
|
||||
rerankModelName: ...
|
||||
rerankMinScore: ...
|
||||
rerankTopN: ...
|
||||
rerankMode: ...
|
||||
enableRewrite: ...
|
||||
denseSimilarityTopK: ...
|
||||
sparseSimilarityTopK: ...
|
||||
Data:
|
||||
sourceType: ...
|
||||
connectorId: ...
|
||||
```
|
||||
|
||||
quiet 模式:输出知识库 ID。
|
||||
|
||||
json 模式:返回知识库完整配置 JSON。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 索引设置(向量模型、切片大小等)不可变,修改需重建知识库。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 查看知识库详情
|
||||
bl knowledge info --index-id idx-xxx --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge create`
|
||||
|
||||
创建知识库并导入数据中心文件或分类。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge create --name <text> (--doc-id <id> | --category-id <id>) [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | -------------------------------------------------------- |
|
||||
| `--name <text>` | string | 是 | 知识库名称(1-20 字符,工作区内唯一) |
|
||||
| `--doc-id <id>` | array | 否¹ | 数据中心文件 ID(可重复);与 `--category-id` 互斥 |
|
||||
| `--category-id <id>` | array | 否¹ | 按分类导入该分类下所有文件(可重复);与 `--doc-id` 互斥 |
|
||||
| `--embedding-model <name>` | string | 否 | 向量模型名称(默认:`text-embedding-v4`) |
|
||||
| `--chunk-size <n>` | number | 否 | 切片大小,字符数(默认:600,建议 300-800) |
|
||||
| `--wait` | switch | 否 | 轮询初始导入任务直到终态 |
|
||||
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
|
||||
|
||||
> ¹ `--doc-id` 和 `--category-id` 二选一,必须提供其一。
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--name` 长度 1-20 字符
|
||||
- `--doc-id` 和 `--category-id` 互斥,必须提供其一
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
index_id: idx-xxx
|
||||
ingestion_id: job-xxx
|
||||
status: COMPLETED
|
||||
Next: check the import job status, then search against this knowledge base.
|
||||
```
|
||||
|
||||
quiet 模式:只输出知识库 ID。
|
||||
|
||||
json 模式:返回 API 原始响应,包含 `pipelineId`(知识库 ID)和 `ingestionId`(导入任务 ID)。`--wait` 时追加 `final_status` 字段。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 结构/存储类型固定为默认文档知识库(非结构化,BUILT_IN 存储)。
|
||||
- 返回知识库 ID(`pipelineId`)和初始导入任务 ID(`ingestionId`)。
|
||||
- 使用 `doc status` 或 `--wait` 跟踪导入进度。
|
||||
- 如果 `--wait` 后部分文档解析失败,CLI 以非零退出码报错,知识库已创建成功的事实会在 hint 中提示。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 从指定文件创建知识库
|
||||
bl knowledge create --name demo --doc-id file-xxx --workspace-id ws-xxx
|
||||
|
||||
# 从分类导入并等待导入完成
|
||||
bl knowledge create --name demo --category-id cate-xxx --wait
|
||||
|
||||
# 指定向量模型和切片大小
|
||||
bl knowledge create --name my-kb --doc-id file-a --doc-id file-b --embedding-model text-embedding-v4 --chunk-size 400 --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge update`
|
||||
|
||||
更新知识库名称、描述或 rerank 阈值。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge update --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------------- | ------ | ---- | -------------------------------------------------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--name <text>` | string | 否 | 新名称(1-20 字符) |
|
||||
| `--description <text>` | string | 否 | 新描述 |
|
||||
| `--rerank-min-score <score>` | number | 否 | rerank 最低分数阈值,范围 0-1(低于此分的 chunk 被过滤) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- 至少提供 `--name`、`--description`、`--rerank-min-score` 之一,否则报错 "Nothing to update"
|
||||
- `--name` 长度 1-20 字符
|
||||
- `--rerank-min-score` 范围 0-1
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
updated: idx-xxx
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 索引设置(向量模型、切片大小等)不可变,修改需重建知识库。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 更新描述
|
||||
bl knowledge update --index-id idx-xxx --description "product docs v2" --workspace-id ws-xxx
|
||||
|
||||
# 调整 rerank 阈值
|
||||
bl knowledge update --index-id idx-xxx --rerank-min-score 0.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge delete`
|
||||
|
||||
删除知识库及其所有文档和 chunk。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge delete --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: idx-xxx
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- **不可逆操作**:知识库及所有索引内容被永久删除。
|
||||
- 数据中心中的源文件不受影响,仅删除知识库索引。
|
||||
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除(交互确认)
|
||||
bl knowledge delete --index-id idx-xxx --workspace-id ws-xxx
|
||||
|
||||
# 跳过确认
|
||||
bl knowledge delete --index-id idx-xxx --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge stats`
|
||||
|
||||
查看知识库存储和 QPS 监控数据。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge stats --index-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ----------------------------------------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--start <time>` | string | 否 | 范围起始:Unix 秒或 ISO 日期(默认:24 小时前) |
|
||||
| `--end <time>` | string | 否 | 范围结束:Unix 秒或 ISO 日期(默认:当前时间) |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
plan: ...
|
||||
storage: 100 / 1000
|
||||
peak qps: 5
|
||||
qps windows: 24 data point(s)
|
||||
```
|
||||
|
||||
quiet 模式:输出 json 格式。
|
||||
|
||||
json 模式:返回 API 原始响应,包含 `storageMonitorData` 和 `qpsMonitorData`。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 默认查询最近 24 小时数据。
|
||||
- 时间戳自动转换为 epoch 秒(API 要求秒级字符串)。13 位毫秒时间戳会自动降为秒。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 查看最近 24 小时监控
|
||||
bl knowledge stats --index-id idx-xxx --workspace-id ws-xxx
|
||||
|
||||
# 指定日期范围
|
||||
bl knowledge stats --index-id idx-xxx --start 2026-07-30 --end 2026-07-31
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,822 @@
|
||||
# `bl knowledge` 命令完整用法指南
|
||||
|
||||
> `bl knowledge` / `kscli` 知识库 CLI 命令总览,覆盖全部 34 个子命令。完整参数与示例请参阅各子域手册。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [核心概念与实体关系](#核心概念与实体关系)
|
||||
3. [通用约定](#通用约定)
|
||||
4. [典型工作流](#典型工作流)
|
||||
5. [命令手册](#命令手册)
|
||||
- [知识库管理](#知识库管理) → [完整手册](knowledge/kb.md)
|
||||
- [文档管理](#文档管理) → [完整手册](knowledge/doc.md)
|
||||
- [检索服务管理](#检索服务管理) → [完整手册](knowledge/service.md)
|
||||
- [Chunk 管理](#chunk-管理) → [完整手册](knowledge/chunk.md)
|
||||
- [数据中心文件管理](#数据中心文件管理) → [完整手册](knowledge/file.md)
|
||||
- [数据中心集合与分类](#数据中心集合与分类) → [完整手册](knowledge/collection-category.md)
|
||||
- [检索与对话](#检索与对话) → [完整手册](knowledge/search-chat.md)
|
||||
6. [常见错误与排查](#常见错误与排查)
|
||||
7. [附录:命令速查表](#附录命令速查表)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
`bl knowledge` 是阿里云百炼 CLI 的知识库命令组,覆盖 RAG(检索增强生成)全链路能力:
|
||||
|
||||
- **知识库全生命周期管理**:创建、查看、更新、删除、监控
|
||||
- **文档管理**:上传本地文件、从 OSS 批量导入、查看解析状态、删除、打标签
|
||||
- **Chunk 级运维**:直接增删改查知识库中的内容切片
|
||||
- **检索服务管理**:创建/部署/复制/删除 Q&A 和检索服务(agent),管理 draft 与发布版本
|
||||
- **数据中心管理**:文件、集合(connector)、分类的增删查
|
||||
- **检索与对话**:语义检索(search)、多轮对话(chat)、兼容旧检索(retrieve)
|
||||
|
||||
共 34 个子命令,按功能域分为 7 组。所有命令均使用 DashScope API Key 鉴权。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念与实体关系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 数据中心 (Data Center) │
|
||||
│ │
|
||||
│ 集合 (Collection) ──┬── 分类 (Category) ── 文件 (File) │
|
||||
│ │ "connector" 可多级嵌套 │
|
||||
│ └── 默认分类 │
|
||||
│ │
|
||||
│ 文件来源:doc upload(本地上传) / doc import-oss(OSS导入) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ 导入 (import job)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 知识库 (Knowledge Base) │
|
||||
│ │
|
||||
│ 知识库 (KB / pipeline / index) │
|
||||
│ ├── 文档 (Doc) ── 解析状态: PENDING/RUNNING/COMPLETED │
|
||||
│ │ └── Chunk ── 内容切片,可增删改查、排除/恢复检索 │
|
||||
│ └── 索引设置 (immutable): 向量模型、切片大小等 │
|
||||
│ │
|
||||
│ 知识库管理命令: create / list / info / update / delete / stats │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ 绑定 (agent_config.kb_search_configs)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 检索服务 (Service / Agent) │
|
||||
│ │
|
||||
│ Service (agent) │
|
||||
│ ├── scene: chat (Q&A) 或 search (检索) │
|
||||
│ ├── 版本: beta (草稿) → 1, 2, 3... (已发布) │
|
||||
│ ├── 状态: draft → deployed → edited → deleted │
|
||||
│ └── 配置: 模型、温度、策略、rerank 等 │
|
||||
│ │
|
||||
│ 消费方式: search (语义检索) / chat (多轮对话) │
|
||||
│ 管理命令: create / update / deploy / copy / delete / list / get │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键关系**:
|
||||
|
||||
- **数据中心文件 → 知识库**:通过 `knowledge create --doc-id` 或 `knowledge doc upload --index-id` 导入,文件解析后自动生成 chunk
|
||||
- **知识库 → 检索服务**:一个服务可绑定多个知识库,服务配置中 `kb_search_configs` 指定关联的知识库 ID
|
||||
- **检索服务 → 检索/对话**:`search` 和 `chat` 命令通过 `--agent-id` 指定服务来执行检索或对话
|
||||
|
||||
---
|
||||
|
||||
## 通用约定
|
||||
|
||||
### 鉴权
|
||||
|
||||
所有 `bl knowledge` 命令均使用 **DashScope API Key**(Bearer token)鉴权。获取方式:百炼控制台 API Key 页面。
|
||||
|
||||
优先级(高 → 低):
|
||||
|
||||
1. `--api-key <key>` 命令行参数
|
||||
2. `DASHSCOPE_API_KEY` 环境变量
|
||||
3. 配置文件中的 `api_key`(`bl config set api_key <key>`)
|
||||
|
||||
### Workspace ID
|
||||
|
||||
知识库 API 使用 workspace 级域名(`{workspaceId}.cn-beijing.maas.aliyuncs.com`),因此 **几乎所有 knowledge 命令都需要 workspace ID**。
|
||||
|
||||
优先级(高 → 低):
|
||||
|
||||
1. `--workspace-id <id>` 命令行参数
|
||||
2. `BAILIAN_WORKSPACE_ID` 环境变量
|
||||
3. 配置文件中的 `workspace_id`(`bl config set workspace_id <id>`)
|
||||
|
||||
缺失时报错:`Workspace ID is required.`
|
||||
|
||||
### 全局通用参数
|
||||
|
||||
以下参数在所有 `bl knowledge` 子命令中通用,后续命令手册中不再逐条列出:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --------------------- | ------ | ----------------------------------------------------------- |
|
||||
| `--output <format>` | string | 输出格式:`text`(默认,人类友好)或 `json`(API 原始响应) |
|
||||
| `--api-key <key>` | string | DashScope API Key |
|
||||
| `--base-url <url>` | string | API 基地址(一般不需要指定) |
|
||||
| `--timeout <seconds>` | number | 请求超时秒数 |
|
||||
| `--quiet` | switch | 静默模式,只输出关键结果(如 ID 列表) |
|
||||
| `--verbose` | switch | 详细模式,打印 HTTP 请求/响应详情到 stderr |
|
||||
| `--dry-run` | switch | 干跑模式,预览将发送的请求结构,不实际调用 API |
|
||||
| `--config <name>` | string | 使用指定配置 profile 执行命令 |
|
||||
|
||||
> **注意**:命令手册中每个命令的参数表只列出该命令**特有**的参数。上述全局参数对所有命令有效。
|
||||
|
||||
### 输出格式约定
|
||||
|
||||
- **text 模式**(默认):人类友好的表格/结构化文本,适合终端查看。不同命令的输出格式见各命令的「输出」部分。
|
||||
- **json 模式**(`--output json`):返回 API 原始 JSON 响应,适合程序化处理和 agent 解析。
|
||||
- **quiet 模式**(`--quiet`):只输出最精简的结果(通常只有 ID),适合管道串联。
|
||||
|
||||
### 危险操作确认
|
||||
|
||||
涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`、`service deploy`)在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本。
|
||||
|
||||
### Dry-run 模式
|
||||
|
||||
`--dry-run` 模式下,命令会输出将发送的 endpoint 和 request body,但**不实际发起网络请求**。部分命令在 dry-run 下仍会执行本地校验(如文件扩展名检查、参数约束检查)。
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景 A:从零搭建知识库并检索
|
||||
|
||||
```bash
|
||||
# 1. 上传本地文件到数据中心,同时导入到新知识库
|
||||
bl knowledge doc upload --file ./docs/intro.md --workspace-id ws-xxx
|
||||
# → 返回 file-id
|
||||
|
||||
# 2. 用文件创建知识库
|
||||
bl knowledge create --name my-kb --doc-id file-xxx --workspace-id ws-xxx --wait
|
||||
# → 返回 index-id (pipelineId) 和导入任务状态
|
||||
|
||||
# 3. 创建检索服务(search 场景)
|
||||
bl knowledge service create --name my-search --scene search --index-id idx-xxx --workspace-id ws-xxx
|
||||
# → 返回 agent-id
|
||||
|
||||
# 4. 部署服务
|
||||
bl knowledge service deploy --agent-id aid-xxx --workspace-id ws-xxx --yes
|
||||
|
||||
# 5. 执行检索
|
||||
bl knowledge search --query "什么是RAG" --agent-id aid-xxx --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
### 场景 B:上传目录并导入到已有知识库
|
||||
|
||||
```bash
|
||||
# 1. 上传整个目录到数据中心并直接导入到知识库(一步到位)
|
||||
bl knowledge doc upload --file ./docs/ --index-id idx-xxx --workspace-id ws-xxx --wait
|
||||
# → 文件逐个上传到 OSS → 注册到数据中心 → 创建合并导入任务 → 轮询到完成
|
||||
|
||||
# 2. 检查文档状态
|
||||
bl knowledge doc list --index-id idx-xxx --workspace-id ws-xxx
|
||||
# → 查看 doc_id 和解析状态
|
||||
|
||||
# 3. 如果有文档解析失败,查看导入任务详情
|
||||
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
### 场景 C:创建并部署 Q&A 服务
|
||||
|
||||
```bash
|
||||
# 1. 创建 chat 场景的检索服务
|
||||
bl knowledge service create --name my-qa --scene chat --index-id idx-xxx --workspace-id ws-xxx
|
||||
# → 初始状态: draft, 版本: beta
|
||||
|
||||
# 2. 调整配置(如修改模型、温度)
|
||||
bl knowledge service update --agent-id aid-xxx --model qwen-max --temperature 0.7 --workspace-id ws-xxx
|
||||
|
||||
# 3. 用 beta 版本测试
|
||||
bl knowledge chat --message "什么是RAG?" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
|
||||
|
||||
# 4. 测试通过后发布
|
||||
bl knowledge service deploy --agent-id aid-xxx --version-desc "首版" --workspace-id ws-xxx --yes
|
||||
```
|
||||
|
||||
### 场景 D:知识库内容运维
|
||||
|
||||
```bash
|
||||
# 1. 查看 chunk 列表
|
||||
bl knowledge chunk list --index-id idx-xxx --workspace-id ws-xxx
|
||||
# → 返回 metadata._id (chunk id) 和 metadata.doc_id (document id)
|
||||
|
||||
# 2. 修改 chunk 内容
|
||||
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id doc-xxx --content "修正后的内容" --workspace-id ws-xxx
|
||||
|
||||
# 3. 排除某个 chunk 不参与检索(不删除内容)
|
||||
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id doc-xxx --exclude --workspace-id ws-xxx
|
||||
|
||||
# 4. 手动添加新 chunk
|
||||
bl knowledge chunk add --index-id idx-xxx --content "新增的知识片段" --title "补充说明" --workspace-id ws-xxx
|
||||
|
||||
# 5. 删除 chunk(批量,自动分批每 10 个一组)
|
||||
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --yes --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
### 场景 E:服务迁移/复用
|
||||
|
||||
```bash
|
||||
# 1. 复制现有服务为新草稿
|
||||
bl knowledge service copy --agent-id aid-source --workspace-id ws-xxx
|
||||
# → 返回新的 agent-id,名称加 copy_ 前缀
|
||||
|
||||
# 2. 修改新服务配置
|
||||
bl knowledge service update --agent-id aid-new --name "改进版" --temperature 0.5 --workspace-id ws-xxx
|
||||
|
||||
# 3. 测试并发布
|
||||
bl knowledge chat --message "测试" --agent-id aid-new --agent-version beta --workspace-id ws-xxx
|
||||
bl knowledge service deploy --agent-id aid-new --workspace-id ws-xxx --yes
|
||||
```
|
||||
|
||||
### 场景 F:从 OSS 批量导入文件
|
||||
|
||||
```bash
|
||||
# 1. 从已授权的 OSS bucket 批量导入文件到数据中心
|
||||
bl knowledge doc import-oss \
|
||||
--bucket my-bucket --region cn-beijing \
|
||||
--oss-key docs/a.pdf --oss-key docs/b.docx \
|
||||
--workspace-id ws-xxx
|
||||
# → 返回各文件的 fileId
|
||||
|
||||
# 2. 创建知识库并导入这些文件
|
||||
bl knowledge create --name oss-kb --doc-id file-a --doc-id file-b --workspace-id ws-xxx --wait
|
||||
|
||||
# 3. 检索
|
||||
bl knowledge search --query "相关内容" --agent-id aid-xxx --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 命令手册
|
||||
|
||||
以下按功能域分组,覆盖全部 34 个子命令。每个条目包含功能说明、用法签名(kscli 前缀)和详细手册链接。
|
||||
|
||||
> 完整参数表、参数约束、输出说明、注意事项与示例请参阅各子域手册。子域手册中的用法签名使用 `bl knowledge` 前缀。
|
||||
|
||||
---
|
||||
|
||||
### 知识库管理
|
||||
|
||||
> 📖 [完整手册](knowledge/kb.md) — 6 个命令
|
||||
|
||||
#### `kscli kb list`
|
||||
|
||||
列出工作区中的知识库。
|
||||
|
||||
```bash
|
||||
kscli kb list [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli kb info`
|
||||
|
||||
查看知识库配置详情。
|
||||
|
||||
```bash
|
||||
kscli kb info --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-info)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli kb create`
|
||||
|
||||
创建知识库并导入数据中心文件或分类。
|
||||
|
||||
```bash
|
||||
kscli kb create --name <text> (--doc-id <id> | --category-id <id>) [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-create)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli kb update`
|
||||
|
||||
更新知识库名称、描述或 rerank 阈值。
|
||||
|
||||
```bash
|
||||
kscli kb update --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-update)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli kb delete`
|
||||
|
||||
删除知识库及其所有文档和 chunk。
|
||||
|
||||
```bash
|
||||
kscli kb delete --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-delete)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli kb stats`
|
||||
|
||||
查看知识库存储和 QPS 监控数据。
|
||||
|
||||
```bash
|
||||
kscli kb stats --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-stats)
|
||||
|
||||
---
|
||||
|
||||
### 文档管理
|
||||
|
||||
> 📖 [完整手册](knowledge/doc.md) — 6 个命令
|
||||
|
||||
#### `kscli doc list`
|
||||
|
||||
列出知识库中的文档及其解析/索引状态。
|
||||
|
||||
```bash
|
||||
kscli doc list --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli doc status`
|
||||
|
||||
查看知识库导入任务状态。
|
||||
|
||||
```bash
|
||||
kscli doc status --index-id <id> --job-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-status)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli doc upload`
|
||||
|
||||
上传本地文件或目录到数据中心,可选导入到知识库。
|
||||
|
||||
```bash
|
||||
kscli doc upload --file <path> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-upload)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli doc delete`
|
||||
|
||||
从知识库中删除文档及其 chunk。
|
||||
|
||||
```bash
|
||||
kscli doc delete --index-id <id> --doc-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-delete)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli doc tag`
|
||||
|
||||
批量更新数据中心文件的标签。
|
||||
|
||||
```bash
|
||||
kscli doc tag --doc-id <id> --tag <text> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-tag)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli doc import-oss`
|
||||
|
||||
从已授权的 OSS bucket 批量导入文件到数据中心。
|
||||
|
||||
```bash
|
||||
kscli doc import-oss --bucket <name> --region <id> --oss-key <key> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-import-oss)
|
||||
|
||||
---
|
||||
|
||||
### 检索服务管理
|
||||
|
||||
> 📖 [完整手册](knowledge/service.md) — 7 个命令
|
||||
|
||||
#### `kscli service list`
|
||||
|
||||
列出工作区中的检索/Q&A 服务。
|
||||
|
||||
```bash
|
||||
kscli service list --scene <chat|search> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service get`
|
||||
|
||||
查看服务详情,含各版本配置。
|
||||
|
||||
```bash
|
||||
kscli service get --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-get)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service create`
|
||||
|
||||
创建检索/Q&A 服务,初始状态为 draft,版本为 beta。
|
||||
|
||||
```bash
|
||||
kscli service create --name <text> --scene <chat|search> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-create)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service update`
|
||||
|
||||
更新服务名称、描述或草稿配置。
|
||||
|
||||
```bash
|
||||
kscli service update --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-update)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service deploy`
|
||||
|
||||
发布 beta 草稿为新版本。
|
||||
|
||||
```bash
|
||||
kscli service deploy --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-deploy)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service delete`
|
||||
|
||||
删除检索/Q&A 服务(软删除,幂等)。
|
||||
|
||||
```bash
|
||||
kscli service delete --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-delete)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli service copy`
|
||||
|
||||
复制服务为新草稿(名称自动加 `copy_` 前缀)。
|
||||
|
||||
```bash
|
||||
kscli service copy --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-copy)
|
||||
|
||||
---
|
||||
|
||||
### Chunk 管理
|
||||
|
||||
> 📖 [完整手册](knowledge/chunk.md) — 4 个命令
|
||||
|
||||
#### `kscli chunk add`
|
||||
|
||||
直接向知识库添加 chunk。
|
||||
|
||||
```bash
|
||||
kscli chunk add --index-id <id> (--content <text> | --field <k=v>) [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-add)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli chunk list`
|
||||
|
||||
列出知识库中的 chunk,含内容和状态。
|
||||
|
||||
```bash
|
||||
kscli chunk list --index-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli chunk update`
|
||||
|
||||
更新 chunk 内容或切换其检索可见性。
|
||||
|
||||
```bash
|
||||
kscli chunk update --index-id <id> --chunk-id <id> --doc-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-update)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli chunk delete`
|
||||
|
||||
从知识库中删除 chunk(不可逆)。
|
||||
|
||||
```bash
|
||||
kscli chunk delete --index-id <id> --chunk-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-delete)
|
||||
|
||||
---
|
||||
|
||||
### 数据中心文件管理
|
||||
|
||||
> 📖 [完整手册](knowledge/file.md) — 3 个命令
|
||||
|
||||
#### `kscli file list`
|
||||
|
||||
列出数据中心分类下的文件。
|
||||
|
||||
```bash
|
||||
kscli file list --category-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli file get`
|
||||
|
||||
查看数据中心文件详情。
|
||||
|
||||
```bash
|
||||
kscli file get --file-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-get)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli file delete`
|
||||
|
||||
从数据中心永久删除文件。
|
||||
|
||||
```bash
|
||||
kscli file delete --file-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-delete)
|
||||
|
||||
---
|
||||
|
||||
### 数据中心集合与分类
|
||||
|
||||
> 📖 [完整手册](knowledge/collection-category.md) — 5 个命令
|
||||
|
||||
#### `kscli collection create`
|
||||
|
||||
创建 FILE 数据集合。
|
||||
|
||||
```bash
|
||||
kscli collection create --name <text> --description <text> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-collection-create)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli collection get`
|
||||
|
||||
查看数据集合详情。
|
||||
|
||||
```bash
|
||||
kscli collection get (--collection-id <id> | --name <text>) [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-collection-get)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli category list`
|
||||
|
||||
列出数据中心分类。
|
||||
|
||||
```bash
|
||||
kscli category list [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-list)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli category add`
|
||||
|
||||
创建数据中心分类。
|
||||
|
||||
```bash
|
||||
kscli category add --name <text> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-add)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli category delete`
|
||||
|
||||
删除数据中心分类。
|
||||
|
||||
```bash
|
||||
kscli category delete --category-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-delete)
|
||||
|
||||
---
|
||||
|
||||
### 检索与对话
|
||||
|
||||
> 📖 [完整手册](knowledge/search-chat.md) — 3 个命令
|
||||
|
||||
#### `kscli retrieve`
|
||||
|
||||
从知识库检索(已废弃,请用 `search` 替代)。
|
||||
|
||||
```bash
|
||||
kscli retrieve --index-id <id> --query <text> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-retrieve)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli search`
|
||||
|
||||
对知识库执行语义检索(RAG 检索)。
|
||||
|
||||
```bash
|
||||
kscli search --query <text> --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-search)
|
||||
|
||||
---
|
||||
|
||||
#### `kscli chat`
|
||||
|
||||
与知识库进行 RAG 对话(流式输出)。
|
||||
|
||||
```bash
|
||||
kscli chat --message <text> --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-chat)
|
||||
|
||||
---
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
### Workspace ID 缺失
|
||||
|
||||
**报错**:`Workspace ID is required.`
|
||||
|
||||
**原因**:所有 knowledge 管理命令都需要 workspace ID 来构造 API 端点(`{workspaceId}.cn-beijing.maas.aliyuncs.com`)。
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
# 方式1:命令行参数
|
||||
bl knowledge list --workspace-id ws-xxx
|
||||
|
||||
# 方式2:环境变量
|
||||
export BAILIAN_WORKSPACE_ID=ws-xxx
|
||||
|
||||
# 方式3:配置文件
|
||||
bl config set workspace_id ws-xxx
|
||||
```
|
||||
|
||||
### 知识库 ID 不存在
|
||||
|
||||
**报错**:`Knowledge base not found: idx-xxx`
|
||||
|
||||
**原因**:`--index-id` 指定的知识库在当前 workspace 中不存在。
|
||||
|
||||
**解决**:先 `bl knowledge list` 确认知识库 ID。
|
||||
|
||||
### 导入任务 SystemError
|
||||
|
||||
**报错**:服务端返回 `SystemError`
|
||||
|
||||
**原因**:`doc status` 传入了不存在的 job ID,或知识库空闲无任务。
|
||||
|
||||
**解决**:检查 `doc list` 输出中的 `ingestionId`,或从 `doc upload`/`knowledge create` 的返回值获取。
|
||||
|
||||
### doc_id 与 fileId 混淆
|
||||
|
||||
**问题**:`doc delete` 时用了 `doc upload` 返回的 `fileId` 而非 `doc list` 返回的 `doc_id`。
|
||||
|
||||
**原因**:通过 `knowledge create --doc-id` 导入的文档,`doc_id` 等于 `fileId`;但通过 `doc upload --index-id` 导入的,`doc_id` 可能含 workspace 后缀。
|
||||
|
||||
**解决**:始终用 `doc list --quiet` 获取 `doc_id`。
|
||||
|
||||
### retrieve 已废弃
|
||||
|
||||
**问题**:`retrieve` 命令输出废弃警告。
|
||||
|
||||
**解决**:改用 `search` 命令。`search` 通过 `--agent-id` 驱动检索策略,支持多知识库、路由、rerank 等高级特性。`retrieve` 直接操作 `--index-id`,功能受限且不再迭代。
|
||||
|
||||
### OSS 导入权限错误
|
||||
|
||||
**报错**:服务端返回权限相关错误。
|
||||
|
||||
**原因**:OSS bucket 未授权给平台服务角色。
|
||||
|
||||
**解决**:检查 RAM 控制台中的 `AliyunServiceRoleForBailian` 角色是否已正确授权。
|
||||
|
||||
### Chat SSE error
|
||||
|
||||
**报错**:`Chat API error` + API error code。
|
||||
|
||||
**原因**:流式对话过程中服务端返回 error 事件。
|
||||
|
||||
**解决**:检查 `--agent-id` 是否存在、服务是否已部署、API Key 是否有效。错误消息和 code 原样透传,不二次包装。
|
||||
|
||||
### file list 返回空
|
||||
|
||||
**问题**:`file list --category-id default` 返回空列表。
|
||||
|
||||
**原因**:与上传 API 不同,`file list` 不解析字面量 `default`,需要真实分类 ID。
|
||||
|
||||
**解决**:通过 `file get` 的 category 字段或 `category list` 获取真实分类 ID。
|
||||
|
||||
### 集合无法删除
|
||||
|
||||
**问题**:没有 `collection delete` 命令。
|
||||
|
||||
**原因**:暂不支持通过 CLI 删除。
|
||||
|
||||
**解决**:创建集合需谨慎。如需隔离,创建新集合并迁移文件。
|
||||
|
||||
---
|
||||
|
||||
## 附录:命令速查表
|
||||
|
||||
| 命令 | 功能 | 关键参数 |
|
||||
| ------------------------- | ------------ | ----------------------------------------------------------- |
|
||||
| `kscli kb list` | 列出知识库 | `--name` |
|
||||
| `kscli kb info` | 知识库详情 | `--index-id` |
|
||||
| `kscli kb create` | 创建知识库 | `--name`, `--doc-id`/`--category-id` |
|
||||
| `kscli kb update` | 更新知识库 | `--index-id`, `--name`/`--description`/`--rerank-min-score` |
|
||||
| `kscli kb delete` | 删除知识库 | `--index-id`, `--yes` |
|
||||
| `kscli kb stats` | 监控数据 | `--index-id`, `--start`/`--end` |
|
||||
| `kscli doc list` | 文档列表 | `--index-id` |
|
||||
| `kscli doc status` | 导入任务状态 | `--index-id`, `--job-id`, `--wait` |
|
||||
| `kscli doc upload` | 上传文件 | `--file`, `--index-id`, `--wait` |
|
||||
| `kscli doc delete` | 删除文档 | `--index-id`, `--doc-id` |
|
||||
| `kscli doc tag` | 文件打标签 | `--doc-id`, `--tag`, `--mode` |
|
||||
| `kscli doc import-oss` | OSS 导入 | `--bucket`, `--region`, `--oss-key` |
|
||||
| `kscli service list` | 服务列表 | `--scene` |
|
||||
| `kscli service get` | 服务详情 | `--agent-id` |
|
||||
| `kscli service create` | 创建服务 | `--name`, `--scene`, `--index-id` |
|
||||
| `kscli service update` | 更新服务 | `--agent-id`, 配置参数 |
|
||||
| `kscli service deploy` | 发布服务 | `--agent-id`, `--yes` |
|
||||
| `kscli service delete` | 删除服务 | `--agent-id`, `--yes` |
|
||||
| `kscli service copy` | 复制服务 | `--agent-id` |
|
||||
| `kscli chunk add` | 添加 chunk | `--index-id`, `--content`/`--field` |
|
||||
| `kscli chunk list` | chunk 列表 | `--index-id`, `--doc-id` |
|
||||
| `kscli chunk update` | 更新 chunk | `--index-id`, `--chunk-id`, `--doc-id` |
|
||||
| `kscli chunk delete` | 删除 chunk | `--index-id`, `--chunk-id`, `--yes` |
|
||||
| `kscli file list` | 文件列表 | `--category-id` |
|
||||
| `kscli file get` | 文件详情 | `--file-id` |
|
||||
| `kscli file delete` | 删除文件 | `--file-id`, `--yes` |
|
||||
| `kscli collection create` | 创建集合 | `--name`, `--description` |
|
||||
| `kscli collection get` | 集合详情 | `--collection-id`/`--name` |
|
||||
| `kscli category list` | 分类列表 | `--collection-id`, `--parent-id` |
|
||||
| `kscli category add` | 创建分类 | `--name`, `--parent-id` |
|
||||
| `kscli category delete` | 删除分类 | `--category-id`, `--yes` |
|
||||
| `kscli retrieve` | 检索(废弃) | `--index-id`, `--query` |
|
||||
| `kscli search` | 语义检索 | `--query`, `--agent-id` |
|
||||
| `kscli chat` | RAG 对话 | `--message`, `--agent-id` |
|
||||
@@ -0,0 +1,218 @@
|
||||
# 检索与对话命令手册
|
||||
|
||||
以下命令通过检索服务(agent)消费知识库。`search` 用于语义检索,`chat` 用于多轮对话。`retrieve` 已废弃。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge retrieve`
|
||||
|
||||
从知识库检索(已废弃,请用 `search` 替代)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge retrieve --index-id <id> --query <text> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------------------- | ------ | ---- | --------------------------------------------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--query <text>` | string | 是 | 检索查询文本 |
|
||||
| `--dense-similarity-top-k <n>` | number | 否 | 稠密检索 top K |
|
||||
| `--sparse-similarity-top-k <n>` | number | 否 | 稀疏检索 top K |
|
||||
| `--rerank` | switch | 否 | 启用 rerank |
|
||||
| `--rerank-top-n <n>` | number | 否 | rerank 返回 top N 结果 |
|
||||
| `--rerank-model <name>` | string | 否 | rerank 模型名,如 `qwen3-rerank-hybrid` |
|
||||
| `--rerank-mode <mode>` | string | 否 | rerank 模式:`qa`、`similar` 或 `custom` |
|
||||
| `--rerank-instruct <text>` | string | 否 | 自定义 rerank 指令(`--rerank-mode custom` 时使用) |
|
||||
| `--top-k <n>` | number | 否 | 返回结果数(已废弃,用 `--rerank-top-n` 替代) |
|
||||
|
||||
**输出**
|
||||
|
||||
text/quiet 模式:
|
||||
|
||||
```
|
||||
[1] (score: 0.9512)
|
||||
检索到的文本内容...
|
||||
|
||||
[2] (score: 0.8734)
|
||||
另一段文本内容...
|
||||
```
|
||||
|
||||
> 无结果时输出 `No results found.`
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- **已废弃**,推荐使用 `search` 命令。`search` 通过 agent_id 驱动检索策略,支持更多高级特性。
|
||||
- `--top-k` 已废弃,使用 `--rerank-top-n` 替代,传入 `--top-k` 会输出 stderr 警告。
|
||||
- 此命令直接用 `--index-id` 检索,不需要创建检索服务。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 基础检索
|
||||
bl knowledge retrieve --index-id idx-xxx --query "How to use Alibaba Cloud Bailian" --workspace-id ws-xxx
|
||||
|
||||
# 启用 rerank
|
||||
bl knowledge retrieve --index-id idx-xxx --query "RAG retrieval" --rerank --rerank-model qwen3-rerank-hybrid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge search`
|
||||
|
||||
对知识库执行语义检索(RAG 检索)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge search --query <text> --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | ------------------------------------------------------------------- |
|
||||
| `--query <text>` | string | 是 | 检索查询文本(不可为空) |
|
||||
| `--agent-id <id>` | string | 是 | 检索服务 ID(在控制台知识检索页面获取,或通过 `service list` 查看) |
|
||||
| `--agent-version <version>` | string | 否 | 服务版本:`beta`(调试草稿)或已发布版本号;默认调用最新已发布版本 |
|
||||
| `--image <url>` | array | 否 | 图片 URL(可重复),用于多模态检索 |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--query` 不可为空(API 要求 `minLength: 1`)
|
||||
|
||||
**输出**
|
||||
|
||||
text/quiet 模式:
|
||||
|
||||
```
|
||||
[1] (score: 0.9512)
|
||||
检索到的文本内容...
|
||||
|
||||
[2] (score: 0.8734)
|
||||
另一段文本内容...
|
||||
```
|
||||
|
||||
> 无结果时输出 `No results found.`
|
||||
|
||||
json 模式:返回 API 原始响应,`data.nodes[]` 包含检索结果。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 检索范围和策略(多知识库加权、路由、rerank 等)由 `--agent-id` 对应的服务配置驱动。只需 `--query` 和 `--agent-id` 即可调用。
|
||||
- `--agent-version beta` 调试草稿配置进行调试,部署前验证效果。
|
||||
- 与 `retrieve` 的区别:`search` 通过 agent_id 间接驱动检索策略(支持多知识库、路由、rerank 等),`retrieve` 直接操作 index_id 且功能较少。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 基础检索
|
||||
bl knowledge search --query "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 多模态检索(带图片)
|
||||
bl knowledge search --query "describe this image" --agent-id aid-xxx --workspace-id ws-xxx --image https://example.com/img.jpg
|
||||
|
||||
# 调试草稿版本
|
||||
bl knowledge search --query "test" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge chat`
|
||||
|
||||
与知识库进行 RAG 对话(流式输出)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge chat --message <text> --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `--message <text>` | array | 是¹ | 消息文本(可重复)。支持 `role:content` 前缀设置角色(如 `user:hello`),默认角色为 `user`。也支持完整 JSON 对象传递结构化消息 |
|
||||
| `--agent-id <id>` | string | 是 | Q&A 服务 ID(在控制台知识问答页面获取,或通过 `service list --scene chat` 查看) |
|
||||
| `--agent-version <version>` | string | 否 | 服务版本:`beta`(调试草稿)或已发布版本号;默认调用最新已发布版本 |
|
||||
| `--image <url>` | array | 否 | 图片 URL(可重复)。附加到最后一条 user 消息作为多模态内容 |
|
||||
|
||||
> ¹ `--message` 或 `--image` 至少提供其一。纯图片查询可以只传 `--image`(CLI 会自动创建空 user 消息承载图片)。
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--message` 或 `--image` 至少提供一个
|
||||
- `--image` 不能与已包含 `image_url` 内容部分的消息同时使用
|
||||
|
||||
**输出**
|
||||
|
||||
**TTY text 模式**(实时流式):
|
||||
|
||||
```
|
||||
🔍 Retrieving...
|
||||
✍️ Generating...
|
||||
这是AI生成的回答内容,逐字流式输出...
|
||||
```
|
||||
|
||||
> 进度标签由 SSE `step_change` 事件驱动:`tool_calling`(检索中)→ `plan_start`(规划中)→ `generation_start`(生成中)。
|
||||
|
||||
**非 TTY text 模式**(缓冲输出):
|
||||
|
||||
```
|
||||
完整的回答文本...
|
||||
```
|
||||
|
||||
**json 模式**(`--output json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"answer": "完整的回答文本...",
|
||||
"request_id": "xxx"
|
||||
}
|
||||
```
|
||||
|
||||
quiet 模式:输出完整的回答文本。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- API 仅支持 SSE 流式响应。TTY 环境下实时打印 token;非 TTY 环境缓冲后输出完整文本。
|
||||
- SSE 事件生命周期:`tool_calling` → `tool_return` → `plan_start` → `planning` → `plan_end` → `generation_start` → `generating` → `generation_end`。`tool_calling` → `tool_return` 可能循环多次。
|
||||
- 多轮对话:用 `--message "user:..."` 和 `--message "assistant:..."` 传递对话历史。
|
||||
- `--agent-version beta` 调用草稿配置进行调试。
|
||||
- `--image` 附加到最后一条 user 消息上。如果消息中已包含 `image_url` 内容部分,则不能再用 `--image`。
|
||||
- `--verbose` 模式下,所有 SSE 事件详情会输出到 stderr。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 单轮对话
|
||||
bl knowledge chat --message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 多轮对话(带历史)
|
||||
bl knowledge chat \
|
||||
--message "user:What is RAG?" \
|
||||
--message "assistant:RAG is retrieval-augmented generation..." \
|
||||
--message "How does it work?" \
|
||||
--agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 多模态对话(带图片)
|
||||
bl knowledge chat \
|
||||
--message "Describe these images" \
|
||||
--image https://example.com/a.png \
|
||||
--image https://example.com/b.png \
|
||||
--agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 调试草稿版本
|
||||
bl knowledge chat --message "test" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -0,0 +1,401 @@
|
||||
# 检索服务管理命令手册
|
||||
|
||||
检索服务(也称 agent)是知识库的检索入口。通过 `--agent-id` 在 search/chat 命令中使用。服务有 `chat`(问答)和 `search`(检索)两种场景。
|
||||
|
||||
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service list`
|
||||
|
||||
列出工作区中的检索/Q&A 服务。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service list --scene <chat|search> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------------ | ------ | ---- | ------------------------------------------------------- |
|
||||
| `--scene <chat\|search>` | string | 是 | 服务场景:`chat`(Q&A)或 `search`(检索) |
|
||||
| `--status <status>` | string | 否 | 按状态过滤:`draft`、`deployed`(含 edited)、`deleted` |
|
||||
| `--name <text>` | string | 否 | 按服务名称模糊过滤 |
|
||||
| `--agent-id <id>` | string | 否 | 按精确 agent ID 过滤 |
|
||||
| `--index-id <id>` | string | 否 | 按关联知识库 ID 过滤 |
|
||||
| `--page-number <n>` | number | 否 | 页码(默认:1) |
|
||||
| `--page-size <n>` | number | 否 | 每页条数(默认:10,最大 100) |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--scene` 只能是 `chat` 或 `search`
|
||||
- `--status` 只能是 `draft`、`deployed`、`deleted`
|
||||
- `--page-size` 范围 1-100
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
aid-xxx deployed 2 my-qa (kb: my-kb)
|
||||
total: 1
|
||||
Use an agent_id above with the knowledge chat command.
|
||||
```
|
||||
|
||||
> 最后一行根据 scene 自动提示用 `search` 还是 `chat` 命令消费。
|
||||
|
||||
quiet 模式:每行一个 `agent_id`。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 服务端要求 `--scene` 必填,要查看两种场景的服务需分别执行。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 列出 chat 服务
|
||||
bl knowledge service list --scene chat --workspace-id ws-xxx
|
||||
|
||||
# 只看已部署的检索服务
|
||||
bl knowledge service list --scene search --status deployed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service get`
|
||||
|
||||
查看服务详情,含各版本配置。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service get --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------------------- | ------ | ---- | --------------------------------------------------------- |
|
||||
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
|
||||
| `--agent-version <version>` | string | 否 | 指定版本查看(`beta` 或已发布版本号);不传则返回所有版本 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
Basic:
|
||||
id: aid-xxx
|
||||
name: my-qa
|
||||
desc: product Q&A
|
||||
scene: chat
|
||||
status: deployed
|
||||
Version beta:
|
||||
desc: draft
|
||||
policy: turbo
|
||||
model: qwen-max
|
||||
temperature: 0.7
|
||||
kb: idx-xxx (my-kb)
|
||||
Version 1:
|
||||
published: 2026-01-01
|
||||
...
|
||||
```
|
||||
|
||||
quiet 模式:输出 JSON 格式。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 不传 `--agent-version` 时返回所有版本(beta 草稿 + 已发布版本号)。
|
||||
- 版本值原样传递,有效值集合由服务端维护。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 查看服务完整详情
|
||||
bl knowledge service get --agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 只看 beta 草稿配置
|
||||
bl knowledge service get --agent-id aid-xxx --agent-version beta
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service create`
|
||||
|
||||
创建检索/Q&A 服务,初始状态为 draft,版本为 beta。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service create --name <text> --scene <chat|search> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------------ | ------ | ---- | ------------------------------------------------- |
|
||||
| `--name <text>` | string | 是 | 服务名称(最多 200 字符,同一场景下工作区内唯一) |
|
||||
| `--scene <chat\|search>` | string | 是 | 服务场景:`chat`(Q&A)或 `search`(检索) |
|
||||
| `--description <text>` | string | 否 | 服务描述(最多 1000 字符) |
|
||||
| `--index-id <id>` | string | 否 | 绑定此知识库;其他配置使用服务端默认值 |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- `--name` 最多 200 字符
|
||||
- `--scene` 只能是 `chat` 或 `search`
|
||||
- `--description` 最多 1000 字符
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
created: aid-xxx (status: draft, version: beta)
|
||||
Test the draft with --agent-version beta on search/chat, then deploy it to publish.
|
||||
```
|
||||
|
||||
quiet 模式:输出 agent ID。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 不指定 `--index-id` 时,服务端使用默认 agent 配置。
|
||||
- beta 草稿可通过 search/chat 的 `--agent-version beta` 测试,部署后才生效。
|
||||
- 需要工作区的知识库创建权限。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 创建 Q&A 服务
|
||||
bl knowledge service create --name my-qa --scene chat --workspace-id ws-xxx
|
||||
|
||||
# 创建检索服务并绑定知识库
|
||||
bl knowledge service create --name my-search --scene search --index-id idx-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service update`
|
||||
|
||||
更新服务名称、描述或草稿配置。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service update --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------------------ | ------ | ---- | ---------------------------------------------------------------------------------------- |
|
||||
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
|
||||
| `--name <text>` | string | 否 | 新名称(最多 200 字符) |
|
||||
| `--description <text>` | string | 否 | 新描述(最多 1000 字符) |
|
||||
| `--agent-version <version>` | string | 否 | 目标版本(默认:beta 草稿。已发布版本只接受 `--version-desc`) |
|
||||
| `--version-desc <text>` | string | 否 | 版本描述 |
|
||||
| `--policy <policy>` | string | 否 | Agent 策略:`turbo`(快速)或 `agentic`(多轮) |
|
||||
| `--model <name>` | string | 否 | 生成模型代码(须在平台白名单中) |
|
||||
| `--temperature <n>` | number | 否 | 采样温度,范围 0-2 |
|
||||
| `--max-llm-calls <n>` | number | 否 | 单次请求最大 LLM 调用次数,范围 1-30 |
|
||||
| `--enable-session-file <bool>` | string | 否 | 启用会话文件:`true` 或 `false` |
|
||||
| `--enable-refusal <bool>` | string | 否 | 启用拒答:`true` 或 `false` |
|
||||
| `--enable-anti-leak <bool>` | string | 否 | 启用防泄漏:`true` 或 `false` |
|
||||
| `--enable-rich-text <bool>` | string | 否 | 启用富文本输出:`true` 或 `false` |
|
||||
| `--enable-citation <bool>` | string | 否 | 启用引用标注:`true` 或 `false` |
|
||||
| `--config-file <path>` | string | 否 | JSON 文件替换整个 `agent_config`(含嵌套设置如 `kb_search_configs`);与标量配置参数互斥 |
|
||||
|
||||
**参数约束**
|
||||
|
||||
- 至少提供一个更新项(`--name`/`--description`/`--version-desc`/`--config-file`/标量配置参数),否则报错 "Nothing to update"
|
||||
- `--config-file` 与标量配置参数(`--policy`/`--model`/`--temperature` 等)互斥
|
||||
- 已发布版本 + 配置变更 → 报错(已发布版本只接受 `--version-desc`)
|
||||
- `--name` 最多 200 字符;`--description` 最多 1000 字符
|
||||
- `--policy` 只能是 `turbo` 或 `agentic`
|
||||
- `--temperature` 范围 0-2
|
||||
- `--max-llm-calls` 范围 1-30
|
||||
- 布尔参数(`--enable-*`)只能是 `true` 或 `false`
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
updated: aid-xxx
|
||||
Draft config changed — verify with --agent-version beta, then deploy.
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 配置变更只作用于 beta 草稿;已发布版本只接受 `--version-desc`。
|
||||
- 标量配置参数采用 read-merge-write:CLI 先读取当前 beta 配置,再合并变更后整体提交(API 是整替换语义)。
|
||||
- `--config-file` 替换整个配置,适合设置嵌套字段(如 `kb_search_configs`)。
|
||||
- 修改草稿后用 `--agent-version beta` 在 search/chat 上测试,通过后 `service deploy` 发布。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 调整温度
|
||||
bl knowledge service update --agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx
|
||||
|
||||
# 用 JSON 文件替换整个配置
|
||||
bl knowledge service update --agent-id aid-xxx --config-file ./agent-config.json
|
||||
|
||||
# 给已发布版本 1 加描述
|
||||
bl knowledge service update --agent-id aid-xxx --agent-version 1 --version-desc "first stable release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service deploy`
|
||||
|
||||
发布 beta 草稿为新版本。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service deploy --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------------- | ------ | ---- | ---------------- |
|
||||
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
|
||||
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deployed: aid-xxx version 2
|
||||
```
|
||||
|
||||
quiet 模式:输出新版本号。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 版本号自动递增,状态变为 `deployed`。
|
||||
- 发布影响线上调用方,确认提示会警告。
|
||||
- 如果当前状态为 `edited`(已发布后又改了草稿),确认提示会额外警告「发布会覆盖线上行为」。
|
||||
- 需要工作区的知识库修改权限。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 发布(交互确认)
|
||||
bl knowledge service deploy --agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 带描述并跳过确认
|
||||
bl knowledge service deploy --agent-id aid-xxx --version-desc "tuned rerank params" --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service delete`
|
||||
|
||||
删除检索/Q&A 服务(软删除,幂等)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service delete --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | --------------- |
|
||||
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
deleted: aid-xxx (status: deleted)
|
||||
```
|
||||
|
||||
quiet 模式:无输出。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 删除不可撤销,`agent_id` 不再可用于 search/chat 调用。
|
||||
- API 是幂等的:删除已删除的服务不会报错。
|
||||
- 如果服务状态为 `deployed` 或 `edited`,确认提示会额外警告「此服务正在线上运行」。
|
||||
- 需要工作区的知识库删除权限。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 删除(交互确认)
|
||||
bl knowledge service delete --agent-id aid-xxx --workspace-id ws-xxx
|
||||
|
||||
# 跳过确认
|
||||
bl knowledge service delete --agent-id aid-xxx --yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl knowledge service copy`
|
||||
|
||||
复制服务为新草稿(名称自动加 `copy_` 前缀)。
|
||||
|
||||
**用法**
|
||||
|
||||
```bash
|
||||
bl knowledge service copy --agent-id <id> [flags]
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ----------------- |
|
||||
| `--agent-id <id>` | string | 是 | 源服务(agent)ID |
|
||||
|
||||
**输出**
|
||||
|
||||
text 模式:
|
||||
|
||||
```
|
||||
new agent_id: aid-new (name: copy_my-qa, status: draft)
|
||||
Test the draft with --agent-version beta on search/chat, then deploy it to publish.
|
||||
```
|
||||
|
||||
quiet 模式:输出新 agent ID。
|
||||
|
||||
json 模式:返回 API 原始响应。
|
||||
|
||||
**注意事项**
|
||||
|
||||
- 副本初始为 beta 草稿,测试后需 deploy 发布。
|
||||
- 需要工作区的知识库创建权限。
|
||||
|
||||
**示例**
|
||||
|
||||
```bash
|
||||
# 复制服务
|
||||
bl knowledge service copy --agent-id aid-source --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [返回总览](../knowledge-cli-guide.md)
|
||||
@@ -21,10 +21,12 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
|
||||
@@ -2,4 +2,7 @@ node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
outputs/
|
||||
outputs/
|
||||
# agents
|
||||
agents.state.json
|
||||
.env
|
||||
|
||||
+93
-131
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
---
|
||||
|
||||
_Chat with Qwen, generate images & videos, understand images, call agents,_
|
||||
_manage memory, search the web — all from your terminal._
|
||||
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
|
||||
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
|
||||
_every AI capability, one command away._
|
||||
|
||||
_Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
@@ -22,28 +23,16 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
## Features
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -56,120 +45,93 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
|
||||
>
|
||||
> _(Original: "帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2分钟左右的视频,尺寸是16:9")_
|
||||
|
||||
### How it works
|
||||
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
|
||||
|
||||
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
**Agent install (recommended)**
|
||||
|
||||
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
|
||||
|
||||
```text
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
**Install with NPM**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
**Install on macOS/Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
**Install on Windows**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Authenticate, recommended
|
||||
bl auth login --console
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
# Multimodal chat (text + image + audio + video)
|
||||
bl omni --message "Describe this image" --image ./photo.jpg
|
||||
|
||||
# Generate an image
|
||||
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
|
||||
|
||||
# Generate a video from local image
|
||||
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
|
||||
|
||||
# Model recommendation — find the best model for your use case
|
||||
bl advisor recommend --message "I need a visual-understanding chatbot"
|
||||
|
||||
# Compare specific models
|
||||
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| Scenario | What to say to your Agent |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
|
||||
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
|
||||
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
|
||||
| Model selection | "Recommend a model for image understanding and customer support." |
|
||||
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## Authentication
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# Option 2: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Option 3: Per-command flag
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -177,26 +139,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
### Alibaba Cloud OpenAPI AK/SK
|
||||
|
||||
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
```bash
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -205,17 +161,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# View current config
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
# List all config profiles
|
||||
bl config list
|
||||
|
||||
# Self-update to latest version
|
||||
bl update
|
||||
# Switch config profile
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
@@ -227,11 +197,3 @@ Config file location: `~/.bailian/config.json`
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
+93
-130
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -22,28 +22,16 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -53,121 +41,96 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
|
||||
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
|
||||
> _“帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9。”_
|
||||
|
||||
### 工作流程
|
||||
## 示例 2:一句话构建短片导演 Managed Agent
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
<p align="center"><i>👆 点击封面播放完整演示</i></p>
|
||||
|
||||
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _“帮我构建一个 managedagent 应用,能够实现短片拍摄,导演专家生成视频,然后也能进行设计对应的分镜图。”_
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
**Agent 安装(推荐)**
|
||||
|
||||
把下面这句话发给你的 Agent,它会自行判断环境并完成安装与校验:
|
||||
|
||||
```text
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
**NPM 安装**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
**macOS/Linux 安装**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
**Windows 安装**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
# 多模态对话(文本 + 图片 + 音频 + 视频)
|
||||
bl omni --message "描述这张图片" --image ./photo.jpg
|
||||
|
||||
# 生成图片
|
||||
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
|
||||
|
||||
# 图生视频(本地文件自动上传)
|
||||
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
|
||||
|
||||
# 模型推荐 — 根据场景推荐最适合的模型
|
||||
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
|
||||
|
||||
# 对比特定模型
|
||||
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
|
||||
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| 场景 | 可以这样对 Agent 说 |
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## 认证方式
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# 方式二:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 方式三:命令行参数
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -175,26 +138,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
### 阿里云 OpenAPI AK/SK
|
||||
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## 配置
|
||||
@@ -203,17 +160,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# 查看当前配置
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
# 查看全部配置档
|
||||
bl config list
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
# 切换配置档
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群,获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
@@ -225,11 +196,3 @@ bl update
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
@@ -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。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.10.0",
|
||||
"version": "1.16.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.zh.md"
|
||||
"README.zh.md",
|
||||
"postinstall.js"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -40,17 +41,19 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
|
||||
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference'",
|
||||
"sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts",
|
||||
"build": "vp pack",
|
||||
"dev": "tsx src/main.ts",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
"check": "vp check",
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-commands": "workspace:*",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*"
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"tar-stream": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
@@ -66,6 +69,6 @@
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
|
||||
*
|
||||
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
|
||||
* package and overwrites the local directory, ensuring data is in place the first time the user runs
|
||||
* `bl advisor recommend`.
|
||||
*
|
||||
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill):
|
||||
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
||||
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
|
||||
* legacy fallback to skill.tar.br when the entry has no valid object field
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
|
||||
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
|
||||
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
|
||||
* 5. Write ~/.bailian/wiki-sync-state.json
|
||||
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
|
||||
*
|
||||
* Design constraints:
|
||||
* - Unconditional overwrite: every install fully replaces, no version comparison
|
||||
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
|
||||
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
|
||||
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
|
||||
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const CONFIG_DIR_NAME = ".bailian";
|
||||
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
||||
const STATE_FILE_NAME = "wiki-sync-state.json";
|
||||
const INDEX_KEY = "index.json";
|
||||
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
|
||||
const LEGACY_ASSET_NAME = "skill.tar.br";
|
||||
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
|
||||
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
|
||||
|
||||
const INDEX_TIMEOUT_MS = 3000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
function getConfigDir() {
|
||||
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
|
||||
return join(homedir(), CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
function getCatalogDir() {
|
||||
return join(getConfigDir(), SKILL_DIR_NAME);
|
||||
}
|
||||
|
||||
function getStatePath() {
|
||||
return join(getConfigDir(), STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function getSkillLockPath() {
|
||||
return join(getConfigDir(), "skills", "skill-lock.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
|
||||
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
|
||||
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
|
||||
* best-effort: failure does not affect data sync results.
|
||||
*/
|
||||
function upsertSkillLock(name, entry) {
|
||||
try {
|
||||
let lock = { version: 1, skills: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
|
||||
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
|
||||
lock = parsed;
|
||||
}
|
||||
} catch {
|
||||
/* absent/corrupted → empty table */
|
||||
}
|
||||
lock.skills[name] = { ...lock.skills[name], ...entry };
|
||||
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
||||
} catch {
|
||||
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, timeoutMs) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadBuffer(url) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
function isSafeEntryName(name) {
|
||||
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
|
||||
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
|
||||
if (name.includes("\\") || name.includes("\0")) return false;
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
|
||||
async function extractTarBr(tarBrBuffer, destDir) {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
if (!isSafeEntryName(header.name)) {
|
||||
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
|
||||
// error; silence the entry stream to avoid its companion error becoming unhandled
|
||||
stream.on("error", () => {});
|
||||
stream.resume();
|
||||
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
|
||||
return;
|
||||
}
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
stream.resume();
|
||||
stream.on("end", next);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const ws = createWriteStream(filePath);
|
||||
stream.pipe(ws);
|
||||
ws.on("finish", next);
|
||||
ws.on("error", next);
|
||||
});
|
||||
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the publisher's deterministic content hash over an extracted directory
|
||||
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
|
||||
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
|
||||
*/
|
||||
function computeDirContentHash(dir) {
|
||||
const relPaths = [];
|
||||
const walk = (sub) => {
|
||||
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
|
||||
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) walk(rel);
|
||||
else if (dirent.isFile()) relPaths.push(rel);
|
||||
}
|
||||
};
|
||||
walk("");
|
||||
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
const hash = createHash("sha256");
|
||||
for (const rel of relPaths) {
|
||||
hash.update(rel);
|
||||
hash.update(readFileSync(join(dir, rel)));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
||||
function atomicSwap(tmpDir, catalogDir) {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
const backup = `${catalogDir}.old-${Date.now()}`;
|
||||
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
|
||||
try {
|
||||
renameSync(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
|
||||
throw err;
|
||||
}
|
||||
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Download skills/index.json and get the wiki entry
|
||||
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
|
||||
const entry = index?.skills?.[WIKI_SKILL_NAME];
|
||||
if (!entry?.contentHash)
|
||||
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
|
||||
|
||||
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
|
||||
const assetName =
|
||||
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
|
||||
|
||||
// 3. Extract to same-volume temp dir + integrity check + atomic swap
|
||||
const catalogDir = getCatalogDir();
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBuf, tmpDir);
|
||||
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
|
||||
// before touching the canonical dir
|
||||
if (entry.contentHash.startsWith("sha256:")) {
|
||||
const actualContentHash = computeDirContentHash(tmpDir);
|
||||
if (actualContentHash !== entry.contentHash) {
|
||||
throw new Error(
|
||||
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
atomicSwap(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 4. Write state
|
||||
try {
|
||||
writeFileSync(
|
||||
getStatePath(),
|
||||
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
|
||||
);
|
||||
} catch {
|
||||
/* state write failure has no impact: first recommend will re-check */
|
||||
}
|
||||
|
||||
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
|
||||
upsertSkillLock(WIKI_SKILL_NAME, {
|
||||
contentHash: entry.contentHash,
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
});
|
||||
|
||||
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Unconditional pass-through: install-time network/permission issues should not block npm install;
|
||||
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
|
||||
);
|
||||
// Force a success exit code so a download failure never fails `npm install`.
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -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,
|
||||
@@ -45,15 +76,20 @@ import {
|
||||
usageFreetier,
|
||||
usageStats,
|
||||
usageSummary,
|
||||
usageTokenPlan,
|
||||
usageCodingPlan,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
modelList,
|
||||
workspaceList,
|
||||
quotaList,
|
||||
quotaRequest,
|
||||
quotaUpdate,
|
||||
quotaHistory,
|
||||
quotaCheck,
|
||||
permissionList,
|
||||
permissionGrant,
|
||||
permissionRevoke,
|
||||
datasetUpload,
|
||||
datasetList,
|
||||
datasetGet,
|
||||
@@ -62,6 +98,7 @@ import {
|
||||
finetuneTextCreate,
|
||||
finetuneAudioCreate,
|
||||
finetuneImageCreate,
|
||||
finetuneVideoCreate,
|
||||
finetuneList,
|
||||
finetuneGet,
|
||||
finetuneCancel,
|
||||
@@ -71,6 +108,7 @@ import {
|
||||
finetuneExport,
|
||||
finetuneWatch,
|
||||
finetuneCapability,
|
||||
finetunePrice,
|
||||
deployTextCreate,
|
||||
deployAudioCreate,
|
||||
deployImageCreate,
|
||||
@@ -80,6 +118,8 @@ import {
|
||||
deployScale,
|
||||
deployUpdate,
|
||||
deployDelete,
|
||||
deployPause,
|
||||
deployResume,
|
||||
tokenPlanListSeats,
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
@@ -89,6 +129,28 @@ import {
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
skillAdd,
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
skillInit,
|
||||
managedAgentInit,
|
||||
managedAgentValidate,
|
||||
managedAgentPlan,
|
||||
managedAgentApply,
|
||||
managedAgentDestroy,
|
||||
managedAgentStateList,
|
||||
managedAgentStateShow,
|
||||
managedAgentStateRm,
|
||||
managedAgentStateImport,
|
||||
managedAgentSessionCreate,
|
||||
managedAgentSessionList,
|
||||
managedAgentSessionGet,
|
||||
managedAgentSessionDelete,
|
||||
managedAgentSessionRun,
|
||||
managedAgentSessionSend,
|
||||
managedAgentSessionEvents,
|
||||
managedAgentSkillList,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
@@ -130,6 +192,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,
|
||||
@@ -142,15 +237,20 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"usage summary": usageSummary,
|
||||
"usage token-plan": usageTokenPlan,
|
||||
"usage coding-plan": usageCodingPlan,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
"model list": modelList,
|
||||
"workspace list": workspaceList,
|
||||
"quota list": quotaList,
|
||||
"quota request": quotaRequest,
|
||||
"quota update": quotaUpdate,
|
||||
"quota history": quotaHistory,
|
||||
"quota check": quotaCheck,
|
||||
"permission list": permissionList,
|
||||
"permission grant": permissionGrant,
|
||||
"permission revoke": permissionRevoke,
|
||||
"dataset upload": datasetUpload,
|
||||
"dataset list": datasetList,
|
||||
"dataset get": datasetGet,
|
||||
@@ -159,6 +259,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"finetune text create": finetuneTextCreate,
|
||||
"finetune audio create": finetuneAudioCreate,
|
||||
"finetune image create": finetuneImageCreate,
|
||||
"finetune video create": finetuneVideoCreate,
|
||||
"finetune list": finetuneList,
|
||||
"finetune get": finetuneGet,
|
||||
"finetune cancel": finetuneCancel,
|
||||
@@ -168,6 +269,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"finetune export": finetuneExport,
|
||||
"finetune watch": finetuneWatch,
|
||||
"finetune capability": finetuneCapability,
|
||||
"finetune price": finetunePrice,
|
||||
"deploy text create": deployTextCreate,
|
||||
"deploy audio create": deployAudioCreate,
|
||||
"deploy image create": deployImageCreate,
|
||||
@@ -177,6 +279,8 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"deploy scale": deployScale,
|
||||
"deploy update": deployUpdate,
|
||||
"deploy delete": deployDelete,
|
||||
"deploy pause": deployPause,
|
||||
"deploy resume": deployResume,
|
||||
"token-plan list-seats": tokenPlanListSeats,
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
@@ -186,4 +290,36 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
"skill add": skillAdd,
|
||||
"skill update": skillUpdate,
|
||||
"skill remove": skillRemove,
|
||||
"skill list": skillList,
|
||||
"skill init": skillInit,
|
||||
"managed-agent init": managedAgentInit,
|
||||
"managed-agent validate": managedAgentValidate,
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
"managed-agent apply": managedAgentApply,
|
||||
"managed-agent destroy": managedAgentDestroy,
|
||||
"managed-agent state list": managedAgentStateList,
|
||||
"managed-agent state show": managedAgentStateShow,
|
||||
"managed-agent state rm": managedAgentStateRm,
|
||||
"managed-agent state import": managedAgentStateImport,
|
||||
"managed-agent session create": managedAgentSessionCreate,
|
||||
"managed-agent session list": managedAgentSessionList,
|
||||
"managed-agent session get": managedAgentSessionGet,
|
||||
"managed-agent session delete": managedAgentSessionDelete,
|
||||
"managed-agent session run": managedAgentSessionRun,
|
||||
"managed-agent session send": managedAgentSessionSend,
|
||||
"managed-agent session events": managedAgentSessionEvents,
|
||||
"managed-agent skill-list": managedAgentSkillList,
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime-only aliases for renamed commands: dispatched by the CLI (merged in
|
||||
* main.ts) but kept out of the canonical map so generate-reference.ts only
|
||||
* documents the canonical path.
|
||||
*/
|
||||
export const commandAliases: Record<string, AnyCommand> = {
|
||||
// Pre-migration name of "quota update".
|
||||
"quota request": quotaUpdate,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCli } from "bailian-cli-runtime";
|
||||
import { commands } from "./commands.ts";
|
||||
import { commandAliases, commands } from "./commands.ts";
|
||||
import { commandPackPolicy } from "./command-pack-policy.ts";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
@@ -10,11 +10,14 @@ const quickStartTasks = [
|
||||
"Help me analyze this video and write a Xiaohongshu-style post",
|
||||
] as const;
|
||||
|
||||
void createCli(commands, {
|
||||
binName: "bl",
|
||||
version: pkg.version,
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
quickStartTasks,
|
||||
commandPacks: commandPackPolicy,
|
||||
}).run();
|
||||
void createCli(
|
||||
{ ...commands, ...commandAliases },
|
||||
{
|
||||
binName: "bl",
|
||||
version: pkg.version,
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
quickStartTasks,
|
||||
commandPacks: commandPackPolicy,
|
||||
},
|
||||
).run();
|
||||
|
||||
@@ -7,10 +7,15 @@ const commandPaths = Object.keys(commands).sort();
|
||||
const groupPaths = deriveGroupPaths(commandPaths);
|
||||
|
||||
describe("e2e: bl registry smoke", () => {
|
||||
test("根帮助展示 bl 与全局 flag", async () => {
|
||||
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/\bbl\b/i);
|
||||
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
|
||||
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
|
||||
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
|
||||
expect(stderr).toMatch(/token-plan create-key\s+\[AK\/SK\]\s+Create a Token Plan API key/);
|
||||
expect(stderr).toMatch(/config show\s+\[No Auth\]\s+Display current configuration/);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
@@ -18,6 +23,24 @@ describe("e2e: bl registry smoke", () => {
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("分组帮助按叶子命令展示不同鉴权域", async () => {
|
||||
const { stderr, exitCode } = await runCli(["app", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
|
||||
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[["text", "chat"], "API Key"],
|
||||
[["app", "list"], "Console"],
|
||||
[["token-plan", "list-seats"], "AK/SK"],
|
||||
[["config", "show"], "No Auth"],
|
||||
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
|
||||
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain(`Authentication: ${authLabel}`);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { commands } from "../src/commands.ts";
|
||||
|
||||
const repositoryRoot = monorepoRoot();
|
||||
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
|
||||
const cliPackage = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
|
||||
) as {
|
||||
engines?: { node?: string };
|
||||
};
|
||||
|
||||
function toFlagName(key: string): string {
|
||||
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
function findDocumentedCommand(snippet: string): {
|
||||
commandPath?: string;
|
||||
command?: AnyCommand;
|
||||
} {
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
const commandPath = Object.keys(commands)
|
||||
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
|
||||
.find((candidatePath) => {
|
||||
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
|
||||
});
|
||||
|
||||
return commandPath ? { commandPath, command: commands[commandPath] } : {};
|
||||
}
|
||||
|
||||
function documentedCommandSnippets(): string[] {
|
||||
const fencedCommands = installGuide
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("bl "));
|
||||
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
|
||||
return [...new Set([...fencedCommands, ...inlineCommands])];
|
||||
}
|
||||
|
||||
describe("INSTALL.md", () => {
|
||||
test("发布包 Node.js 要求与安装文档一致", () => {
|
||||
const nodeEngine = cliPackage.engines?.node;
|
||||
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
|
||||
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
|
||||
});
|
||||
|
||||
test("示例只使用当前命令支持的 flags", () => {
|
||||
for (const snippet of documentedCommandSnippets()) {
|
||||
const { commandPath, command } = findDocumentedCommand(snippet);
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
|
||||
if (!commandPath || !command) {
|
||||
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
|
||||
}
|
||||
|
||||
const supportedFlags = {
|
||||
...GLOBAL_FLAGS,
|
||||
...(command ? credentialFlagDefs(command) : {}),
|
||||
...command?.flags,
|
||||
};
|
||||
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
|
||||
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
|
||||
const unsupportedFlagNames = usedFlagNames.filter(
|
||||
(flagName) => !supportedFlagNames.has(flagName),
|
||||
);
|
||||
|
||||
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag:${snippet}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.10.0",
|
||||
"version": "1.16.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -40,6 +40,7 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openagentpack/sdk": "0.3.1",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
@@ -55,6 +56,6 @@
|
||||
"vite-plus": "0.1.22"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GetModelsOptions,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
maybeSyncWikiData,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
@@ -248,6 +249,12 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const userInput = flags.message;
|
||||
const top = 3;
|
||||
|
||||
// Keep the local wiki catalog fresh: throttled (12h) version check against
|
||||
// the remote manifest, silently replaces data when a newer version exists.
|
||||
// Never throws — a sync failure must not block recommendation.
|
||||
await maybeSyncWikiData();
|
||||
|
||||
// Default to JSON for structured output; render boxen cards only when the
|
||||
// user explicitly asked for text output.
|
||||
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
|
||||
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
|
||||
|
||||
/** Read-only auth snapshot the config UI account widget renders. bl stores no
|
||||
* user profile (name/avatar), so this exposes only which credential domains
|
||||
* resolve, the console region/site, and a masked token. */
|
||||
export interface AuthUiStatus {
|
||||
authenticated: boolean;
|
||||
methods: { apiKey: boolean; console: boolean; openapi: boolean };
|
||||
primary: "console" | "apiKey" | "openapi" | null;
|
||||
region?: string;
|
||||
site?: "domestic" | "international";
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth capability surface the config UI is allowed to use. All `authStore`
|
||||
* access is kept inside this module (commands/auth/**), which the lint boundary
|
||||
* permits; commands/config/** consumes only this opaque bridge and never
|
||||
* touches `authStore` directly.
|
||||
*/
|
||||
export interface AuthUiBridge {
|
||||
status(): AuthUiStatus;
|
||||
/** Start browser-based console login (fire-and-forget; UI polls status). */
|
||||
startConsoleLogin(): void;
|
||||
/** Clear all stored credentials. Returns whether anything changed. */
|
||||
logout(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Build the bridge from a command context (identity/settings/authStore). */
|
||||
export function makeAuthUiBridge(ctx: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
authStore: AuthStore;
|
||||
}): AuthUiBridge {
|
||||
const { identity, settings, authStore } = ctx;
|
||||
return {
|
||||
status() {
|
||||
const a = authStore.describe();
|
||||
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
|
||||
let masked: string | undefined;
|
||||
if (a.console) masked = maskToken(a.console.token);
|
||||
else if (a.apiKey) masked = maskToken(a.apiKey.token);
|
||||
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
|
||||
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
|
||||
return {
|
||||
authenticated: methods.apiKey || methods.console || methods.openapi,
|
||||
methods,
|
||||
primary,
|
||||
region: a.console?.region,
|
||||
site: a.console?.site,
|
||||
masked,
|
||||
};
|
||||
},
|
||||
startConsoleLogin() {
|
||||
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
|
||||
// Mirror the CLI (`bl auth login --console`): request an api_key from the
|
||||
// console only when one isn't already stored, so a first console login in
|
||||
// the config UI also provisions the model api_key (not just access_token).
|
||||
const hasApiKey = !!authStore.stored().apiKey;
|
||||
// runConsoleLogin opens the browser and runs its own callback server
|
||||
// (up to 15 min). We don't await it — the config UI polls the status
|
||||
// endpoint to detect completion. Errors are logged, not surfaced.
|
||||
void runConsoleLogin(
|
||||
origin,
|
||||
{ identity, settings, authStore },
|
||||
{
|
||||
needApiKey: !hasApiKey,
|
||||
},
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`console login failed: ${msg}\n`);
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
return authStore.logout("all");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,9 @@ interface ApiKeyLoginProfile {
|
||||
baseUrl: string;
|
||||
persistBaseUrl?: string;
|
||||
defaultTextModel?: string;
|
||||
defaultVideoModel?: string;
|
||||
defaultImageToVideoModel?: string;
|
||||
defaultReferenceToVideoModel?: string;
|
||||
defaultImageModel?: string;
|
||||
persistPatch?: AuthPersistPatch;
|
||||
}
|
||||
@@ -54,17 +57,17 @@ export async function validateAndPersistApiKey(
|
||||
const persistBaseUrl = profile.persistBaseUrl
|
||||
? normalizeModelBaseUrl(profile.persistBaseUrl)
|
||||
: undefined;
|
||||
const validationModel = "qwen3.8-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: Math.min(deps.settings.timeout, 30),
|
||||
body: {
|
||||
model: profile.defaultTextModel || "qwen3.7-max",
|
||||
model: validationModel,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
enable_thinking: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -88,6 +91,9 @@ export async function validateAndPersistApiKey(
|
||||
api_key: key,
|
||||
base_url: persistBaseUrl,
|
||||
default_text_model: profile.defaultTextModel,
|
||||
default_video_model: profile.defaultVideoModel,
|
||||
default_image_to_video_model: profile.defaultImageToVideoModel,
|
||||
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
|
||||
default_image_model: profile.defaultImageModel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ export default defineCommand({
|
||||
usageArgs:
|
||||
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "Model API key to store",
|
||||
},
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
@@ -150,6 +154,9 @@ export default defineCommand({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
persistBaseUrl,
|
||||
defaultTextModel: profilePreset?.defaultTextModel,
|
||||
defaultVideoModel: profilePreset?.defaultVideoModel,
|
||||
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
|
||||
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
|
||||
defaultImageModel: profilePreset?.defaultImageModel,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
|
||||
*
|
||||
* The command for each agent is taken from a fixed allowlist keyed by the
|
||||
* agent id, so no user-controlled string is ever executed. Every child process
|
||||
* is spawned via `execFile` (array args, no shell) to avoid injection.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
|
||||
export const AGENT_COMMANDS: Record<string, string> = {
|
||||
"claude-code": "claude",
|
||||
"qwen-code": "qwen",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
codex: "codex",
|
||||
};
|
||||
|
||||
/** The launch binary for a known agent id, or undefined when unknown. */
|
||||
export function agentCommand(id: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent argv that passes an initial task prompt while keeping the agent
|
||||
* interactive in the terminal. Only verified contracts are listed; an agent
|
||||
* absent here cannot be dispatched a prompt (its bare launch still works).
|
||||
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
|
||||
* - claude-code: `claude "<prompt>"` (positional initial prompt)
|
||||
* - codex: `codex "<prompt>"` (positional initial prompt)
|
||||
*/
|
||||
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
|
||||
"qwen-code": (p) => ["-i", p],
|
||||
"claude-code": (p) => [p],
|
||||
codex: (p) => [p],
|
||||
};
|
||||
|
||||
/** Whether a known agent supports being dispatched an initial task prompt. */
|
||||
export function agentSupportsPrompt(id: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
|
||||
}
|
||||
|
||||
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
|
||||
function onPath(bin: string): Promise<boolean> {
|
||||
const cmd = process.platform === "win32" ? "where" : "which";
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a known agent can actually be quick-launched right now: its id maps to
|
||||
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
|
||||
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
|
||||
* CLI is not installed do not offer a launch that would immediately fail.
|
||||
*/
|
||||
export function agentLaunchable(id: string): Promise<boolean> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) return Promise.resolve(false);
|
||||
return onPath(command);
|
||||
}
|
||||
|
||||
/** Single-quote a path for a POSIX shell command line. */
|
||||
function shQuote(p: string): string {
|
||||
return `'${p.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
|
||||
function spawnTerminal(command: string, cwd: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (platform === "darwin") {
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}`;
|
||||
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const args = [
|
||||
"-e",
|
||||
`tell application "Terminal" to do script "${escaped}"`,
|
||||
"-e",
|
||||
'tell application "Terminal" to activate',
|
||||
];
|
||||
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
|
||||
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
// Linux / other: best-effort via the distro's default terminal emulator.
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
|
||||
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
|
||||
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface LaunchResult {
|
||||
launched: boolean;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a known coding agent's local CLI in a new terminal window. When
|
||||
* `prompt` is provided, it is passed as a single quoted argument using the
|
||||
* agent's verified prompt contract so the agent starts with that task.
|
||||
* Rejects when the id is unknown, the binary is missing from PATH, the agent
|
||||
* does not support prompt dispatch, or the platform terminal could not open.
|
||||
*/
|
||||
export async function launchAgent(
|
||||
id: string,
|
||||
cwd: string = process.cwd(),
|
||||
prompt?: string,
|
||||
): Promise<LaunchResult> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) throw new Error(`Unknown agent: ${id}`);
|
||||
if (!(await onPath(command))) {
|
||||
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
|
||||
}
|
||||
let fullCommand = command;
|
||||
const task = (prompt ?? "").trim();
|
||||
if (task) {
|
||||
const build = AGENT_PROMPT_ARGV[id];
|
||||
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
|
||||
// shQuote keeps the whole prompt as one shell argument (no injection); the
|
||||
// platform terminal layer escapes the resulting command line separately.
|
||||
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
|
||||
}
|
||||
await spawnTerminal(fullCommand, cwd);
|
||||
return { launched: true, command: fullCommand };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
|
||||
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
|
||||
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
|
||||
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
|
||||
* ever needs the decode direction.
|
||||
*/
|
||||
|
||||
const TOKEN_PREFIX = "o1_";
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
|
||||
const ALPHABET_SIZE = ALPHABET.length;
|
||||
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
|
||||
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const SALT_LENGTH = 6;
|
||||
const CHECKSUM_LENGTH = 6;
|
||||
const FEISTEL_ROUNDS = 8;
|
||||
|
||||
function invalidCredential(): BailianError {
|
||||
return new BailianError(
|
||||
"Invalid obfuscated API key.",
|
||||
ExitCode.USAGE,
|
||||
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
|
||||
);
|
||||
}
|
||||
|
||||
function toDigits(value: string): number[] {
|
||||
const digits: number[] = [];
|
||||
for (const character of value) {
|
||||
const digit = ALPHABET_INDEX.get(character);
|
||||
if (digit === undefined) throw invalidCredential();
|
||||
digits.push(digit);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function fromDigits(digits: number[]): string {
|
||||
return digits.map((digit) => ALPHABET[digit]).join("");
|
||||
}
|
||||
|
||||
function mixState(state: number, value: number): number {
|
||||
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
function nextState(state: number): number {
|
||||
let next = state >>> 0;
|
||||
next ^= next << 13;
|
||||
next ^= next >>> 17;
|
||||
next ^= next << 5;
|
||||
return next >>> 0;
|
||||
}
|
||||
|
||||
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
|
||||
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
|
||||
|
||||
state = mixState(state, right.length);
|
||||
state = mixState(state, length);
|
||||
for (const character of salt) {
|
||||
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
|
||||
}
|
||||
for (const digit of right) {
|
||||
state = mixState(state, digit + 1);
|
||||
}
|
||||
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 0x85ebca6b) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 0xc2b2ae35) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = state >>> 0 || 0x6d2b79f5;
|
||||
|
||||
const mask: number[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
|
||||
state = nextState(state);
|
||||
mask.push(state % ALPHABET_SIZE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function deobfuscatePayload(payload: string, salt: string): string {
|
||||
const digits = toDigits(payload);
|
||||
const midpoint = Math.floor(digits.length / 2);
|
||||
let left = digits.slice(0, midpoint);
|
||||
let right = digits.slice(midpoint);
|
||||
|
||||
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
|
||||
const previousRight = left;
|
||||
const mask = createRoundMask(previousRight, salt, round, right.length);
|
||||
const previousLeft = right.map(
|
||||
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
|
||||
);
|
||||
left = previousLeft;
|
||||
right = previousRight;
|
||||
}
|
||||
|
||||
return fromDigits([...left, ...right]);
|
||||
}
|
||||
|
||||
function crc32(value: string): number {
|
||||
let checksum = 0xffffffff;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
checksum ^= value.charCodeAt(index);
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
const mask = -(checksum & 1);
|
||||
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
|
||||
}
|
||||
}
|
||||
return (checksum ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeBase65Number(value: number, length: number): string {
|
||||
let remaining = value >>> 0;
|
||||
const encoded = Array<string>(length).fill(ALPHABET[0]);
|
||||
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
|
||||
remaining = Math.floor(remaining / ALPHABET_SIZE);
|
||||
}
|
||||
if (remaining !== 0) throw invalidCredential();
|
||||
return encoded.join("");
|
||||
}
|
||||
|
||||
function validateSalt(salt: string): void {
|
||||
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an "o1_…" obfuscated token back into the plain API key. */
|
||||
export function decodeTokenPlanKey(token: string): string {
|
||||
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
|
||||
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
|
||||
const body = token.slice(TOKEN_PREFIX.length);
|
||||
if (!KEY_PATTERN.test(body)) throw invalidCredential();
|
||||
|
||||
const salt = body.slice(0, SALT_LENGTH);
|
||||
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
|
||||
const checksum = body.slice(-CHECKSUM_LENGTH);
|
||||
validateSalt(salt);
|
||||
if (!payload) throw invalidCredential();
|
||||
|
||||
const apiKey = deobfuscatePayload(payload, salt);
|
||||
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
|
||||
|
||||
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
|
||||
if (checksum !== expectedChecksum) throw invalidCredential();
|
||||
return apiKey;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
import { decodeTokenPlanKey } from "./decode-key.ts";
|
||||
import { resolveRegionBaseUrl } from "./writers/utils.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
@@ -11,30 +13,76 @@ const FLAGS = {
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "API base URL",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
valueHint: "<region>",
|
||||
description:
|
||||
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
valueHint: "<encoded>",
|
||||
description:
|
||||
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description: "OpenClaw only: model context window in tokens (default: 256000)",
|
||||
},
|
||||
wireApi: {
|
||||
type: "string",
|
||||
valueHint: "<api>",
|
||||
description:
|
||||
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
|
||||
choices: ["chat", "responses"],
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
|
||||
usageArgs:
|
||||
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
validate(flags) {
|
||||
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
|
||||
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
|
||||
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
|
||||
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const { model, contextWindow, wireApi } = flags;
|
||||
// --region is a Token Plan convenience: convert it into a base URL and use
|
||||
// it exactly as --base-url would be.
|
||||
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
|
||||
// --key carries the web console's obfuscated form; decode it up front so
|
||||
// even --dry-run validates the token.
|
||||
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -59,13 +107,22 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const params: WriteParams = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
contextWindow,
|
||||
wireApi,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
for (const warning of summary.warnings ?? []) {
|
||||
process.stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
resolveClaudeCodeBaseUrl,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Fill a tier/default model env only when the user has not set it yet. */
|
||||
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
|
||||
const current = env[key];
|
||||
if (current === undefined || current.trim() === "") {
|
||||
env[key] = model;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
||||
const settingsPath = join(configDir, "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
const warnings: string[] = [];
|
||||
|
||||
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
|
||||
if (resolved.rewrittenFrom) {
|
||||
warnings.push(
|
||||
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
|
||||
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
|
||||
);
|
||||
}
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the endpoint; the model tier vars force every tier onto the chosen model.
|
||||
// the Anthropic-compatible endpoint; primary model always updates, while
|
||||
// tier/subagent defaults are filled only when absent so existing setups
|
||||
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_BASE_URL = resolved.url;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
// AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a
|
||||
// stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote.
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
@@ -32,6 +62,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath, onboardingPath],
|
||||
nextStep: "Run `claude` to start using Claude Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -8,8 +8,9 @@ const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
const warnings: string[] = [];
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
@@ -25,8 +26,19 @@ export default {
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
// wire_api — current Codex releases only load `wire_api = "responses"`
|
||||
// ("chat" is rejected at config load, see openai/codex discussion #7782).
|
||||
// "chat" remains an explicit opt-in for users pinned to legacy Codex
|
||||
// <= 0.80.0 (the Model Studio path for models without Responses support).
|
||||
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
|
||||
if (wireApi === "chat") {
|
||||
warnings.push(
|
||||
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
|
||||
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
|
||||
"(e.g. `npm install -g @openai/codex@0.80.0`).",
|
||||
);
|
||||
}
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
@@ -34,14 +46,17 @@ export default {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
// env_key is the official-doc credential mechanism: Codex resolves the
|
||||
// key from the OPENAI_API_KEY env var, falling back to auth.json below.
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: wireApi,
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
@@ -51,6 +66,7 @@ export default {
|
||||
return {
|
||||
paths: [configPath, authPath],
|
||||
nextStep: "Run `codex` to start using Codex with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
@@ -22,26 +20,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
// Official Model Studio doc shape: a single flat `model` block holding the
|
||||
// active endpoint + credentials. `api_mode: anthropic_messages` is required
|
||||
// for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the
|
||||
// doc says to omit api_mode entirely (chat completions is the default).
|
||||
const block: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages";
|
||||
config.model = block;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
|
||||
@@ -2,20 +2,36 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
// Safe default when --context-window is not given: most Model Studio models
|
||||
// offer ≥256K context; users can raise it per model via the flag.
|
||||
const DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
|
||||
const PROVIDER_ID = "bailian-cli";
|
||||
|
||||
function readPrimary(defaults: Record<string, unknown>): string | undefined {
|
||||
const model = defaults.model;
|
||||
if (!model || typeof model !== "object") return undefined;
|
||||
const primary = (model as Record<string, unknown>).primary;
|
||||
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const warnings: string[] = [];
|
||||
const modelRef = `${PROVIDER_ID}/${model}`;
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"]
|
||||
// models.providers["bailian-cli"] — upsert without removing other providers
|
||||
// (e.g. an existing working bailian-token-plan setup).
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
providers[PROVIDER_ID] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
@@ -23,18 +39,35 @@ export default {
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
// agents.defaults — register the model in the allow-list. Only set primary
|
||||
// when unset, or when primary already points at bailian-cli (reconfigure).
|
||||
// Never steal primary away from another provider such as bailian-token-plan.
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
|
||||
defaults.models = allowedModels;
|
||||
|
||||
const existingPrimary = readPrimary(defaults);
|
||||
if (!existingPrimary) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else {
|
||||
warnings.push(
|
||||
`Left existing primary model unchanged ("${existingPrimary}"). ` +
|
||||
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
|
||||
);
|
||||
}
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
@@ -42,7 +75,9 @@ export default {
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
nextStep:
|
||||
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
// opencode.json is JSONC — tolerate comments and trailing commas on read.
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
const config = readJsonc(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
|
||||
@@ -2,53 +2,110 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
const ENV_KEY = "DASHSCOPE_API_KEY";
|
||||
|
||||
function displayName(model: string): string {
|
||||
return `[Bailian] ${model}`;
|
||||
}
|
||||
|
||||
/** Entries we previously wrote, or still own via envKey / display brand. */
|
||||
function isBailianCliEntry(entry: Record<string, unknown>): boolean {
|
||||
if (entry.envKey === ENV_KEY) return true;
|
||||
const name = typeof entry.name === "string" ? entry.name : "";
|
||||
return name === "bailian-cli" || name.startsWith("[Bailian]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
* therefore lives in the env var name (`BAILIAN_CLI_API_KEY`) and the display
|
||||
* label (`[Bailian] …`); Qwen Code keys models by id (+ baseUrl), never by name.
|
||||
*
|
||||
* Qwen Code does not support duplicate model `id`s (only the first loads), so
|
||||
* we must never overwrite a pre-existing Token Plan / third-party entry that
|
||||
* shares the same id.
|
||||
*
|
||||
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
|
||||
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
|
||||
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
|
||||
* picked up when the provider→envKey path does not resolve first. The active
|
||||
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
|
||||
* same-id providers.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
const warnings: string[] = [];
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
|
||||
settings.$version = 3;
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
// Qwen Code treats settings.json `env` as lowest priority; a process/shell
|
||||
// value for the same key wins and can make the first launch fail.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
const processEnvValue = process.env[ENV_KEY];
|
||||
if (processEnvValue !== undefined && processEnvValue !== apiKey) {
|
||||
warnings.push(
|
||||
`Shell/environment ${ENV_KEY} is set and overrides settings.json. ` +
|
||||
`Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`,
|
||||
);
|
||||
}
|
||||
|
||||
// modelProviders[<protocol>] — upsert only bailian-cli-owned entries.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model);
|
||||
const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model);
|
||||
|
||||
if (owned) {
|
||||
owned.baseUrl = baseUrl;
|
||||
owned.envKey = ENV_KEY;
|
||||
const currentName = typeof owned.name === "string" ? owned.name.trim() : "";
|
||||
if (!currentName || currentName === "bailian-cli") owned.name = displayName(model);
|
||||
} else if (conflicting) {
|
||||
const existingName =
|
||||
typeof conflicting.name === "string" && conflicting.name.length > 0
|
||||
? conflicting.name
|
||||
: String(conflicting.id);
|
||||
warnings.push(
|
||||
`Model id "${model}" already exists as "${existingName}"; left unchanged ` +
|
||||
`(Qwen Code loads only the first entry per id). Remove or rename that ` +
|
||||
`entry if you want bailian-cli to own this model.`,
|
||||
);
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
entries.push({
|
||||
id: model,
|
||||
name: displayName(model),
|
||||
baseUrl,
|
||||
envKey: ENV_KEY,
|
||||
});
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
// security.auth — select the protocol AND keep credentials as a fallback
|
||||
// layer (see the file-level note): without this, a stray system
|
||||
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
// model — active model. baseUrl MUST be written alongside name; Qwen Code
|
||||
// uses it to disambiguate same-id providers, and omitting it can misroute
|
||||
// to a different entry (and thus a different credential).
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
@@ -56,6 +113,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** OpenClaw model entry context window (tokens). */
|
||||
contextWindow?: number;
|
||||
/** Codex provider wire protocol: "responses" or "chat". */
|
||||
wireApi?: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
/** Non-fatal issues the command should surface to the user. */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
@@ -20,6 +27,83 @@ export interface AgentDef {
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip JSONC syntax (line / block comments and trailing commas) so the result
|
||||
* parses with `JSON.parse`. String contents are preserved verbatim.
|
||||
*/
|
||||
export function stripJsonc(text: string): string {
|
||||
// Pass 1 — drop comments (string contents preserved verbatim).
|
||||
let uncommented = "";
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (inString) {
|
||||
uncommented += char;
|
||||
if (char === "\\") {
|
||||
uncommented += next ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
while (index < text.length && text[index] !== "\n") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Pass 2 — drop trailing commas (a comma whose next non-whitespace char
|
||||
// closes an object/array). Runs after comment removal so a trailing comment
|
||||
// cannot hide the closing bracket.
|
||||
let output = "";
|
||||
index = 0;
|
||||
inString = false;
|
||||
while (index < uncommented.length) {
|
||||
const char = uncommented[index];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (char === "\\") {
|
||||
output += uncommented[index + 1] ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1;
|
||||
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
@@ -30,6 +114,16 @@ export function readJson(path: string): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
|
||||
export function readJsonc(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -57,3 +151,65 @@ export function backup(path: string): void {
|
||||
export function isAnthropicEndpoint(baseUrl: string): boolean {
|
||||
return baseUrl.includes("/apps/anthropic");
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
|
||||
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
|
||||
* fail with a clear USAGE error before writing a broken config.
|
||||
*/
|
||||
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
|
||||
url: string;
|
||||
rewrittenFrom?: string;
|
||||
} {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
if (isAnthropicEndpoint(trimmed)) {
|
||||
return { url: trimmed };
|
||||
}
|
||||
|
||||
if (trimmed.includes("/compatible-mode")) {
|
||||
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
|
||||
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname;
|
||||
const isDashScopeHost =
|
||||
host.includes("dashscope") ||
|
||||
host.includes("maas.aliyuncs.com") ||
|
||||
host.includes("token-plan");
|
||||
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
|
||||
return {
|
||||
url: `${parsed.origin}/apps/anthropic`,
|
||||
rewrittenFrom: baseUrl.trim(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the USAGE error below.
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Model Studio region id into a Token Plan base URL, used in place of
|
||||
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
|
||||
* rewrites it to /apps/anthropic on its own, and the other writers consume the
|
||||
* compatible-mode URL directly.
|
||||
*/
|
||||
export function resolveRegionBaseUrl(region: string): string {
|
||||
const normalized = region.trim();
|
||||
if (!/^[a-z0-9-]+$/.test(normalized)) {
|
||||
throw new BailianError(
|
||||
`Invalid --region "${region}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
|
||||
);
|
||||
}
|
||||
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Read/manage the local assets that `bl` writes into the output directory
|
||||
// (default ~/bailian-output, overridable via the `output_dir` config key).
|
||||
// Generated media may live directly under the base or in any subfolder (bl's
|
||||
// own images/, videos/, speech/, omni/, or user-created folders). This module
|
||||
// recursively discovers every file under the base, classifies each by type,
|
||||
// derives its category from the top-level folder, and provides safe path
|
||||
// resolution for serving/deleting individual assets.
|
||||
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, extname, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type AssetKind = "image" | "video" | "audio" | "other";
|
||||
|
||||
/** One generated file discovered under the output directory. */
|
||||
export interface AssetInfo {
|
||||
name: string;
|
||||
/** Category folder the file lives in: images | videos | speech | omni | other. */
|
||||
category: string;
|
||||
kind: AssetKind;
|
||||
/** Path relative to the output base (used as the API handle). */
|
||||
relPath: string;
|
||||
size: number;
|
||||
/** Modification time in epoch milliseconds ~= generation time. */
|
||||
mtime: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/** Max directory depth to descend from the output base when scanning. */
|
||||
const MAX_SCAN_DEPTH = 8;
|
||||
|
||||
const KIND_BY_EXT: Record<string, AssetKind> = {
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".webp": "image",
|
||||
".gif": "image",
|
||||
".bmp": "image",
|
||||
".svg": "image",
|
||||
".mp4": "video",
|
||||
".mov": "video",
|
||||
".webm": "video",
|
||||
".mkv": "video",
|
||||
".avi": "video",
|
||||
".mp3": "audio",
|
||||
".wav": "audio",
|
||||
".m4a": "audio",
|
||||
".aac": "audio",
|
||||
".flac": "audio",
|
||||
".ogg": "audio",
|
||||
};
|
||||
|
||||
const CONTENT_TYPE: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
};
|
||||
|
||||
/** The default output base when `output_dir` is not configured. */
|
||||
export function defaultOutputBase(home: string = homedir()): string {
|
||||
return join(home, "bailian-output");
|
||||
}
|
||||
|
||||
function kindOf(ext: string): AssetKind {
|
||||
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
|
||||
}
|
||||
|
||||
/** MIME type for serving an asset; falls back to a safe binary type. */
|
||||
export function contentType(ext: string): string {
|
||||
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
|
||||
function walk(dir: string, depth: number, out: string[]): void {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (depth > 0) walk(full, depth - 1, out);
|
||||
} else if (e.isFile() || e.isSymbolicLink()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List generated assets under `base`, newest first. Recursively scans every
|
||||
* subfolder under the base (plus loose files at the root), so assets in bl's
|
||||
* own category dirs and any user-created folders are all discovered. Each
|
||||
* file's `category` is its top-level folder name, or "other" for root files.
|
||||
* Returns the resolved base so callers can surface it in the UI.
|
||||
*/
|
||||
export function listAssets(base: string = defaultOutputBase()): {
|
||||
base: string;
|
||||
assets: AssetInfo[];
|
||||
} {
|
||||
const assets: AssetInfo[] = [];
|
||||
if (!existsSync(base)) return { base, assets };
|
||||
|
||||
const files: string[] = [];
|
||||
walk(base, MAX_SCAN_DEPTH, files);
|
||||
|
||||
for (const full of files) {
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) continue;
|
||||
const rel = relative(base, full);
|
||||
const segments = rel.split(sep);
|
||||
const category = segments.length > 1 ? segments[0]! : "other";
|
||||
const ext = extname(full);
|
||||
assets.push({
|
||||
name: full.split(sep).pop() ?? full,
|
||||
category,
|
||||
kind: kindOf(ext),
|
||||
relPath: rel,
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
ext: ext.replace(/^\./, "").toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
assets.sort((a, b) => b.mtime - a.mtime);
|
||||
return { base, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied relative path to an absolute path strictly inside
|
||||
* `base`. Returns null for empty input or any path that would escape the base
|
||||
* (path traversal guard).
|
||||
*/
|
||||
export function resolveAssetPath(base: string, relPath: string): string | null {
|
||||
if (typeof relPath !== "string" || relPath.length === 0) return null;
|
||||
const root = resolve(base);
|
||||
const abs = resolve(root, relPath);
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Minimal, dependency-free QR Code encoder used by the config UI to show a
|
||||
* scannable code for the current session URL.
|
||||
*
|
||||
* Scope is deliberately narrow: byte mode, error-correction level L, versions
|
||||
* 1–5 (21x21 … 37x37). Restricting to level L keeps every supported version a
|
||||
* single Reed–Solomon block, so no codeword interleaving is required. Version 5
|
||||
* (level L) holds up to 108 data bytes, comfortably more than a
|
||||
* `http://127.0.0.1:<port>/?token=<hex>` URL.
|
||||
*
|
||||
* The output is an SVG string with a 4-module quiet zone and a `viewBox` only
|
||||
* (no fixed width/height), so the caller sizes it via CSS.
|
||||
*/
|
||||
|
||||
// --- GF(256) arithmetic (primitive polynomial 0x11D) ---
|
||||
|
||||
const EXP = new Uint8Array(512);
|
||||
const LOG = new Uint8Array(256);
|
||||
(() => {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x100) x ^= 0x11d;
|
||||
}
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
|
||||
function gmul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return EXP[LOG[a] + LOG[b]];
|
||||
}
|
||||
|
||||
/** Reed–Solomon generator polynomial for `degree` EC codewords (alpha exponents). */
|
||||
export function rsGeneratorExp(degree: number): number[] {
|
||||
let poly = [1];
|
||||
for (let i = 0; i < degree; i++) {
|
||||
const next: number[] = Array.from({ length: poly.length + 1 }, () => 0);
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
next[j] ^= poly[j];
|
||||
next[j + 1] ^= gmul(poly[j], EXP[i]);
|
||||
}
|
||||
poly = next;
|
||||
}
|
||||
return poly.map((v) => LOG[v]);
|
||||
}
|
||||
|
||||
/** Compute `ecLen` Reed–Solomon error-correction codewords for `data`. */
|
||||
export function rsEncode(data: number[], ecLen: number): number[] {
|
||||
const gen = rsGeneratorExp(ecLen);
|
||||
const res = new Uint8Array(data.length + ecLen);
|
||||
res.set(data, 0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const coef = res[i];
|
||||
if (coef !== 0) {
|
||||
const lead = LOG[coef];
|
||||
for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255];
|
||||
}
|
||||
}
|
||||
return Array.from(res.slice(data.length));
|
||||
}
|
||||
|
||||
// --- Capacity table: [data codewords, EC codewords] per version at level L ---
|
||||
|
||||
const CAP_L: Array<[number, number]> = [
|
||||
[19, 7], // V1 (21x21)
|
||||
[34, 10], // V2 (25x25)
|
||||
[55, 15], // V3 (29x29)
|
||||
[80, 20], // V4 (33x33)
|
||||
[108, 26], // V5 (37x37)
|
||||
];
|
||||
|
||||
const EC_BITS_L = 0b01; // format-info error-correction level bits for L
|
||||
|
||||
function pickVersion(byteLen: number): number {
|
||||
const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V1–9) + payload
|
||||
for (let v = 0; v < CAP_L.length; v++) {
|
||||
if (CAP_L[v][0] * 8 >= bits) return v + 1;
|
||||
}
|
||||
throw new Error("qr: data too large for supported versions (max 108 bytes)");
|
||||
}
|
||||
|
||||
// --- Bit/codeword assembly ---
|
||||
|
||||
function toCodewords(bytes: Uint8Array, version: number): number[] {
|
||||
const [dataCw] = CAP_L[version - 1];
|
||||
const bits: number[] = [];
|
||||
const put = (val: number, len: number) => {
|
||||
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
|
||||
};
|
||||
put(0b0100, 4); // byte mode
|
||||
put(bytes.length, 8); // character count (versions 1–9)
|
||||
for (const b of bytes) put(b, 8);
|
||||
|
||||
const capBits = dataCw * 8;
|
||||
put(0, Math.min(4, capBits - bits.length)); // terminator
|
||||
while (bits.length % 8 !== 0) bits.push(0); // pad to byte
|
||||
|
||||
const data: number[] = [];
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
let v = 0;
|
||||
for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j];
|
||||
data.push(v);
|
||||
}
|
||||
const pads = [0xec, 0x11];
|
||||
for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]);
|
||||
|
||||
return data.concat(rsEncode(data, CAP_L[version - 1][1]));
|
||||
}
|
||||
|
||||
// --- Matrix construction ---
|
||||
|
||||
interface Grid {
|
||||
size: number;
|
||||
mod: Uint8Array; // 0/1
|
||||
fn: Uint8Array; // 1 = function/reserved module (skip during data placement)
|
||||
}
|
||||
|
||||
function newGrid(size: number): Grid {
|
||||
return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) };
|
||||
}
|
||||
|
||||
function setFn(g: Grid, r: number, c: number, dark: number): void {
|
||||
g.mod[r * g.size + c] = dark;
|
||||
g.fn[r * g.size + c] = 1;
|
||||
}
|
||||
|
||||
function drawFinder(g: Grid, r: number, c: number): void {
|
||||
for (let dr = -1; dr <= 7; dr++) {
|
||||
for (let dc = -1; dc <= 7; dc++) {
|
||||
const rr = r + dr;
|
||||
const cc = c + dc;
|
||||
if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue;
|
||||
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
||||
const isDark =
|
||||
inRing &&
|
||||
(dr === 0 ||
|
||||
dr === 6 ||
|
||||
dc === 0 ||
|
||||
dc === 6 ||
|
||||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||
setFn(g, rr, cc, isDark ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawAlignment(g: Grid, cr: number, cc: number): void {
|
||||
for (let dr = -2; dr <= 2; dr++) {
|
||||
for (let dc = -2; dc <= 2; dc++) {
|
||||
const ring = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFunctionPatterns(g: Grid, version: number): void {
|
||||
const size = g.size;
|
||||
// Timing patterns.
|
||||
for (let i = 0; i < size; i++) {
|
||||
setFn(g, 6, i, i % 2 === 0 ? 1 : 0);
|
||||
setFn(g, i, 6, i % 2 === 0 ? 1 : 0);
|
||||
}
|
||||
// Finder patterns + separators (drawn as the -1 border above).
|
||||
drawFinder(g, 0, 0);
|
||||
drawFinder(g, 0, size - 7);
|
||||
drawFinder(g, size - 7, 0);
|
||||
// Alignment pattern (single, centered) for versions 2–5.
|
||||
if (version >= 2) {
|
||||
const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5)
|
||||
drawAlignment(g, pos, pos);
|
||||
}
|
||||
// Reserve format-info areas (values written later).
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (!(i === 6)) g.fn[8 * size + i] = 1;
|
||||
if (!(i === 6)) g.fn[i * size + 8] = 1;
|
||||
}
|
||||
g.fn[8 * size + 6] = 1;
|
||||
g.fn[6 * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1;
|
||||
// Dark module.
|
||||
setFn(g, size - 8, 8, 1);
|
||||
}
|
||||
|
||||
function placeData(g: Grid, codewords: number[]): void {
|
||||
const size = g.size;
|
||||
const stream: number[] = [];
|
||||
for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1);
|
||||
let idx = 0;
|
||||
let upward = true;
|
||||
for (let col = size - 1; col >= 1; col -= 2) {
|
||||
if (col === 6) col = 5; // skip the vertical timing column
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (const off of [0, 1]) {
|
||||
const cc = col - off;
|
||||
if (g.fn[row * size + cc]) continue;
|
||||
g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0;
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
}
|
||||
|
||||
const MASKS: Array<(r: number, c: number) => boolean> = [
|
||||
(r, c) => (r + c) % 2 === 0,
|
||||
(r) => r % 2 === 0,
|
||||
(_r, c) => c % 3 === 0,
|
||||
(r, c) => (r + c) % 3 === 0,
|
||||
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
||||
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
||||
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
];
|
||||
|
||||
function applyMask(g: Grid, mask: number): void {
|
||||
const cond = MASKS[mask];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
for (let c = 0; c < g.size; c++) {
|
||||
if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function penalty(g: Grid): number {
|
||||
const size = g.size;
|
||||
const at = (r: number, c: number) => g.mod[r * size + c];
|
||||
let score = 0;
|
||||
// Rule 1: runs of >=5 same-color modules in rows and columns.
|
||||
for (let r = 0; r < size; r++) {
|
||||
let runC = 1;
|
||||
let runR = 1;
|
||||
for (let c = 1; c < size; c++) {
|
||||
if (at(r, c) === at(r, c - 1)) runC++;
|
||||
else {
|
||||
if (runC >= 5) score += runC - 2;
|
||||
runC = 1;
|
||||
}
|
||||
if (at(c, r) === at(c - 1, r)) runR++;
|
||||
else {
|
||||
if (runR >= 5) score += runR - 2;
|
||||
runR = 1;
|
||||
}
|
||||
}
|
||||
if (runC >= 5) score += runC - 2;
|
||||
if (runR >= 5) score += runR - 2;
|
||||
}
|
||||
// Rule 2: 2x2 blocks of the same color.
|
||||
for (let r = 0; r < size - 1; r++) {
|
||||
for (let c = 0; c < size - 1; c++) {
|
||||
const v = at(r, c);
|
||||
if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3;
|
||||
}
|
||||
}
|
||||
// Rule 3: finder-like 1:1:3:1:1 patterns.
|
||||
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
|
||||
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
|
||||
const match = (get: (k: number) => number, start: number, pat: number[]) => {
|
||||
for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false;
|
||||
return true;
|
||||
};
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c <= size - 11; c++) {
|
||||
if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40;
|
||||
if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40;
|
||||
}
|
||||
}
|
||||
// Rule 4: proportion of dark modules.
|
||||
let dark = 0;
|
||||
for (let i = 0; i < size * size; i++) dark += g.mod[i];
|
||||
const percent = (dark * 100) / (size * size);
|
||||
const k = Math.floor(Math.abs(percent - 50) / 5);
|
||||
score += k * 10;
|
||||
return score;
|
||||
}
|
||||
|
||||
function formatBits(mask: number): number {
|
||||
const data = (EC_BITS_L << 3) | mask; // 5 bits
|
||||
let rem = data << 10;
|
||||
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
|
||||
return ((data << 10) | rem) ^ 0x5412;
|
||||
}
|
||||
|
||||
function drawFormat(g: Grid, mask: number): void {
|
||||
const size = g.size;
|
||||
const fmt = formatBits(mask);
|
||||
const bit = (i: number) => (fmt >> i) & 1;
|
||||
// First copy: around the top-left finder. Bits 0–5 run down column 8
|
||||
// (rows 0–5); bits 9–14 run left along row 8 (cols 5–0).
|
||||
for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i);
|
||||
g.mod[7 * size + 8] = bit(6);
|
||||
g.mod[8 * size + 8] = bit(7);
|
||||
g.mod[8 * size + 7] = bit(8);
|
||||
for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i);
|
||||
// Second copy: split across top-right and bottom-left.
|
||||
for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i);
|
||||
for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i);
|
||||
g.mod[(size - 8) * size + 8] = 1; // dark module stays set
|
||||
}
|
||||
|
||||
/** Build the final QR module matrix (true = dark) for `text`. */
|
||||
export function qrMatrix(text: string): boolean[][] {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const version = pickVersion(bytes.length);
|
||||
const codewords = toCodewords(bytes, version);
|
||||
const g = newGrid(17 + 4 * version);
|
||||
drawFunctionPatterns(g, version);
|
||||
placeData(g, codewords);
|
||||
|
||||
let best = 0;
|
||||
let bestScore = Infinity;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
applyMask(g, m);
|
||||
drawFormat(g, m);
|
||||
const s = penalty(g);
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = m;
|
||||
}
|
||||
applyMask(g, m); // undo (XOR is its own inverse)
|
||||
}
|
||||
applyMask(g, best);
|
||||
drawFormat(g, best);
|
||||
|
||||
const out: boolean[][] = [];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
const row: boolean[] = [];
|
||||
for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */
|
||||
export function qrSvg(text: string): string {
|
||||
const m = qrMatrix(text);
|
||||
const size = m.length;
|
||||
const quiet = 4;
|
||||
const dim = size + quiet * 2;
|
||||
let rects = "";
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (m[r][c]) rects += `<rect x="${c + quiet}" y="${r + quiet}" width="1" height="1"/>`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
|
||||
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
|
||||
`<rect width="${dim}" height="${dim}" fill="#ffffff"/>` +
|
||||
`<g fill="#000000">${rects}</g></svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Curated "Playground" scenarios surfaced in the config UI.
|
||||
*
|
||||
* Each scenario is a fixed, reviewable prompt template that the UI can dispatch
|
||||
* to a connected local coding agent (e.g. qwen-code), which then runs it in a
|
||||
* new terminal. Optional `{{inputs}}` are filled by the user before dispatch.
|
||||
*
|
||||
* Prompts are defined here and never accepted as free-form text from the web,
|
||||
* so the instruction handed to a local agent is always known and auditable.
|
||||
*/
|
||||
export interface ScenarioInput {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
prompt: string;
|
||||
inputs?: ScenarioInput[];
|
||||
}
|
||||
|
||||
export const SCENARIOS: Scenario[] = [
|
||||
// ---- 图像 ----
|
||||
{
|
||||
id: "image-generate",
|
||||
title: "文生图",
|
||||
description: "一键生成一张示例图片并保存到输出目录。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。",
|
||||
},
|
||||
{
|
||||
id: "image-describe",
|
||||
title: "图片理解",
|
||||
description: "从输出目录任选一张图片,详细描述内容与风格。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,用中文详细描述它的内容、主体、构图、色彩与风格,并推测它适合的使用场景。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-alt-batch",
|
||||
title: "批量 Alt 文本",
|
||||
description: "为输出目录下的图片批量生成无障碍 alt 文本。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请扫描输出目录(默认 output/images)下的所有图片,逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-to-code",
|
||||
title: "截图转代码",
|
||||
description: "把输出目录里的界面截图还原成 HTML+CSS。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中查找一张界面截图,用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。",
|
||||
},
|
||||
// ---- 音频 ----
|
||||
{
|
||||
id: "speech-generate",
|
||||
title: "文字转语音",
|
||||
description: "把一句示例文字合成为自然语音。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。",
|
||||
},
|
||||
{
|
||||
id: "audio-summarize",
|
||||
title: "音频转写总结",
|
||||
description: "转写输出目录里的音频并提炼要点。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/speech)中找到一个音频文件,转写其内容,先给出完整文字,再用要点列表总结关键信息。若目录为空或缺少转写能力,请说明并尝试用可用的能力完成。",
|
||||
},
|
||||
// ---- 视频 ----
|
||||
{
|
||||
id: "video-generate",
|
||||
title: "文生视频",
|
||||
description: "一键生成一段示例短视频。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。",
|
||||
},
|
||||
{
|
||||
id: "video-storyboard",
|
||||
title: "视频分镜脚本",
|
||||
description: "围绕示例主题产出可用于文生视频的分镜。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。",
|
||||
},
|
||||
// ---- 多模态 ----
|
||||
{
|
||||
id: "media-prompt-craft",
|
||||
title: "多模态提示词",
|
||||
description: "把一个示例创意扩展成图/视频/语音提示词。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词:1) 文生图;2) 文生视频;3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。",
|
||||
},
|
||||
{
|
||||
id: "image-story-narration",
|
||||
title: "图片配音文案",
|
||||
description: "为输出目录里的图片写解说词并给出可合成文本。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。",
|
||||
},
|
||||
// ---- 代码 ----
|
||||
{
|
||||
id: "summarize-project",
|
||||
title: "总结当前项目",
|
||||
description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请阅读当前工作目录的项目结构和关键源码,用简洁的中文总结:1) 它是做什么的;2) 技术栈;3) 主要模块及其职责;4) 值得注意的设计。先浏览再下结论,不要臆测。",
|
||||
},
|
||||
{
|
||||
id: "write-tests",
|
||||
title: "为核心模块写单测",
|
||||
description: "自动挑选缺测试的核心模块并补全单元测试。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。",
|
||||
},
|
||||
{
|
||||
id: "code-review",
|
||||
title: "代码审查",
|
||||
description: "审查当前项目核心代码,指出问题与改进建议。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题,并给出具体、可操作的改进建议,按严重程度排序。先浏览项目结构,选取关键文件再审查。",
|
||||
},
|
||||
{
|
||||
id: "explain-code",
|
||||
title: "解释核心代码",
|
||||
description: "挑选入口或核心模块,解释其实现与依赖。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。",
|
||||
},
|
||||
// ---- 文档 ----
|
||||
{
|
||||
id: "generate-readme",
|
||||
title: "生成 README",
|
||||
description: "阅读代码后生成结构清晰、与实现一致的 README.md。",
|
||||
category: "文档",
|
||||
prompt:
|
||||
"为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。",
|
||||
},
|
||||
];
|
||||
|
||||
/** Look up a scenario by id, or undefined when unknown. */
|
||||
export function getScenario(id: string): Scenario | undefined {
|
||||
return SCENARIOS.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */
|
||||
export function renderScenarioPrompt(scenario: Scenario, values: Record<string, string>): string {
|
||||
return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
|
||||
const v = values[key];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,12 @@ export default defineCommand({
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
|
||||
required: true,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
value: {
|
||||
type: "string",
|
||||
valueHint: "<value>",
|
||||
description: "Value to set",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
"--key output --value json",
|
||||
@@ -44,7 +49,9 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
await ctx.configStore.write({
|
||||
[resolvedKey]: coerced,
|
||||
} as Partial<ConfigFile>);
|
||||
|
||||
if (!settings.quiet) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
|
||||
|
||||
@@ -13,6 +13,8 @@ export const VALID_KEYS = [
|
||||
"security_token",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_to_video_model",
|
||||
"default_reference_to_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
@@ -30,6 +32,80 @@ export const SECRET_KEYS = new Set<string>([
|
||||
"security_token",
|
||||
]);
|
||||
|
||||
// The web UI edits the full ConfigFile, so it exposes these extra keys on top
|
||||
// of VALID_KEYS (which `config set` keeps as its narrower, documented surface).
|
||||
// This lets `config ui` surface and edit every field that lives in config.json
|
||||
// rather than silently hiding console/telemetry settings.
|
||||
export const UI_EXTRA_KEYS = [
|
||||
"console_site",
|
||||
"console_region",
|
||||
"console_switch_agent",
|
||||
"telemetry",
|
||||
] as const;
|
||||
|
||||
export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const;
|
||||
|
||||
// Keys the UI renders as a fixed-choice dropdown instead of a free-text input.
|
||||
export const UI_ENUM_KEYS: Record<string, string[]> = {
|
||||
output: ["text", "json"],
|
||||
console_site: ["domestic", "international"],
|
||||
};
|
||||
|
||||
// Keys the UI renders as a true/false dropdown and stores as a boolean.
|
||||
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
|
||||
|
||||
// Default model each `default_*_model` key falls back to when left unset. These
|
||||
// mirror the inline `|| "<model>"` fallbacks in the generation commands
|
||||
// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and
|
||||
// are surfaced as input placeholders so users can see the effective default
|
||||
// without persisting a value that would pin the model.
|
||||
export const UI_MODEL_DEFAULTS: Record<string, string> = {
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_image_model: "qwen-image-3.0",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_speech_model: "cosyvoice-v3-flash",
|
||||
default_omni_model: "qwen3.5-omni-plus",
|
||||
};
|
||||
|
||||
/** One selectable model plus a short note on where the CLI uses it. */
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// A per-category catalog of the model names the `bl` pipeline actually
|
||||
// references (packages/runtime/src/pipeline/steps/bl-api.ts, plus the advisor
|
||||
// and agent-writer helpers). The UI groups these under each `default_*_model`
|
||||
// field as click-to-fill suggestions; the first entry is the fallback default.
|
||||
// Only names present in the codebase are listed here — no invented models.
|
||||
export const UI_MODEL_CATALOG: Record<string, ModelOption[]> = {
|
||||
default_text_model: [
|
||||
{ id: "qwen3.8-max", role: "text/chat default" },
|
||||
{ id: "qwen3-coder-plus", role: "coding-oriented (agent config)" },
|
||||
{ id: "qwen-flash", role: "fast · advisor ranking" },
|
||||
{ id: "qwen3.6-flash", role: "fast · advisor intent" },
|
||||
],
|
||||
default_image_model: [
|
||||
{ id: "qwen-image-3.0", role: "image/generate default · sync" },
|
||||
{ id: "qwen-image-2.0", role: "image/generate · sync" },
|
||||
{ id: "qwen-image-max", role: "image/generate · sync" },
|
||||
{ id: "qwen-image-edit-2.0", role: "image/edit · sync" },
|
||||
{ id: "wanx2.x", role: "image/generate · async series" },
|
||||
],
|
||||
default_video_model: [
|
||||
{ id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" },
|
||||
{ id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" },
|
||||
],
|
||||
default_speech_model: [
|
||||
{ id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" },
|
||||
{ id: "fun-asr", role: "speech/recognize (ASR)" },
|
||||
],
|
||||
default_omni_model: [
|
||||
{ id: "qwen3.5-omni-plus", role: "omni/chat default" },
|
||||
{ id: "qwen3-vl-plus", role: "vision/describe · multimodal input" },
|
||||
],
|
||||
};
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
|
||||
export const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
@@ -41,6 +117,8 @@ export const KEY_ALIASES: Record<string, string> = {
|
||||
"security-token": "security_token",
|
||||
"default-text-model": "default_text_model",
|
||||
"default-video-model": "default_video_model",
|
||||
"default-image-to-video-model": "default_image_to_video_model",
|
||||
"default-reference-to-video-model": "default_reference_to_video_model",
|
||||
"default-image-model": "default_image_model",
|
||||
"default-speech-model": "default_speech_model",
|
||||
"default-omni-model": "default_omni_model",
|
||||
@@ -88,3 +166,55 @@ export function validateAndCoerce(key: string, value: string): string | number {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate/coerce a value for the wider set of keys the web UI can edit
|
||||
* (UI_VALID_KEYS). Standard keys delegate to `validateAndCoerce`; the UI-only
|
||||
* extras (console_*, telemetry) are validated here. Booleans are returned as
|
||||
* real booleans so they persist correctly in config.json.
|
||||
*/
|
||||
export function validateAndCoerceUi(key: string, value: string): string | number | boolean {
|
||||
const resolvedKey = resolveKey(key);
|
||||
|
||||
if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) {
|
||||
return validateAndCoerce(key, value);
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_site") {
|
||||
if (!["domestic", "international"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid console_site "${value}". Valid values: domestic, international`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_region") return value;
|
||||
|
||||
if (resolvedKey === "console_switch_agent") {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid console_switch_agent "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
if (resolvedKey === "telemetry") {
|
||||
if (value !== "true" && value !== "false") {
|
||||
throw new BailianError(
|
||||
`Invalid telemetry "${value}". Valid values: true, false`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${UI_VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,7 @@
|
||||
import http from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import {
|
||||
defineCommand,
|
||||
@@ -10,13 +12,38 @@ import {
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
deleteConfigProfile,
|
||||
REGIONS,
|
||||
type ConfigStore,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts";
|
||||
import { PAGE_HTML } from "./ui-html.ts";
|
||||
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
import {
|
||||
UI_VALID_KEYS,
|
||||
UI_ENUM_KEYS,
|
||||
UI_BOOLEAN_KEYS,
|
||||
UI_MODEL_DEFAULTS,
|
||||
UI_MODEL_CATALOG,
|
||||
SECRET_KEYS,
|
||||
resolveKey,
|
||||
validateAndCoerceUi,
|
||||
} from "./shared.ts";
|
||||
import {
|
||||
listSkills,
|
||||
listMcpServers,
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
getAgentDetail,
|
||||
writeMcpServer,
|
||||
deleteMcpServer,
|
||||
installSkillZip,
|
||||
} from "./inventory.ts";
|
||||
import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts";
|
||||
import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts";
|
||||
import { qrSvg } from "./qr.ts";
|
||||
import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts";
|
||||
import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts";
|
||||
|
||||
const FLAGS = {
|
||||
port: {
|
||||
@@ -50,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
@@ -59,16 +87,47 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Max size for binary uploads (skill .zip packages). */
|
||||
const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB
|
||||
|
||||
function readBodyBuffer(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > max) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Constant-time token comparison (avoids timing side channels). */
|
||||
function tokenMatches(provided: string | null, expected: string): boolean {
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
function buildProfilePatch(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, string | number | boolean> {
|
||||
const cleaned: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
let value = "";
|
||||
if (typeof v === "string") value = v;
|
||||
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
|
||||
// null/undefined/objects fall through as "" and clear the key
|
||||
if (value === "") continue;
|
||||
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
|
||||
cleaned[resolveKey(k)] = validateAndCoerceUi(k, value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -76,9 +135,9 @@ function buildProfilePatch(data: Record<string, unknown>): Record<string, string
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
managedPatch: Record<string, string | number | boolean>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const managedKeys = new Set<string>(UI_VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (!managedKeys.has(key)) merged[key] = value;
|
||||
@@ -91,7 +150,12 @@ function mergeUnmanagedProfileFields(
|
||||
* - Host header must be a loopback name (anti DNS-rebinding).
|
||||
* - every request must carry `?token=` matching the session token.
|
||||
*/
|
||||
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
|
||||
export function createConfigUiServer(
|
||||
token: string,
|
||||
configStore: ConfigStore,
|
||||
outputBase: string = defaultOutputBase(),
|
||||
authBridge?: AuthUiBridge,
|
||||
): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
@@ -102,7 +166,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("token") !== token) {
|
||||
if (!tokenMatches(u.searchParams.get("token"), token)) {
|
||||
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("unauthorized\n");
|
||||
return;
|
||||
@@ -112,17 +176,54 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
const path = u.pathname;
|
||||
|
||||
if (path === "/" && method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
// The page URL carries the session token, so never cache it.
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " +
|
||||
"media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
});
|
||||
res.end(PAGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/qr" && method === "GET") {
|
||||
const data = (u.searchParams.get("data") ?? "").slice(0, 512);
|
||||
if (!data) {
|
||||
sendJson(res, 400, { error: "missing data" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const svg = qrSvg(data);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(svg);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
const profiles = configStore.profiles();
|
||||
sendJson(res, 200, {
|
||||
configFile: configStore.path,
|
||||
keys: VALID_KEYS,
|
||||
keys: UI_VALID_KEYS,
|
||||
secretKeys: [...SECRET_KEYS],
|
||||
enums: UI_ENUM_KEYS,
|
||||
booleanKeys: [...UI_BOOLEAN_KEYS],
|
||||
fieldDefaults: {
|
||||
...UI_MODEL_DEFAULTS,
|
||||
base_url: REGIONS.cn,
|
||||
output_dir: defaultOutputBase(),
|
||||
timeout: "300",
|
||||
},
|
||||
modelCatalog: UI_MODEL_CATALOG,
|
||||
activeProfile: profiles.active,
|
||||
default: profiles.default,
|
||||
named: profiles.named,
|
||||
@@ -130,6 +231,342 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skills" && method === "GET") {
|
||||
sendJson(res, 200, { skills: listSkills() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill" && method === "GET") {
|
||||
const detail = getSkillDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill/install" && method === "POST") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
const buf = await readBodyBuffer(req, MAX_UPLOAD);
|
||||
const result = installSkillZip(source, buf, name);
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "GET") {
|
||||
sendJson(res, 200, { servers: listMcpServers() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
source?: unknown;
|
||||
scope?: unknown;
|
||||
name?: unknown;
|
||||
config?: unknown;
|
||||
};
|
||||
const source = typeof body.source === "string" ? body.source : "";
|
||||
const scope = typeof body.scope === "string" && body.scope ? body.scope : "global";
|
||||
const name = typeof body.name === "string" ? body.name : "";
|
||||
try {
|
||||
writeMcpServer(source, scope, name, body.config);
|
||||
sendJson(res, 200, { saved: name.trim() });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "DELETE") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const scope = u.searchParams.get("scope") || "global";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
deleteMcpServer(source, scope, name);
|
||||
sendJson(res, 200, { deleted: name });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/health" && method === "GET") {
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
sendJson(res, 200, {
|
||||
node: process.version,
|
||||
nodeOk: Number.isFinite(major) && major >= 18,
|
||||
platform: process.platform,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agents" && method === "GET") {
|
||||
// Augment each agent with `launchable`: whether its CLI binary is on
|
||||
// PATH. "Connected" only means bl is wired into the agent's config, so
|
||||
// the UI uses this to avoid offering a launch that would instantly fail.
|
||||
// `dispatchable` additionally requires a verified prompt contract.
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
sendJson(res, 200, {
|
||||
agents: agents.map((a, i) => ({
|
||||
...a,
|
||||
launchable: launchable[i],
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent" && method === "GET") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/open" && method === "POST") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
const target = u.searchParams.get("path") ?? "";
|
||||
const allowed = detail?.settings.some((s) => s.path === target) ?? false;
|
||||
if (!detail || !allowed || !existsSync(target)) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(target);
|
||||
sendJson(res, 200, { opened: target });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/scenarios" && method === "GET") {
|
||||
// Curated Playground scenarios plus the connected agents that can be
|
||||
// dispatched a prompt right now (on PATH + verified prompt contract).
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
const targets = agents
|
||||
.map((a, i) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
}))
|
||||
.filter((a) => a.dispatchable);
|
||||
sendJson(res, 200, { scenarios: SCENARIOS, agents: targets });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/status" && method === "GET") {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
authBridge
|
||||
? authBridge.status()
|
||||
: {
|
||||
authenticated: false,
|
||||
methods: { apiKey: false, console: false, openapi: false },
|
||||
primary: null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "login unavailable" });
|
||||
return;
|
||||
}
|
||||
authBridge.startConsoleLogin();
|
||||
sendJson(res, 200, { started: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "logout unavailable" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedOut = await authBridge.logout();
|
||||
sendJson(res, 200, { loggedOut });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/assets" && method === "GET") {
|
||||
sendJson(res, 200, listAssets(outputBase));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/file" && method === "GET") {
|
||||
const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? "");
|
||||
const st = abs && existsSync(abs) ? statSync(abs) : null;
|
||||
if (!abs || !st || !st.isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": contentType(extname(abs)),
|
||||
"Content-Length": st.size,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const stream = createReadStream(abs);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset" && method === "DELETE") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(abs);
|
||||
sendJson(res, 200, { deleted: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/open" && method === "POST") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(abs);
|
||||
sendJson(res, 200, { opened: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/launch" && method === "POST") {
|
||||
try {
|
||||
const result = await launchAgent(u.searchParams.get("id") ?? "");
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/dispatch" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
scenario?: unknown;
|
||||
agent?: unknown;
|
||||
values?: unknown;
|
||||
custom?: unknown;
|
||||
};
|
||||
const agentId = typeof body.agent === "string" ? body.agent : "";
|
||||
if (!agentSupportsPrompt(agentId)) {
|
||||
sendJson(res, 400, { error: "agent cannot be dispatched a prompt" });
|
||||
return;
|
||||
}
|
||||
let scenario: Scenario | undefined;
|
||||
const custom = body.custom;
|
||||
if (custom && typeof custom === "object" && !Array.isArray(custom)) {
|
||||
const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown };
|
||||
const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : "";
|
||||
if (!promptTpl) {
|
||||
sendJson(res, 400, { error: "custom scenario needs a prompt" });
|
||||
return;
|
||||
}
|
||||
const inputs: { key: string; label: string }[] = [];
|
||||
if (Array.isArray(c.inputs)) {
|
||||
for (const it of c.inputs as unknown[]) {
|
||||
if (it && typeof it === "object") {
|
||||
const o = it as { key?: unknown; label?: unknown };
|
||||
const key = typeof o.key === "string" ? o.key.trim() : "";
|
||||
if (key) {
|
||||
const label =
|
||||
typeof o.label === "string" && o.label.trim() ? o.label.trim() : key;
|
||||
inputs.push({ key, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scenario = {
|
||||
id: "custom",
|
||||
title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom",
|
||||
description: "",
|
||||
category: "\u81ea\u5b9a\u4e49",
|
||||
prompt: promptTpl,
|
||||
inputs,
|
||||
};
|
||||
} else {
|
||||
scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined;
|
||||
}
|
||||
if (!scenario) {
|
||||
sendJson(res, 400, { error: "unknown scenario" });
|
||||
return;
|
||||
}
|
||||
const values: Record<string, string> = {};
|
||||
if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) {
|
||||
for (const [k, v] of Object.entries(body.values as Record<string, unknown>)) {
|
||||
if (typeof v === "string") values[k] = v;
|
||||
}
|
||||
}
|
||||
for (const inp of scenario.inputs ?? []) {
|
||||
if (!values[inp.key] || !values[inp.key]!.trim()) {
|
||||
sendJson(res, 400, { error: `Missing input: ${inp.label}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const prompt = renderScenarioPrompt(scenario, values);
|
||||
try {
|
||||
const result = await launchAgent(agentId, process.cwd(), prompt);
|
||||
sendJson(res, 200, {
|
||||
launched: true,
|
||||
agent: agentId,
|
||||
scenario: scenario.id,
|
||||
command: result.command,
|
||||
});
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/active" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
@@ -164,7 +601,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
let normalized: string | undefined;
|
||||
let cleaned: Record<string, string | number>;
|
||||
let cleaned: Record<string, string | number | boolean>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
@@ -191,9 +628,15 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found\n");
|
||||
} catch {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// Log server-side so failures are diagnosable, and return a JSON error
|
||||
// instead of an empty 500 body.
|
||||
console.error("[config ui] request failed:", err);
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, { error: errMessage(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -217,9 +660,29 @@ export default defineCommand({
|
||||
routes: [
|
||||
"GET / -> web UI",
|
||||
"GET /api/config -> read all profiles",
|
||||
"GET /api/skills -> list installed agent skills",
|
||||
"GET /api/skill -> read one skill's SKILL.md detail",
|
||||
"POST /api/skill/install -> install a skill from an uploaded .zip into a skills root",
|
||||
"GET /api/mcp -> list local MCP servers",
|
||||
"POST /api/mcp -> create or update one MCP server (writes its source config)",
|
||||
"DELETE /api/mcp -> remove one MCP server from its source config",
|
||||
"GET /api/health -> runtime environment info (node, platform, cwd)",
|
||||
"GET /api/agents -> list coding agent frameworks",
|
||||
"GET /api/agent -> one agent's config detail (secrets masked)",
|
||||
"POST /api/agent/open -> open one agent's config file with the OS default app",
|
||||
"GET /api/auth/status -> current auth state",
|
||||
"POST /api/auth/login -> start console login (opens browser)",
|
||||
"POST /api/auth/logout -> clear all stored credentials",
|
||||
"GET /api/assets -> list generated assets",
|
||||
"GET /api/asset/file -> stream one asset file",
|
||||
"POST /api/asset/open -> open one asset with the OS default app",
|
||||
"POST /api/agent/launch -> launch a coding agent CLI in a new terminal",
|
||||
"GET /api/scenarios -> list Playground scenarios and dispatchable agents",
|
||||
"POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent",
|
||||
"POST /api/profile -> save a profile",
|
||||
"POST /api/active -> activate a profile",
|
||||
"DELETE /api/profile -> delete a named profile",
|
||||
"DELETE /api/asset -> delete one asset file",
|
||||
],
|
||||
},
|
||||
format,
|
||||
@@ -228,7 +691,8 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const server = createConfigUiServer(token, ctx.configStore);
|
||||
const outputBase = settings.outputDir || defaultOutputBase();
|
||||
const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx));
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineCommand({
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`--api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
|
||||
],
|
||||
async run(ctx) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { defineCommand, deleteDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
@@ -19,19 +19,18 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const fileId = flags.fileId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "dataset.delete", file_id: fileId }, format);
|
||||
emitResult({ action: "dataset.delete", file_id: fileId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await deleteDataset(ctx.client, fileId);
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`Deleted ${fileId}.`);
|
||||
if (settings.quiet) {
|
||||
emitBare(fileId);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { defineCommand, getDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
@@ -19,10 +19,9 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const fileId = flags.fileId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "dataset.get", file_id: fileId }, format);
|
||||
emitResult({ action: "dataset.get", file_id: fileId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,18 +44,10 @@ export default defineCommand({
|
||||
description: file.description ?? "",
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
if (settings.quiet) {
|
||||
emitBare(item.file_id);
|
||||
} else {
|
||||
emitResult({ ...item, request_id: response.request_id }, "json");
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
emitBare(`file_id: ${item.file_id}`);
|
||||
emitBare(`name: ${item.name}`);
|
||||
emitBare(`size: ${item.size}`);
|
||||
if (item.md5) emitBare(`md5: ${item.md5}`);
|
||||
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.description) emitBare(`description: ${item.description}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { defineCommand, listDatasets, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -23,7 +23,6 @@ export default defineCommand({
|
||||
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
@@ -33,7 +32,7 @@ export default defineCommand({
|
||||
page_size: flags.pageSize,
|
||||
purpose: flags.purpose,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -46,7 +45,6 @@ export default defineCommand({
|
||||
const files = response.data?.files ?? [];
|
||||
const total = response.data?.total;
|
||||
|
||||
// Normalize to consistent structure for both text/json output.
|
||||
const items = files.map((item) => ({
|
||||
file_id: item.file_id ?? "",
|
||||
name: item.name ?? "",
|
||||
@@ -54,19 +52,10 @@ export default defineCommand({
|
||||
purpose: item.purpose ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
if (settings.quiet) {
|
||||
for (const item of items) emitBare(item.file_id);
|
||||
} else {
|
||||
emitResult({ items, total, request_id: response.request_id }, "json");
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No dataset files found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["FILE_ID", "NAME", "SIZE", "PURPOSE"];
|
||||
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
uploadDataset,
|
||||
validateDataset,
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
MAX_DATASET_BYTES,
|
||||
MAX_CPT_BYTES,
|
||||
MAX_MEDIA_ZIP_BYTES,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type DatasetFile,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
@@ -18,7 +17,7 @@ const UPLOAD_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
|
||||
description: "Local dataset file (.jsonl or .zip; ≤200MB SFT/DPO, ≤300MB CPT, ≤2GB media zip)",
|
||||
required: true,
|
||||
},
|
||||
purpose: {
|
||||
@@ -30,7 +29,7 @@ const UPLOAD_FLAGS = {
|
||||
type: "string",
|
||||
valueHint: "<s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
|
||||
},
|
||||
noValidate: {
|
||||
type: "switch",
|
||||
@@ -46,7 +45,7 @@ export default defineCommand({
|
||||
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
|
||||
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image|video>] [--no-validate] [--full-validate]",
|
||||
flags: UPLOAD_FLAGS,
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
@@ -59,13 +58,14 @@ export default defineCommand({
|
||||
],
|
||||
notes: [
|
||||
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
|
||||
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
|
||||
"manifest). Six record schemas are recognized: chatml = {messages:[...]}",
|
||||
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
|
||||
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
|
||||
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
|
||||
"generation). With no --schema, a record carrying wav_fn is validated as",
|
||||
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
|
||||
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
|
||||
"generation); video = {first_frame_path:...} (video generation). With no",
|
||||
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
|
||||
"chosen/rejected as DPO, text (no messages) as CPT, otherwise ChatML.",
|
||||
"Upload cap: 200MB SFT/DPO text, 300MB CPT, 2GB media zip. Upload uses the",
|
||||
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
|
||||
"persisted (the DashScope-native /api/v1/files drops it).",
|
||||
],
|
||||
@@ -74,19 +74,15 @@ export default defineCommand({
|
||||
const filePath = flags.file;
|
||||
const purpose = flags.purpose || "fine-tune";
|
||||
const schema = parseDatasetSchemaFlag(flags.schema);
|
||||
if (schema === "video") {
|
||||
throw new BailianError(
|
||||
`--schema video is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported schemas: chatml, dpo, cpt, tts, image.`,
|
||||
);
|
||||
}
|
||||
const format = detectOutputFormat(settings.output);
|
||||
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
|
||||
const isMediaSchema = schema === "image";
|
||||
// Size caps differ per training type: SFT/DPO 200MB, CPT 300MB, media ZIP 2GB.
|
||||
const isMediaSchema = schema === "image" || schema === "video";
|
||||
const maxBytes = isMediaSchema
|
||||
? MAX_MEDIA_ZIP_BYTES
|
||||
: schema === "cpt"
|
||||
? MAX_CPT_BYTES
|
||||
: MAX_DATASET_BYTES;
|
||||
|
||||
if (!flags.noValidate) {
|
||||
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
|
||||
const result = await validateDataset(filePath, {
|
||||
fullValidate: flags.fullValidate,
|
||||
schema,
|
||||
@@ -126,26 +122,25 @@ export default defineCommand({
|
||||
action: "dataset.upload",
|
||||
file: filePath,
|
||||
purpose,
|
||||
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
|
||||
max_bytes: maxBytes,
|
||||
validate: !flags.noValidate,
|
||||
schema: schema ?? "auto",
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded: DatasetFile = await uploadDataset(ctx.client, {
|
||||
const uploaded = await uploadDataset(ctx.client, {
|
||||
filePath,
|
||||
purpose,
|
||||
});
|
||||
const { request_id, ...file } = uploaded;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(uploaded.file_id);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
|
||||
emitBare(file.file_id);
|
||||
} else {
|
||||
emitResult(uploaded, format);
|
||||
emitResult({ ...file, request_id }, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
validateDataset,
|
||||
parseDatasetSchemaFlag,
|
||||
formatIssue,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type ValidationResult,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
function formatStats(result: ValidationResult): string[] {
|
||||
const out: string[] = [];
|
||||
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
|
||||
if (result.stats.sampledRecords !== undefined)
|
||||
out.push(`sampled: ${result.stats.sampledRecords}`);
|
||||
if (result.stats.bytes !== undefined) out.push(`bytes: ${result.stats.bytes}`);
|
||||
if (result.stats.durationMs !== undefined) out.push(`took: ${result.stats.durationMs}ms`);
|
||||
return out;
|
||||
}
|
||||
|
||||
const VALIDATE_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
@@ -36,7 +23,7 @@ const VALIDATE_FLAGS = {
|
||||
type: "string",
|
||||
valueHint: "<s>",
|
||||
description:
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
|
||||
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
@@ -44,13 +31,14 @@ export default defineCommand({
|
||||
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
|
||||
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
|
||||
auth: "none",
|
||||
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
|
||||
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image|video>]",
|
||||
flags: VALIDATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--file train.jsonl",
|
||||
"--file dpo.jsonl --schema dpo",
|
||||
"--file cpt.jsonl --schema cpt",
|
||||
"--file audio.zip --schema tts",
|
||||
"--file wan-i2v-training-dataset.zip --schema video",
|
||||
"--file eval.jsonl --full-validate",
|
||||
"--file train.jsonl --output json",
|
||||
],
|
||||
@@ -60,27 +48,20 @@ export default defineCommand({
|
||||
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
|
||||
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
|
||||
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
|
||||
'image = {img_path:"..."} (image generation). With no --schema, a record',
|
||||
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
|
||||
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
|
||||
"require a specific shape on every record. ZIP archives (.zip) are",
|
||||
"validated structurally (data.jsonl present, media references resolve) in",
|
||||
"addition to per-record content checks. Use --full-validate to JSON.parse",
|
||||
"every line.",
|
||||
'image = {img_path:"..."} (image generation);',
|
||||
'video = {first_frame_path:"...", video_path:"..."} (video generation,',
|
||||
"i2v first-frame or kf2v first+last-frame with last_frame_path). With no",
|
||||
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
|
||||
"first_frame_path/video_path as video, chosen/rejected as DPO, text (no",
|
||||
"messages) as CPT, otherwise ChatML. Pass --schema to require a specific",
|
||||
"shape on every record. ZIP archives (.zip) are validated structurally",
|
||||
"(data.jsonl present, media references resolve) in addition to per-record",
|
||||
"content checks. Use --full-validate to JSON.parse every line.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const filePath = flags.file;
|
||||
const schema = parseDatasetSchemaFlag(flags.schema);
|
||||
if (schema === "video") {
|
||||
throw new BailianError(
|
||||
`--schema video is not supported.`,
|
||||
ExitCode.USAGE,
|
||||
`Supported schemas: chatml, dpo, cpt, tts, image.`,
|
||||
);
|
||||
}
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
@@ -89,38 +70,17 @@ export default defineCommand({
|
||||
full: flags.fullValidate,
|
||||
schema: schema ?? "auto",
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
|
||||
|
||||
if (format === "json") {
|
||||
// For json output we always emit the structured result, exit code conveys validity.
|
||||
emitResult(result, format);
|
||||
} else if (settings.quiet) {
|
||||
if (settings.quiet) {
|
||||
emitBare(result.valid ? "ok" : "fail");
|
||||
} else {
|
||||
const status = result.valid ? "PASSED" : "FAILED";
|
||||
emitBare(`Dataset validation ${status} for ${result.filePath}`);
|
||||
const stats = formatStats(result);
|
||||
if (stats.length) emitBare(` ${stats.join(" · ")}`);
|
||||
|
||||
if (result.errors.length) {
|
||||
emitBare(`Errors (${result.errors.length}):`);
|
||||
for (const error of result.errors.slice(0, 20)) emitBare(formatIssue(error));
|
||||
if (result.errors.length > 20) {
|
||||
emitBare(` … and ${result.errors.length - 20} more.`);
|
||||
}
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
emitBare(`Warnings (${result.warnings.length}):`);
|
||||
for (const warning of result.warnings.slice(0, 10)) emitBare(formatIssue(warning));
|
||||
if (result.warnings.length > 10) {
|
||||
emitBare(` … and ${result.warnings.length - 10} more.`);
|
||||
}
|
||||
}
|
||||
emitResult(result, "json");
|
||||
}
|
||||
|
||||
if (!result.valid) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createDeployment,
|
||||
pickPlanStrategy,
|
||||
STRATEGIES,
|
||||
@@ -14,13 +13,13 @@ import {
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
model: {
|
||||
modelName: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Model name (catalog model or fine-tuned output) (required)",
|
||||
valueHint: "<model_name>",
|
||||
description: "Model to deploy — fine-tuned output name or catalog model (required)",
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
displayName: {
|
||||
type: "string",
|
||||
valueHint: "<display_name>",
|
||||
description: "Console display name for the deployment (required)",
|
||||
@@ -64,7 +63,7 @@ const CREATE_FLAGS = {
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const CREATE_USAGE =
|
||||
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
|
||||
"--model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
|
||||
|
||||
const CREATE_NOTES = [
|
||||
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
|
||||
@@ -78,14 +77,11 @@ const CREATE_NOTES = [
|
||||
"Use `bl deploy models --source base` to inspect available templates.",
|
||||
"After creation, status starts at PENDING and transitions to RUNNING.",
|
||||
"Invoke the deployed model with: bl text chat --model <deployed_model>",
|
||||
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
|
||||
"values. `bl deploy <modality> create --model` takes the exported model_name",
|
||||
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
|
||||
"`deployed_model` field (the deployment instance id, e.g.",
|
||||
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
|
||||
"the `deployed_model` from the create response — NOT the `model_name` you",
|
||||
"passed to `deploy <modality> create`. Do not reuse the value across the two",
|
||||
"commands.",
|
||||
"NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).",
|
||||
"The create response also returns a `deployed_model` field — the deployment",
|
||||
"instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference",
|
||||
"(`bl text chat --model <deployed_model>`) and lifecycle commands",
|
||||
"(`deploy get/scale/pause/resume/delete --deployed-model <id>`).",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -119,10 +115,9 @@ async function runCreate(
|
||||
ctx: CommandContext<typeof CREATE_FLAGS>,
|
||||
): Promise<void> {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const model = flags.model as string;
|
||||
const name = flags.name as string;
|
||||
const model = flags.modelName as string;
|
||||
const name = flags.displayName as string;
|
||||
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
|
||||
// the plan-specific body fragment (mu may auto-pick a template from the
|
||||
@@ -146,7 +141,7 @@ async function runCreate(
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.create", body }, format);
|
||||
emitResult({ action: "deploy.create", body }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,16 +150,8 @@ async function runCreate(
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployment?.deployed_model ?? "");
|
||||
} else if (format === "text") {
|
||||
emitBare(`Created deployment.`);
|
||||
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
|
||||
if (deployment?.status) emitBare(` status: ${deployment.status}`);
|
||||
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,10 +162,10 @@ export const deployTextCreate = defineCommand({
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-qwen-sft --name my-sft-test",
|
||||
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
|
||||
"--model qwen3-8b --name my-qwen3-mu --plan mu",
|
||||
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
|
||||
"--model-name my-qwen-sft --display-name my-sft-test",
|
||||
"--model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
|
||||
"--model-name qwen3-8b --display-name my-qwen3-mu --plan mu",
|
||||
"--model-name qwen3-8b --display-name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("text", flags),
|
||||
@@ -192,9 +179,9 @@ export const deployAudioCreate = defineCommand({
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-cosyvoice-ft --name my-tts",
|
||||
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
|
||||
"--model my-cosyvoice-ft --name my-tts --dry-run",
|
||||
"--model-name my-cosyvoice-ft --display-name my-tts",
|
||||
"--model-name my-cosyvoice-ft --display-name my-tts --deploy-spec dps-xxxx --capacity 1",
|
||||
"--model-name my-cosyvoice-ft --display-name my-tts --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("audio", flags),
|
||||
@@ -208,9 +195,9 @@ export const deployImageCreate = defineCommand({
|
||||
usageArgs: CREATE_USAGE,
|
||||
flags: CREATE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model my-wan-ft --name my-wan",
|
||||
"--model my-wan-ft --name my-wan-mu --plan mu",
|
||||
"--model my-wan-ft --name my-wan --dry-run",
|
||||
"--model-name my-wan-ft --display-name my-wan",
|
||||
"--model-name my-wan-ft --display-name my-wan-mu --plan mu",
|
||||
"--model-name my-wan-ft --display-name my-wan --dry-run",
|
||||
],
|
||||
notes: CREATE_NOTES,
|
||||
validate: (flags) => validateCreate("image", flags),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
deleteDeployment,
|
||||
getDeployment,
|
||||
BailianError,
|
||||
@@ -38,10 +37,9 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
|
||||
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +53,8 @@ export default defineCommand({
|
||||
if (status && status !== "STOPPED" && status !== "FAILED") {
|
||||
throw new BailianError(
|
||||
`Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` +
|
||||
`Stop it first via the platform console, or pass --skip-precheck to attempt deletion anyway.`,
|
||||
`Run \`bl deploy pause --deployed-model ${deployedModel}\` to pause it first, ` +
|
||||
`or pass --skip-precheck to attempt deletion anyway.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
@@ -69,10 +68,8 @@ export default defineCommand({
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployedModel);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${deployedModel}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { defineCommand, getDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -22,10 +22,9 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
|
||||
emitResult({ action: "deploy.get", deployed_model: deployedModel }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +32,7 @@ export default defineCommand({
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (!deployment) {
|
||||
emitBare(`No data returned for ${deployedModel}`);
|
||||
emitResult({ deployed_model: deployedModel, request_id: response.request_id }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,17 +56,6 @@ export default defineCommand({
|
||||
if (deployment.gmt_create) item.created_at = deployment.gmt_create;
|
||||
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet — fixed-width label column for alignment
|
||||
const label = (key: string) => `${key}:`.padEnd(18);
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
if (value === "" || value === undefined) continue;
|
||||
const display = typeof value === "string" ? value : JSON.stringify(value);
|
||||
emitBare(`${label(key)}${display}`);
|
||||
}
|
||||
emitResult({ ...item, request_id: response.request_id }, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listDeployments,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { defineCommand, listDeployments, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -28,13 +23,12 @@ export default defineCommand({
|
||||
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const status = flags.status || undefined;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{ action: "deploy.list", page: flags.page, page_size: flags.pageSize, status },
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -57,26 +51,6 @@ export default defineCommand({
|
||||
created_at: item.gmt_create ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No deployments found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
|
||||
const rows = items.map((item) => [
|
||||
item.deployed_model,
|
||||
item.model_name,
|
||||
item.status,
|
||||
item.plan,
|
||||
item.capacity,
|
||||
item.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitResult({ items, total, request_id: response.request_id }, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listDeployableModels,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { defineCommand, listDeployableModels, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const MODELS_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -39,7 +34,6 @@ export default defineCommand({
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
// Default version to v1.0 — without it, the API returns the legacy catalog
|
||||
// (only old fine-tune outputs). Pass --catalog-version "" to opt out.
|
||||
const version = flags.catalogVersion === "" ? undefined : (flags.catalogVersion ?? "v1.0");
|
||||
@@ -54,7 +48,7 @@ export default defineCommand({
|
||||
version,
|
||||
model_source: modelSource,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -72,101 +66,55 @@ export default defineCommand({
|
||||
// Two response shapes:
|
||||
// - custom (fine-tuned): top-level supported_plans: string[]
|
||||
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
|
||||
// For json: surface the deployment-relevant fields preserved as a tree, so
|
||||
// Surface the deployment-relevant fields preserved as a tree, so
|
||||
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
|
||||
// without a second round-trip. For text: keep the compact one-line summary.
|
||||
if (format === "json") {
|
||||
const items = models.map((model) => {
|
||||
const out: Record<string, unknown> = {
|
||||
model_name: model.model_name ?? "",
|
||||
};
|
||||
if (model.base_model) out.base_model = model.base_model;
|
||||
if (model.model_source) out.model_source = model.model_source;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
out.supported_plans = model.supported_plans;
|
||||
}
|
||||
if (model.plans && model.plans.length > 0) {
|
||||
out.plans = model.plans.map((plan) => {
|
||||
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = plan.cu_specs;
|
||||
}
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
|
||||
// Drop noisy/redundant: template_source, template_type,
|
||||
// template_version, deploy_spec (typically == template_id).
|
||||
planEntry.templates = plan.templates.map((template) => {
|
||||
const tpl: Record<string, unknown> = {};
|
||||
if (template.template_id) tpl.template_id = template.template_id;
|
||||
if (template.template_name) tpl.template_name = template.template_name;
|
||||
if (template.charge_type) tpl.charge_type = template.charge_type;
|
||||
// Flatten roles.unified for the common COUPLED case.
|
||||
const unified = template.roles?.unified;
|
||||
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
|
||||
if (unified?.capacity_unit_per_instance !== undefined)
|
||||
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
|
||||
// Preserve split-role configs (SEPERATED) as-is so callers
|
||||
// can still drive prefill/decode sizing.
|
||||
if (template.roles?.prefill || template.roles?.decode) {
|
||||
tpl.roles = {
|
||||
prefill: template.roles?.prefill,
|
||||
decode: template.roles?.decode,
|
||||
};
|
||||
}
|
||||
if (template.template_desc) tpl.template_desc = template.template_desc;
|
||||
return tpl;
|
||||
});
|
||||
}
|
||||
return planEntry;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet — keep the compact single-line summary table.
|
||||
const textItems = models.map((model) => {
|
||||
let plansSummary = "";
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
plansSummary = model.supported_plans.join(",");
|
||||
} else if (model.plans && model.plans.length > 0) {
|
||||
plansSummary = model.plans
|
||||
.map((plan) => {
|
||||
const planName = plan.plan ?? "?";
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
return `${planName}(${plan.templates.length}t)`;
|
||||
}
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
return `${planName}(${plan.cu_specs.join("/")})`;
|
||||
}
|
||||
return planName;
|
||||
})
|
||||
.join(",");
|
||||
} else {
|
||||
plansSummary = "-";
|
||||
}
|
||||
return {
|
||||
// without a second round-trip.
|
||||
const items = models.map((model) => {
|
||||
const out: Record<string, unknown> = {
|
||||
model_name: model.model_name ?? "",
|
||||
base_model: model.base_model ?? "",
|
||||
source: model.model_source ?? "",
|
||||
plans: plansSummary,
|
||||
};
|
||||
if (model.base_model) out.base_model = model.base_model;
|
||||
if (model.model_source) out.model_source = model.model_source;
|
||||
if (model.supported_plans && model.supported_plans.length > 0) {
|
||||
out.supported_plans = model.supported_plans;
|
||||
}
|
||||
if (model.plans && model.plans.length > 0) {
|
||||
out.plans = model.plans.map((plan) => {
|
||||
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
|
||||
if (plan.cu_specs && plan.cu_specs.length > 0) {
|
||||
planEntry.cu_specs = plan.cu_specs;
|
||||
}
|
||||
if (plan.templates && plan.templates.length > 0) {
|
||||
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
|
||||
// Drop noisy/redundant: template_source, template_type,
|
||||
// template_version, deploy_spec (typically == template_id).
|
||||
planEntry.templates = plan.templates.map((template) => {
|
||||
const tpl: Record<string, unknown> = {};
|
||||
if (template.template_id) tpl.template_id = template.template_id;
|
||||
if (template.template_name) tpl.template_name = template.template_name;
|
||||
if (template.charge_type) tpl.charge_type = template.charge_type;
|
||||
// Flatten roles.unified for the common COUPLED case.
|
||||
const unified = template.roles?.unified;
|
||||
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
|
||||
if (unified?.capacity_unit_per_instance !== undefined)
|
||||
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
|
||||
// Preserve split-role configs (SEPERATED) as-is so callers
|
||||
// can still drive prefill/decode sizing.
|
||||
if (template.roles?.prefill || template.roles?.decode) {
|
||||
tpl.roles = {
|
||||
prefill: template.roles?.prefill,
|
||||
decode: template.roles?.decode,
|
||||
};
|
||||
}
|
||||
if (template.template_desc) tpl.template_desc = template.template_desc;
|
||||
return tpl;
|
||||
});
|
||||
}
|
||||
return planEntry;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
if (textItems.length === 0) {
|
||||
emitBare("No deployable models found.");
|
||||
return;
|
||||
}
|
||||
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
|
||||
const rows = textItems.map((item) => [
|
||||
item.model_name,
|
||||
item.base_model,
|
||||
item.source,
|
||||
item.plans,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitResult({ items, total, request_id: response.request_id }, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
defineCommand,
|
||||
stopModelService,
|
||||
listIndependentDeployedModels,
|
||||
findDeploymentEntry,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const PAUSE_FLAGS = {
|
||||
deployedModel: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
skipPrecheck: {
|
||||
type: "switch",
|
||||
description: "Skip the local RUNNING/PENDING status precheck",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl deploy pause` — pause a running deployment.
|
||||
*
|
||||
* Takes the model service offline so it no longer serves inference requests.
|
||||
* For mu/ptu plans, billing stops while paused.
|
||||
* Precheck: status must be RUNNING or PENDING.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Pause a running model deployment (stops billing for mu/ptu)",
|
||||
auth: "console",
|
||||
usageArgs: "--deployed-model <id> [--skip-precheck]",
|
||||
flags: PAUSE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--deployed-model dep-...",
|
||||
"--deployed-model dep-... --skip-precheck",
|
||||
"--deployed-model dep-... --dry-run",
|
||||
],
|
||||
notes: [
|
||||
"While paused, billing ceases for mu/ptu plans. Use `deploy resume` to bring it back online or `deploy delete` to remove.",
|
||||
"Precheck verifies status is RUNNING/PENDING before issuing the pause; pass --skip-precheck to bypass.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.pause", deployed_model: deployedModel }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
// Precheck: verify the deployment is in a pausable state.
|
||||
if (!flags.skipPrecheck) {
|
||||
try {
|
||||
const entries = await listIndependentDeployedModels(ctx.client);
|
||||
const entry = findDeploymentEntry(entries, deployedModel);
|
||||
if (entry) {
|
||||
const status = (entry.status ?? "").toUpperCase();
|
||||
if (status && status !== "RUNNING" && status !== "PENDING") {
|
||||
throw new BailianError(
|
||||
`Deployment ${deployedModel} is ${status}. Only RUNNING / PENDING deployments can be paused. ` +
|
||||
`Pass --skip-precheck to attempt the pause anyway.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
// If entry not found in list, proceed — the server will surface the real error.
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
// If the list call itself failed, proceed and let the API call surface the error.
|
||||
}
|
||||
}
|
||||
|
||||
const response = await stopModelService(ctx.client, deployedModel);
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployedModel);
|
||||
} else {
|
||||
emitResult({ deployed_model: deployedModel, action: "pause", ...response }, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
defineCommand,
|
||||
startModelService,
|
||||
listIndependentDeployedModels,
|
||||
findDeploymentEntry,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const RESUME_FLAGS = {
|
||||
deployedModel: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Deployed model identifier (required)",
|
||||
required: true,
|
||||
},
|
||||
skipPrecheck: {
|
||||
type: "switch",
|
||||
description: "Skip the local STOPPED status precheck",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl deploy resume` — resume a paused deployment.
|
||||
*
|
||||
* Brings the model service back online so it can serve inference requests.
|
||||
* Precheck: status must be STOPPED.
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Resume a paused model deployment (brings service back online)",
|
||||
auth: "console",
|
||||
usageArgs: "--deployed-model <id> [--skip-precheck]",
|
||||
flags: RESUME_FLAGS,
|
||||
exampleArgs: [
|
||||
"--deployed-model dep-...",
|
||||
"--deployed-model dep-... --skip-precheck",
|
||||
"--deployed-model dep-... --dry-run",
|
||||
],
|
||||
notes: [
|
||||
"Precheck verifies status is STOPPED before issuing the resume; pass --skip-precheck to bypass.",
|
||||
"For mu/ptu plans, billing resumes once the service is back online.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.resume", deployed_model: deployedModel }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
// Precheck: verify the deployment is in a resumable state.
|
||||
if (!flags.skipPrecheck) {
|
||||
try {
|
||||
const entries = await listIndependentDeployedModels(ctx.client);
|
||||
const entry = findDeploymentEntry(entries, deployedModel);
|
||||
if (entry) {
|
||||
const status = (entry.status ?? "").toUpperCase();
|
||||
if (status && status !== "STOPPED") {
|
||||
throw new BailianError(
|
||||
`Deployment ${deployedModel} is ${status}. Only STOPPED deployments can be resumed. ` +
|
||||
`Pass --skip-precheck to attempt the resume anyway.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
// If entry not found in list, proceed — the server will surface the real error.
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
// If the list call itself failed, proceed and let the API call surface the error.
|
||||
}
|
||||
}
|
||||
|
||||
const response = await startModelService(ctx.client, deployedModel);
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployedModel);
|
||||
} else {
|
||||
emitResult({ deployed_model: deployedModel, action: "resume", ...response }, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
scaleDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, scaleDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const SCALE_FLAGS = {
|
||||
@@ -52,7 +47,6 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.capacity !== undefined) body.capacity = flags.capacity;
|
||||
@@ -60,20 +54,16 @@ export default defineCommand({
|
||||
if (flags.outputTpm !== undefined) body.output_tpm = flags.outputTpm;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
|
||||
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await scaleDeployment(ctx.client, deployedModel, body);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployedModel);
|
||||
} else if (format === "text") {
|
||||
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
|
||||
emitBare(`Scaled ${deployedModel}${cap}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
updateDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, updateDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const UPDATE_FLAGS = {
|
||||
@@ -48,30 +43,22 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const deployedModel = flags.deployedModel;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.rpmLimit !== undefined) body.rpm_limit = flags.rpmLimit;
|
||||
if (flags.tpmLimit !== undefined) body.tpm_limit = flags.tpmLimit;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
|
||||
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await updateDeployment(ctx.client, deployedModel, body);
|
||||
const deployment = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(deployedModel);
|
||||
} else if (format === "text") {
|
||||
const parts: string[] = [];
|
||||
if (deployment?.rpm_limit !== undefined) parts.push(`rpm_limit=${deployment.rpm_limit}`);
|
||||
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
|
||||
const summary = parts.length ? ` (${parts.join(", ")})` : "";
|
||||
emitBare(`Updated ${deployedModel}${summary}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export default defineCommand({
|
||||
"--file photo.jpg --model qwen3-vl-plus",
|
||||
"--file video.mp4 --model wan2.1-t2v-plus",
|
||||
"--file audio.wav --model qwen3-asr-flash",
|
||||
"--file cat.png --model qwen-image-2.0",
|
||||
"--file cat.png --model qwen-image-3.0",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { defineCommand, cancelFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const CANCEL_FLAGS = {
|
||||
@@ -23,23 +23,18 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const jobId = flags.jobId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
|
||||
emitResult({ action: "finetune.cancel", job_id: jobId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await cancelFineTune(ctx.client, jobId);
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(jobId);
|
||||
} else if (format === "text") {
|
||||
const status = job?.status ? ` (status=${job.status})` : "";
|
||||
emitBare(`Cancelled ${jobId}${status}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
fetchModelList,
|
||||
fetchModelListAll,
|
||||
fetchModelCapability,
|
||||
listSupportedTrainingTypes,
|
||||
modelSupportsTrainingType,
|
||||
isTrainingTypeCli,
|
||||
trainingTypeMethodVariant,
|
||||
TRAINING_TYPES_CLI,
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
anonymousConsoleCall,
|
||||
UsageError,
|
||||
type Settings,
|
||||
type ModelCapability,
|
||||
@@ -17,8 +15,6 @@ import {
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Page through every foundation-model page (listFoundationModels, public — no
|
||||
* console login needed, so the gateway is called anonymously). Returns raw
|
||||
@@ -26,36 +22,12 @@ const PAGE_SIZE = 50;
|
||||
* for filtering.
|
||||
*/
|
||||
async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapability[]> {
|
||||
const eff = effectiveConsoleGatewayConfig(settings);
|
||||
const call = (api: string, data: Record<string, unknown>) =>
|
||||
callConsoleGateway(
|
||||
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
|
||||
settings.timeout,
|
||||
{ api, data },
|
||||
);
|
||||
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
|
||||
const all = [...first.models];
|
||||
const totalPages = Math.ceil(first.total / PAGE_SIZE);
|
||||
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
|
||||
const result = await fetchModelList(call, { pageNo, pageSize: PAGE_SIZE });
|
||||
all.push(...result.models);
|
||||
}
|
||||
const all = await fetchModelListAll(anonymousConsoleCall(settings));
|
||||
return all as ModelCapability[];
|
||||
}
|
||||
|
||||
const VARIANT_LABEL: Record<string, string> = {
|
||||
full: "full-parameter",
|
||||
lora: "LoRA",
|
||||
};
|
||||
|
||||
function describeTrainingType(value: string): string {
|
||||
if (!isTrainingTypeCli(value)) return value;
|
||||
const { method, variant } = trainingTypeMethodVariant(value);
|
||||
return `${VARIANT_LABEL[variant] ?? variant} ${method.toUpperCase()}`;
|
||||
}
|
||||
|
||||
const CAPABILITY_FLAGS = {
|
||||
model: {
|
||||
baseModel: {
|
||||
type: "string",
|
||||
valueHint: "<m>",
|
||||
description: "List training types supported by this base model.",
|
||||
@@ -71,31 +43,31 @@ export default defineCommand({
|
||||
description:
|
||||
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
|
||||
auth: "none",
|
||||
usageArgs: "--model <m> | --training-type <t>",
|
||||
usageArgs: "--base-model <m> | --training-type <t>",
|
||||
flags: CAPABILITY_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b",
|
||||
"--base-model qwen3-8b",
|
||||
"--training-type sft-lora",
|
||||
"--training-type cpt --output json",
|
||||
"--training-type sft --quiet",
|
||||
],
|
||||
notes: [
|
||||
"Exactly one of --model / --training-type is required.",
|
||||
"Exactly one of --base-model / --training-type is required.",
|
||||
"Training-type values use the `<method>` / `<method>-lora` convention:",
|
||||
"sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)",
|
||||
"Queries listFoundationModels, a public API — no console login needed.",
|
||||
],
|
||||
validate: (f) => {
|
||||
if (f.model && f.trainingType)
|
||||
return "--model and --training-type are mutually exclusive; pass one.";
|
||||
if (!f.model && !f.trainingType) return "one of --model / --training-type is required.";
|
||||
if (f.baseModel && f.trainingType)
|
||||
return "--base-model and --training-type are mutually exclusive; pass one.";
|
||||
if (!f.baseModel && !f.trainingType)
|
||||
return "one of --base-model / --training-type is required.";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const model = flags.model || undefined;
|
||||
const model = flags.baseModel || undefined;
|
||||
const trainingType = flags.trainingType || undefined;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
@@ -104,7 +76,7 @@ export default defineCommand({
|
||||
model,
|
||||
training_type: trainingType,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +85,7 @@ export default defineCommand({
|
||||
if (model) {
|
||||
const capability = await fetchModelCapability(settings, model);
|
||||
if (!capability) {
|
||||
emitBare(`No foundation model found matching "${model}".`);
|
||||
emitResult({ model, error: `No foundation model found matching "${model}".` }, "json");
|
||||
return;
|
||||
}
|
||||
const supported = listSupportedTrainingTypes(capability);
|
||||
@@ -121,23 +93,15 @@ export default defineCommand({
|
||||
for (const value of supported) emitBare(value);
|
||||
return;
|
||||
}
|
||||
if (format !== "text") {
|
||||
emitResult(
|
||||
{
|
||||
model: capability.model ?? model,
|
||||
supported,
|
||||
supports: capability.supports,
|
||||
trainingTypes: capability.trainingTypes,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitBare(`${capability.model ?? model}`);
|
||||
emitBare(supported.length ? "Supported training types:" : "No supported training types.");
|
||||
for (const value of supported) {
|
||||
emitBare(` ${value.padEnd(10)} ${describeTrainingType(value)}`);
|
||||
}
|
||||
emitResult(
|
||||
{
|
||||
model: capability.model ?? model,
|
||||
supported,
|
||||
supports: capability.supports,
|
||||
trainingTypes: capability.trainingTypes,
|
||||
},
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,20 +126,15 @@ export default defineCommand({
|
||||
for (const entry of matched) emitBare(entry.model);
|
||||
return;
|
||||
}
|
||||
if (format !== "text") {
|
||||
emitResult(
|
||||
{
|
||||
training_type: trainingType,
|
||||
method,
|
||||
variant,
|
||||
count: matched.length,
|
||||
models: matched,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
emitBare(`Models supporting ${trainingType} (${method} / ${variant}): ${matched.length}`);
|
||||
for (const entry of matched) emitBare(` ${entry.model}`);
|
||||
emitResult(
|
||||
{
|
||||
training_type: trainingType,
|
||||
method,
|
||||
variant,
|
||||
count: matched.length,
|
||||
models: matched,
|
||||
},
|
||||
"json",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listCheckpoints,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { defineCommand, listCheckpoints, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const CHECKPOINTS_FLAGS = {
|
||||
jobId: {
|
||||
@@ -15,6 +10,8 @@ const CHECKPOINTS_FLAGS = {
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const EXPIRY_WARN_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
|
||||
export default defineCommand({
|
||||
description: "List checkpoints produced by a fine-tune job",
|
||||
auth: "apiKey",
|
||||
@@ -22,16 +19,15 @@ export default defineCommand({
|
||||
flags: CHECKPOINTS_FLAGS,
|
||||
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
|
||||
notes: [
|
||||
"Use the returned `checkpoint` value with `finetune export` to publish",
|
||||
"a deployable model.",
|
||||
"`model_name` (shown for SUCCEEDED checkpoints) is the direct input for `deploy create --model-name`.",
|
||||
"Checkpoints expire ~15 days after creation; `expire_time` shows the deadline. Export or deploy before expiry.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const jobId = flags.jobId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
|
||||
emitResult({ action: "finetune.checkpoints", job_id: jobId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,21 +40,26 @@ export default defineCommand({
|
||||
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
|
||||
step: item.step !== undefined ? String(item.step) : "",
|
||||
status: item.status ?? "",
|
||||
model_name: item.model_name ?? "",
|
||||
expire_time: item.expire_time ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
emitResult({ items, total, request_id: response.request_id }, "json");
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No checkpoints found.");
|
||||
return;
|
||||
// Near-expiry warning: check if any non-expired checkpoint is within 72h of expiry.
|
||||
const now = Date.now();
|
||||
const expiringSoon = items.filter((item) => {
|
||||
if (!item.expire_time) return false;
|
||||
const deadline = new Date(item.expire_time).getTime();
|
||||
if (Number.isNaN(deadline)) return false;
|
||||
const remaining = deadline - now;
|
||||
return remaining > 0 && remaining < EXPIRY_WARN_THRESHOLD_MS;
|
||||
});
|
||||
if (expiringSoon.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n[warning] ${expiringSoon.length} checkpoint(s) will expire within 72 hours. ` +
|
||||
"Export or deploy before expiry to avoid losing the model artifact.\n",
|
||||
);
|
||||
}
|
||||
const headers = ["CHECKPOINT", "STEP", "STATUS"];
|
||||
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${total}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
createFineTune,
|
||||
getDataset,
|
||||
uploadDataset,
|
||||
@@ -208,7 +207,7 @@ async function uploadResolvedLocal(
|
||||
}
|
||||
|
||||
/** The modality a `finetune <modality> create` subcommand is bound to. */
|
||||
type CommandModality = "text" | "audio" | "image";
|
||||
type CommandModality = "text" | "audio" | "image" | "video";
|
||||
|
||||
/**
|
||||
* Flags shared by every `finetune <modality> create` subcommand: what to train
|
||||
@@ -216,10 +215,10 @@ type CommandModality = "text" | "audio" | "image";
|
||||
* output. Every modality's model consumes these.
|
||||
*/
|
||||
const COMMON_FLAGS = {
|
||||
model: {
|
||||
baseModel: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Base model to fine-tune",
|
||||
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
|
||||
required: true,
|
||||
},
|
||||
datasets: {
|
||||
@@ -317,13 +316,41 @@ const IMAGE_FLAGS = {
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const TEXT_USAGE =
|
||||
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
|
||||
"--base-model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
|
||||
|
||||
const AUDIO_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
|
||||
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
|
||||
|
||||
const IMAGE_USAGE =
|
||||
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
|
||||
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
|
||||
|
||||
/**
|
||||
* Video (Wan i2v/kf2v) flags: exposes the three hyper-parameters that the
|
||||
* video API supports and users may want to override. Defaults are model-specific
|
||||
* (resolved by the sft-lora profile: wan2.7 → batch_size 1 / max_pixels 102400,
|
||||
* wan2.5 → 4 / 36864, wan2.2 → 4 / 262144).
|
||||
*/
|
||||
const VIDEO_FLAGS = {
|
||||
...COMMON_FLAGS,
|
||||
nEpochs: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Training epochs (default: 50)",
|
||||
},
|
||||
batchSize: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2)",
|
||||
},
|
||||
learningRate: {
|
||||
type: "string",
|
||||
valueHint: "<str>",
|
||||
description: 'Learning rate as a string to preserve precision (default: "2e-5")',
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const VIDEO_USAGE =
|
||||
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]";
|
||||
|
||||
const COMMON_NOTES = [
|
||||
"Creating a job uploads any local datasets and consumes training quota.",
|
||||
@@ -383,7 +410,7 @@ async function runCreate<F extends FlagsDef>(
|
||||
): Promise<void> {
|
||||
const { identity, settings } = ctx;
|
||||
const flags = ctx.flags as Record<string, unknown>;
|
||||
const model = flags.model as string;
|
||||
const model = flags.baseModel as string;
|
||||
const datasetsRaw = flags.datasets as string;
|
||||
|
||||
// CosyVoice audio fine-tuning accepts exactly one training file
|
||||
@@ -441,6 +468,10 @@ async function runCreate<F extends FlagsDef>(
|
||||
if (detected === "image-i2i") modality = "image-i2i";
|
||||
}
|
||||
}
|
||||
if (commandModality === "video" && firstLocalPath && !settings.dryRun) {
|
||||
const detected = await detectModality(firstLocalPath);
|
||||
if (detected === "video-kf2v") modality = "video-kf2v";
|
||||
}
|
||||
|
||||
const training = await analyzeDatasetTokens(
|
||||
settings,
|
||||
@@ -606,8 +637,6 @@ async function runCreate<F extends FlagsDef>(
|
||||
if (modelName) body.model_name = modelName;
|
||||
if (suffix) body.finetuned_output_suffix = suffix;
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const pending = [
|
||||
...training.localPaths.map((path) => ({ field: "datasets", path })),
|
||||
@@ -617,7 +646,7 @@ async function runCreate<F extends FlagsDef>(
|
||||
pending.length > 0
|
||||
? { action: "finetune.create", body, pending_uploads: pending }
|
||||
: { action: "finetune.create", body },
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -627,15 +656,8 @@ async function runCreate<F extends FlagsDef>(
|
||||
|
||||
if (settings.quiet) {
|
||||
if (job?.job_id) emitBare(job.job_id);
|
||||
} else if (format === "text") {
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,14 +668,14 @@ export const finetuneTextCreate = defineCommand({
|
||||
usageArgs: TEXT_USAGE,
|
||||
flags: TEXT_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model qwen3-8b --datasets file-xxx",
|
||||
"--model qwen3-8b --datasets ./train.jsonl",
|
||||
"--model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
|
||||
"--model qwen3-8b --datasets file-aaa,./extra.jsonl",
|
||||
"--model qwen3-8b --datasets ./train.jsonl --training-type sft",
|
||||
'--model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
|
||||
"--model qwen3-8b --datasets file-xxx --output json",
|
||||
"--model qwen3-8b --datasets file-xxx --dry-run",
|
||||
"--base-model qwen3-8b --datasets file-xxx",
|
||||
"--base-model qwen3-8b --datasets ./train.jsonl",
|
||||
"--base-model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
|
||||
"--base-model qwen3-8b --datasets file-aaa,./extra.jsonl",
|
||||
"--base-model qwen3-8b --datasets ./train.jsonl --training-type sft",
|
||||
'--base-model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
|
||||
"--base-model qwen3-8b --datasets file-xxx --output json",
|
||||
"--base-model qwen3-8b --datasets file-xxx --dry-run",
|
||||
],
|
||||
notes: TEXT_NOTES,
|
||||
run: (ctx) => runCreate("text", ctx),
|
||||
@@ -666,11 +688,11 @@ export const finetuneAudioCreate = defineCommand({
|
||||
usageArgs: AUDIO_USAGE,
|
||||
flags: AUDIO_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
|
||||
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
|
||||
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
|
||||
"--base-model cosyvoice-v3-flash --datasets ./audio.zip",
|
||||
"--base-model cosyvoice-v3-flash --datasets file-xxx",
|
||||
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
|
||||
"--base-model cosyvoice-v3-flash --datasets file-xxx --output json",
|
||||
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
|
||||
],
|
||||
notes: AUDIO_NOTES,
|
||||
run: (ctx) => runCreate("audio", ctx),
|
||||
@@ -683,13 +705,38 @@ export const finetuneImageCreate = defineCommand({
|
||||
usageArgs: IMAGE_USAGE,
|
||||
flags: IMAGE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--model wan2.7-image-pro --datasets ./images.zip",
|
||||
"--model wan2.7-image-pro --datasets file-xxx",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
|
||||
"--model wan2.7-image-pro --datasets file-xxx --output json",
|
||||
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
|
||||
"--base-model wan2.7-image-pro --datasets ./images.zip",
|
||||
"--base-model wan2.7-image-pro --datasets file-xxx",
|
||||
"--base-model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
|
||||
"--base-model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
|
||||
"--base-model wan2.7-image-pro --datasets file-xxx --output json",
|
||||
"--base-model wan2.7-image-pro --datasets ./images.zip --dry-run",
|
||||
],
|
||||
notes: IMAGE_NOTES,
|
||||
run: (ctx) => runCreate("image", ctx),
|
||||
});
|
||||
|
||||
const VIDEO_NOTES = [
|
||||
...COMMON_NOTES,
|
||||
"Video generation training (Wan i2v/kf2v) runs efficient_sft with model-",
|
||||
"specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2",
|
||||
"(batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.",
|
||||
"Datasets are .zip archives with data.jsonl + frame images + videos.",
|
||||
"Recommended: ≥10 training samples, 20-100 for stable results.",
|
||||
];
|
||||
|
||||
/** `bl finetune video create` — fine-tune a video generation model. Datasets are `.zip`. */
|
||||
export const finetuneVideoCreate = defineCommand({
|
||||
description: "Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft)",
|
||||
auth: "apiKey",
|
||||
usageArgs: VIDEO_USAGE,
|
||||
flags: VIDEO_FLAGS,
|
||||
exampleArgs: [
|
||||
"--base-model wan2.7-i2v --datasets file-xxx",
|
||||
"--base-model wan2.7-i2v --datasets ./i2v-data.zip",
|
||||
"--base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100",
|
||||
"--base-model wan2.7-i2v --datasets file-xxx --dry-run",
|
||||
],
|
||||
notes: VIDEO_NOTES,
|
||||
run: (ctx) => runCreate("video", ctx),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { defineCommand, deleteFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
@@ -23,10 +23,9 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const jobId = flags.jobId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "finetune.delete", job_id: jobId }, format);
|
||||
emitResult({ action: "finetune.delete", job_id: jobId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,10 +33,8 @@ export default defineCommand({
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(jobId);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${jobId}.`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
exportCheckpoint,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, exportCheckpoint, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const EXPORT_FLAGS = {
|
||||
@@ -39,11 +34,10 @@ export default defineCommand({
|
||||
"explicit export is the canonical path for non-best checkpoints.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const { settings, flags } = ctx;
|
||||
const jobId = flags.jobId;
|
||||
const checkpoint = flags.checkpoint;
|
||||
const modelName = flags.modelName;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
@@ -53,7 +47,7 @@ export default defineCommand({
|
||||
checkpoint,
|
||||
model_name: modelName,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -64,13 +58,8 @@ export default defineCommand({
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(exported);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
|
||||
emitBare(
|
||||
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
|
||||
);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
emitResult(response, "json");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Best-effort actual training fee calculation using the model catalog's
|
||||
* "ft" (fine-tune) price entry. Pure API-key domain — no console auth needed.
|
||||
*
|
||||
* The model catalog (`listFoundationModels` via public gateway) returns a
|
||||
* `prices[]` array **only when `queryPrice: true` is passed** (the same flag
|
||||
* `fetchModelDetail` uses). Combined with the job's `output.usage` (actual
|
||||
* consumed tokens, present on SUCCEEDED / CANCELED), this gives the exact
|
||||
* training cost without any console-domain login.
|
||||
*/
|
||||
import {
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
unwrapResponse,
|
||||
MODEL_LIST_API,
|
||||
type Settings,
|
||||
type ModelPriceInfo,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export interface ActualFee {
|
||||
cost: number;
|
||||
unitPrice: number;
|
||||
priceUnit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the model's training price from the public catalog gateway.
|
||||
* Uses the same anonymous gateway path as `fetchModelCapability` (no console
|
||||
* token required), but adds `queryPrice: true` to include the prices array.
|
||||
*/
|
||||
async function fetchTrainingPrice(
|
||||
settings: Settings,
|
||||
model: string,
|
||||
): Promise<ModelPriceInfo | null> {
|
||||
const eff = effectiveConsoleGatewayConfig(settings);
|
||||
const result = await callConsoleGateway(
|
||||
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
|
||||
settings.timeout,
|
||||
{
|
||||
api: MODEL_LIST_API,
|
||||
data: {
|
||||
input: {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
group: true,
|
||||
model,
|
||||
queryPrice: true,
|
||||
querySampleCode: false,
|
||||
queryGroupByModel: true,
|
||||
queryQuota: false,
|
||||
queryQpmInfo: false,
|
||||
queryApplyStatus: false,
|
||||
queryPermissions: false,
|
||||
queryActivationStatus: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const responseData = unwrapResponse(result as Record<string, unknown>);
|
||||
const list = (responseData.list as Record<string, unknown>[]) ?? [];
|
||||
// The response is grouped; find the exact model in items.
|
||||
for (const group of list) {
|
||||
const items = (group.items as Record<string, unknown>[]) ?? [];
|
||||
for (const item of items) {
|
||||
if (item.model === model) {
|
||||
const prices = (item.prices as ModelPriceInfo[]) ?? [];
|
||||
return prices.find((entry) => entry.type === "ft") ?? null;
|
||||
}
|
||||
}
|
||||
// Flat response fallback (no items nesting).
|
||||
if (group.model === model) {
|
||||
const prices = (group.prices as ModelPriceInfo[]) ?? [];
|
||||
return prices.find((entry) => entry.type === "ft") ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the actual training fee from the model catalog's "ft" price entry.
|
||||
* Returns null when the price is unavailable (network error, model not in
|
||||
* catalog, or no "ft" entry). Never throws.
|
||||
*
|
||||
* Only uses the public model catalog (model metadata) — does NOT call
|
||||
* console-domain pricing APIs (modelCenter.getModelPrice). Models whose
|
||||
* catalog entry lacks a "ft" price (e.g. CosyVoice) will simply omit the
|
||||
* training_cost field until the platform adds it to the catalog.
|
||||
*/
|
||||
export async function computeActualFee(
|
||||
settings: Settings,
|
||||
model: string,
|
||||
usageTokens: number,
|
||||
): Promise<ActualFee | null> {
|
||||
try {
|
||||
const ftEntry = await fetchTrainingPrice(settings, model);
|
||||
const unitPrice = Number(ftEntry?.price);
|
||||
if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null;
|
||||
const priceUnit = ftEntry?.priceUnit ?? "每百万tokens";
|
||||
// Catalog price is yuan per million tokens.
|
||||
const cost = (usageTokens / 1_000_000) * unitPrice;
|
||||
return { cost: Number(cost.toFixed(4)), unitPrice, priceUnit };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { defineCommand, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { computeActualFee } from "./fee.ts";
|
||||
|
||||
const GET_FLAGS = {
|
||||
jobId: {
|
||||
@@ -17,12 +18,11 @@ export default defineCommand({
|
||||
flags: GET_FLAGS,
|
||||
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const { settings, flags } = ctx;
|
||||
const jobId = flags.jobId;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "finetune.get", job_id: jobId }, format);
|
||||
emitResult({ action: "finetune.get", job_id: jobId }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,18 +30,24 @@ export default defineCommand({
|
||||
const job = response.output ?? response.data;
|
||||
|
||||
if (!job) {
|
||||
emitBare(`No data returned for ${jobId}`);
|
||||
emitResult({ job_id: jobId, error: "No data returned" }, "json");
|
||||
return;
|
||||
}
|
||||
|
||||
const hp = job.hyper_parameters;
|
||||
const hyperParameters = job.hyper_parameters;
|
||||
const hyperParts: string[] = [];
|
||||
if (hp?.n_epochs !== undefined) hyperParts.push(`n_epochs=${hp.n_epochs}`);
|
||||
if (hp?.batch_size !== undefined) hyperParts.push(`batch_size=${hp.batch_size}`);
|
||||
if (hp?.learning_rate !== undefined) hyperParts.push(`learning_rate=${hp.learning_rate}`);
|
||||
if (hp?.max_length !== undefined) hyperParts.push(`max_length=${hp.max_length}`);
|
||||
if (hyperParameters?.n_epochs !== undefined)
|
||||
hyperParts.push(`n_epochs=${hyperParameters.n_epochs}`);
|
||||
if (hyperParameters?.batch_size !== undefined)
|
||||
hyperParts.push(`batch_size=${hyperParameters.batch_size}`);
|
||||
if (hyperParameters?.learning_rate !== undefined)
|
||||
hyperParts.push(`learning_rate=${hyperParameters.learning_rate}`);
|
||||
if (hyperParameters?.max_length !== undefined)
|
||||
hyperParts.push(`max_length=${hyperParameters.max_length}`);
|
||||
|
||||
const item = {
|
||||
const usageTokens = typeof job.usage === "number" ? job.usage : undefined;
|
||||
|
||||
const item: Record<string, unknown> = {
|
||||
job_id: job.job_id ?? jobId,
|
||||
base_model: job.model ?? "",
|
||||
status: job.status ?? "",
|
||||
@@ -53,28 +59,20 @@ export default defineCommand({
|
||||
model_name: job.model_name ?? "",
|
||||
created_at: job.create_time ?? job.gmt_create ?? "",
|
||||
updated_at: job.end_time ?? job.gmt_modified ?? "",
|
||||
usage_tokens: usageTokens ?? "",
|
||||
charge_type: typeof job.charge_type === "string" ? job.charge_type : "",
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
// Actual fee: only when the platform reports a concrete token count
|
||||
// (SUCCEEDED / CANCELED). Best-effort — silently omitted on lookup failure.
|
||||
if (usageTokens !== undefined && usageTokens > 0 && job.model) {
|
||||
const fee = await computeActualFee(settings, job.model, usageTokens);
|
||||
if (fee) {
|
||||
item.training_cost = fee.cost;
|
||||
item.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
|
||||
}
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
emitBare(`job_id: ${item.job_id}`);
|
||||
if (item.base_model) emitBare(`base_model: ${item.base_model}`);
|
||||
if (item.status) emitBare(`status: ${item.status}`);
|
||||
if (item.training_type) emitBare(`training_type: ${item.training_type}`);
|
||||
if (item.training_files.length) emitBare(`training_files: ${item.training_files.join(", ")}`);
|
||||
if (item.validation_files.length)
|
||||
emitBare(`validation_files: ${item.validation_files.join(", ")}`);
|
||||
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
|
||||
if (item.output_model)
|
||||
emitBare(
|
||||
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
|
||||
);
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
|
||||
emitResult({ ...item, request_id: response.request_id }, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { defineCommand, listFineTunes, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -13,70 +13,48 @@ const LIST_FLAGS = {
|
||||
valueHint: "<s>",
|
||||
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
|
||||
},
|
||||
baseModel: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Filter by base model ID (server-side)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List fine-tune jobs",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
|
||||
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>] [--base-model <model>]",
|
||||
flags: LIST_FLAGS,
|
||||
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
|
||||
exampleArgs: ["", "--status RUNNING", "--base-model qwen3-8b", "--page-size 20"],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const { settings, flags } = ctx;
|
||||
const pageNo = flags.page;
|
||||
const pageSize = flags.pageSize;
|
||||
const status = flags.status || undefined;
|
||||
const model = flags.baseModel || undefined;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
|
||||
emitResult(
|
||||
{ action: "finetune.list", page: pageNo, page_size: pageSize, status, model },
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status });
|
||||
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status, model });
|
||||
const payload = response.output ?? response.data;
|
||||
const jobs = payload?.jobs ?? [];
|
||||
const total = payload?.total;
|
||||
|
||||
const items = jobs.map((item) => ({
|
||||
job_id: item.job_id ?? "",
|
||||
base_model: item.model ?? "",
|
||||
status: item.status ?? "",
|
||||
training_type: item.training_type ?? "",
|
||||
output_model: item.finetuned_output ?? "",
|
||||
created_at: item.create_time ?? item.gmt_create ?? "",
|
||||
const items = jobs.map((job) => ({
|
||||
job_id: job.job_id ?? "",
|
||||
base_model: job.model ?? "",
|
||||
status: job.status ?? "",
|
||||
training_type: job.training_type ?? "",
|
||||
output_model: job.finetuned_output ?? "",
|
||||
created_at: job.create_time ?? job.gmt_create ?? "",
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
// text / quiet
|
||||
if (items.length === 0) {
|
||||
emitBare("No fine-tune jobs found.");
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
"JOB_ID",
|
||||
"BASE_MODEL",
|
||||
"STATUS",
|
||||
"TRAINING_TYPE",
|
||||
"OUTPUT_MODEL",
|
||||
"CREATED_AT",
|
||||
];
|
||||
const rows = items.map((i) => [
|
||||
i.job_id,
|
||||
i.base_model,
|
||||
i.status,
|
||||
i.training_type,
|
||||
i.output_model,
|
||||
i.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitBare(
|
||||
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
|
||||
);
|
||||
emitResult({ items, total, request_id: response.request_id }, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTuneLogs,
|
||||
type Client,
|
||||
type FineTuneLogEntry,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* Render a single log entry as a single line (mirrors the flatten logic used
|
||||
* for non-search text output: prefer common fields, fall back to JSON).
|
||||
* Render a single log entry as a single line (used for search matching:
|
||||
* prefer common fields, fall back to JSON).
|
||||
*/
|
||||
function renderEntry(entry: FineTuneLogEntry | string): string {
|
||||
if (typeof entry === "string") return entry;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const ts = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
|
||||
const timestamp = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
|
||||
const level = (record.level ?? "") as string;
|
||||
const msg = (record.message ?? record.msg ?? record.log ?? "") as string;
|
||||
if (msg || ts || level) {
|
||||
return [ts, level, msg].filter(Boolean).join("\t");
|
||||
const message = (record.message ?? record.msg ?? record.log ?? "") as string;
|
||||
if (message || timestamp || level) {
|
||||
return [timestamp, level, message].filter(Boolean).join("\t");
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
@@ -48,16 +47,16 @@ async function fetchAllLogs(
|
||||
let total = 0;
|
||||
// Hard cap to avoid an unbounded loop if the server misreports `total`.
|
||||
const maxPages = 200;
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const response = await getFineTuneLogs(client, jobId, { pageNo, pageSize });
|
||||
const payload = response.output ?? response.data;
|
||||
const page = payload?.logs ?? [];
|
||||
const logs = payload?.logs ?? [];
|
||||
total = payload?.total ?? total;
|
||||
if (page.length === 0) break;
|
||||
entries.push(...page);
|
||||
if (logs.length === 0) break;
|
||||
entries.push(...logs);
|
||||
// Stop once we've collected everything the server claims exists.
|
||||
if (total && entries.length >= total) break;
|
||||
if (page.length < pageSize) break;
|
||||
if (logs.length < pageSize) break;
|
||||
pageNo++;
|
||||
}
|
||||
return { entries, total };
|
||||
@@ -110,7 +109,6 @@ export default defineCommand({
|
||||
const pageSize = flags.pageSize;
|
||||
const search = flags.search || undefined;
|
||||
const tail = flags.tail;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
@@ -122,7 +120,7 @@ export default defineCommand({
|
||||
search,
|
||||
tail,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -147,18 +145,6 @@ export default defineCommand({
|
||||
const result =
|
||||
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
if (result.length === 0) {
|
||||
emitBare(search ? `No logs matched "${search}".` : "No logs returned.");
|
||||
return;
|
||||
}
|
||||
for (const entry of result) emitBare(renderEntry(entry));
|
||||
const parts: string[] = [`${result.length} shown`];
|
||||
if (matched !== undefined) parts.push(`matched ${matched}`);
|
||||
parts.push(`of ${entries.length}` + (total ? ` (total ${total})` : ""));
|
||||
emitBare(`\n${parts.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
emitResult(
|
||||
{
|
||||
...(matched !== undefined ? { matched } : {}),
|
||||
@@ -168,27 +154,13 @@ export default defineCommand({
|
||||
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
|
||||
logs: result,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: single page, verbatim response.
|
||||
const response = await getFineTuneLogs(ctx.client, jobId, { pageNo, pageSize });
|
||||
const payload = response.output ?? response.data;
|
||||
const logs = payload?.logs ?? [];
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
if (logs.length === 0) {
|
||||
emitBare("No logs returned.");
|
||||
return;
|
||||
}
|
||||
for (const entry of logs) {
|
||||
emitBare(renderEntry(entry));
|
||||
}
|
||||
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
emitResult(response, "json");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
defineCommand,
|
||||
fetchTrainingModelPrice,
|
||||
estimateSftDpoTokens,
|
||||
estimateCptTokens,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const PRICE_FLAGS = {
|
||||
baseModel: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
|
||||
required: true,
|
||||
},
|
||||
datasets: {
|
||||
type: "string",
|
||||
valueHint: "<ids>",
|
||||
description: "Training dataset file IDs, comma-separated (required)",
|
||||
required: true,
|
||||
},
|
||||
trainingType: {
|
||||
type: "string",
|
||||
valueHint: "<type>",
|
||||
description: "Training type: sft | dpo | cpt (default: sft)",
|
||||
},
|
||||
nEpochs: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Number of training epochs (default: 3)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const SUPPORTED_TRAINING_TYPES = ["sft", "dpo", "cpt"];
|
||||
|
||||
// Fixed hyper-parameters used for estimation. Only n_epochs materially affects
|
||||
// the estimate; the rest are held at representative defaults (not exposed as
|
||||
// flags to keep the command surface minimal).
|
||||
const ESTIMATE_BATCH_SIZE = 16;
|
||||
const ESTIMATE_MAX_LENGTH = 8192;
|
||||
const DEFAULT_N_EPOCHS = 3;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Estimate the training cost for a fine-tune job (token billing)",
|
||||
auth: "console",
|
||||
usageArgs: "--base-model <model> --datasets <ids> [--training-type <type>] [--n-epochs <n>]",
|
||||
flags: PRICE_FLAGS,
|
||||
exampleArgs: [
|
||||
"--base-model qwen3-8b --datasets file-ft-xxx",
|
||||
"--base-model qwen3-8b --datasets file-ft-xxx,file-ft-yyy --n-epochs 2",
|
||||
"--base-model qwen3-8b --datasets file-ft-xxx --training-type cpt",
|
||||
],
|
||||
notes: [
|
||||
"Estimate only — the server computes token usage from the datasets; final cost is subject to the bill.",
|
||||
"Covers token billing for sft / dpo / cpt. Training-unit (MTU) billing is not supported by this command.",
|
||||
"Hyper-parameters other than --n-epochs are fixed at representative defaults for estimation.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const model = flags.baseModel;
|
||||
const datasetIds = flags.datasets
|
||||
.split(",")
|
||||
.map((datasetId) => datasetId.trim())
|
||||
.filter(Boolean);
|
||||
const trainingType = (flags.trainingType ?? "sft").toLowerCase();
|
||||
const nEpochs = flags.nEpochs ?? DEFAULT_N_EPOCHS;
|
||||
|
||||
if (!SUPPORTED_TRAINING_TYPES.includes(trainingType)) {
|
||||
throw new BailianError(
|
||||
`Unsupported training type "${trainingType}". Supported: ${SUPPORTED_TRAINING_TYPES.join(", ")}.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
if (datasetIds.length === 0) {
|
||||
throw new BailianError("--datasets must contain at least one file ID.", ExitCode.USAGE);
|
||||
}
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{ action: "finetune.price", model, datasets: datasetIds, trainingType, nEpochs },
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unit price (yuan per 千Token).
|
||||
const priceInfo = await fetchTrainingModelPrice(ctx.client, model);
|
||||
const unitPrice = Number(priceInfo.price);
|
||||
if (!Number.isFinite(unitPrice)) {
|
||||
throw new BailianError(
|
||||
`No training price found for model "${model}".`,
|
||||
ExitCode.GENERAL,
|
||||
undefined,
|
||||
{ rawResponse: JSON.stringify(priceInfo) },
|
||||
);
|
||||
}
|
||||
|
||||
// Per-epoch token estimate (min/max range).
|
||||
const estimate =
|
||||
trainingType === "cpt"
|
||||
? await estimateCptTokens(ctx.client, model, datasetIds.join(","), nEpochs)
|
||||
: await estimateSftDpoTokens(ctx.client, datasetIds, {
|
||||
nEpochs,
|
||||
batchSize: ESTIMATE_BATCH_SIZE,
|
||||
maxLength: ESTIMATE_MAX_LENGTH,
|
||||
});
|
||||
|
||||
const minPerEpoch = estimate.estimatedDatasetConsumedTokensMinPerEpoch ?? 0;
|
||||
const maxPerEpoch = estimate.estimatedDatasetConsumedTokensMaxPerEpoch ?? 0;
|
||||
const mixedMinPerEpoch = estimate.estimatedMixedConsumedTokensMinPerEpoch ?? 0;
|
||||
const mixedMaxPerEpoch = estimate.estimatedMixedConsumedTokensMaxPerEpoch ?? 0;
|
||||
|
||||
const minTokens = (minPerEpoch + mixedMinPerEpoch) * nEpochs;
|
||||
const maxTokens = (maxPerEpoch + mixedMaxPerEpoch) * nEpochs;
|
||||
// price is yuan per 1000 tokens.
|
||||
const minFee = (minTokens / 1000) * unitPrice;
|
||||
const maxFee = (maxTokens / 1000) * unitPrice;
|
||||
|
||||
emitResult(
|
||||
{
|
||||
model,
|
||||
training_type: trainingType,
|
||||
n_epochs: nEpochs,
|
||||
unit_price: unitPrice,
|
||||
price_unit: priceInfo.priceUnit ?? "千Token",
|
||||
estimated_tokens: { min: minTokens, max: maxTokens },
|
||||
estimated_fee_yuan: {
|
||||
min: Number(minFee.toFixed(4)),
|
||||
max: Number(maxFee.toFixed(4)),
|
||||
},
|
||||
disclaimer: "Server-side estimate; final cost is subject to the bill.",
|
||||
},
|
||||
"json",
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
getFineTune,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { computeActualFee } from "./fee.ts";
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = 10;
|
||||
const MIN_INTERVAL_SEC = 1;
|
||||
@@ -103,7 +103,6 @@ export default defineCommand({
|
||||
const follow = flags.follow;
|
||||
const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC);
|
||||
const pollTimeoutSec = flags.pollTimeout;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
@@ -114,7 +113,7 @@ export default defineCommand({
|
||||
interval: intervalSec,
|
||||
timeout: pollTimeoutSec,
|
||||
},
|
||||
format,
|
||||
"json",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -132,12 +131,24 @@ export default defineCommand({
|
||||
if (settings.quiet) {
|
||||
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
|
||||
emitBare(status || "UNKNOWN");
|
||||
} else if (format === "text") {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
|
||||
} else {
|
||||
// json: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
const output: Record<string, unknown> = {
|
||||
job_id: jobId,
|
||||
status: status || "UNKNOWN",
|
||||
terminal,
|
||||
request_id: response.request_id,
|
||||
};
|
||||
// Enrich terminal output with actual fee when usage is reported.
|
||||
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
|
||||
if (terminal && usageTokens && usageTokens > 0 && job?.model) {
|
||||
output.usage_tokens = usageTokens;
|
||||
const fee = await computeActualFee(settings, job.model as string, usageTokens);
|
||||
if (fee) {
|
||||
output.training_cost = fee.cost;
|
||||
output.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
|
||||
}
|
||||
}
|
||||
emitResult(output, "json");
|
||||
}
|
||||
|
||||
if (terminal && status !== "SUCCEEDED") {
|
||||
@@ -164,17 +175,28 @@ export default defineCommand({
|
||||
const job = response.output ?? response.data;
|
||||
const status = String(job?.status ?? "").toUpperCase();
|
||||
|
||||
if (format === "text" && !settings.quiet && status !== lastStatus) {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (!settings.quiet && status !== lastStatus) {
|
||||
process.stderr.write(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}\n`);
|
||||
lastStatus = status;
|
||||
}
|
||||
|
||||
if (TERMINAL_STATUSES.has(status)) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (format !== "text" || settings.quiet) {
|
||||
emitResult(response, format);
|
||||
} else if (status === "SUCCEEDED") {
|
||||
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
if (settings.quiet) {
|
||||
emitBare(status || "UNKNOWN");
|
||||
} else {
|
||||
// Enrich the raw response with actual fee when usage is available.
|
||||
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
|
||||
const enriched: Record<string, unknown> = { ...response };
|
||||
if (usageTokens && usageTokens > 0 && job?.model) {
|
||||
const fee = await computeActualFee(settings, job.model as string, usageTokens);
|
||||
if (fee) {
|
||||
enriched.training_cost = fee.cost;
|
||||
enriched.usage_tokens = usageTokens;
|
||||
enriched.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
|
||||
}
|
||||
}
|
||||
emitResult(enriched, "json");
|
||||
}
|
||||
if (status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
@@ -200,7 +222,7 @@ export default defineCommand({
|
||||
// Any other error (including the BailianError thrown above) propagates to
|
||||
// the central handler.
|
||||
if (controller.signal.aborted) {
|
||||
emitBare("\nInterrupted.");
|
||||
process.stderr.write("\nInterrupted.\n");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
resolveOutputDir,
|
||||
@@ -20,8 +18,10 @@ import {
|
||||
BailianError,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageEditApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
@@ -31,12 +31,6 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -53,7 +47,7 @@ const EDIT_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Model ID (default: qwen-image-2.0)",
|
||||
description: "Model ID (default: qwen-image-3.0)",
|
||||
},
|
||||
size: {
|
||||
type: "string",
|
||||
@@ -71,6 +65,12 @@ const EDIT_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Negative prompt to exclude unwanted content",
|
||||
},
|
||||
function: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description:
|
||||
"wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit",
|
||||
},
|
||||
promptExtend: {
|
||||
type: "boolean",
|
||||
valueHint: "<bool>",
|
||||
@@ -98,7 +98,7 @@ const EDIT_FLAGS = {
|
||||
type EditFlags = ParsedFlags<typeof EDIT_FLAGS>;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Edit an existing image with text instructions (Qwen-Image)",
|
||||
description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--image <url> --prompt <text> [flags]",
|
||||
flags: EDIT_FLAGS,
|
||||
@@ -107,6 +107,9 @@ export default defineCommand({
|
||||
'--image https://example.com/logo.png --prompt "Change color to blue" --n 3',
|
||||
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
|
||||
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
|
||||
'--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview',
|
||||
'--image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -120,71 +123,139 @@ export default defineCommand({
|
||||
}
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageEditApi(model);
|
||||
|
||||
// Auto-upload local files (resolve all images in parallel)
|
||||
const resolvedImages = await Promise.all(
|
||||
rawImages.map((img) => ctx.client.uploadFile(img, model)),
|
||||
rawImages.map((image) => ctx.client.resolveImageInput(image, model)),
|
||||
);
|
||||
const n = flags.n ?? 1;
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
// Build content: all images first, then text prompt
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(u: string) => ({ image: u }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
size: resolveImageSize(flags.size, useSync),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size: resolveImageSize(flags.size, route.sizeProfile),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
let body: DashScopeImageRequest;
|
||||
if (route.inputStyle === "function-base-image") {
|
||||
const baseImageUrl = resolvedImages[0];
|
||||
if (!baseImageUrl) {
|
||||
throw new BailianError(
|
||||
"wanx*-imageedit requires at least one --image as base_image_url.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
function: flags.function || "description_edit",
|
||||
prompt,
|
||||
base_image_url: baseImageUrl,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else if (route.inputStyle === "prompt-images") {
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
images: resolvedImages,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else {
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(imageUrl: string) => ({ image: imageUrl }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Remove undefined parameters
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
let previewBody: DashScopeImageRequest = body;
|
||||
if ("messages" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
} else if ("images" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)),
|
||||
},
|
||||
};
|
||||
} else if ("base_image_url" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
base_image_url: redactDataUri(body.input.base_image_url),
|
||||
mask_image_url: body.input.mask_image_url
|
||||
? redactDataUri(body.input.mask_image_url)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
emitResult(
|
||||
{ request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -192,6 +263,7 @@ export default defineCommand({
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -199,15 +271,15 @@ async function handleSyncMode(
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({
|
||||
path: imageSyncPath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -221,6 +293,7 @@ async function handleSyncMode(
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -231,14 +304,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
if (flags.async) {
|
||||
emitResult({ task_ids: taskIds }, format);
|
||||
@@ -251,12 +324,12 @@ async function handleAsyncMode(
|
||||
url: client.url(taskPath(taskId)),
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -267,13 +340,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -303,8 +376,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
generateFilename,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageGenerateApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
} from "bailian-cli-core";
|
||||
@@ -31,19 +30,12 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
prompt: { type: "string", valueHint: "<text>", description: "Image description", required: true },
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Model ID (default: qwen-image-2.0)",
|
||||
description: "Model ID (default: qwen-image-3.0)",
|
||||
},
|
||||
size: {
|
||||
type: "string",
|
||||
@@ -104,6 +96,8 @@ export default defineCommand({
|
||||
'--prompt "Logo" --watermark false',
|
||||
'--prompt "An alien in the space" --watermark false',
|
||||
'--prompt "sunset" --model wan2.6-t2i --async --quiet',
|
||||
'--prompt "plush doll" --model z-image-turbo --size 1024*1024',
|
||||
'--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024',
|
||||
'--prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
],
|
||||
@@ -111,74 +105,91 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const defaultSize = useSync ? "1:1" : "1:1";
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageGenerateApi(model);
|
||||
const defaultSize = "1:1";
|
||||
const sizeInput = flags.size || defaultSize;
|
||||
const size = resolveImageSize(sizeInput, useSync);
|
||||
const size = resolveImageSize(sizeInput, route.sizeProfile);
|
||||
const n = flags.n ?? 1;
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
const body: DashScopeImageRequest =
|
||||
route.inputStyle === "prompt"
|
||||
? {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
emitResult(
|
||||
{ request: body, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`);
|
||||
}
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Sync mode: qwen-image-2.0 series ----
|
||||
// ---- Sync mode: qwen-image / wan2.7-image / z-image ----
|
||||
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path: imageSyncPath(), method: "POST", body }),
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path, method: "POST", body }),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -189,12 +200,12 @@ async function handleSyncMode(
|
||||
await saveImages(imageUrls, flags, settings, format);
|
||||
}
|
||||
|
||||
// ---- Async mode: wan2.x / qwen-image-plus ----
|
||||
// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ----
|
||||
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
@@ -205,14 +216,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
// --async: return all task IDs immediately
|
||||
if (flags.async) {
|
||||
@@ -229,12 +240,12 @@ async function handleAsyncMode(
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -245,13 +256,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -293,8 +304,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user