Compare commits

..

20 Commits

Author SHA1 Message Date
zeyu.fz 75699966d4 Merge remote-tracking branch 'origin/main' into feat/memory 2026-09-10 20:43:23 +08:00
Gong Shiqi 2220f932b5 Merge pull request #198 from modelstudioai/codex/release-1.23.0
chore(release): prepare 1.23.0
2026-09-10 17:37:40 +08:00
zeyu.fz d9134d6f0a feat(memory): 增加长度校验及限流提示并强化安全确认
- 在多条命令中统一添加对 userId 和 memoryLibraryId 长度的校验
- 新增 profile schema 名称、描述及属性字段长度断言
- 为 delete 内存节点命令添加高风险删除确认机制及 --yes 标志
- 增加账号级别限流说明,提示遇 HTTP 429 需降低请求频率
- 统一移除分散的长度常量,集中定义并使用共享常量
- 相关 E2E 测试覆盖长度限制和高风险操作确认情况
- 支持内存消息内容多样化,新增 MemoryContentPart 类型定义
- 内存文档中新增限流描述及删除命令风险提示,强调确认操作要求
2026-09-10 17:31:42 +08:00
若麒 5c390fad74 chore(release): prepare 1.23.0 2026-09-10 16:57:58 +08:00
Gong Shiqi 6218637f7c Merge pull request #194 from modelstudioai/feat/global-watermark-config
Feat/global watermark config
2026-09-10 16:49:33 +08:00
若麒 0c7ba721a7 Merge remote-tracking branch 'origin/main' into feat/global-watermark-config 2026-09-10 15:39:12 +08:00
Gong Shiqi 0dbf071368 Merge pull request #197 from modelstudioai/feat/speech-vocabulary
Add ASR instant hotwords, context, and vocabulary CRUD
2026-09-10 15:11:35 +08:00
zeyu.fz 75a7100702 feat(memory): 完善记忆及画像相关命令支持并统一参数
- 在 CLI 文档中新增记忆 live 场景说明与变量需求
- 扩展 CLI 命令集,添加记忆画像 Profile 的增删改查功能
- 统一所有记忆相关命令加入 workspace 和 library 参数支持
- 记忆添加命令支持自定义 meta_data,完善输入校验和互斥说明
- 记忆删除、列表、获取用户画像等命令增加工作空间参数支持
- 画像模板相关命令新增分页参数及属性操作接口
- 重构知识命令共享模块,移除重复的 workspace 解析实现,统一导出
- 优化命令输出格式,文本模式下显示更多详细信息和异常提示
- 提供完整的示例参数,增强命令使用说明和提示文档
2026-09-10 14:40:39 +08:00
clh02467605 16e30686f0 fix(speech): validate --words JSON before auth
Invalid vocabulary JSON was parsed in run(), so CI without an API key failed with AUTH(3) instead of USAGE(2).
2026-09-10 14:28:16 +08:00
clh02467605 7803d91e3f chore: fixed Formatting issues 2026-09-10 14:07:37 +08:00
clh02467605 c2d17707c0 feat(speech): add instant hotwords, context, and vocabulary CRUD 2026-09-10 14:02:05 +08:00
rendianmeng 1dfb4400cc Merge branch 'main' of github.com:modelstudioai/cli into feat/global-watermark-config 2026-09-10 10:33:23 +08:00
rendianmeng fd2077b373 feat: pipeline watermark 处理完善 2026-09-09 17:40:49 +08:00
chenanran555 2090293f85 Merge pull request #192 from modelstudioai/feat/managed-agent-ci
fix(managed-agent): improve project initialization and build validation
2026-09-08 19:39:03 +08:00
chenanran555 a245359792 chore(release): prepare 1.22.0 2026-09-08 19:26:17 +08:00
chenanran555 19bd5a5ea8 test(managed-agent): remove obsolete build confirmation flag 2026-09-08 19:01:47 +08:00
chenanran555 e262f2e574 chore(deps): bump OpenAgentPack SDK to 0.7.1 2026-09-08 18:01:41 +08:00
chenanran555 180aac9f28 fix(managed-agent): show absolute YAML paths during init 2026-09-08 16:57:15 +08:00
若麒 3477a08dad feat(config): add profile-level watermark setting 2026-09-08 16:33:40 +08:00
chenanran555 aa1484264e fix(managed-agent): improve project initialization and build validation 2026-09-08 14:46:56 +08:00
114 changed files with 7381 additions and 763 deletions
+25
View File
@@ -6,6 +6,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.23.0] - 2026-09-10
### Added
- **Profile-level watermark control** — configure `watermark` with `bl config set --key watermark --value true|false` to control the default watermark behavior for image generation and editing, video generation and editing, and reference-to-video commands.
- **ASR accuracy controls** — `bl speech recognize` now supports instant hot words with `--vocabulary`, contextual word enhancement with `--context`, and reusable pre-built vocabularies with `--vocabulary-id` for supported ASR models.
- **Speech vocabulary management** — added `bl speech vocabulary create|list|get|update|delete` to manage reusable pre-built hot-word vocabularies.
## [1.22.0] - 2026-09-08
### Changed
- **Project initialization** — `managed-agent project init` now creates `./managed-agent` by default. Use `--project .` to initialize in place. **(BREAKING)**
- **Build confirmation** — `managed-agent project build` no longer requires confirmation and rejects `--yes`. Use `--dry-run` for a read-only preview; Publish still requires confirmation. **(BREAKING)**
- **Managed Agent SDK** — upgrade to `0.7.1`. Build automatically associates active Agent-local resources while preserving explicit bindings, Skill versions, and File mount paths. Ambiguous Environment or Vault selections are rejected before writing.
### Fixed
- **Project diagnostics** — provide actionable project-root guidance and surface the underlying Build validation error.
- **YAML initialization paths** — show the absolute YAML path in creation messages and existing-file errors.
### Internal
- Expand project initialization and Build regression coverage, and remove the obsolete Build confirmation flag from the local lifecycle E2E test.
## [1.21.0] - 2026-09-07
### Added
+25
View File
@@ -6,6 +6,31 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.23.0] - 2026-09-10
### 新增
- **Profile 级水印控制** —— 可通过 `bl config set --key watermark --value true|false` 设置图片生成与编辑、视频生成与编辑以及参考生视频命令的默认水印行为。
- **ASR 准确率增强** —— `bl speech recognize` 现支持通过 `--vocabulary` 传入即时热词、通过 `--context` 增强上下文词表,以及通过 `--vocabulary-id` 使用适用于对应 ASR 模型的预编译热词表。
- **语音热词表管理** —— 新增 `bl speech vocabulary create|list|get|update|delete`,用于管理可复用的预编译热词表。
## [1.22.0] - 2026-09-08
### 变更
- **项目初始化** —— `managed-agent project init` 默认创建 `./managed-agent` 子目录;如需原地初始化,请使用 `--project .`。**(BREAKING)**
- **Build 确认机制** —— `managed-agent project build` 无需确认,并且不再接受 `--yes`。使用 `--dry-run` 可只读预览Publish 仍需显式确认。**(BREAKING)**
- **Managed Agent SDK** —— 升级至 `0.7.1`。Build 自动关联 Agent 目录下已启用的资源保留显式引用、Skill 版本和 File 挂载路径Environment 或 Vault 选择存在歧义时,在写入前报错。
### 修复
- **项目诊断** —— 提供可操作的项目根目录提示,并展示 Build 校验失败的具体原因。
- **YAML 初始化路径** —— 创建成功及文件已存在的错误信息均展示 YAML 绝对路径。
### 内部
- 补充项目初始化和 Build 回归覆盖,移除本地闭环 E2E 测试中过时的 Build 确认参数。
## [1.21.0] - 2026-09-07
### 新增
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+2 -1
View File
@@ -71,12 +71,13 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
| 场景 | 条件 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 文本/搜索/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| OpenAPI AK/SK | `isOpenApiE2EReady()``.env` 中必须同时提供完整 AK/SK |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()``knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID |
| 记忆 live | `isMemoryE2EReady()`(另需 `BAILIAN_E2E_MEMORY_LIBRARY_ID`;记忆服务需账号单独开通) |
## 用例类型
+1
View File
@@ -19,6 +19,7 @@
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
- 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。
### B. 鉴权 / 全局选项
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.21.0",
"version": "1.23.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+18
View File
@@ -29,6 +29,10 @@ import {
memoryUpdate,
memoryDelete,
memoryProfileCreate,
memoryProfileList,
memoryProfileShow,
memoryProfileUpdate,
memoryProfileDelete,
memoryProfileGet,
knowledgeRetrieve,
knowledgeSearch,
@@ -70,6 +74,11 @@ import {
searchWeb,
speechSynthesize,
speechRecognize,
speechVocabularyCreate,
speechVocabularyList,
speechVocabularyGet,
speechVocabularyUpdate,
speechVocabularyDelete,
fileUpload,
consoleCall,
usageFree,
@@ -243,6 +252,10 @@ export const commands: Record<string, AnyCommand> = {
"memory update": memoryUpdate,
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile list": memoryProfileList,
"memory profile show": memoryProfileShow,
"memory profile update": memoryProfileUpdate,
"memory profile delete": memoryProfileDelete,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"knowledge search": knowledgeSearch,
@@ -286,6 +299,11 @@ export const commands: Record<string, AnyCommand> = {
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"speech vocabulary create": speechVocabularyCreate,
"speech vocabulary list": speechVocabularyList,
"speech vocabulary get": speechVocabularyGet,
"speech vocabulary update": speechVocabularyUpdate,
"speech vocabulary delete": speechVocabularyDelete,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.21.0",
"version": "1.23.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -40,7 +40,7 @@
"check": "vp check"
},
"dependencies": {
"@openagentpack/sdk": "0.7.0",
"@openagentpack/sdk": "0.7.1",
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
+3 -2
View File
@@ -12,9 +12,9 @@ export default defineCommand({
valueHint: "<key>",
description: {
"en-US":
"Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"Config key (language, base_url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"zh-CN":
"配置项名称language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id",
"配置项名称language、base_url、output、output_dir、timeout、watermark、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id",
},
required: true,
},
@@ -29,6 +29,7 @@ export default defineCommand({
"--key language --value zh-CN",
"--key output --value json",
"--key timeout --value 600",
"--key watermark --value false",
"--key base_url --value https://dashscope.aliyuncs.com",
"--config company-plan --key api-key-capabilities --value text.chat,image.generate",
],
@@ -3,6 +3,7 @@ import {
ExitCode,
isApiKeyCapability,
normalizeModelBaseUrl,
parseBooleanValue,
SUPPORTED_LANGUAGES,
} from "bailian-cli-core";
@@ -13,6 +14,7 @@ export const VALID_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"api_key",
"access_token",
"access_key_id",
@@ -62,7 +64,7 @@ export const UI_ENUM_KEYS: Record<string, string[]> = {
};
// Keys the UI renders as a true/false dropdown and stores as a boolean.
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry", "watermark"]);
// Default model each `default_*_model` key falls back to when left unset. These
// mirror the inline `|| "<model>"` fallbacks in the generation commands
@@ -164,7 +166,10 @@ export function resolveKey(key: string): string {
* Validate a single config entry and coerce its value to the stored type.
* Throws BailianError(USAGE) for unknown keys or invalid values.
*/
export function validateAndCoerce(key: string, value: string): string | number | string[] {
export function validateAndCoerce(
key: string,
value: string,
): string | number | boolean | string[] {
const resolvedKey = resolveKey(key);
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
@@ -201,6 +206,8 @@ export function validateAndCoerce(key: string, value: string): string | number |
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
if (resolvedKey === "watermark") return parseBooleanValue(value, "watermark");
if (resolvedKey === "api_key_capabilities") {
let rawCapabilities: unknown;
if (value.trim().startsWith("[")) {
@@ -18,6 +18,7 @@ export default defineCommand({
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
watermark: settings.watermark,
config: settings.configName ?? "default",
config_file: store.path,
};
+1 -1
View File
@@ -207,7 +207,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(flags.size, route.sizeProfile),
@@ -185,7 +185,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size,
@@ -1,7 +1,6 @@
// Shared building blocks for the knowledge admin commands.
import {
BailianError,
ExitCode,
ragEndpoint,
RAG_PATHS,
type Client,
@@ -12,18 +11,9 @@ import {
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
export const WORKSPACE_FLAG = {
workspaceId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
"zh-CN": "API Endpoint URL 使用的 Workspace ID也可设置 BAILIAN_WORKSPACE_ID",
},
},
} satisfies FlagsDef;
// The workspace scope is shared with the memory commands, so it lives in
// ../shared/workspace.ts; re-exported here to keep the knowledge imports local.
export { resolveWorkspaceId, WORKSPACE_FLAG } from "../shared/workspace.ts";
// Unified pagination flags for admin list commands. The server-side page/size
// parameter names differ per endpoint (page_number/page_num/pageNum/pageNumber…)
@@ -43,23 +33,6 @@ export const PAGE_FLAGS = {
},
} satisfies FlagsDef;
/** Three-level fallback: flag > BAILIAN_WORKSPACE_ID env > config (env/config are merged into settings); missing → USAGE. */
export function resolveWorkspaceId(ctx: {
flags: { workspaceId?: string };
settings: { workspaceId?: string };
identity: { binName: string };
}): string {
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
return workspaceId;
}
/** Truncate text-mode table rows to the terminal width; no truncation when not a TTY (pipe/redirect). */
export function truncateLine(line: string): string {
if (!process.stdout.isTTY) return line;
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
BailianError,
defineCommand,
@@ -87,7 +88,7 @@ export default defineCommand({
if (existsSync(file) && !flags.force) {
throw new BailianError(
`${file} already exists.`,
`${resolve(file)} already exists.`,
ExitCode.USAGE,
"Pass --force to overwrite.",
);
@@ -128,7 +129,7 @@ export default defineCommand({
if (format === "json") {
emitResult({ created: file, provider: "bailian", agent: agentName }, format);
} else {
emitBare(`Created ${file}`);
emitBare(`Created ${resolve(file)}`);
emitBare(
"Credentials: run `bl auth login --api-key <key> --base-url <url>`, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.",
);
@@ -1,3 +1,5 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import {
BailianError,
defineCommand,
@@ -14,6 +16,7 @@ import {
planProjectPublish,
previewProjectBuild,
type ProjectBuildResolver,
resolveDirectoryProjectRoot,
validateDirectoryProject,
} from "@openagentpack/sdk/project-workspace";
import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts";
@@ -44,9 +47,23 @@ export const managedAgentProjectInit = defineCommand({
},
auth: "none",
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["", "--project ./my-agent"],
flags: {
project: {
...PROJECT_FLAG.project,
description: {
"en-US": "Directory project root (default: ./managed-agent under the current directory)",
"zh-CN": "目录项目根路径(默认:当前目录下的 ./managed-agent",
},
},
},
exampleArgs: ["", "--project ./my-agent", "--project ."],
notes: [
{
"en-US":
"Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten.",
"zh-CN":
"不传 --project 时创建 managed-agent/ 子目录;后续项目操作请先进入该目录。使用 --project . 可在当前目录初始化或转换 agents.yaml不会覆盖已有项目文件。",
},
{
"en-US":
"New projects include Skill, File, Vault, and Environment examples under each resource directory's _examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside _examples/ to enable it, then configure its Agent reference.",
@@ -55,16 +72,17 @@ export const managedAgentProjectInit = defineCommand({
},
],
async run(ctx) {
const projectRoot = ctx.flags.project ?? "./managed-agent";
if (ctx.settings.dryRun) {
emitResult(
{
would_initialize_project: ctx.flags.project ?? ".",
would_initialize_project: projectRoot,
},
detectOutputFormat(ctx.settings.output),
);
return;
}
const result = await initializeDirectoryProject({ projectRoot: ctx.flags.project ?? "." });
const result = await initializeDirectoryProject({ projectRoot });
emitResult(result, detectOutputFormat(ctx.settings.output));
},
});
@@ -99,18 +117,34 @@ export const managedAgentProjectBuild = defineCommand({
"zh-CN": "整理目录源文件并生成不可变的发布 Build",
},
auth: "none",
risk: {
level: "high",
message: {
notes: [
{
"en-US":
"This organizes project source, moves literal Vault secrets into the local .env, and writes the previewed immutable Build.",
"Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes.",
"zh-CN":
"该操作会整理项目文件,将 Vault 明文密钥移入本地 .env,并写入已预览的不可变 Build。",
"Build 无需确认即可写入本地项目文件,包括推断的资源关联及将 Vault 明文密钥移入 .env。使用 --dry-run 可只预览不写入。Publish 变更远端资源前仍需显式确认。",
},
},
],
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["--dry-run", "--yes", "--project ./my-agent --yes"],
exampleArgs: ["", "--dry-run", "--project ./my-agent"],
async validate(flags) {
await withAgentErrors(async () => {
const root = await resolveDirectoryProjectRoot(flags.project ?? ".");
const metadata = await stat(join(root, "project.json")).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (!metadata?.isFile()) {
throw new BailianError(
`Not a project root: ${root} (project.json is missing).`,
ExitCode.USAGE,
"Run from the directory containing project.json, or pass --project <directory>.",
);
}
});
return undefined;
},
async run(ctx) {
const root = ctx.flags.project ?? ".";
const preview = await previewProjectBuild(root);
@@ -120,7 +154,11 @@ export const managedAgentProjectBuild = defineCommand({
return;
}
if (!preview.can_build)
throw new BailianError("Directory project is invalid and cannot be built.", ExitCode.GENERAL);
throw new BailianError(
preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ??
"Directory project is invalid and cannot be built.",
ExitCode.GENERAL,
);
const built = await commitProjectBuild({
projectRoot: root,
baseRevision: preview.project_revision,
+84 -28
View File
@@ -2,53 +2,79 @@ import {
defineCommand,
UsageError,
memoryAddPath,
memoryEndpoint,
detectOutputFormat,
type FlagsDef,
type ParsedFlags,
type MemoryAddRequest,
type MemoryAddResponse,
type MemoryMessage,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_RATE_LIMIT_NOTE,
MEMORY_WORKSPACE_NOTE,
MAX_CUSTOM_CONTENT_LENGTH,
PROJECT_ID_FLAG,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
parseJsonArrayFlag,
parseJsonObjectFlag,
resolveWorkspaceId,
} from "./shared.ts";
const ADD_FLAGS = {
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
description: {
"en-US": "Memory entity ID that owns the memory (required)",
"zh-CN": "记忆实体 ID标识记忆归属对象必填",
},
required: true,
},
messages: {
type: "string",
valueHint: "<json>",
description: {
"en-US": 'Messages JSON array: [{"role":"user","content":"..."},...]',
"zh-CN": '消息 JSON 数组:[{"role":"user","content":"..."},...]',
"en-US": 'Messages JSON array: [{"role":"user","content":"..."},...] (max 50)',
"zh-CN": '消息 JSON 数组:[{"role":"user","content":"..."},...](最多 50 条)',
},
},
content: {
type: "string",
valueHint: "<text>",
description: { "en-US": "Custom content text to memorize", "zh-CN": "要记忆的自定义内容文本" },
description: {
"en-US": "Custom content to memorize verbatim; takes precedence over --messages",
"zh-CN": "要原样记忆的自定义内容;优先级高于 --messages",
},
},
profileSchema: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID for user profiling",
"zh-CN": "用于用户画像的 Profile Schema ID",
"en-US": "Profile schema ID; without it no user profile is extracted",
"zh-CN": "画像模板 ID不传则不提取用户画像",
},
},
memoryLibraryId: {
metaData: {
type: "string",
valueHint: "<id>",
valueHint: "<json>",
description: {
"en-US": "Memory library ID (isolate memory space)",
"zh-CN": "记忆库 ID用于隔离记忆空间",
"en-US": 'Custom metadata JSON object: {"location_name":"Beijing"}',
"zh-CN": '用户自定义信息 JSON 对象:{"location_name":"北京"}',
},
},
...PROJECT_ID_FLAG,
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
type AddFlags = ParsedFlags<typeof ADD_FLAGS>;
/** Max messages accepted per AddMemory call (a Q&A pair counts as 2). */
const MAX_MESSAGES = 50;
export default defineCommand({
description: {
"en-US": "Add memory from messages or custom content",
@@ -57,10 +83,25 @@ export default defineCommand({
auth: "apiKey",
usageArgs: "--user-id <id> [--messages <json>] [--content <text>] [flags]",
flags: ADD_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"--content and --messages are mutually exclusive: when --content is set, --messages is ignored by the server.",
"zh-CN": "--content 与 --messages 互斥:传了 --content 时服务端会忽略 --messages。",
},
{
"en-US":
"The response lists the changed memory nodes; one call can add, update or delete several at once.",
"zh-CN": "返回结果是变更的记忆片段列表;一次调用可能同时新增、更新或删除多条。",
},
MEMORY_RATE_LIMIT_NOTE,
],
exampleArgs: [
{
"en-US": '--user-id user1 --content "The user likes Python programming"',
"zh-CN": '--user-id user1 --content "用户喜欢使用 Python 编程"',
"en-US":
'--user-id user1 --content "The user likes Python programming" --workspace-id ws_xxx',
"zh-CN": '--user-id user1 --content "用户喜欢使用 Python 编程" --workspace-id ws_xxx',
},
{
"en-US": '--user-id user1 --messages \'[{"role":"user","content":"I like traveling"}]\'',
@@ -70,46 +111,61 @@ export default defineCommand({
"en-US": '--user-id user1 --content "Lives in Beijing" --profile-schema schema_xxx',
"zh-CN": '--user-id user1 --content "居住在北京" --profile-schema schema_xxx',
},
{
"en-US": '--user-id user1 --content "Attended WAIC" --meta-data \'{"location":"Shanghai"}\'',
"zh-CN": '--user-id user1 --content "参加了 WAIC" --meta-data \'{"location":"上海"}\'',
},
],
validate: (f: AddFlags) =>
!f.messages && !f.content ? "Provide --messages or --content." : undefined,
validate: (flags: AddFlags) => {
if (!flags.messages && !flags.content) return "Provide --messages or --content.";
const scopeError = checkMemoryScopeLengths(flags);
if (scopeError) return scopeError;
if (flags.content && flags.content.length > MAX_CUSTOM_CONTENT_LENGTH)
return `--content must be at most ${MAX_CUSTOM_CONTENT_LENGTH} characters.`;
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const userId = flags.userId;
const body: MemoryAddRequest = { user_id: userId };
const body: MemoryAddRequest = { user_id: flags.userId };
if (flags.messages) {
try {
body.messages = JSON.parse(flags.messages);
} catch {
throw new UsageError("--messages must be valid JSON array");
const messages = parseJsonArrayFlag<MemoryMessage>("--messages", flags.messages);
if (messages.length > MAX_MESSAGES) {
throw new UsageError(`--messages accepts at most ${MAX_MESSAGES} messages`);
}
body.messages = messages;
}
if (flags.content) {
body.custom_content = flags.content;
}
if (flags.content) body.custom_content = flags.content;
if (flags.metaData) body.meta_data = parseJsonObjectFlag("--meta-data", flags.metaData);
if (flags.profileSchema) body.profile_schema = flags.profileSchema;
if (flags.projectId) body.project_id = flags.projectId;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
const format = detectOutputFormat(settings.output);
const url = memoryEndpoint(resolveWorkspaceId(ctx), memoryAddPath());
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(memoryAddPath()), request: body }, format);
emitResult({ endpoint: url, method: "POST", request: body }, format);
return;
}
const response = await ctx.client.requestJson<MemoryAddResponse>({
path: memoryAddPath(),
path: url,
method: "POST",
body,
});
if (settings.quiet || format === "text") {
const ids = response.memory_ids?.join(", ") || "none";
emitBare(`Memory added. IDs: ${ids}`);
const nodes = response.memory_nodes ?? [];
if (nodes.length === 0) {
emitBare("No memory node changed.");
return;
}
for (const node of nodes) {
emitBare(`[${node.event ?? "ADD"}] ${node.memory_node_id} ${node.content}`);
}
} else {
emitResult(response, format);
}
+60 -29
View File
@@ -1,50 +1,81 @@
import { defineCommand, memoryNodePath, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
memoryEndpoint,
memoryNodePath,
detectOutputFormat,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const DELETE_FLAGS = {
nodeId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory node ID (required)", "zh-CN": "记忆节点 ID必填" },
required: true,
},
userId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory entity ID that owns the memory (required)",
"zh-CN": "记忆实体 ID标识记忆归属对象必填",
},
required: true,
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: { "en-US": "Delete a memory node", "zh-CN": "删除记忆节点" },
auth: "apiKey",
usageArgs: "--node-id <id> --user-id <id>",
flags: {
nodeId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory node ID (required)", "zh-CN": "记忆节点 ID必填" },
required: true,
},
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
required: true,
},
memoryLibraryId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory library ID (non-default library)",
"zh-CN": "记忆库 ID非默认记忆库",
},
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the specified memory node and cannot be undone.",
"zh-CN": "该操作会永久删除指定的记忆片段,且无法恢复。",
},
},
exampleArgs: ["--node-id node_xxx --user-id user1"],
usageArgs: "--node-id <id> --user-id <id> [flags]",
flags: DELETE_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"Irreversible — the memory node is permanently removed. Run `memory list` first to confirm the node ID.",
"zh-CN": "该操作不可撤销——记忆片段将被永久删除。建议先用 `memory list` 确认节点 ID。",
},
],
exampleArgs: [
"--node-id node_xxx --user-id user1 --workspace-id ws_xxx",
"--node-id node_xxx --user-id user1 --yes",
],
validate: (flags) => checkMemoryScopeLengths(flags),
async run(ctx) {
const { settings, flags } = ctx;
const nodeId = flags.nodeId;
const userId = flags.userId;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams({ user_id: userId });
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
const path = `${memoryNodePath(nodeId)}?${params.toString()}`;
const url =
memoryEndpoint(resolveWorkspaceId(ctx), memoryNodePath(nodeId)) +
buildQuery({ user_id: flags.userId, memory_library_id: flags.memoryLibraryId });
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "DELETE" }, format);
emitResult({ endpoint: url, method: "DELETE" }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path,
path: url,
method: "DELETE",
});
+64 -33
View File
@@ -1,59 +1,87 @@
import {
defineCommand,
memoryEndpoint,
memoryListPath,
detectOutputFormat,
type FlagsDef,
type MemoryNodeListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
PROJECT_ID_FLAG,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const LIST_FLAGS = {
userId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory entity ID that owns the memory (required)",
"zh-CN": "记忆实体 ID标识记忆归属对象必填",
},
required: true,
},
pageSize: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Results per page (default: 10)", "zh-CN": "每页结果数默认10" },
},
page: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Page number (default: 1)", "zh-CN": "页码默认1" },
},
...PROJECT_ID_FLAG,
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: { "en-US": "List memory nodes for a user", "zh-CN": "列出用户的记忆节点" },
auth: "apiKey",
usageArgs: "--user-id <id> [flags]",
flags: {
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
required: true,
},
pageSize: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Results per page (default: 10)", "zh-CN": "每页结果数默认10" },
},
page: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Page number (default: 1)", "zh-CN": "页码默认1" },
},
memoryLibraryId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory library ID", "zh-CN": "记忆库 ID" },
},
flags: LIST_FLAGS,
notes: [MEMORY_WORKSPACE_NOTE],
exampleArgs: [
"--user-id user1 --workspace-id ws_xxx",
"--user-id user1 --page-size 20 --page 2",
"--user-id user1 --memory-library-id lib_xxx --output json",
],
validate: (flags) => {
const scopeError = checkMemoryScopeLengths(flags);
if (scopeError) return scopeError;
if (flags.page !== undefined && flags.page < 1) return "--page must be at least 1.";
if (flags.pageSize !== undefined && flags.pageSize < 1)
return "--page-size must be at least 1.";
return undefined;
},
exampleArgs: ["--user-id user1", "--user-id user1 --page-size 20 --page 2"],
async run(ctx) {
const { settings, flags } = ctx;
const userId = flags.userId;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams();
params.set("user_id", userId);
if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize));
if (flags.page !== undefined) params.set("page_num", String(flags.page));
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
const path = `${memoryListPath()}?${params.toString()}`;
const url =
memoryEndpoint(resolveWorkspaceId(ctx), memoryListPath()) +
buildQuery({
user_id: flags.userId,
page_size: flags.pageSize,
page_num: flags.page,
project_id: flags.projectId,
memory_library_id: flags.memoryLibraryId,
});
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format);
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<MemoryNodeListResponse>({
path,
path: url,
method: "GET",
});
@@ -63,6 +91,9 @@ export default defineCommand({
} else {
for (const node of response.memory_nodes) {
emitBare(`[${node.memory_node_id}] ${node.content}`);
if (node.meta_data && Object.keys(node.meta_data).length > 0) {
emitBare(` meta: ${JSON.stringify(node.meta_data)}`);
}
}
if (response.total !== undefined) {
emitBare(`\nTotal: ${response.total}`);
@@ -1,12 +1,53 @@
import {
defineCommand,
UsageError,
memoryEndpoint,
profileSchemaPath,
detectOutputFormat,
type FlagsDef,
type MemoryPlanVersion,
type ProfileAttribute,
type ProfileSchemaCreateRequest,
type ProfileSchemaCreateResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
PLAN_VERSION_FLAG,
WORKSPACE_FLAG,
assertAttributeFieldLengths,
checkMemoryScopeLengths,
checkProfileSchemaTextLengths,
parseJsonArrayFlag,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<name>",
description: { "en-US": "Schema name (required)", "zh-CN": "Schema 名称(必填)" },
required: true,
},
description: {
type: "string",
valueHint: "<text>",
description: { "en-US": "Schema description", "zh-CN": "Schema 描述" },
},
attributes: {
type: "string",
valueHint: "<json>",
description: {
"en-US": 'Attributes JSON array: [{"name":"age","description":"age","default_value":"18"}]',
"zh-CN": '属性 JSON 数组:[{"name":"age","description":"年龄","default_value":"18"}]',
},
required: true,
},
...PLAN_VERSION_FLAG,
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: {
@@ -15,60 +56,60 @@ export default defineCommand({
},
auth: "apiKey",
usageArgs: "--name <name> --attributes <json> [flags]",
flags: {
name: {
type: "string",
valueHint: "<name>",
description: { "en-US": "Schema name (required)", "zh-CN": "Schema 名称(必填)" },
required: true,
flags: PROFILE_CREATE_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"Each attribute needs a name; description and default_value are optional. --plan-version picks the extraction tier (pro / lite) and is billed differently.",
"zh-CN":
"每个属性必须有 namedescription 与 default_value 可选。--plan-version 决定抽取策略档位pro / lite计费单价不同。",
},
description: {
type: "string",
valueHint: "<text>",
description: { "en-US": "Schema description", "zh-CN": "Schema 描述" },
},
attributes: {
type: "string",
valueHint: "<json>",
description: {
"en-US": 'Attributes JSON array: [{"name":"age","description":"age"}]',
"zh-CN": '属性 JSON 数组:[{"name":"age","description":"age"}]',
},
required: true,
},
},
],
exampleArgs: [
{
"en-US":
'--name "user_basic" --attributes \'[{"name":"age","description":"age"},{"name":"hobby","description":"hobby"}]\'',
'--name "user_basic" --attributes \'[{"name":"age","description":"age"},{"name":"hobby","description":"hobby"}]\' --workspace-id ws_xxx',
"zh-CN":
'--name "user_basic" --attributes \'[{"name":"age","description":"年龄"},{"name":"hobby","description":"爱好"}]\'',
'--name "user_basic" --attributes \'[{"name":"age","description":"年龄"},{"name":"hobby","description":"爱好"}]\' --workspace-id ws_xxx',
},
{
"en-US":
'--name "user_basic" --attributes \'[{"name":"age"}]\' --plan-version lite --memory-library-id lib_xxx',
"zh-CN":
'--name "user_basic" --attributes \'[{"name":"age"}]\' --plan-version lite --memory-library-id lib_xxx',
},
],
validate: (flags) =>
checkMemoryScopeLengths(flags) ?? checkProfileSchemaTextLengths(flags) ?? undefined,
async run(ctx) {
const { settings, flags } = ctx;
const name = flags.name;
const attrStr = flags.attributes;
let attributes;
try {
attributes = JSON.parse(attrStr);
} catch {
throw new UsageError("--attributes must be valid JSON array");
const attributes = parseJsonArrayFlag<ProfileAttribute>("--attributes", flags.attributes);
if (attributes.length === 0) {
throw new UsageError("--attributes must contain at least one attribute");
}
attributes.forEach((attribute, index) => {
const position = `--attributes[${index}]`;
if (!attribute.name) throw new UsageError(`${position}.name is required`);
assertAttributeFieldLengths(position, attribute);
});
const body: ProfileSchemaCreateRequest = { name, attributes };
const body: ProfileSchemaCreateRequest = { name: flags.name, attributes };
if (flags.description) body.description = flags.description;
if (flags.planVersion) body.plan_version = flags.planVersion as MemoryPlanVersion;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
const format = detectOutputFormat(settings.output);
const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaPath());
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(profileSchemaPath()), request: body }, format);
emitResult({ endpoint: url, method: "POST", request: body }, format);
return;
}
const response = await ctx.client.requestJson<ProfileSchemaCreateResponse>({
path: profileSchemaPath(),
path: url,
method: "POST",
body,
});
@@ -0,0 +1,83 @@
import {
defineCommand,
memoryEndpoint,
profileSchemaItemPath,
detectOutputFormat,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_DELETE_FLAGS = {
schemaId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID (required)",
"zh-CN": "Profile Schema ID必填",
},
required: true,
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: {
"en-US": "Delete a profile schema",
"zh-CN": "删除画像模板",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This permanently deletes the profile schema and its attribute definitions. Profiles already extracted for this schema become unreachable.",
"zh-CN": "该操作会永久删除画像模板及其属性定义,已基于该模板提取的用户画像也将无法访问。",
},
},
usageArgs: "--schema-id <id> [flags]",
flags: PROFILE_DELETE_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"Irreversible — the schema and its attribute definitions are permanently removed. Profiles already extracted for this schema become unreachable.",
"zh-CN":
"该操作不可撤销——模板及其属性定义将被永久删除,已基于该模板提取的用户画像也将无法访问。",
},
],
exampleArgs: ["--schema-id schema_xxx --workspace-id ws_xxx", "--schema-id schema_xxx --yes"],
validate: (flags) => checkMemoryScopeLengths(flags),
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const url =
memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId)) +
buildQuery({ memory_library_id: flags.memoryLibraryId });
if (settings.dryRun) {
emitResult({ endpoint: url, method: "DELETE" }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path: url,
method: "DELETE",
});
if (settings.quiet || format === "text") {
emitBare(`Profile schema ${flags.schemaId} deleted.`);
} else {
emitResult(response, format);
}
},
});
@@ -1,62 +1,89 @@
import {
defineCommand,
memoryEndpoint,
userProfilePath,
detectOutputFormat,
type FlagsDef,
type UserProfileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_GET_FLAGS = {
schemaId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID (required)",
"zh-CN": "Profile Schema ID必填",
},
required: true,
},
userId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory entity ID that owns the profile (required)",
"zh-CN": "记忆实体 ID标识画像归属对象必填",
},
required: true,
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: {
"en-US": "Get user profile by schema ID and user ID",
"zh-CN": "通过 Schema ID 和用户 ID 获取用户画像",
"en-US": "Get the extracted user profile for a schema",
"zh-CN": "获取某个 Schema 下已提取的用户画像",
},
auth: "apiKey",
usageArgs: "--schema-id <id> --user-id <id>",
flags: {
schemaId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID (required)",
"zh-CN": "Profile Schema ID必填",
},
required: true,
usageArgs: "--schema-id <id> --user-id <id> [flags]",
flags: PROFILE_GET_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"Values are extracted only when `memory add --profile-schema` used the same schema ID; otherwise every attribute comes back empty. Use `memory profile show` for the schema definition itself.",
"zh-CN":
"只有 `memory add --profile-schema` 传入同一个 Schema ID 时才会提取属性值,否则所有属性都为空。查看模板定义本身请用 `memory profile show`。",
},
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
required: true,
},
},
exampleArgs: ["--schema-id schema_xxx --user-id user1"],
],
exampleArgs: ["--schema-id schema_xxx --user-id user1 --workspace-id ws_xxx"],
validate: (flags) => checkMemoryScopeLengths(flags),
async run(ctx) {
const { settings, flags } = ctx;
const schemaId = flags.schemaId;
const userId = flags.userId;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams({ user_id: userId });
const path = `${userProfilePath(schemaId)}?${params.toString()}`;
const url =
memoryEndpoint(resolveWorkspaceId(ctx), userProfilePath(flags.schemaId)) +
buildQuery({ user_id: flags.userId, memory_library_id: flags.memoryLibraryId });
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format);
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<UserProfileResponse>({
path,
path: url,
method: "GET",
});
if (settings.quiet || format === "text") {
if (response.profile?.attributes) {
for (const attr of response.profile.attributes) {
emitBare(`${attr.name}: ${attr.value ?? "(empty)"}`);
}
} else {
const attributes = response.profile?.attributes ?? [];
if (attributes.length === 0) {
emitBare("No profile data found.");
} else {
for (const attribute of attributes) {
emitBare(`${attribute.name}: ${attribute.value ?? "(empty)"}`);
}
}
} else {
emitResult(response, format);
@@ -0,0 +1,92 @@
import {
defineCommand,
memoryEndpoint,
profileSchemaPath,
detectOutputFormat,
type FlagsDef,
type ProfileSchemaListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_LIST_FLAGS = {
pageSize: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Results per page (default: 10)", "zh-CN": "每页结果数默认10" },
},
page: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Page number (default: 1)", "zh-CN": "页码默认1" },
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: { "en-US": "List profile schemas", "zh-CN": "列出画像模板" },
auth: "apiKey",
usageArgs: "[flags]",
flags: PROFILE_LIST_FLAGS,
notes: [MEMORY_WORKSPACE_NOTE],
exampleArgs: [
"--workspace-id ws_xxx",
"--page-size 20 --page 2",
"--memory-library-id lib_xxx --output json",
],
validate: (flags) => {
const scopeError = checkMemoryScopeLengths(flags);
if (scopeError) return scopeError;
if (flags.page !== undefined && flags.page < 1) return "--page must be at least 1.";
if (flags.pageSize !== undefined && flags.pageSize < 1)
return "--page-size must be at least 1.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const url =
memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaPath()) +
buildQuery({
page_size: flags.pageSize,
page_num: flags.page,
memory_library_id: flags.memoryLibraryId,
});
if (settings.dryRun) {
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<ProfileSchemaListResponse>({
path: url,
method: "GET",
});
if (settings.quiet || format === "text") {
const schemas = response.profile_schemas ?? [];
if (schemas.length === 0) {
emitBare("No profile schemas found.");
} else {
for (const schema of schemas) {
emitBare(`[${schema.profile_schema_id}] ${schema.name}`);
if (schema.description) emitBare(` ${schema.description}`);
}
if (response.total !== undefined) {
emitBare(`\nTotal: ${response.total}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,92 @@
import {
defineCommand,
memoryEndpoint,
profileSchemaItemPath,
detectOutputFormat,
type FlagsDef,
type ProfileSchemaDetailResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_SHOW_FLAGS = {
schemaId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID (required)",
"zh-CN": "Profile Schema ID必填",
},
required: true,
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: {
"en-US": "Show a profile schema definition with attribute IDs",
"zh-CN": "查看画像模板定义及属性 ID",
},
auth: "apiKey",
usageArgs: "--schema-id <id> [flags]",
flags: PROFILE_SHOW_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"The attribute_id values returned here are the handles `memory profile update --attributes-operations` needs for update / delete operations.",
"zh-CN":
"这里返回的 attribute_id 就是 `memory profile update --attributes-operations` 做 update / delete 时需要的属性句柄。",
},
],
exampleArgs: [
"--schema-id schema_xxx --workspace-id ws_xxx",
"--schema-id schema_xxx --output json",
],
validate: (flags) => checkMemoryScopeLengths(flags),
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const url =
memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId)) +
buildQuery({ memory_library_id: flags.memoryLibraryId });
if (settings.dryRun) {
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<ProfileSchemaDetailResponse>({
path: url,
method: "GET",
});
if (settings.quiet || format === "text") {
emitBare(`Name: ${response.name ?? "(none)"}`);
if (response.description) emitBare(`Description: ${response.description}`);
const attributes = response.attributes ?? [];
if (attributes.length === 0) {
emitBare("No attributes.");
} else {
emitBare("Attributes:");
for (const attribute of attributes) {
emitBare(` [${attribute.attribute_id}] ${attribute.name}`);
if (attribute.description) emitBare(` desc: ${attribute.description}`);
if (attribute.default_value !== undefined)
emitBare(` default: ${attribute.default_value}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,144 @@
import {
defineCommand,
UsageError,
memoryEndpoint,
profileSchemaItemPath,
detectOutputFormat,
type FlagsDef,
type ProfileSchemaAttributeOperation,
type ProfileSchemaUpdateRequest,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
WORKSPACE_FLAG,
assertAttributeFieldLengths,
checkMemoryScopeLengths,
checkProfileSchemaTextLengths,
parseJsonArrayFlag,
resolveWorkspaceId,
} from "./shared.ts";
const PROFILE_UPDATE_FLAGS = {
schemaId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Profile schema ID (required)",
"zh-CN": "Profile Schema ID必填",
},
required: true,
},
name: {
type: "string",
valueHint: "<name>",
description: { "en-US": "New schema name", "zh-CN": "新的 Schema 名称" },
},
description: {
type: "string",
valueHint: "<text>",
description: { "en-US": "New schema description", "zh-CN": "新的 Schema 描述" },
},
attributesOperations: {
type: "string",
valueHint: "<json>",
description: {
"en-US": 'Attribute operations JSON array: [{"op":"add","name":"plan"}]',
"zh-CN": '属性操作 JSON 数组:[{"op":"add","name":"plan"}]',
},
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
const VALID_OPS = ["add", "update", "delete"] as const;
/** Reject operation shapes the API would reject anyway, so the error stays local and precise. */
function validateOperations(operations: ProfileSchemaAttributeOperation[]): void {
operations.forEach((operation, index) => {
const position = `--attributes-operations[${index}]`;
if (!VALID_OPS.includes(operation.op)) {
throw new UsageError(`${position}.op must be one of ${VALID_OPS.join(" / ")}`);
}
if (operation.op === "add" && !operation.name) {
throw new UsageError(`${position}.name is required when op is "add"`);
}
if (operation.op !== "add" && !operation.attribute_id) {
throw new UsageError(`${position}.attribute_id is required when op is "${operation.op}"`);
}
assertAttributeFieldLengths(position, operation);
});
}
export default defineCommand({
description: {
"en-US": "Update a profile schema name, description, or attributes",
"zh-CN": "更新画像模板的名称、描述或属性",
},
auth: "apiKey",
usageArgs: "--schema-id <id> [flags]",
flags: PROFILE_UPDATE_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
'Each operation needs "op": add (requires name), update / delete (require attribute_id). Run `memory profile show` first to get attribute IDs.',
"zh-CN":
'每条操作必须带 "op"add需要 name、update / delete需要 attribute_id。属性 ID 先用 `memory profile show` 查。',
},
],
exampleArgs: [
{
"en-US": '--schema-id schema_xxx --name "user_basic_v2" --workspace-id ws_xxx',
"zh-CN": '--schema-id schema_xxx --name "user_basic_v2" --workspace-id ws_xxx',
},
{
"en-US":
'--schema-id schema_xxx --attributes-operations \'[{"op":"add","name":"plan","default_value":"free"},{"op":"delete","attribute_id":"attr_2"}]\'',
"zh-CN":
'--schema-id schema_xxx --attributes-operations \'[{"op":"add","name":"plan","default_value":"free"},{"op":"delete","attribute_id":"attr_2"}]\'',
},
],
validate: (flags) => {
if (!flags.name && !flags.description && !flags.attributesOperations)
return "Provide --name, --description, or --attributes-operations.";
return checkMemoryScopeLengths(flags) ?? checkProfileSchemaTextLengths(flags) ?? undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const body: ProfileSchemaUpdateRequest = {};
if (flags.name) body.name = flags.name;
if (flags.description) body.description = flags.description;
if (flags.attributesOperations) {
const operations = parseJsonArrayFlag<ProfileSchemaAttributeOperation>(
"--attributes-operations",
flags.attributesOperations,
);
validateOperations(operations);
body.attributes_operations = operations;
}
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
const format = detectOutputFormat(settings.output);
const url = memoryEndpoint(resolveWorkspaceId(ctx), profileSchemaItemPath(flags.schemaId));
if (settings.dryRun) {
emitResult({ endpoint: url, method: "PATCH", request: body }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path: url,
method: "PATCH",
body,
});
if (settings.quiet || format === "text") {
emitBare(`Profile schema ${flags.schemaId} updated.`);
} else {
emitResult(response, format);
}
},
});
+108 -33
View File
@@ -1,51 +1,109 @@
import {
defineCommand,
UsageError,
memoryEndpoint,
memorySearchPath,
detectOutputFormat,
type FlagsDef,
type ParsedFlags,
type MemoryMessage,
type MemoryPlanVersion,
type MemorySearchRequest,
type MemorySearchResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_RATE_LIMIT_NOTE,
MEMORY_WORKSPACE_NOTE,
PLAN_VERSION_FLAG,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
parseJsonArrayFlag,
resolveWorkspaceId,
} from "./shared.ts";
const SEARCH_FLAGS = {
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
description: {
"en-US": "Memory entity ID that owns the memory (required)",
"zh-CN": "记忆实体 ID标识记忆归属对象必填",
},
required: true,
},
query: {
type: "string",
valueHint: "<text>",
description: { "en-US": "Search query text", "zh-CN": "搜索查询文本" },
description: {
"en-US": "Search text; sent as a single user message",
"zh-CN": "搜索文本;作为单条用户消息发送",
},
},
messages: {
type: "string",
valueHint: "<json>",
description: {
"en-US": "Messages JSON array for context-based search",
"zh-CN": "用于上下文搜索的消息 JSON 数组",
"en-US": "Messages JSON array for context-based search; overrides --query",
"zh-CN": "用于上下文搜索的消息 JSON 数组;优先于 --query",
},
},
topK: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Number of results to return (default: 10)",
"zh-CN": "返回结果数量默认10",
"en-US": "Max results, 1-100 (default: 10)",
"zh-CN": "最大召回个数1~100默认10",
},
},
memoryLibraryId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory library ID", "zh-CN": "记忆库 ID" },
minScore: {
type: "number",
valueHint: "<score>",
description: {
"en-US": "Minimum similarity score, 0-1 (default: 0.3)",
"zh-CN": "最小相似度分数阈值0~1默认0.3",
},
},
enableRerank: {
type: "boolean",
valueHint: "<bool>",
description: {
"en-US": "Rerank results (default: false); ignored when --plan-version is set",
"zh-CN": "是否重排序搜索结果默认false传了 --plan-version 时本参数被忽略",
},
},
enableJudge: {
type: "boolean",
valueHint: "<bool>",
description: {
"en-US": "Run the intent judge callback (default: false)",
"zh-CN": "是否开启意图判别回调默认false",
},
},
enableRewrite: {
type: "boolean",
valueHint: "<bool>",
description: {
"en-US": "Rewrite the query before searching (default: false)",
"zh-CN": "是否开启 query 重写默认false",
},
},
projectId: {
type: "array",
valueHint: "<id>",
description: {
"en-US": "Memory fragment rule ID (repeatable for hybrid retrieval)",
"zh-CN": "记忆片段规则 ID可重复用于多规则混合检索",
},
},
...PLAN_VERSION_FLAG,
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
type SearchFlags = ParsedFlags<typeof SEARCH_FLAGS>;
const MAX_TOP_K = 100;
export default defineCommand({
description: {
"en-US": "Search memory nodes by query or messages",
@@ -54,52 +112,69 @@ export default defineCommand({
auth: "apiKey",
usageArgs: "--user-id <id> [--query <text>] [flags]",
flags: SEARCH_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US":
"--plan-version overrides --enable-rerank and changes the price: pro reranks, lite does not.",
"zh-CN": "--plan-version 覆盖 --enable-rerank 且影响计费pro 开启重排lite 不开启。",
},
MEMORY_RATE_LIMIT_NOTE,
],
exampleArgs: [
{
"en-US": '--user-id user1 --query "programming preferences"',
"zh-CN": '--user-id user1 --query "编程偏好"',
"en-US": '--user-id user1 --query "programming preferences" --workspace-id ws_xxx',
"zh-CN": '--user-id user1 --query "编程偏好" --workspace-id ws_xxx',
},
{
"en-US":
'--user-id user1 --messages \'[{"role":"user","content":"recommend a book"}]\' --top-k 5',
"zh-CN": '--user-id user1 --messages \'[{"role":"user","content":"推荐一本书"}]\' --top-k 5',
},
{
"en-US": '--user-id user1 --query "reminders" --plan-version lite --min-score 0',
"zh-CN": '--user-id user1 --query "提醒事项" --plan-version lite --min-score 0',
},
],
validate: (f: SearchFlags) =>
!f.query && !f.messages ? "Provide --query or --messages." : undefined,
validate: (flags: SearchFlags) => {
if (!flags.query && !flags.messages) return "Provide --query or --messages.";
const scopeError = checkMemoryScopeLengths(flags);
if (scopeError) return scopeError;
if (flags.topK !== undefined && (flags.topK < 1 || flags.topK > MAX_TOP_K))
return `--top-k must be between 1 and ${MAX_TOP_K}.`;
if (flags.minScore !== undefined && (flags.minScore < 0 || flags.minScore > 1))
return "--min-score must be between 0 and 1.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const userId = flags.userId;
const body: MemorySearchRequest = { user_id: userId };
// The API only accepts `messages`; a bare --query is wrapped as one user turn.
const messages = flags.messages
? parseJsonArrayFlag<MemoryMessage>("--messages", flags.messages)
: [{ role: "user" as const, content: flags.query! }];
if (flags.query) body.query = flags.query;
if (flags.messages) {
try {
body.messages = JSON.parse(flags.messages);
} catch {
throw new UsageError("--messages must be valid JSON array");
}
}
// API requires messages; if only query is given, wrap it as a user message
if (!body.messages && body.query) {
body.messages = [{ role: "user", content: body.query }];
}
const body: MemorySearchRequest = { user_id: flags.userId, messages };
if (flags.topK !== undefined) body.top_k = flags.topK;
if (flags.minScore !== undefined) body.min_score = flags.minScore;
if (flags.enableRerank !== undefined) body.enable_rerank = flags.enableRerank;
if (flags.enableJudge !== undefined) body.enable_judge = flags.enableJudge;
if (flags.enableRewrite !== undefined) body.enable_rewrite = flags.enableRewrite;
if (flags.planVersion) body.plan_version = flags.planVersion as MemoryPlanVersion;
if (flags.projectId?.length) body.project_ids = flags.projectId;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
const format = detectOutputFormat(settings.output);
const url = memoryEndpoint(resolveWorkspaceId(ctx), memorySearchPath());
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(memorySearchPath()), request: body }, format);
emitResult({ endpoint: url, method: "POST", request: body }, format);
return;
}
const response = await ctx.client.requestJson<MemorySearchResponse>({
path: memorySearchPath(),
path: url,
method: "POST",
body,
});
@@ -0,0 +1,175 @@
import { UsageError, type FlagsDef } from "bailian-cli-core";
// The memory API is served on the per-workspace host, so every memory command
// carries the workspace scope (shared with the knowledge commands).
export { resolveWorkspaceId, WORKSPACE_FLAG } from "../shared/workspace.ts";
/** Shared help note: the workspace scope is required because it selects the API host. */
export const MEMORY_WORKSPACE_NOTE = {
"en-US":
"The memory API lives on a workspace-specific host, so --workspace-id is required; it can also come from BAILIAN_WORKSPACE_ID or the workspace_id config field.",
"zh-CN":
"记忆 API 部署在 workspace 专属域名上,因此 --workspace-id 必填;也可通过 BAILIAN_WORKSPACE_ID 环境变量或 workspace_id 配置项提供。",
};
/**
* Memory library scope. Every memory API accepts it and falls back to the
* account's default library, so it stays optional on every command.
*/
export const MEMORY_LIBRARY_FLAG = {
memoryLibraryId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory library ID (default: the account's default library)",
"zh-CN": "记忆库 ID默认账号的默认记忆库",
},
},
} satisfies FlagsDef;
/** Billing tier shared by search and profile schema creation. */
export const PLAN_VERSION_FLAG = {
planVersion: {
type: "string",
valueHint: "<version>",
choices: ["pro", "lite"] as const,
description: {
"en-US": "Strategy version: pro (rerank on) or lite (rerank off); billed differently",
"zh-CN": "策略版本pro开启 Rerank或 lite关闭 Rerank计费单价不同",
},
},
} satisfies FlagsDef;
/** Memory fragment rule scope, repeatable for the search hybrid form. */
export const PROJECT_ID_FLAG = {
projectId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory fragment rule ID (default: the library's default rule)",
"zh-CN": "记忆片段规则 ID默认记忆库的默认规则",
},
},
} satisfies FlagsDef;
// ---- Documented field limits (long-term memory API reference) ----
/** Max characters accepted for user_id. */
export const MAX_USER_ID_LENGTH = 64;
/** Max characters accepted for memory_library_id. */
export const MAX_MEMORY_LIBRARY_ID_LENGTH = 32;
/** Max characters accepted for custom_content. */
export const MAX_CUSTOM_CONTENT_LENGTH = 512;
/** Max characters accepted for a profile schema name. */
export const MAX_SCHEMA_NAME_LENGTH = 32;
/** Max characters accepted for a profile schema description. */
export const MAX_SCHEMA_DESCRIPTION_LENGTH = 128;
/** Max characters accepted for a profile attribute name. */
export const MAX_ATTRIBUTE_NAME_LENGTH = 32;
/** Max characters accepted for a profile attribute description. */
export const MAX_ATTRIBUTE_DESCRIPTION_LENGTH = 128;
/** Max characters accepted for a profile attribute default value. */
export const MAX_ATTRIBUTE_DEFAULT_VALUE_LENGTH = 128;
/**
* Length guard for the scope flags every memory command carries, so `--dry-run`
* already rejects input the API would reject.
*/
export function checkMemoryScopeLengths(flags: {
userId?: string;
memoryLibraryId?: string;
}): string | undefined {
if (flags.userId !== undefined && flags.userId.length > MAX_USER_ID_LENGTH) {
return `--user-id must be at most ${MAX_USER_ID_LENGTH} characters.`;
}
if (
flags.memoryLibraryId !== undefined &&
flags.memoryLibraryId.length > MAX_MEMORY_LIBRARY_ID_LENGTH
) {
return `--memory-library-id must be at most ${MAX_MEMORY_LIBRARY_ID_LENGTH} characters.`;
}
return undefined;
}
/** Length guard for the profile schema name / description pair. */
export function checkProfileSchemaTextLengths(flags: {
name?: string;
description?: string;
}): string | undefined {
if (flags.name !== undefined && flags.name.length > MAX_SCHEMA_NAME_LENGTH) {
return `--name must be at most ${MAX_SCHEMA_NAME_LENGTH} characters.`;
}
if (flags.description !== undefined && flags.description.length > MAX_SCHEMA_DESCRIPTION_LENGTH) {
return `--description must be at most ${MAX_SCHEMA_DESCRIPTION_LENGTH} characters.`;
}
return undefined;
}
/**
* Length guard for one attribute payload, shared by `profile create` attributes
* and `profile update` attribute operations. Throws because it runs after the
* JSON flag is parsed.
*/
export function assertAttributeFieldLengths(
position: string,
attribute: { name?: string; description?: string; default_value?: string | null },
): void {
if (attribute.name !== undefined && attribute.name.length > MAX_ATTRIBUTE_NAME_LENGTH) {
throw new UsageError(
`${position}.name must be at most ${MAX_ATTRIBUTE_NAME_LENGTH} characters`,
);
}
if (
attribute.description !== undefined &&
attribute.description.length > MAX_ATTRIBUTE_DESCRIPTION_LENGTH
) {
throw new UsageError(
`${position}.description must be at most ${MAX_ATTRIBUTE_DESCRIPTION_LENGTH} characters`,
);
}
if (
attribute.default_value !== undefined &&
attribute.default_value !== null &&
attribute.default_value.length > MAX_ATTRIBUTE_DEFAULT_VALUE_LENGTH
) {
throw new UsageError(
`${position}.default_value must be at most ${MAX_ATTRIBUTE_DEFAULT_VALUE_LENGTH} characters`,
);
}
}
/** Shared help note: the documented account-level QPM ceilings for the memory API. */
export const MEMORY_RATE_LIMIT_NOTE = {
"en-US":
"Account-level rate limits: add 120 QPM, search 300 QPM, 3000 QPM across all memory APIs. On HTTP 429 back off and leave at least 1s between calls.",
"zh-CN":
"账号级限流add 120 QPM、search 300 QPM记忆库全部接口合计 3000 QPM。遇到 HTTP 429 请降速,两次请求间隔建议至少 1 秒。",
};
/** Parse a JSON object flag (`--meta-data`), rejecting arrays and primitives. */
export function parseJsonObjectFlag(flagName: string, raw: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new UsageError(`${flagName} must be valid JSON`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new UsageError(`${flagName} must be a JSON object`);
}
return parsed as Record<string, unknown>;
}
/** Parse a JSON array flag (`--messages`, `--attributes`), rejecting objects and primitives. */
export function parseJsonArrayFlag<T>(flagName: string, raw: string): T[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new UsageError(`${flagName} must be valid JSON`);
}
if (!Array.isArray(parsed)) {
throw new UsageError(`${flagName} must be a JSON array`);
}
return parsed as T[];
}
+92 -44
View File
@@ -1,76 +1,124 @@
import {
defineCommand,
memoryEndpoint,
memoryNodePath,
detectOutputFormat,
type FlagsDef,
type MemoryNodeUpdateRequest,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
MEMORY_LIBRARY_FLAG,
MEMORY_WORKSPACE_NOTE,
MAX_CUSTOM_CONTENT_LENGTH,
WORKSPACE_FLAG,
checkMemoryScopeLengths,
parseJsonObjectFlag,
resolveWorkspaceId,
} from "./shared.ts";
const UPDATE_FLAGS = {
nodeId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory node ID (required)", "zh-CN": "记忆节点 ID必填" },
required: true,
},
userId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory entity ID that owns the memory (required)",
"zh-CN": "记忆实体 ID标识记忆归属对象必填",
},
required: true,
},
content: {
type: "string",
valueHint: "<text>",
description: {
"en-US": "New content for the memory node, max 512 characters (required)",
"zh-CN": "记忆节点的新内容,最多 512 个字符(必填)",
},
required: true,
},
timestamp: {
type: "number",
valueHint: "<seconds>",
description: {
"en-US": "Unix timestamp (seconds) of when the remembered event happened",
"zh-CN": "记忆片段对应事件发生时的秒级 Unix 时间戳",
},
},
metaData: {
type: "string",
valueHint: "<json>",
description: {
"en-US": 'Custom metadata JSON object, merged incrementally: {"key":"value"}',
"zh-CN": '用户自定义信息 JSON 对象,增量合并:{"key":"value"}',
},
},
...MEMORY_LIBRARY_FLAG,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: { "en-US": "Update a memory node content", "zh-CN": "更新记忆节点内容" },
auth: "apiKey",
usageArgs: "--node-id <id> --user-id <id> --content <text>",
flags: {
nodeId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Memory node ID (required)", "zh-CN": "记忆节点 ID必填" },
required: true,
},
userId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "User ID (required)", "zh-CN": "用户 ID必填" },
required: true,
},
content: {
type: "string",
valueHint: "<text>",
description: {
"en-US": "New content for the memory node (required)",
"zh-CN": "记忆节点的新内容(必填)",
},
required: true,
},
memoryLibraryId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Memory library ID (non-default library)",
"zh-CN": "记忆库 ID非默认记忆库",
},
},
},
exampleArgs: [
usageArgs: "--node-id <id> --user-id <id> --content <text> [flags]",
flags: UPDATE_FLAGS,
notes: [
MEMORY_WORKSPACE_NOTE,
{
"en-US": '--node-id node_xxx --user-id user1 --content "updated memory content"',
"zh-CN": '--node-id node_xxx --user-id user1 --content "更新后的记忆内容"',
"en-US": "--content replaces the node content in full; --meta-data merges incrementally.",
"zh-CN": "--content 整体替换节点内容;--meta-data 为增量合并。",
},
],
exampleArgs: [
{
"en-US":
'--node-id node_xxx --user-id user1 --content "updated memory content" --workspace-id ws_xxx',
"zh-CN":
'--node-id node_xxx --user-id user1 --content "更新后的记忆内容" --workspace-id ws_xxx',
},
{
"en-US":
'--node-id node_xxx --user-id user1 --content "met at WAIC" --timestamp 1747278460 --meta-data \'{"city":"Shanghai"}\'',
"zh-CN":
'--node-id node_xxx --user-id user1 --content "在 WAIC 见面" --timestamp 1747278460 --meta-data \'{"city":"上海"}\'',
},
],
validate: (flags) => {
const scopeError = checkMemoryScopeLengths(flags);
if (scopeError) return scopeError;
if (flags.content.length > MAX_CUSTOM_CONTENT_LENGTH)
return `--content must be at most ${MAX_CUSTOM_CONTENT_LENGTH} characters.`;
if (flags.timestamp !== undefined && flags.timestamp < 0)
return "--timestamp must be a non-negative Unix timestamp in seconds.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const nodeId = flags.nodeId;
const userId = flags.userId;
const content = flags.content;
const body: MemoryNodeUpdateRequest = {
user_id: userId,
custom_content: content,
user_id: flags.userId,
custom_content: flags.content,
};
if (flags.timestamp !== undefined) body.timestamp = flags.timestamp;
if (flags.metaData) body.meta_data = parseJsonObjectFlag("--meta-data", flags.metaData);
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
const format = detectOutputFormat(settings.output);
const url = memoryEndpoint(resolveWorkspaceId(ctx), memoryNodePath(nodeId));
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(memoryNodePath(nodeId)), method: "PATCH", request: body },
format,
);
emitResult({ endpoint: url, method: "PATCH", request: body }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path: memoryNodePath(nodeId),
path: url,
method: "PATCH",
body,
});
@@ -0,0 +1,33 @@
// Workspace scope shared by every command whose API lives on a per-workspace
// host (knowledge admin plane, knowledge search/chat, memory). The console
// credential scope does not apply to these, so the workspace is a per-command
// flag instead.
import { BailianError, ExitCode, type FlagsDef } from "bailian-cli-core";
export const WORKSPACE_FLAG = {
workspaceId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
"zh-CN": "API Endpoint URL 使用的 Workspace ID也可设置 BAILIAN_WORKSPACE_ID",
},
},
} satisfies FlagsDef;
/** Three-level fallback: flag > BAILIAN_WORKSPACE_ID env > config (env/config are merged into settings); missing → USAGE. */
export function resolveWorkspaceId(ctx: {
flags: { workspaceId?: string };
settings: { workspaceId?: string };
identity: { binName: string };
}): string {
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
return workspaceId;
}
@@ -14,9 +14,11 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
parseInstantVocabulary,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
@@ -73,8 +75,30 @@ const RECOGNIZE_FLAGS = {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID for improved accuracy",
"zh-CN": "用于提升识别准确率的热词表 ID",
"en-US":
"Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer",
"zh-CN":
"预编译热词列表 ID可用 `speech vocabulary create` 创建)。其 target_model 必须与 --model 完全一致,否则静默失效且无报错。支持模型比 --vocabulary 更广,含 Fun-ASR 与 Paraformer 系列",
},
},
vocabulary: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Instant hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models",
"zh-CN":
"即时热词JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}'。权重 1-5推荐 4过高会拖累其他词50 表示超级热词。无需预先创建热词表。仅 Qwen-Audio-3.0-ASR-Flash 系列模型生效",
},
},
context: {
type: "string",
valueHint: "<text>",
description: {
"en-US":
"Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models",
"zh-CN":
"上下文增强词表,提升专有名词准确率;须包含待识别的原词本身(只写主题描述效果有限),最长 400 字符。仅 Qwen-Audio-3.0-ASR-Flash 系列与 Fun-ASR-Flash 模型生效",
},
},
channelId: {
@@ -110,9 +134,11 @@ function assertSyncFlashFlagsAllowed(
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (flashFamily === "qwen3") {
if (flags.vocabularyId !== undefined) unsupported.push("--vocabulary-id");
if (flags.vocabulary !== undefined) unsupported.push("--vocabulary");
if (flags.context !== undefined) unsupported.push("--context");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
@@ -121,12 +147,34 @@ function assertSyncFlashFlagsAllowed(
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
syncFlashUnsupportedHint(unsupported),
ExitCode.USAGE,
);
}
}
/** Pick a hint that matches the rejected flags (vocab/context vs diarization/async/…). */
function syncFlashUnsupportedHint(unsupported: string[]): string {
const vocabularyRelated = new Set(["--vocabulary", "--vocabulary-id", "--context"]);
const hasVocabularyRelated = unsupported.some((flag) => vocabularyRelated.has(flag));
const hasOtherFlags = unsupported.some((flag) => !vocabularyRelated.has(flag));
if (hasVocabularyRelated && !hasOtherFlags) {
return (
"Hint: Use qwen-audio-3.0-asr-flash (or an async filetrans model such as " +
"qwen-audio-3.0-asr-flash-filetrans) for vocabulary/context flags."
);
}
if (hasVocabularyRelated && hasOtherFlags) {
return (
"Hint: For vocabulary/context flags use qwen-audio-3.0-asr-flash or " +
"qwen-audio-3.0-asr-flash-filetrans; for the other flags use an async filetrans model " +
"(e.g. fun-asr)."
);
}
return "Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.";
}
export default defineCommand({
description: {
"en-US": "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
@@ -141,6 +189,8 @@ export default defineCommand({
"--url https://example.com/meeting.wav --diarization --speaker-count 3",
"--url https://example.com/audio.mp3 --language zh",
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary \'{"奋斗者":4,"鲸落":4}\'',
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"',
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
@@ -198,6 +248,9 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const vocabulary =
flags.vocabulary !== undefined ? parseInstantVocabulary(flags.vocabulary) : undefined;
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
@@ -210,6 +263,7 @@ export default defineCommand({
model,
route,
resolvedUrls[0]!,
vocabulary,
);
return;
}
@@ -223,16 +277,19 @@ export default defineCommand({
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url"
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
: { file_urls: resolvedUrls }),
...(flags.context !== undefined ? { context: buildAsrContextMessages(flags.context) } : {}),
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
vocabulary,
},
};
@@ -260,6 +317,7 @@ async function handleSyncFlashMode(
model: string,
route: AsrApiRoute,
audioUrl: string,
vocabulary: Record<string, number> | undefined,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
@@ -267,6 +325,8 @@ async function handleSyncFlashMode(
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
vocabulary,
context: flags.context,
flashFamily,
});
@@ -0,0 +1,117 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
createVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const CREATE_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: {
"en-US":
"ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored",
"zh-CN":
"该热词表服务的 ASR 模型(必填)。必须与后续 `speech recognize` 的 --model 完全一致,否则热词表静默失效",
},
required: true,
},
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US":
"Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars",
"zh-CN": "热词表自定义前缀(必填)。仅允许数字和小写字母,最长 10 个字符",
},
required: true,
},
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type CreateFlags = ParsedFlags<typeof CREATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Create a precompiled hot-word vocabulary for ASR",
"zh-CN": "创建用于语音识别的预编译热词表",
},
auth: "apiKey",
usageArgs: "--model <model> --prefix <prefix> (--words <json> | --words-file <path>) [flags]",
flags: CREATE_FLAGS,
notes: [
{
"en-US":
"The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.",
"zh-CN":
"--model 必须与后续 `speech recognize --vocabulary-id` 使用的 --model 完全一致;不一致时热词表会静默失效且无报错。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": '--model fun-asr --prefix demo --words \'{"Fendouzhe":4,"Jingluo":4}\'',
"zh-CN": '--model fun-asr --prefix demo --words \'{"奋斗者":4,"鲸落":4}\'',
},
{
"en-US":
'--model paraformer-v2 --prefix demo --words \'[{"text":"Fendouzhe","weight":4,"lang":"zh"}]\'',
"zh-CN":
'--model paraformer-v2 --prefix demo --words \'[{"text":"奋斗者","weight":4,"lang":"zh"}]\'',
},
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --lang zh",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --lang zh",
},
"--model fun-asr --prefix demo --words-file ./hotwords.json",
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --quiet",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --quiet",
},
],
validate: (flags: CreateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("create_vocabulary", {
target_model: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await createVocabulary(ctx.client, {
targetModel: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.quiet || format === "text") {
emitBare(response.output?.vocabulary_id ?? "");
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,60 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
deleteVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const DELETE_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type DeleteFlags = ParsedFlags<typeof DELETE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Delete a precompiled hot-word vocabulary",
"zh-CN": "删除预编译热词表",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the specified hot-word vocabulary and cannot be undone.",
"zh-CN": "该操作会永久删除指定的热词表,且无法撤销。",
},
},
usageArgs: "--id <id>",
flags: DELETE_FLAGS,
exampleArgs: ["--id vocab-demo-xxx --dry-run", "--id vocab-demo-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as DeleteFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("delete_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await deleteVocabulary(ctx.client, vocabularyId);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,79 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
queryVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const GET_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type GetFlags = ParsedFlags<typeof GET_FLAGS>;
export default defineCommand({
description: {
"en-US": "Get details of a precompiled hot-word vocabulary",
"zh-CN": "查看预编译热词表详情",
},
auth: "apiKey",
usageArgs: "--id <id>",
flags: GET_FLAGS,
notes: [
{
"en-US":
"Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.",
"zh-CN":
"调用 `speech recognize --vocabulary-id` 前请用本命令确认 target_model模型不一致会导致静默失效。",
},
],
exampleArgs: ["--id vocab-demo-xxx", "--id vocab-demo-xxx --quiet"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as GetFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("query_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await queryVocabulary(ctx.client, vocabularyId);
const output = response.output;
if (settings.quiet) {
emitBare(output?.target_model ?? "");
return;
}
if (format === "text") {
emitBare(`vocabulary_id: ${vocabularyId}`);
emitBare(`status: ${output?.status ?? ""}`);
emitBare(`target_model: ${output?.target_model ?? ""}`);
emitBare(`gmt_create: ${output?.gmt_create ?? ""}`);
emitBare(`gmt_modified: ${output?.gmt_modified ?? ""}`);
const entries = output?.vocabulary ?? [];
if (entries.length > 0) {
emitBare("vocabulary:");
for (const entry of entries) {
const langPart = entry.lang ? ` lang=${entry.lang}` : "";
emitBare(` ${entry.text} weight=${entry.weight}${langPart}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
listVocabularies,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US": "Filter by vocabulary prefix",
"zh-CN": "按热词表前缀过滤",
},
},
page: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1",
"zh-CN": "页码,从 1 开始默认1。映射为 API 的 page_index从 0 开始page - 1",
},
},
pageSize: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Results per page (default: 10)",
"zh-CN": "每页结果数默认10",
},
},
} satisfies FlagsDef;
type ListFlags = ParsedFlags<typeof LIST_FLAGS>;
export default defineCommand({
description: {
"en-US": "List precompiled hot-word vocabularies",
"zh-CN": "列出预编译热词表",
},
auth: "apiKey",
usageArgs: "[--prefix <prefix>] [--page <n>] [--page-size <n>]",
flags: LIST_FLAGS,
notes: [
{
"en-US":
"List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.",
"zh-CN": "list 响应不含 target_model要对齐模型请使用 `speech vocabulary get`。",
},
{
"en-US": "Vocabularies with status UNDEPLOYED are silently ignored by ASR.",
"zh-CN": "status 为 UNDEPLOYED 的热词表会被 ASR 静默忽略。",
},
],
exampleArgs: ["", "--prefix demo", "--page 2 --page-size 20"],
validate: (flags: ListFlags) => {
if (flags.page !== undefined && flags.page < 1) {
return "--page must be >= 1.";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const pageIndex = flags.page !== undefined ? flags.page - 1 : undefined;
const input: Record<string, unknown> = {};
if (flags.prefix !== undefined) input.prefix = flags.prefix;
if (pageIndex !== undefined) input.page_index = pageIndex;
if (flags.pageSize !== undefined) input.page_size = flags.pageSize;
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("list_vocabulary", input),
},
format,
);
return;
}
const response = await listVocabularies(ctx.client, {
prefix: flags.prefix,
pageIndex,
pageSize: flags.pageSize,
});
if (settings.quiet || format === "text") {
const items = response.output?.vocabulary_list ?? [];
if (items.length === 0) {
emitBare("No vocabularies found.");
} else {
let hasUndeployed = false;
for (const item of items) {
const id = item.vocabulary_id ?? "";
const status = item.status ?? "";
const modified = item.gmt_modified ?? "";
if (status === "UNDEPLOYED") hasUndeployed = true;
emitBare(`[${id}] ${status} ${modified}`.trimEnd());
}
if (hasUndeployed) {
emitBare(
"Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.",
);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,103 @@
import {
readTextFromPathOrStdin,
parseVocabularyEntries,
UsageError,
type FlagsDef,
type ParsedFlags,
type VocabularyEntry,
} from "bailian-cli-core";
/** Shared --id flag for get / update / delete. */
export const VOCABULARY_ID_FLAG = {
id: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID (required)",
"zh-CN": "热词表 ID必填",
},
required: true,
},
} satisfies FlagsDef;
/** Shared hot-word body flags for create / update. */
export const VOCABULARY_BODY_FLAGS = {
words: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file",
"zh-CN":
"热词JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}';需要逐条指定语言时可传 API 的条目数组。权重 1-5推荐 4当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50超级热词。也可使用 --words-file",
},
},
wordsFile: {
type: "string",
valueHint: "<path>",
description: {
"en-US": "JSON file with the hot words (use - for stdin)",
"zh-CN": "包含热词的 JSON 文件(使用 - 从 stdin 读取)",
},
},
lang: {
type: "string",
valueHint: "<code>",
description: {
"en-US":
"Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja",
"zh-CN":
"对象形态时应用到所有热词的语言代码选填数组形态忽略。Paraformer 支持 zh/en/ja/yue/ko/de/fr/ruFun-ASR 支持 zh/en/ja",
},
},
} satisfies FlagsDef;
type VocabularySourceFlags = ParsedFlags<typeof VOCABULARY_BODY_FLAGS>;
/** Cross-flag validation for --words / --words-file. */
export function validateVocabularySource(flags: VocabularySourceFlags): string | undefined {
if (!flags.words && !flags.wordsFile) {
return "Provide --words or --words-file.";
}
if (flags.words && flags.wordsFile) {
return "Use either --words or --words-file, not both.";
}
if (!flags.words) {
return undefined;
}
try {
parseVocabularyEntries(flags.words, flags.lang);
} catch (error) {
if (error instanceof UsageError) {
return error.message;
}
throw error;
}
return undefined;
}
/** Read and parse vocabulary entries from flag or file. */
export function readVocabularyEntries(flags: VocabularySourceFlags): VocabularyEntry[] {
const raw = flags.wordsFile ? readTextFromPathOrStdin(flags.wordsFile) : (flags.words as string);
return parseVocabularyEntries(raw, flags.lang);
}
/** Shared notes covering account limits and silent-failure pitfalls. */
export const VOCABULARY_LIMIT_NOTES = [
{
"en-US":
"Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.",
"zh-CN": "每个账号最多 10 个热词表;两次更新间隔至少 5 分钟。完整限制见 improve-asr-accuracy。",
},
{
"en-US":
"Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.",
"zh-CN": "新加坡子业务空间不支持热词表;服务端错误会原样透传。",
},
{
"en-US":
"Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.",
"zh-CN":
"权重 1-5推荐 4当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50超级热词。",
},
] as const;
@@ -0,0 +1,90 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
updateVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_ID_FLAG,
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const UPDATE_FLAGS = {
...VOCABULARY_ID_FLAG,
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type UpdateFlags = ParsedFlags<typeof UPDATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Replace the contents of a precompiled hot-word vocabulary",
"zh-CN": "完全替换预编译热词表的内容",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone.",
"zh-CN": "该操作会完全替换热词表中的全部词条。未列出的词将被丢弃,且无法撤销。",
},
},
usageArgs: "--id <id> (--words <json> | --words-file <path>) [flags]",
flags: UPDATE_FLAGS,
notes: [
{
"en-US":
"update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.",
"zh-CN": "update 是完全替换,不是增量追加。建议先用 --dry-run 预览将要写入的完整词表。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": "--id vocab-demo-xxx --words '{\"Fendouzhe\":4}' --dry-run",
"zh-CN": "--id vocab-demo-xxx --words '{\"奋斗者\":4}' --dry-run",
},
{
"en-US": '--id vocab-demo-xxx --words \'{"Fendouzhe":4,"Jingluo":4}\' --yes',
"zh-CN": '--id vocab-demo-xxx --words \'{"奋斗者":4,"鲸落":4}\' --yes',
},
],
validate: (flags: UpdateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = flags.id;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("update_vocabulary", {
vocabulary_id: vocabularyId,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await updateVocabulary(ctx.client, vocabularyId, vocabulary);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
+1 -1
View File
@@ -197,7 +197,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoEditRequest = {
model,
@@ -208,7 +208,7 @@ export default defineCommand({
resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model);
}
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const body: DashScopeVideoRequest = {
+1 -1
View File
@@ -249,7 +249,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoRefRequest = {
model,
+9
View File
@@ -32,6 +32,10 @@ export { default as memoryList } from "./commands/memory/list.ts";
export { default as memoryUpdate } from "./commands/memory/update.ts";
export { default as memoryDelete } from "./commands/memory/delete.ts";
export { default as memoryProfileCreate } from "./commands/memory/profile-create.ts";
export { default as memoryProfileList } from "./commands/memory/profile-list.ts";
export { default as memoryProfileShow } from "./commands/memory/profile-show.ts";
export { default as memoryProfileUpdate } from "./commands/memory/profile-update.ts";
export { default as memoryProfileDelete } from "./commands/memory/profile-delete.ts";
export { default as memoryProfileGet } from "./commands/memory/profile-get.ts";
export { default as knowledgeRetrieve } from "./commands/knowledge/retrieve.ts";
export { default as knowledgeSearch } from "./commands/knowledge/search.ts";
@@ -73,6 +77,11 @@ export { default as mcpTools } from "./commands/mcp/tools.ts";
export { default as searchWeb } from "./commands/search/web.ts";
export { default as speechSynthesize } from "./commands/speech/synthesize.ts";
export { default as speechRecognize } from "./commands/speech/recognize.ts";
export { default as speechVocabularyCreate } from "./commands/speech/vocabulary/create.ts";
export { default as speechVocabularyList } from "./commands/speech/vocabulary/list.ts";
export { default as speechVocabularyGet } from "./commands/speech/vocabulary/get.ts";
export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/update.ts";
export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts";
export { default as fileUpload } from "./commands/file/upload.ts";
export { default as consoleCall } from "./commands/console/call.ts";
export { default as usageFree } from "./commands/usage/free.ts";
@@ -33,3 +33,9 @@ test("default-speech-recognition-model alias accepts an ASR model ID", () => {
"qwen-audio-3.0-asr-flash",
);
});
test("watermark config accepts only boolean text and stores a boolean", () => {
expect(validateAndCoerce("watermark", "false")).toBe(false);
expect(validateAndCoerce("watermark", "TRUE")).toBe(true);
expect(() => validateAndCoerce("watermark", "yes")).toThrow(/true or false/i);
});
@@ -200,8 +200,10 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.keys).toContain("console_site");
expect(res.json.keys).toContain("telemetry");
expect(res.json.keys).toContain("default_speech_recognition_model");
expect(res.json.keys).toContain("watermark");
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
expect(res.json.booleanKeys).toContain("watermark");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
@@ -64,10 +64,53 @@ describe("e2e: config", () => {
config_file?: string;
base_url?: string;
timeout?: number;
watermark?: boolean;
}>(stdout);
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
expect(data.watermark).toBe(true);
});
test("config set 将 watermark 作为 boolean 写入并由 config show 读回", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-watermark-"));
try {
const env = { BAILIAN_CONFIG_DIR: configDir };
const setResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--config",
"media",
"--key",
"watermark",
"--value",
"false",
"--output",
"json",
],
env,
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(setResult.stdout).watermark).toBe(false);
const persisted = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
Record<string, unknown>
>;
expect(persisted.media?.watermark).toBe(false);
const showResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "show", "--config", "media", "--output", "json"],
env,
);
expect(showResult.exitCode, showResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(showResult.stdout).watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config show --output text", async () => {
+1
View File
@@ -34,6 +34,7 @@ export {
isDashScopeE2EReady,
isImageKbE2EReady,
isKbAdminE2EReady,
isMemoryE2EReady,
isMultimodalChatE2EReady,
isMultimodalSearchE2EReady,
isOpenApiE2EReady,
@@ -0,0 +1,112 @@
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runNodeMain } from "e2e/runner";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson } from "./helpers.ts";
const directories: string[] = [];
async function temporaryDirectory() {
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-yaml-init-")));
directories.push(directory);
return directory;
}
afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
async function runInit(directory: string, args: string[] = []) {
return runNodeMain(
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
["managed-agent", "init", ...args],
{
cwd: directory,
env: {
BAILIAN_CONFIG_DIR: await temporaryDirectory(),
BAILIAN_E2E_ROUTES: JSON.stringify([
{ path: "managed-agent init", export: "managedAgentInit" },
]),
},
},
);
}
describe("e2e: managed-agent init output path", () => {
test.each(["default", "relative", "absolute"] as const)(
"reports the absolute YAML path for a %s output path",
async (pathKind) => {
const directory = await temporaryDirectory();
const relativePath =
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
const outputPath = join(directory, relativePath);
await mkdir(dirname(outputPath), { recursive: true });
const args =
pathKind === "default"
? []
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
const result = await runInit(directory, args);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout.split(/\r?\n/)[0]).toBe(`Created ${outputPath}`);
expect(await readFile(outputPath, "utf8")).toContain("agents:");
},
);
test("preserves the JSON output contract", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--file", "custom.yaml", "--output", "json"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({
created: "custom.yaml",
provider: "bailian",
agent: "assistant",
});
expect((await stat(join(directory, "custom.yaml"))).isFile()).toBe(true);
});
test("keeps dry-run read-only without reporting a created file", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--dry-run", "--output", "json"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({
would_create: "agents.yaml",
provider: "bailian",
agent: "assistant",
would_update_gitignore: true,
});
expect(await stat(join(directory, "agents.yaml")).catch(() => null)).toBeNull();
expect(await stat(join(directory, ".gitignore")).catch(() => null)).toBeNull();
});
test.each(["default", "relative", "absolute"] as const)(
"reports the absolute existing %s path without overwriting the YAML",
async (pathKind) => {
const directory = await temporaryDirectory();
const relativePath =
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
const outputPath = join(directory, relativePath);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, "Keep user configuration.\n");
const args =
pathKind === "default"
? []
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
const result = await runInit(directory, args);
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain(`${outputPath} already exists.`);
expect(result.stderr).toContain("Pass --force to overwrite.");
expect(result.stdout).not.toContain("Created ");
expect(await readFile(outputPath, "utf8")).toBe("Keep user configuration.\n");
},
);
});
@@ -0,0 +1,247 @@
import {
cp,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { runNodeMain } from "e2e/runner";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandHelp } from "./helpers.ts";
const routes = {
"managed-agent project init": "managedAgentProjectInit",
"managed-agent project build": "managedAgentProjectBuild",
"managed-agent project publish": "managedAgentProjectPublish",
"managed-agent project validate": "managedAgentProjectValidate",
} as const;
const directories: string[] = [];
async function temporaryDirectory() {
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-project-init-")));
directories.push(directory);
return directory;
}
afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
function runInit(directory: string, args: string[] = []) {
return runProject(directory, "init", args);
}
async function runProject(directory: string, subcommand: string, args: string[] = [], json = true) {
const configRoot = await temporaryDirectory();
return runNodeMain(
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
["managed-agent", "project", subcommand, ...args, ...(json ? ["--output", "json"] : [])],
{
cwd: directory,
env: {
BAILIAN_CONFIG_DIR: configRoot,
BAILIAN_E2E_ROUTES: JSON.stringify(
Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })),
),
},
},
);
}
describe("e2e: managed-agent project init directory defaults", () => {
test("build links copied resources and reports ambiguous environment bindings without writing", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
for (const [section, id] of [
["skills", "example-skill"],
["files", "example-file"],
["environments", "example-env"],
["vaults", "example-vault"],
] as const) {
await cp(
join(root, "agents/assistant", section, "_examples", id),
join(root, "agents/assistant", section, id),
{ recursive: true },
);
}
const agentPath = join(root, "agents/assistant/agent.json");
const original = await readFile(agentPath, "utf8");
const preview = await runProject(root, "build", ["--dry-run"]);
expect(preview.exitCode, preview.stderr).toBe(0);
expect(parseStdoutJson<{ can_build: boolean }>(preview.stdout).can_build).toBe(true);
expect(await readFile(agentPath, "utf8")).toBe(original);
const built = await runProject(root, "build");
expect(built.exitCode, built.stderr).toBe(0);
const agent = JSON.parse(await readFile(agentPath, "utf8"));
expect(agent).toMatchObject({
environment: "example-env",
vault: "example-vault",
skills: ["example-skill"],
files: [{ file: "example-file", mount_path: "/mnt/example.md" }],
});
const alternatePath = join(root, "agents/assistant/environments/alternate");
await mkdir(alternatePath);
await writeFile(
join(alternatePath, "environment.json"),
JSON.stringify({ id: "alternate", config: { type: "cloud" } }),
);
delete agent.environment;
await writeFile(agentPath, JSON.stringify(agent));
const beforeConflict = await readFile(agentPath, "utf8");
const buildPath = join(root, ".openagentpack/build/agents.yaml");
const beforeBuild = await readFile(buildPath, "utf8");
for (const json of [false, true]) {
const conflict = await runProject(root, "build", [], json);
expect(conflict.exitCode).toBe(1);
expect(conflict.stderr).toContain("multiple local environment resources");
expect(conflict.stderr).toContain("Set 'environment' explicitly");
expect(conflict.stderr).not.toMatch(/\p{Script=Han}/u);
}
expect(await readFile(agentPath, "utf8")).toBe(beforeConflict);
expect(await readFile(buildPath, "utf8")).toBe(beforeBuild);
});
test("build checks directories and writes without confirmation while publish stays gated", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
const nested = join(root, "agents/assistant/skills");
for (const args of [[], ["--dry-run"]]) {
const result = await runProject(nested, "build", args, false);
expect(result.exitCode, result.stderr).toBe(2);
expect(result.stderr).toContain("Not a project root:");
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
expect(result.stderr).toContain(`cd '${root}'`);
expect(result.stderr).not.toContain("high-risk");
expect(result.stderr).not.toContain("Usage:");
}
const explicit = await runProject(root, "build", ["--project", nested]);
expect(explicit.exitCode).toBe(2);
expect(explicit.stderr).toContain(`--project '${root}'`);
const noMarker = await runProject(directory, "build", [], false);
expect(noMarker.exitCode).toBe(2);
expect(noMarker.stderr).toContain("project.json");
expect(noMarker.stderr).not.toMatch(/\p{Script=Han}/u);
expect(noMarker.stderr).not.toContain("high-risk");
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
const preview = await runProject(root, "build", ["--dry-run"]);
expect(preview.exitCode, preview.stderr).toBe(0);
expect(await stat(join(root, ".openagentpack/build")).catch(() => null)).toBeNull();
const built = await runProject(root, "build");
expect(built.exitCode, built.stderr).toBe(0);
expect(built.stderr).not.toContain("requires_confirmation");
expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true);
const explicitValid = await runProject(nested, "build", ["--project", root]);
expect(explicitValid.exitCode, explicitValid.stderr).toBe(0);
const storePath = join(root, ".openagentpack/versions/project/store.json");
const beforePublish = await readFile(storePath, "utf8");
const publish = await runProject(root, "publish");
expect(publish.exitCode).toBe(7);
expect(publish.stderr).toContain("requires_confirmation");
expect(await readFile(storePath, "utf8")).toBe(beforePublish);
});
test("only publish help includes confirmation; build keeps dry-run", async () => {
const build = await runCommandHelp(routes, ["managed-agent", "project", "build", "--help"]);
expect(build.stderr).not.toContain("--yes");
expect(build.stderr).toContain("--dry-run");
const publish = await runCommandHelp(routes, ["managed-agent", "project", "publish", "--help"]);
expect(publish.stderr).toContain("--yes");
const directory = await temporaryDirectory();
const removedFlag = await runProject(directory, "build", ["--yes"]);
expect(removedFlag.exitCode).toBe(2);
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
});
test("nested build and validate explain the project root without changing directories", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
const nested = join(root, "agents/assistant/skills");
for (const command of ["build", "validate"]) {
const result = await runProject(nested, command, ["--dry-run"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("Not a project root:");
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
expect(result.stderr).toContain(`cd '${root}'`);
expect(result.stderr).toContain(`--project '${root}'`);
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
}
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
const corrected = await runProject(nested, "build", ["--project", root, "--dry-run"]);
expect(corrected.exitCode, corrected.stderr).toBe(0);
});
test("help describes the subdirectory default and explicit in-place initialization", async () => {
const result = await runCommandHelp(routes, ["managed-agent", "project", "init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain("./managed-agent");
expect(result.stderr).toContain("--project .");
});
test("default dry-run reports the child directory without creating it", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--dry-run"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({ would_initialize_project: "./managed-agent" });
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
});
test("initializes only the child, ignores parent source, and rejects repeated init without overwriting", async () => {
const directory = await temporaryDirectory();
const parentYaml = "not a valid project declaration";
await writeFile(join(directory, "agents.yaml"), parentYaml);
if (process.platform !== "win32") {
await symlink("missing-instructions.md", join(directory, "CLAUDE.md"));
}
const result = await runInit(directory);
expect(result.exitCode, result.stderr).toBe(0);
const projectRoot = join(directory, "managed-agent");
const initialized = parseStdoutJson<{
project_root: string;
baseline_version: string;
converted_from_yaml: boolean;
}>(result.stdout);
expect(initialized.project_root).toBe(projectRoot);
expect(initialized.baseline_version).toHaveLength(64);
expect(initialized.converted_from_yaml).toBe(false);
expect(await stat(join(directory, "project.json")).catch(() => null)).toBeNull();
expect(await readFile(join(directory, "agents.yaml"), "utf8")).toBe(parentYaml);
expect((await stat(join(projectRoot, "agents/assistant/agent.json"))).isFile()).toBe(true);
expect(
(await stat(join(projectRoot, ".openagentpack/versions/project/store.json"))).isFile(),
).toBe(true);
const instructions = join(projectRoot, "agents/assistant/instructions.md");
await writeFile(instructions, "user changes");
const repeated = await runInit(directory);
expect(repeated.exitCode).not.toBe(0);
expect(repeated.stderr).toContain("already exists");
expect(await readFile(instructions, "utf8")).toBe("user changes");
});
test("explicit paths, including dot, remain exact project roots", async () => {
for (const target of ["custom-agent", "."]) {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--project", target]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson<{ project_root: string }>(result.stdout).project_root).toBe(
join(directory, target),
);
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
}
});
});
@@ -368,7 +368,6 @@ describe("e2e: managed-agent", () => {
"build",
"--project",
projectRoot,
"--yes",
"--output",
"json",
]);
@@ -1,215 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
isBailianE2EEnabled,
isDashScopeE2EReady,
parseStdoutJson,
runCommandHelp,
runCommandE2e,
} from "./helpers.ts";
import { MEMORY_ROUTES } from "./topic-routes.ts";
interface MemoryAddBody {
memory_ids?: string[];
}
interface MemoryListBody {
memory_nodes?: Array<{ memory_node_id: string; content: string }>;
}
interface MemorySearchBody {
memory_nodes?: Array<{ memory_node_id: string; content: string }>;
}
const DEFAULT_E2E_MEMORY_USER_ID = "e2e-vp-test";
// bailian-cli-test
const DEFAULT_E2E_MEMORY_LIBRARY_ID = "92e8626561c4472e8805d2328030d642";
/**
* 优先使用环境变量,若未设置则使用默认值。
*/
function memoryLibraryCliArgs(): string[] {
const id = process.env.BAILIAN_E2E_MEMORY_LIBRARY_ID?.trim() || DEFAULT_E2E_MEMORY_LIBRARY_ID;
return id ? ["--memory-library-id", id] : [];
}
/**
* Memory先做 help / 分组等常规检测(不依赖密钥、不调记忆 API
* 需 E2E + DashScope 的缺参、dry-run 与 CRUD 放在 skip 块内。
*/
describe("e2e: memory", () => {
test("memory add --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_ROUTES, ["memory", "add", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/add|--user-id|--content|messages/i);
});
test("memory list --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_ROUTES, ["memory", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|--user-id|memory-library/i);
});
test("memory search --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_ROUTES, [
"memory",
"search",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/search|--query|user-id/i);
});
test("memory profile create --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_ROUTES, [
"memory",
"profile",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/profile|create|user-id/i);
});
});
/**
* 记忆库增 → 列 → 搜 → 改 → 删
*/
describe.skipIf(!isBailianE2EEnabled() || !isDashScopeE2EReady())(
"e2e: memory CRUD + search",
() => {
test("memory add 缺少 --user-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
"--content",
"仅内容无用户",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--user-id|Usage:/i);
});
test("memory add 缺少 --messages 与 --content 时报错正常退出", async () => {
const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
const { stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
"--user-id",
userId,
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/messages|content|required/i);
});
test("memory add --dry-run 仅输出计划且不入网", async () => {
const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
"--dry-run",
...memoryLibraryCliArgs(),
"--user-id",
userId,
"--content",
"dry-run 不入网",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { user_id?: string; custom_content?: string } }>(
stdout,
);
expect(data.request?.user_id).toBe(userId);
expect(data.request?.custom_content).toContain("dry-run");
});
test("记忆库增删改查", async () => {
const userId = process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
const contentA = "CLI vp test记忆写入可删";
const contentB = "CLI vp test记忆已更新";
const addRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"add",
...memoryLibraryCliArgs(),
"--user-id",
userId,
"--content",
contentA,
"--output",
"json",
]);
expect(addRes.exitCode, addRes.stderr).toBe(0);
const added = parseStdoutJson<MemoryAddBody & { request_id?: string }>(addRes.stdout);
expect(added.request_id?.length ?? 0, addRes.stdout + addRes.stderr).toBeGreaterThan(0);
const listRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"list",
...memoryLibraryCliArgs(),
"--user-id",
userId,
"--output",
"json",
]);
expect(listRes.exitCode, listRes.stderr).toBe(0);
const listed = parseStdoutJson<MemoryListBody>(listRes.stdout);
if ((listed.memory_nodes?.length ?? 0) === 0) {
throw new Error(
"memory list 为空add 已成功但列表无节点。请设置 BAILIAN_E2E_MEMORY_LIBRARY_ID 与阿里云百炼控制台当前「记忆库」ID 一致;" +
"并设置 BAILIAN_E2E_MEMORY_USER_ID 与控制台筛选的「用户 ID」一致。stdout=" +
listRes.stdout,
);
}
const nodeId = listed.memory_nodes![0]!.memory_node_id.trim();
expect(nodeId.length).toBeGreaterThan(0);
const searchRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"search",
...memoryLibraryCliArgs(),
"--user-id",
userId,
"--query",
"vp test",
"--top-k",
"5",
"--output",
"json",
]);
expect(searchRes.exitCode, searchRes.stderr).toBe(0);
const searched = parseStdoutJson<MemorySearchBody>(searchRes.stdout);
expect(searched.memory_nodes?.length ?? 0).toBeGreaterThan(0);
const updRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"update",
...memoryLibraryCliArgs(),
"--node-id",
nodeId!,
"--user-id",
userId,
"--content",
contentB,
"--output",
"json",
]);
expect(updRes.exitCode, updRes.stderr).toBe(0);
const delRes = await runCommandE2e(MEMORY_ROUTES, [
"memory",
"delete",
...memoryLibraryCliArgs(),
"--node-id",
nodeId!,
"--user-id",
userId,
"--output",
"json",
]);
expect(delRes.exitCode, delRes.stderr).toBe(0);
}, 180_000);
},
);
@@ -0,0 +1,262 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_ADD_ROUTES } from "../topic-routes.ts";
import { TEST_WORKSPACE_ARGS, memoryUserId, type MemoryDryRunBody } from "./shared.ts";
describe("e2e: memory add", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--messages/i);
expect(stderr).toMatch(/--content/i);
expect(stderr).toMatch(/--profile-schema/i);
expect(stderr).toMatch(/--meta-data/i);
expect(stderr).toMatch(/--project-id/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--content",
"仅内容无用户",
]);
expect(exitCode).toBe(2);
});
test("缺 --messages 与 --content 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/messages|content/i);
});
test("--user-id 65 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
"u".repeat(65),
"--content",
"over-long user id",
]);
expect(exitCode).toBe(2);
});
test("--content 513 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--content",
"x".repeat(513),
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--content",
"over-long library id",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--messages 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--messages",
"not-json",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--messages 传对象而非数组报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--messages",
'{"role":"user","content":"hi"}',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--messages 超过 50 条报 USAGE (2)", async () => {
const messages = Array.from({ length: 51 }, (_unused, index) => ({
role: "user",
content: `turn ${index}`,
}));
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--messages",
JSON.stringify(messages),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--meta-data 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--content",
"meta 格式错误",
"--meta-data",
"{oops",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--meta-data 传数组而非对象报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
memoryUserId(),
"--content",
"meta 类型错误",
"--meta-data",
'["a","b"]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run + --content 断言 endpoint / POST / custom_content", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
"user1",
"--content",
"dry-run 不入网",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/^https:\/\/ws_test\.cn-beijing\.maas\.aliyuncs\.com\//);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/add$/);
expect(data.method).toBe("POST");
expect(data.request?.user_id).toBe("user1");
expect(data.request?.custom_content).toBe("dry-run 不入网");
// Not provided → must stay out of the body entirely
expect(data.request?.messages).toBeUndefined();
expect(data.request?.meta_data).toBeUndefined();
});
test("--dry-run + --messages 断言 messages 原样进 body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
"user1",
"--messages",
'[{"role":"user","content":"我喜欢旅行"},{"role":"assistant","content":"记住了"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.messages).toEqual([
{ role: "user", content: "我喜欢旅行" },
{ role: "assistant", content: "记住了" },
]);
expect(data.request?.custom_content).toBeUndefined();
});
test("--dry-run 断言 --meta-data/--project-id/--profile-schema/--memory-library-id 映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
"user1",
"--content",
"参加了 WAIC",
"--meta-data",
'{"location":"上海","year":2026}',
"--project-id",
"proj_test",
"--profile-schema",
"schema_test",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.meta_data).toEqual({ location: "上海", year: 2026 });
expect(data.request?.project_id).toBe("proj_test");
expect(data.request?.profile_schema).toBe("schema_test");
expect(data.request?.memory_library_id).toBe("lib_test");
});
test("--dry-run 同传 --content 与 --messages 时两者都进 body服务端择一", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_ADD_ROUTES, [
"memory",
"add",
"--user-id",
"user1",
"--content",
"content wins",
"--messages",
'[{"role":"user","content":"ignored"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.custom_content).toBe("content wins");
expect(data.request?.messages).toHaveLength(1);
});
});
// The live self-cleaning chain (add → list → search → update → delete) lives in
// memory-delete.e2e.test.ts.
@@ -0,0 +1,283 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_DELETE_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryAddBody,
type MemoryDryRunBody,
type MemoryNodeListBody,
} from "./shared.ts";
describe("e2e: memory delete", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--node-id/i);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --yes 报 CONFIRMATION_REQUIRED (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--node-id",
"node_test",
"--user-id",
"user1",
...TEST_WORKSPACE_ARGS,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(7);
expect(stderr).toMatch(/--yes/i);
});
test("缺 --node-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--user-id",
memoryUserId(),
]);
expect(exitCode).toBe(2);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--node-id",
"node_test",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--node-id",
"node_test",
"--user-id",
"user1",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / DELETE / query 参数", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--node-id",
"node_test",
"--user-id",
"user1",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
// dry-run must short-circuit before the runtime confirmation gate, otherwise
// this non-TTY run would fail asking for --yes
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
const endpoint = data.endpoint ?? "";
expect(endpoint).toMatch(/api\/v2\/apps\/memory\/memory_nodes\/node_test\?/);
expect(endpoint).toMatch(/user_id=user1/);
expect(endpoint).toMatch(/memory_library_id=lib_test/);
expect(data.method).toBe("DELETE");
});
test("--dry-run 不传 --memory-library-id 时 query 里不出现该键", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
"--node-id",
"node_test",
"--user-id",
"user1",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).not.toMatch(/memory_library_id/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory delete (live)", () => {
test("删除不存在的节点时服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
...memoryScopeCliArgs(),
"--node-id",
`no-such-node-${Date.now()}`,
"--user-id",
memoryUserId(),
"--yes",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
test("记忆节点自清理闭环add → list → search → update → delete → list", async () => {
const userId = memoryUserId();
const marker = `vp-${Date.now()}`;
const contentBefore = `CLI vp test ${marker}:记忆写入(可删)`;
const contentAfter = `CLI vp test ${marker}:记忆已更新`;
const addRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"add",
...memoryScopeCliArgs(),
"--user-id",
userId,
"--content",
contentBefore,
"--meta-data",
`{"marker":"${marker}"}`,
"--output",
"json",
]);
expect(addRes.exitCode, addRes.stderr).toBe(0);
const added = parseStdoutJson<MemoryAddBody>(addRes.stdout);
expect(added.request_id?.length ?? 0, addRes.stdout).toBeGreaterThan(0);
// The contract says AddMemory reports the changed nodes; a bare request_id
// with no node means the write silently did nothing.
expect(added.memory_nodes?.length ?? 0, addRes.stdout).toBeGreaterThan(0);
const addedNodeId = added.memory_nodes![0]!.memory_node_id;
expect(addedNodeId.length).toBeGreaterThan(0);
const listRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"list",
...memoryScopeCliArgs(),
"--user-id",
userId,
"--page-size",
"50",
"--output",
"json",
]);
expect(listRes.exitCode, listRes.stderr).toBe(0);
const listed = parseStdoutJson<MemoryNodeListBody>(listRes.stdout);
const listedNode = listed.memory_nodes?.find((node) => node.memory_node_id === addedNodeId);
if (!listedNode) {
throw new Error(
`memory list 未包含刚写入的节点 ${addedNodeId}。请确认 BAILIAN_E2E_MEMORY_LIBRARY_ID 与控制台「记忆库」ID 一致。stdout=${listRes.stdout}`,
);
}
expect(listedNode.meta_data?.marker).toBe(marker);
const searchRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"search",
...memoryScopeCliArgs(),
"--user-id",
userId,
"--query",
marker,
"--top-k",
"5",
"--min-score",
"0",
"--plan-version",
"lite",
"--output",
"json",
]);
expect(searchRes.exitCode, searchRes.stderr).toBe(0);
const searched = parseStdoutJson<MemoryNodeListBody>(searchRes.stdout);
expect(searched.memory_nodes?.length ?? 0, searchRes.stdout).toBeGreaterThan(0);
const updateRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"update",
...memoryScopeCliArgs(),
"--node-id",
addedNodeId,
"--user-id",
userId,
"--content",
contentAfter,
"--meta-data",
'{"updated":"true"}',
"--output",
"json",
]);
expect(updateRes.exitCode, updateRes.stderr).toBe(0);
// Verify the update landed on the server rather than trusting the 200
const afterUpdateRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"list",
...memoryScopeCliArgs(),
"--user-id",
userId,
"--page-size",
"50",
"--output",
"json",
]);
expect(afterUpdateRes.exitCode, afterUpdateRes.stderr).toBe(0);
const afterUpdate = parseStdoutJson<MemoryNodeListBody>(afterUpdateRes.stdout);
const updatedNode = afterUpdate.memory_nodes?.find(
(node) => node.memory_node_id === addedNodeId,
);
expect(updatedNode?.content, afterUpdateRes.stdout).toBe(contentAfter);
// update mutated both content and meta_data; verify the meta_data write
// landed too rather than only the content
expect(updatedNode?.meta_data?.updated, afterUpdateRes.stdout).toBe("true");
const deleteRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"delete",
...memoryScopeCliArgs(),
"--node-id",
addedNodeId,
"--user-id",
userId,
"--yes",
"--output",
"json",
]);
expect(deleteRes.exitCode, deleteRes.stderr).toBe(0);
const afterDeleteRes = await runCommandE2e(MEMORY_DELETE_ROUTES, [
"memory",
"list",
...memoryScopeCliArgs(),
"--user-id",
userId,
"--page-size",
"50",
"--output",
"json",
]);
expect(afterDeleteRes.exitCode, afterDeleteRes.stderr).toBe(0);
const afterDelete = parseStdoutJson<MemoryNodeListBody>(afterDeleteRes.stdout);
expect(
afterDelete.memory_nodes?.some((node) => node.memory_node_id === addedNodeId) ?? false,
afterDeleteRes.stdout,
).toBe(false);
}, 240_000);
});
@@ -0,0 +1,181 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_LIST_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryDryRunBody,
type MemoryNodeListBody,
} from "./shared.ts";
describe("e2e: memory list", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--page-size/i);
expect(stderr).toMatch(/--page/i);
expect(stderr).toMatch(/--project-id/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 workspace 时报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(
MEMORY_LIST_ROUTES,
["memory", "list", "--user-id", "user1", "--api-key", "sk-fake", "--output", "json"],
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/workspace.*required/i);
});
test("裸调用(无任何 flag打 help 并 exit 0", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, ["memory", "list"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--user-id/i);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--page-size",
"10",
]);
expect(exitCode).toBe(2);
});
test("--page 0 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
memoryUserId(),
"--page",
"0",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
memoryUserId(),
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--page-size 0 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
memoryUserId(),
"--page-size",
"0",
]);
expect(exitCode).toBe(2);
});
test("--page 非数字报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
memoryUserId(),
"--page",
"abc",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / GET / user_id query", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
"user1",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
// The memory API is served on the workspace-specific host, not the model domain
expect(data.endpoint).toMatch(/^https:\/\/ws_test\.cn-beijing\.maas\.aliyuncs\.com\//);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/memory_nodes\?user_id=user1$/);
expect(data.method).toBe("GET");
});
test("--dry-run 断言分页 / project-id / memory-library-id 进 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
"--user-id",
"user1",
"--page-size",
"20",
"--page",
"2",
"--project-id",
"proj_test",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
const endpoint = data.endpoint ?? "";
expect(endpoint).toMatch(/page_size=20/);
// The API paginates with page_num, not page
expect(endpoint).toMatch(/page_num=2/);
expect(endpoint).toMatch(/project_id=proj_test/);
expect(endpoint).toMatch(/memory_library_id=lib_test/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory list (live)", () => {
test("列出不存在的记忆实体返回空列表而非报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
...memoryScopeCliArgs(),
"--user-id",
`no-such-entity-${Date.now()}`,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryNodeListBody>(stdout);
expect(data.request_id?.length ?? 0).toBeGreaterThan(0);
expect(data.memory_nodes ?? []).toHaveLength(0);
});
test("text 输出对空列表给出明确提示", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_LIST_ROUTES, [
"memory",
"list",
...memoryScopeCliArgs(),
"--user-id",
`no-such-entity-${Date.now()}`,
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/No memory nodes found/i);
});
});
@@ -0,0 +1,211 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_CREATE_ROUTES } from "../topic-routes.ts";
import { TEST_WORKSPACE_ARGS, type MemoryDryRunBody } from "./shared.ts";
describe("e2e: memory profile create", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/i);
expect(stderr).toMatch(/--description/i);
expect(stderr).toMatch(/--attributes/i);
expect(stderr).toMatch(/--plan-version/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --name 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--attributes",
'[{"name":"age"}]',
]);
expect(exitCode).toBe(2);
});
test("缺 --attributes 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
]);
expect(exitCode).toBe(2);
});
test("--attributes 空数组报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
"[]",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/attribute/i);
});
test("--name 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"n".repeat(33),
"--attributes",
'[{"name":"age"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--description 129 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--description",
"d".repeat(129),
"--attributes",
'[{"name":"age"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--attributes 内属性 name 超长报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
`[{"name":"${"a".repeat(33)}"}]`,
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--attributes 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
"[oops",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--attributes 传对象而非数组报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
'{"name":"age"}',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--plan-version 非法取值报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
'[{"name":"age"}]',
"--plan-version",
"ultra",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/pro|lite/i);
});
test("--dry-run 断言 endpoint / POST / name / attributes", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--attributes",
'[{"name":"age","description":"年龄"},{"name":"hobby"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/profile_schemas$/);
expect(data.method).toBe("POST");
expect(data.request?.name).toBe("user_basic");
expect(data.request?.attributes).toEqual([
{ name: "age", description: "年龄" },
{ name: "hobby" },
]);
expect(data.request?.description).toBeUndefined();
expect(data.request?.plan_version).toBeUndefined();
});
test("--dry-run 断言 --description/--plan-version/--memory-library-id 映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_CREATE_ROUTES, [
"memory",
"profile",
"create",
"--name",
"user_basic",
"--description",
"基础画像",
"--attributes",
'[{"name":"age","default_value":"18"}]',
"--plan-version",
"lite",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.description).toBe("基础画像");
expect(data.request?.plan_version).toBe("lite");
expect(data.request?.memory_library_id).toBe("lib_test");
expect(data.request?.attributes).toEqual([{ name: "age", default_value: "18" }]);
});
});
// The live self-cleaning chain (create → list → show → update → get → delete)
// lives in memory-profile-delete.e2e.test.ts.
@@ -0,0 +1,293 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_DELETE_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryDryRunBody,
type ProfileSchemaCreateBody,
type ProfileSchemaDetailBody,
type ProfileSchemaListBody,
type UserProfileBody,
} from "./shared.ts";
describe("e2e: memory profile delete", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--schema-id/i);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --schema-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--yes",
]);
expect(exitCode).toBe(2);
});
test("缺 --yes 报 CONFIRMATION_REQUIRED (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--schema-id",
"schema_test",
"--api-key",
"sk-fake",
...TEST_WORKSPACE_ARGS,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(7);
expect(stderr).toMatch(/--yes/i);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--schema-id",
"schema_test",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / DELETE且不触发确认", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--schema-id",
"schema_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
// dry-run must short-circuit before the runtime confirmation gate, otherwise
// this non-TTY run would fail asking for --yes
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/profile_schemas\/schema_test$/);
expect(data.method).toBe("DELETE");
});
test("--dry-run 断言 --memory-library-id 进 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
"--schema-id",
"schema_test",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/profile_schemas\/schema_test\?memory_library_id=lib_test$/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory profile delete (live)", () => {
test("删除不存在的模板时服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
...memoryScopeCliArgs(),
"--schema-id",
`no-such-schema-${Date.now()}`,
"--yes",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
test("画像模板自清理闭环create → list → show → update → show → get → delete → list", async () => {
const marker = `vp-${Date.now()}`;
const schemaName = `vp_profile_${marker}`;
// plan_version lite keeps the per-call price at the cheaper tier
const createRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"create",
...memoryScopeCliArgs(),
"--name",
schemaName,
"--description",
`CLI vp test ${marker}(可删)`,
"--attributes",
'[{"name":"plan","description":"套餐"},{"name":"tier","description":"等级"}]',
"--plan-version",
"lite",
"--output",
"json",
]);
expect(createRes.exitCode, createRes.stderr).toBe(0);
const created = parseStdoutJson<ProfileSchemaCreateBody>(createRes.stdout);
const schemaId = created.profile_schema_id ?? "";
expect(schemaId.length, createRes.stdout).toBeGreaterThan(0);
try {
const listRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"list",
...memoryScopeCliArgs(),
"--page-size",
"100",
"--output",
"json",
]);
expect(listRes.exitCode, listRes.stderr).toBe(0);
const listed = parseStdoutJson<ProfileSchemaListBody>(listRes.stdout);
const listedSchema = listed.profile_schemas?.find(
(schema) => schema.profile_schema_id === schemaId,
);
if (!listedSchema) {
throw new Error(
`memory profile list 未包含刚创建的模板 ${schemaId}。请确认 BAILIAN_E2E_MEMORY_LIBRARY_ID 与控制台「记忆库」ID 一致。stdout=${listRes.stdout}`,
);
}
expect(listedSchema.name).toBe(schemaName);
const showRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"show",
...memoryScopeCliArgs(),
"--schema-id",
schemaId,
"--output",
"json",
]);
expect(showRes.exitCode, showRes.stderr).toBe(0);
const detail = parseStdoutJson<ProfileSchemaDetailBody>(showRes.stdout);
expect(detail.name).toBe(schemaName);
const planAttribute = detail.attributes?.find((attribute) => attribute.name === "plan");
const tierAttribute = detail.attributes?.find((attribute) => attribute.name === "tier");
// attribute_id is the handle profile update needs; without it the
// update / delete operations below cannot be expressed at all
expect(planAttribute?.attribute_id, showRes.stdout).toBeTruthy();
expect(tierAttribute?.attribute_id, showRes.stdout).toBeTruthy();
const renamed = `${schemaName}_v2`;
const updateRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"update",
...memoryScopeCliArgs(),
"--schema-id",
schemaId,
"--name",
renamed,
"--attributes-operations",
JSON.stringify([
{ op: "add", name: "region", description: "地域" },
{
op: "update",
attribute_id: planAttribute!.attribute_id,
description: "套餐(已更新)",
},
{ op: "delete", attribute_id: tierAttribute!.attribute_id },
]),
"--output",
"json",
]);
expect(updateRes.exitCode, updateRes.stderr).toBe(0);
// Verify the mutation landed on the server rather than trusting the 200
const afterUpdateRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"show",
...memoryScopeCliArgs(),
"--schema-id",
schemaId,
"--output",
"json",
]);
expect(afterUpdateRes.exitCode, afterUpdateRes.stderr).toBe(0);
const afterUpdate = parseStdoutJson<ProfileSchemaDetailBody>(afterUpdateRes.stdout);
expect(afterUpdate.name, afterUpdateRes.stdout).toBe(renamed);
const attributeNames = (afterUpdate.attributes ?? []).map((attribute) => attribute.name);
expect(attributeNames, afterUpdateRes.stdout).toContain("region");
expect(attributeNames, afterUpdateRes.stdout).not.toContain("tier");
expect(
afterUpdate.attributes?.find((attribute) => attribute.name === "plan")?.description,
afterUpdateRes.stdout,
).toBe("套餐(已更新)");
// No memory was written against this schema, so every attribute value
// is expected to be empty — the point is that the endpoint resolves
const getRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"get",
...memoryScopeCliArgs(),
"--schema-id",
schemaId,
"--user-id",
memoryUserId(),
"--output",
"json",
]);
expect(getRes.exitCode, getRes.stderr).toBe(0);
const profile = parseStdoutJson<UserProfileBody>(getRes.stdout);
expect(profile.request_id?.length ?? 0, getRes.stdout).toBeGreaterThan(0);
} finally {
const deleteRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"delete",
...memoryScopeCliArgs(),
"--schema-id",
schemaId,
"--yes",
"--output",
"json",
]);
expect(deleteRes.exitCode, deleteRes.stderr).toBe(0);
}
const afterDeleteRes = await runCommandE2e(MEMORY_PROFILE_DELETE_ROUTES, [
"memory",
"profile",
"list",
...memoryScopeCliArgs(),
"--page-size",
"100",
"--output",
"json",
]);
expect(afterDeleteRes.exitCode, afterDeleteRes.stderr).toBe(0);
const afterDelete = parseStdoutJson<ProfileSchemaListBody>(afterDeleteRes.stdout);
expect(
afterDelete.profile_schemas?.some((schema) => schema.profile_schema_id === schemaId) ?? false,
afterDeleteRes.stdout,
).toBe(false);
}, 240_000);
});
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_GET_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryDryRunBody,
} from "./shared.ts";
describe("e2e: memory profile get", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--schema-id/i);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --schema-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--user-id",
"user1",
]);
expect(exitCode).toBe(2);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--schema-id",
"schema_test",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--schema-id",
"schema_test",
"--user-id",
"user1",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run endpoint 命中 /user_profile 子资源", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--schema-id",
"schema_test",
"--user-id",
"user1",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
// Regression guard: this path used to point at /profiles, which the API rejects
expect(data.endpoint).toMatch(
/api\/v2\/apps\/memory\/profile_schemas\/schema_test\/user_profile\?user_id=user1$/,
);
expect(data.method).toBe("GET");
});
test("--dry-run 断言 --memory-library-id 进 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
"--schema-id",
"schema_test",
"--user-id",
"user1",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const endpoint = parseStdoutJson<MemoryDryRunBody>(stdout).endpoint ?? "";
expect(endpoint).toMatch(/user_id=user1/);
expect(endpoint).toMatch(/memory_library_id=lib_test/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory profile get (live)", () => {
test("不存在的 schema 被服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_GET_ROUTES, [
"memory",
"profile",
"get",
...memoryScopeCliArgs(),
"--schema-id",
`no-such-schema-${Date.now()}`,
"--user-id",
memoryUserId(),
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
});
@@ -0,0 +1,146 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_LIST_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
type MemoryDryRunBody,
type ProfileSchemaListBody,
} from "./shared.ts";
describe("e2e: memory profile list", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--page-size/i);
expect(stderr).toMatch(/--page/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("--page 0 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--page",
"0",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--page-size 0 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--page-size",
"0",
]);
expect(exitCode).toBe(2);
});
test("未知 flag 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--user-id",
"user1",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 无参数时 endpoint 不带 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/profile_schemas$/);
expect(data.method).toBe("GET");
});
test("--dry-run 断言分页与 memory-library-id 进 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--page-size",
"20",
"--page",
"2",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
const endpoint = data.endpoint ?? "";
expect(endpoint).toMatch(/page_size=20/);
expect(endpoint).toMatch(/page_num=2/);
expect(endpoint).toMatch(/memory_library_id=lib_test/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory profile list (live)", () => {
test("列出画像模板返回 request_id 与 total", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
...memoryScopeCliArgs(),
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ProfileSchemaListBody>(stdout);
expect(data.request_id?.length ?? 0).toBeGreaterThan(0);
for (const schema of data.profile_schemas ?? []) {
expect(schema.profile_schema_id.length).toBeGreaterThan(0);
expect(schema.name.length).toBeGreaterThan(0);
}
});
test("--memory-library-id 不存在时服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_LIST_ROUTES, [
"memory",
"profile",
"list",
"--memory-library-id",
"no-such-library-000000000000000",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_SHOW_ROUTES } from "../topic-routes.ts";
import { TEST_WORKSPACE_ARGS, memoryScopeCliArgs, type MemoryDryRunBody } from "./shared.ts";
describe("e2e: memory profile show", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--schema-id/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --schema-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--memory-library-id",
"lib_test",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--schema-id",
"schema_test",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / GET", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--schema-id",
"schema_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/profile_schemas\/schema_test$/);
expect(data.method).toBe("GET");
});
test("--dry-run 断言 --memory-library-id 进 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--schema-id",
"schema_test",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/profile_schemas\/schema_test\?memory_library_id=lib_test$/);
});
test("--dry-run schema id 做 URL 编码", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
"--schema-id",
"a/b c",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/profile_schemas\/a%2Fb%20c$/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory profile show (live)", () => {
test("不存在的 schema 被服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_SHOW_ROUTES, [
"memory",
"profile",
"show",
...memoryScopeCliArgs(),
"--schema-id",
`no-such-schema-${Date.now()}`,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
});
@@ -0,0 +1,244 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_PROFILE_UPDATE_ROUTES } from "../topic-routes.ts";
import { TEST_WORKSPACE_ARGS, type MemoryDryRunBody } from "./shared.ts";
describe("e2e: memory profile update", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--schema-id/i);
expect(stderr).toMatch(/--name/i);
expect(stderr).toMatch(/--description/i);
expect(stderr).toMatch(/--attributes-operations/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --schema-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--name",
"user_basic_v2",
]);
expect(exitCode).toBe(2);
});
test("三个可改字段全不传报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--attributes-operations/i);
});
test("--attributes-operations 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
"[oops",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--attributes-operations 传对象而非数组报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'{"op":"add","name":"plan"}',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("op 非法取值报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'[{"op":"upsert","name":"plan"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/op/i);
});
test("op=add 缺 name 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'[{"op":"add","description":"套餐"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/name/i);
});
test("op=delete 缺 attribute_id 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'[{"op":"delete"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/attribute_id/i);
});
test("op=update 缺 attribute_id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'[{"op":"update","name":"plan"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--name 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--name",
"n".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("op=add 属性 name 超长报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
`[{"op":"add","name":"${"a".repeat(33)}"}]`,
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / PATCH / name 与 description", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--name",
"user_basic_v2",
"--description",
"升级后的画像",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/profile_schemas\/schema_test$/);
expect(data.method).toBe("PATCH");
expect(data.request?.name).toBe("user_basic_v2");
expect(data.request?.description).toBe("升级后的画像");
expect(data.request?.attributes_operations).toBeUndefined();
});
test("--dry-run 断言三种 op 原样进 body含 default_value: null", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--attributes-operations",
'[{"op":"add","name":"plan","default_value":"free"},' +
'{"op":"update","attribute_id":"attr_1","default_value":null},' +
'{"op":"delete","attribute_id":"attr_2"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.attributes_operations).toEqual([
{ op: "add", name: "plan", default_value: "free" },
{ op: "update", attribute_id: "attr_1", default_value: null },
{ op: "delete", attribute_id: "attr_2" },
]);
});
test("--dry-run 断言 memory_library_id 进 body 而不是 query string", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_PROFILE_UPDATE_ROUTES, [
"memory",
"profile",
"update",
"--schema-id",
"schema_test",
"--name",
"user_basic_v2",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
// UpdateProfileSchema is the only profile endpoint carrying the library in the body
expect(data.request?.memory_library_id).toBe("lib_test");
expect(data.endpoint).not.toMatch(/memory_library_id/);
});
});
// The live update path is exercised inside the self-cleaning chain in
// memory-profile-delete.e2e.test.ts, which owns the schema it mutates.
@@ -0,0 +1,300 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_SEARCH_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryDryRunBody,
type MemoryNodeListBody,
} from "./shared.ts";
describe("e2e: memory search", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--messages/i);
expect(stderr).toMatch(/--top-k/i);
expect(stderr).toMatch(/--min-score/i);
expect(stderr).toMatch(/--enable-rerank/i);
expect(stderr).toMatch(/--enable-judge/i);
expect(stderr).toMatch(/--enable-rewrite/i);
expect(stderr).toMatch(/--plan-version/i);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).toMatch(/--project-id/i);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--query",
"编程偏好",
]);
expect(exitCode).toBe(2);
});
test("缺 --query 与 --messages 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/query|messages/i);
});
test("--top-k 0 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--top-k",
"0",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--top-k 101 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--top-k",
"101",
]);
expect(exitCode).toBe(2);
});
test("--min-score 1.5 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--min-score",
"1.5",
]);
expect(exitCode).toBe(2);
});
test("--plan-version 非法取值报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--plan-version",
"ultra",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/pro|lite/i);
});
test("--enable-rerank 非 true/false 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--query",
"x",
"--enable-rerank",
"yes",
]);
expect(exitCode).toBe(2);
});
test("--messages 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
memoryUserId(),
"--messages",
"[oops",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run + --query 断言 endpoint 与 query 被包成单条 user message", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
"user1",
"--query",
"编程偏好",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/memory_nodes\/search$/);
expect(data.method).toBe("POST");
expect(data.request?.messages).toEqual([{ role: "user", content: "编程偏好" }]);
// `query` is not part of the API contract — it must never reach the body
expect(data.request?.query).toBeUndefined();
});
test("--dry-run + --messages 覆盖 --query", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
"user1",
"--query",
"被忽略",
"--messages",
'[{"role":"user","content":"推荐一本书"}]',
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.messages).toEqual([{ role: "user", content: "推荐一本书" }]);
});
test("--dry-run 断言检索调参全部映射进 body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
"user1",
"--query",
"提醒事项",
"--top-k",
"5",
"--min-score",
"0",
"--enable-rerank",
"true",
"--enable-judge",
"true",
"--enable-rewrite",
"false",
"--plan-version",
"lite",
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.top_k).toBe(5);
// 0 is a meaningful threshold — it must not be dropped as falsy
expect(data.request?.min_score).toBe(0);
expect(data.request?.enable_rerank).toBe(true);
expect(data.request?.enable_judge).toBe(true);
expect(data.request?.enable_rewrite).toBe(false);
expect(data.request?.plan_version).toBe("lite");
expect(data.request?.memory_library_id).toBe("lib_test");
});
test("--dry-run 断言可重复 --project-id 汇成 project_ids", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--user-id",
"user1",
"--query",
"多规则混检",
"--project-id",
"proj_a",
"--project-id",
"proj_b",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.project_ids).toEqual(["proj_a", "proj_b"]);
expect(data.request?.project_id).toBeUndefined();
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory search (live)", () => {
test("检索不存在的记忆实体返回空列表而非报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
...memoryScopeCliArgs(),
"--user-id",
`no-such-entity-${Date.now()}`,
"--query",
"任何内容",
"--top-k",
"3",
"--plan-version",
"lite",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryNodeListBody>(stdout);
expect(data.request_id?.length ?? 0).toBeGreaterThan(0);
expect(data.memory_nodes ?? []).toHaveLength(0);
});
test("--memory-library-id 不存在时服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_SEARCH_ROUTES, [
"memory",
"search",
"--memory-library-id",
"no-such-library-000000000000000",
"--user-id",
memoryUserId(),
"--query",
"任何内容",
"--plan-version",
"lite",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
});
@@ -0,0 +1,223 @@
import { describe, expect, test } from "vite-plus/test";
import { isMemoryE2EReady, parseStdoutJson, runCommandE2e, runCommandHelp } from "../helpers.ts";
import { MEMORY_UPDATE_ROUTES } from "../topic-routes.ts";
import {
TEST_WORKSPACE_ARGS,
memoryScopeCliArgs,
memoryUserId,
type MemoryDryRunBody,
} from "./shared.ts";
describe("e2e: memory update", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--node-id/i);
expect(stderr).toMatch(/--user-id/i);
expect(stderr).toMatch(/--content/i);
expect(stderr).toMatch(/--timestamp/i);
expect(stderr).toMatch(/--meta-data/i);
expect(stderr).toMatch(/--memory-library-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --node-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--user-id",
memoryUserId(),
"--content",
"新内容",
]);
expect(exitCode).toBe(2);
});
test("缺 --user-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--content",
"新内容",
]);
expect(exitCode).toBe(2);
});
test("缺 --content 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
memoryUserId(),
]);
expect(exitCode).toBe(2);
});
test("--content 513 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
memoryUserId(),
"--content",
"x".repeat(513),
]);
expect(exitCode).toBe(2);
});
test("--timestamp 负数报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
memoryUserId(),
"--content",
"新内容",
"--timestamp",
"-1",
]);
expect(exitCode).toBe(2);
});
test("--memory-library-id 33 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
memoryUserId(),
"--content",
"新内容",
"--memory-library-id",
"l".repeat(33),
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--meta-data 非法 JSON 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
memoryUserId(),
"--content",
"新内容",
"--meta-data",
"{oops",
...TEST_WORKSPACE_ARGS,
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 endpoint / PATCH / custom_content", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
"user1",
"--content",
"更新后的记忆内容",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v2\/apps\/memory\/memory_nodes\/node_test$/);
expect(data.method).toBe("PATCH");
expect(data.request?.user_id).toBe("user1");
expect(data.request?.custom_content).toBe("更新后的记忆内容");
expect(data.request?.timestamp).toBeUndefined();
expect(data.request?.meta_data).toBeUndefined();
});
test("--dry-run 断言 --timestamp/--meta-data/--memory-library-id 映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node_test",
"--user-id",
"user1",
"--content",
"在 WAIC 见面",
"--timestamp",
"1747278460",
"--meta-data",
'{"city":"上海"}',
"--memory-library-id",
"lib_test",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.request?.timestamp).toBe(1747278460);
expect(data.request?.meta_data).toEqual({ city: "上海" });
expect(data.request?.memory_library_id).toBe("lib_test");
});
test("--dry-run 断言 node id 被 URL 编码", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
"--node-id",
"node/with space",
"--user-id",
"user1",
"--content",
"编码校验",
...TEST_WORKSPACE_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<MemoryDryRunBody>(stdout);
expect(data.endpoint).toMatch(/memory_nodes\/node%2Fwith%20space$/);
});
});
describe.skipIf(!isMemoryE2EReady())("e2e: memory update (live)", () => {
test("更新不存在的节点时服务端拒绝(非 0 退出)", async () => {
const { exitCode } = await runCommandE2e(MEMORY_UPDATE_ROUTES, [
"memory",
"update",
...memoryScopeCliArgs(),
"--node-id",
`no-such-node-${Date.now()}`,
"--user-id",
memoryUserId(),
"--content",
"不存在的节点",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
});
});
// The live update-in-place assertion lives in the CRUD chain in memory-delete.e2e.test.ts.
@@ -0,0 +1,126 @@
/**
* Shared fixtures for the memory E2E suite.
*
* The memory API has no "list memory libraries" endpoint, so the library ID
* cannot be discovered from the CLI — it has to be copied from the console
* (Bailian → 应用 → 记忆 → 记忆库 card) into `BAILIAN_E2E_MEMORY_LIBRARY_ID`.
* No hardcoded fallback on purpose: a stale ID would make live cases fail with
* a confusing InvalidParameter instead of skipping via `isMemoryE2EReady()`.
*
* The memory API is served on a workspace-specific host, so every case that
* reaches `run()` — including `--dry-run` — needs a workspace ID.
*/
const DEFAULT_E2E_MEMORY_USER_ID = "e2e-vp-test";
/** Memory entity ID used by every live case; env var wins over the default. */
export function memoryUserId(): string {
return process.env.BAILIAN_E2E_MEMORY_USER_ID?.trim() || DEFAULT_E2E_MEMORY_USER_ID;
}
/**
* Fixed workspace for offline cases: it only shapes the endpoint host, so a
* literal keeps `--dry-run` assertions deterministic on any machine.
*/
export const TEST_WORKSPACE_ARGS = ["--workspace-id", "ws_test"];
/**
* Scope argv for live cases: memory library + workspace. `isMemoryE2EReady()`
* guarantees both env vars are set.
*/
export function memoryScopeCliArgs(): string[] {
const libraryId = process.env.BAILIAN_E2E_MEMORY_LIBRARY_ID?.trim();
const workspaceId = process.env.BAILIAN_WORKSPACE_ID?.trim();
return [
...(libraryId ? ["--memory-library-id", libraryId] : []),
...(workspaceId ? ["--workspace-id", workspaceId] : []),
];
}
/** `--dry-run --output json` payload shape shared by the memory commands. */
export interface MemoryDryRunBody {
endpoint?: string;
method?: string;
request?: {
user_id?: string;
custom_content?: string;
messages?: Array<{ role?: string; content?: string }>;
meta_data?: Record<string, unknown>;
project_id?: string;
project_ids?: string[];
profile_schema?: string;
memory_library_id?: string;
timestamp?: number;
top_k?: number;
min_score?: number;
enable_rerank?: boolean;
enable_judge?: boolean;
enable_rewrite?: boolean;
plan_version?: string;
query?: string;
name?: string;
description?: string;
attributes?: Array<{ name?: string; description?: string; default_value?: string }>;
attributes_operations?: Array<{
op?: string;
attribute_id?: string;
name?: string;
description?: string;
default_value?: string | null;
}>;
};
}
export interface MemoryNodeListBody {
request_id?: string;
total?: number;
memory_nodes?: Array<{
memory_node_id: string;
content: string;
meta_data?: Record<string, unknown>;
created_at?: number;
updated_at?: number;
}>;
}
export interface MemoryAddBody {
request_id?: string;
memory_nodes?: Array<{
memory_node_id: string;
content: string;
event?: string;
old_content?: string;
}>;
}
export interface ProfileSchemaCreateBody {
request_id?: string;
profile_schema_id?: string;
}
export interface ProfileSchemaListBody {
request_id?: string;
total?: number;
profile_schemas?: Array<{ profile_schema_id: string; name: string; description?: string }>;
}
export interface ProfileSchemaDetailBody {
request_id?: string;
name?: string;
description?: string;
attributes?: Array<{
attribute_id: string;
name: string;
description?: string;
default_value?: string;
}>;
}
export interface UserProfileBody {
request_id?: string;
profile?: {
schema_name?: string;
schema_description?: string;
attributes?: Array<{ id: string; name: string; value?: string }>;
};
}
@@ -240,4 +240,75 @@ describe("e2e: pipeline", () => {
expect(stdout).toBe("");
expect(stderr).toMatch(/--events must be one of: jsonl/i);
});
test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [{ id: "gen", type: "image/generate", input: { prompt: "A cat" } }],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
status?: string;
steps?: Array<{ type?: string; input?: { watermark?: boolean; prompt?: string } }>;
}>(stdout);
expect(report.status).toBe("planned");
expect(report.steps?.[0]).toMatchObject({
type: "image/generate",
input: { prompt: "A cat", watermark: false },
});
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
test("pipeline 步骤显式 watermark=true 覆盖 Profile false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-ov-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [
{
id: "gen",
type: "image/generate",
input: { prompt: "A cat", watermark: true },
},
],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
steps?: Array<{ input?: { watermark?: boolean } }>;
}>(stdout);
expect(report.steps?.[0]?.input?.watermark).toBe(true);
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
});
@@ -40,11 +40,18 @@ describe("e2e: speech recognize", () => {
language_hints?: string[];
language?: string;
vocabulary_id?: string;
vocabulary?: Record<string, number>;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
context?: Array<{
role?: string;
content?: Array<{ type?: string; text?: string }>;
}>;
messages?: Array<{
content?: Array<{ type?: string; text?: string; input_audio?: { data?: string } }>;
}>;
};
};
}>(stdout);
@@ -120,6 +127,116 @@ describe("e2e: speech recognize", () => {
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize async dry-run 注入 input.context 与 parameters.vocabulary", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/audio.mp3",
"--vocabulary",
'{"奋斗者":4,"鲸落":4}',
"--context",
"奋斗者号 鲸落 深海勇士",
]);
expect(body.mode).toBe("async");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4, 鲸落: 4 });
expect(body.request?.input?.context).toEqual([
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落 深海勇士" }],
},
]);
});
test("speech recognize sync input-audio dry-run 将 context 前置且 input_audio 在最后", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/audio.wav",
"--vocabulary",
'{"奋斗者":4}',
"--context",
"奋斗者号",
]);
expect(body.mode).toBe("sync");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4 });
const messages = body.request?.input?.messages ?? [];
expect(messages).toHaveLength(2);
expect(messages[0]?.content?.[0]).toMatchObject({ type: "input_text", text: "奋斗者号" });
expect(messages[1]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize 非法 --vocabulary JSON 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"{bad json",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize 空 --vocabulary 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize qwen3 sync 拒绝 --vocabulary", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash",
"--url",
"https://example.com/a.wav",
"--vocabulary",
'{"奋斗者":4}',
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--vocabulary|does not support/i);
expect(stderr).toMatch(/qwen-audio-3\.0-asr-flash|vocabulary\/context/i);
});
test("speech recognize sync Flash 拒绝 --diarization 时提示 async filetrans", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash",
"--url",
"https://example.com/a.wav",
"--diarization",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--diarization|does not support/i);
expect(stderr).toMatch(/async filetrans|fun-asr/i);
expect(stderr).not.toMatch(/vocabulary\/context/i);
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
@@ -366,5 +483,88 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
const raw = readFileSync(asrJson, "utf8");
expect(raw.length).toBeGreaterThan(2);
}, 300_000);
test("【qwen-audio】synthesize → recognize 即时热词/上下文", async () => {
// 生造专名:无热词时常被听错;带 --vocabulary/--context 后应能正确召回。
const hotwordScript =
"请把录音同步到听悟匣,并启动澜舟芯做摘要。听悟匣负责转写,澜舟芯负责归档。最后确认玄甲协议是否已开启。";
const hotwords = ["听悟匣", "澜舟芯", "玄甲协议"] as const;
const vocabularyJson = '{"听悟匣":4,"澜舟芯":4,"玄甲协议":4}';
const contextText = "听悟匣 澜舟芯 玄甲协议";
const missingHotwords = (text: string): string[] => {
const normalized = text.replace(/\s+/g, "");
return hotwords.filter((word) => !normalized.includes(word));
};
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const outMp3 = join(outDir, "hotword-tts.mp3");
const syn = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
"cosyvoice-v3-flash",
"--voice",
"longxiaochun_v3",
"--text",
hotwordScript,
"--out",
outMp3,
"--output",
"json",
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const synBody = parseStdoutJson<{ audio_url?: string }>(syn.stdout);
const audioUrl = synBody.audio_url;
expect(audioUrl?.startsWith("http")).toBe(true);
const baselineOut = join(outDir, "asr-baseline.json");
const baseline = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--out",
baselineOut,
"--quiet",
]);
expect(baseline.exitCode, baseline.stderr).toBe(0);
writeFileSync(join(outDir, "asr-baseline.txt"), baseline.stdout);
const baselineMissing = missingHotwords(baseline.stdout);
// soft仅落盘对照不 fail无热词偶发也能认出专名
writeFileSync(
join(outDir, "asr-baseline-missing.txt"),
baselineMissing.length > 0 ? baselineMissing.join("\n") + "\n" : "(none)\n",
);
const hotOut = join(outDir, "asr-hot.json");
const hot = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--vocabulary",
vocabularyJson,
"--context",
contextText,
"--out",
hotOut,
"--quiet",
]);
expect(hot.exitCode, hot.stderr).toBe(0);
writeFileSync(join(outDir, "asr-hot.txt"), hot.stdout);
expect(
missingHotwords(hot.stdout),
`expected hotwords in ASR text, got: ${hot.stdout.trim()}`,
).toEqual([]);
}, 420_000);
},
);
@@ -0,0 +1,551 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandHelp,
runCommandE2e,
} from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech vocabularyhelp / dry-run / 确认闸门无密钥;真实 CRUD 需媒体 E2E + DashScope。
*/
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function makeTempJson(content: string): string {
const tempDir = mkdtempSync(join(tmpdir(), "bl-vocab-e2e-"));
tempDirs.push(tempDir);
const filePath = join(tempDir, "hotwords.json");
writeFileSync(filePath, content);
return filePath;
}
describe("e2e: speech vocabulary", () => {
test("speech vocabulary --help 列出 5 个子命令", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/create/i);
expect(stderr).toMatch(/list/i);
expect(stderr).toMatch(/get/i);
expect(stderr).toMatch(/update/i);
expect(stderr).toMatch(/delete/i);
});
test("create --help 展示关键 flags 与静默失效 / weight 50 文案", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model/i);
expect(stderr).toMatch(/--prefix/i);
expect(stderr).toMatch(/--words/i);
expect(stderr).toMatch(/--words-file/i);
expect(stderr).toMatch(/silently ignored|静默失效/i);
expect(stderr).toMatch(/50/);
});
test("delete --help 展示 Risk 与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
});
test("update --help 展示 Risk、replace 语义与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
expect(stderr).toMatch(/replaces|替换/i);
});
test("create 缺少 --model 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--model|Missing required/i);
});
test("create 缺少 --prefix 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prefix|Missing required/i);
});
test("create 两个词表 flag 都不传时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--words|--words-file/i);
});
test("create 同时传 --words 与 --words-file 时退出为用法错误 (2)", async () => {
const filePath = makeTempJson('{"x":4}');
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--words-file",
filePath,
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/either|--words|--words-file/i);
});
test("create 非法 JSON 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{bad json",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|JSON/i);
});
test("create 字符串权重时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":"4"}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/must be a number|number/i);
});
test("create 空词表 {} 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{}",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/at least one|hot word/i);
});
test("get 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("delete 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("create object 形态 --dry-run 输出 speech-biasing 信封", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"奋斗者":4,"鲸落":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
model?: string;
input?: {
action?: string;
target_model?: string;
prefix?: string;
vocabulary?: Array<{ text?: string; weight?: number; lang?: string }>;
};
};
}>(stdout);
expect(body.request?.model).toBe("speech-biasing");
expect(body.request?.input?.action).toBe("create_vocabulary");
expect(body.request?.input?.target_model).toBe("fun-asr");
expect(body.request?.input?.prefix).toBe("demo");
expect(body.request?.input?.vocabulary).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 4 },
]);
});
test("create array 形态 --dry-run 原样透传 lang", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'[{"text":"x","weight":4,"lang":"zh"}]',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create object + --lang --dry-run 下发到每一条", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--lang",
"zh",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create --words-file --dry-run 读取文件", async () => {
const filePath = makeTempJson('{"from-file":4}');
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words-file",
filePath,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "from-file", weight: 4 }]);
});
test("list --page 2 --dry-run 将 page_index 转为 1", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--page",
"2",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; page_index?: number } };
}>(stdout);
expect(body.request?.input?.action).toBe("list_vocabulary");
expect(body.request?.input?.page_index).toBe(1);
});
test("get --dry-run 使用 query_vocabulary", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("query_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
test("update --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: {
action?: string;
vocabulary_id?: string;
vocabulary?: Array<{ text?: string; weight?: number }>;
};
};
}>(stdout);
expect(body.request?.input?.action).toBe("update_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4 }]);
});
test("delete --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("delete_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
});
describe("e2e: speech vocabulary high-risk confirmation", () => {
test("delete 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("update 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: speech vocabularyDashScope 媒体)",
() => {
test("create → list/get → delete 完整链路", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
let vocabularyId = "";
try {
const created = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"blcli",
"--words",
'{"奋斗者":4}',
"--quiet",
]);
expect(created.exitCode, created.stderr).toBe(0);
vocabularyId = created.stdout.trim();
expect(vocabularyId.length).toBeGreaterThan(0);
writeFileSync(join(outDir, "vocabulary-id.txt"), vocabularyId + "\n");
const listed = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--prefix",
"blcli",
"--output",
"json",
]);
expect(listed.exitCode, listed.stderr).toBe(0);
const listBody = parseStdoutJson<{
output?: { vocabulary_list?: Array<{ vocabulary_id?: string; status?: string }> };
}>(listed.stdout);
const listedItem = listBody.output?.vocabulary_list?.find(
(item) => item.vocabulary_id === vocabularyId,
);
expect(listedItem).toBeTruthy();
expect(listedItem?.status).toBe("OK");
const got = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
vocabularyId,
"--output",
"json",
]);
expect(got.exitCode, got.stderr).toBe(0);
const getBody = parseStdoutJson<{
output?: { status?: string; target_model?: string };
}>(got.stdout);
expect(getBody.output?.status).toBe("OK");
expect(getBody.output?.target_model).toBe("fun-asr");
} finally {
if (vocabularyId) {
const deleted = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
vocabularyId,
"--yes",
"--quiet",
]);
expect(deleted.exitCode, deleted.stderr).toBe(0);
}
}
}, 120_000);
},
);
+49 -1
View File
@@ -25,16 +25,59 @@ export const CONFIG_ROUTES: E2eRouteExports = {
"config agent": "configAgent",
};
export const MEMORY_ROUTES: E2eRouteExports = {
export const MEMORY_ADD_ROUTES: E2eRouteExports = {
"memory add": "memoryAdd",
};
export const MEMORY_SEARCH_ROUTES: E2eRouteExports = {
"memory search": "memorySearch",
};
export const MEMORY_LIST_ROUTES: E2eRouteExports = {
"memory list": "memoryList",
};
export const MEMORY_UPDATE_ROUTES: E2eRouteExports = {
"memory update": "memoryUpdate",
};
export const MEMORY_DELETE_ROUTES: E2eRouteExports = {
"memory delete": "memoryDelete",
"memory add": "memoryAdd", // live self-cleaning chain
"memory list": "memoryList", // live: resolve the node id add just produced
"memory search": "memorySearch", // live: verify the node is retrievable
"memory update": "memoryUpdate", // live update step in the chain
};
export const MEMORY_PROFILE_CREATE_ROUTES: E2eRouteExports = {
"memory profile create": "memoryProfileCreate",
};
export const MEMORY_PROFILE_LIST_ROUTES: E2eRouteExports = {
"memory profile list": "memoryProfileList",
};
export const MEMORY_PROFILE_SHOW_ROUTES: E2eRouteExports = {
"memory profile show": "memoryProfileShow",
};
export const MEMORY_PROFILE_UPDATE_ROUTES: E2eRouteExports = {
"memory profile update": "memoryProfileUpdate",
};
export const MEMORY_PROFILE_GET_ROUTES: E2eRouteExports = {
"memory profile get": "memoryProfileGet",
};
export const MEMORY_PROFILE_DELETE_ROUTES: E2eRouteExports = {
"memory profile delete": "memoryProfileDelete",
"memory profile create": "memoryProfileCreate", // live self-cleaning chain
"memory profile list": "memoryProfileList", // live: the new schema shows up in the list
"memory profile show": "memoryProfileShow", // live: attribute_id handles for update
"memory profile update": "memoryProfileUpdate", // live update step in the chain
"memory profile get": "memoryProfileGet", // live: user profile reachable through the schema
};
export const KNOWLEDGE_ROUTES: E2eRouteExports = {
"knowledge retrieve": "knowledgeRetrieve",
"knowledge search": "knowledgeSearch",
@@ -72,6 +115,11 @@ export const VISION_ROUTES: E2eRouteExports = {
export const SPEECH_ROUTES: E2eRouteExports = {
"speech synthesize": "speechSynthesize",
"speech recognize": "speechRecognize",
"speech vocabulary create": "speechVocabularyCreate",
"speech vocabulary list": "speechVocabularyList",
"speech vocabulary get": "speechVocabularyGet",
"speech vocabulary update": "speechVocabularyUpdate",
"speech vocabulary delete": "speechVocabularyDelete",
};
export const MCP_ROUTES: E2eRouteExports = {
@@ -0,0 +1,80 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { IMAGE_ROUTES, VIDEO_ROUTES, type E2eRouteExports } from "./topic-routes.ts";
interface WatermarkScenario {
name: string;
routes: E2eRouteExports;
args: string[];
}
const scenarios: WatermarkScenario[] = [
{
name: "image generate",
routes: IMAGE_ROUTES,
args: ["image", "generate", "--prompt", "A cat"],
},
{
name: "image edit",
routes: IMAGE_ROUTES,
args: [
"image",
"edit",
"--image",
"https://example.com/input.png",
"--prompt",
"Blue background",
],
},
{
name: "video generate",
routes: VIDEO_ROUTES,
args: ["video", "generate", "--prompt", "A cat waves"],
},
{
name: "video edit",
routes: VIDEO_ROUTES,
args: ["video", "edit", "--video", "https://example.com/input.mp4", "--prompt", "Warm colors"],
},
{
name: "video ref",
routes: VIDEO_ROUTES,
args: [
"video",
"ref",
"--image",
"https://example.com/person.png",
"--prompt",
"Image 1 waves",
],
},
];
describe("e2e: global watermark config", () => {
for (const scenario of scenarios) {
test(`${scenario.name} uses watermark=false from the selected Profile`, async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-watermark-profile-"));
try {
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ media: { watermark: false } }, null, 2) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
scenario.routes,
[...scenario.args, "--config", "media", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { parameters?: { watermark?: boolean } };
}>(stdout);
expect(data.request?.parameters?.watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.21.0",
"version": "1.23.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+38 -18
View File
@@ -1,16 +1,20 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
import type { AsrContextMessage } from "../types/api.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* language via `parameters.language_hints`; optional `input.context` /
* `parameters.vocabulary` (model-dependent effective range)
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* (qwen3-asr-flash*) — no vocabulary / context fields in this body shape
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* `format`/`sample_rate` + optional `language_hints` /
* `vocabulary_id` / `vocabulary`; optional leading `input_text` for context
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket — not supported by `speech recognize`
*/
@@ -158,9 +162,18 @@ export interface BuildAsrFlashRequestOpts {
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
/** Instant hot words (word → weight); input-audio Flash only (command layer rejects qwen3). */
vocabulary?: Record<string, number>;
/** Context enhancement text; prepended as input_text before input_audio. */
context?: string;
flashFamily: AsrFlashFamily;
}
/** Wrap plain text as a single user context message for ASR. */
export function buildAsrContextMessages(text: string): AsrContextMessage[] {
return [{ role: "user", content: [{ type: "input_text", text }] }];
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
@@ -178,10 +191,10 @@ export function buildAsyncAsrLanguageFields(
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
const { model, audioUrl, language, vocabularyId, vocabulary, context, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary(_id)
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
@@ -192,21 +205,28 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<str
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
if (vocabulary) {
parameters.vocabulary = vocabulary;
}
// input_audio must be the last message; prepend context as input_text when present
const messages: Array<Record<string, unknown>> = [];
if (context) {
for (const message of buildAsrContextMessages(context)) {
messages.push(message as unknown as Record<string, unknown>);
}
}
messages.push({
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
});
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
input: { messages },
parameters,
};
}
+35 -6
View File
@@ -1,5 +1,18 @@
// API path builders — return the path only; the Client prepends the
// credential's baseUrl. Commands never see baseUrl.
//
// Exception: workspace-scoped APIs (RAG admin plane, knowledge search/chat,
// memory) live on a per-workspace host, so their builders return an absolute
// URL — see `workspaceEndpoint`.
/**
* Workspace-scoped API host. Everything reached through the workspace gateway
* (RAG admin plane, knowledge search/chat, memory) shares this host shape, so
* it is spelled out exactly once here.
*/
function workspaceEndpoint(workspaceId: string, path: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`;
}
// ---- Chat (OpenAI Compatible) ----
export function chatPath(): string {
@@ -62,7 +75,14 @@ export function appCompletionPath(appId: string): string {
return `/api/v1/apps/${encodeURIComponent(appId)}/completion`;
}
// ---- Memory (DashScope v2) ----
// ---- Memory (long-term memory v2, workspace-based host) ----
// The path builders below return the path portion only; wrap them in
// `memoryEndpoint` to get the URL the memory API is actually served on.
export function memoryEndpoint(workspaceId: string, path: string): string {
return workspaceEndpoint(workspaceId, path);
}
export function memoryAddPath(): string {
return "/api/v2/apps/memory/add";
}
@@ -89,13 +109,22 @@ export function speechRecognizePath(): string {
return "/api/v1/services/audio/asr/transcription";
}
// ---- Memory Profile (DashScope v2) ----
// ---- Hot-word Vocabulary (ASR customization) ----
export function speechVocabularyPath(): string {
return "/api/v1/services/audio/asr/customization";
}
// ---- Memory Profile (long-term memory v2, workspace-based host) ----
export function profileSchemaPath(): string {
return "/api/v2/apps/memory/profile_schemas";
}
export function profileSchemaItemPath(schemaId: string): string {
return `/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}`;
}
export function userProfilePath(schemaId: string): string {
return `/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}/profiles`;
return `${profileSchemaItemPath(schemaId)}/user_profile`;
}
// ---- Knowledge Base Retrieve (DashScope) ----
@@ -106,13 +135,13 @@ export function knowledgeRetrievePath(): string {
// ---- Knowledge Search (新版 RAG 检索, workspace-based host) ----
export function knowledgeSearchEndpoint(workspaceId: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search`;
return workspaceEndpoint(workspaceId, "/api/v1/indices/knowledge/search");
}
// ---- Knowledge Chat (新版 RAG 问答, workspace-based host) ----
export function knowledgeChatEndpoint(workspaceId: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com/api/v2/apps/knowledge/chat`;
return workspaceEndpoint(workspaceId, "/api/v2/apps/knowledge/chat");
}
// ---- MCP Services (Streamable HTTP) ----
@@ -223,7 +252,7 @@ export function deploymentsModelsPath(): string {
// RAG_PATHS to avoid per-endpoint boilerplate.
export function ragEndpoint(workspaceId: string, path: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`;
return workspaceEndpoint(workspaceId, path);
}
export const RAG_PATHS = {
+4
View File
@@ -9,18 +9,21 @@ export {
knowledgeRetrievePath,
knowledgeSearchEndpoint,
memoryAddPath,
memoryEndpoint,
memoryListPath,
memoryNodePath,
memorySearchPath,
mcpWebSearchPath,
modelsLimitsPath,
modelsPermissionsPath,
profileSchemaItemPath,
profileSchemaPath,
ragEndpoint,
RAG_PATHS,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
speechVocabularyPath,
taskPath,
userProfilePath,
videoGeneratePath,
@@ -41,6 +44,7 @@ export {
type ImageSizeProfile,
} from "./image-routes.ts";
export {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
+1
View File
@@ -272,6 +272,7 @@ export function buildSettings(s: ResolutionSources): Settings {
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputDir: file.output_dir || undefined,
timeout,
watermark: file.watermark ?? true,
defaultTextModel: file.default_text_model,
defaultVideoModel: file.default_video_model,
defaultImageToVideoModel: file.default_image_to_video_model,
+4
View File
@@ -35,6 +35,7 @@ export interface ConfigFile {
output?: "text" | "json";
output_dir?: string;
timeout?: number;
watermark?: boolean;
default_text_model?: string;
default_video_model?: string;
default_image_to_video_model?: string;
@@ -63,6 +64,7 @@ export const CONFIG_FILE_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
@@ -156,6 +158,7 @@ export function parseConfigFile(raw: unknown): ConfigFile {
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
out.output_dir = obj.output_dir;
if (typeof obj.timeout === "number" && obj.timeout > 0) out.timeout = obj.timeout;
if (typeof obj.watermark === "boolean") out.watermark = obj.watermark;
if (typeof obj.default_text_model === "string" && obj.default_text_model.length > 0)
out.default_text_model = obj.default_text_model;
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
@@ -224,6 +227,7 @@ export interface Settings {
outputExplicit: boolean;
outputDir?: string;
timeout: number;
watermark: boolean;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
+1
View File
@@ -13,6 +13,7 @@ export * from "./files/index.ts";
export * from "./dataset/index.ts";
export * from "./finetune/index.ts";
export * from "./deploy/index.ts";
export * from "./speech/index.ts";
export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
+14
View File
@@ -0,0 +1,14 @@
export {
SPEECH_BIASING_MODEL,
buildVocabularyRequest,
createVocabulary,
listVocabularies,
queryVocabulary,
updateVocabulary,
deleteVocabulary,
type VocabularyEntry,
type VocabularyListItem,
type VocabularyEnvelope,
type VocabularyRequest,
} from "./vocabulary.ts";
export { parseInstantVocabulary, parseVocabularyEntries } from "./vocabulary-input.ts";
@@ -0,0 +1,76 @@
import { UsageError } from "../errors/base.ts";
import type { VocabularyEntry } from "./vocabulary.ts";
/** Shared JSON decode + top-level shape guard for both hot-word flags. */
function decodeVocabularyJson(raw: string, flagName: string): unknown {
try {
return JSON.parse(raw);
} catch (error) {
throw new UsageError(`${flagName} is not valid JSON — ${(error as Error).message}`);
}
}
/**
* Instant hot words for `recognize --vocabulary`.
* The API field is a flat wordweight object, so an array is a usage error here.
*/
export function parseInstantVocabulary(raw: string): Record<string, number> {
const parsed = decodeVocabularyJson(raw, "--vocabulary");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new UsageError("--vocabulary must decode to a JSON object of word→weight.");
}
for (const [word, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`--vocabulary weight for "${word}" must be a number.`);
}
}
return parsed as Record<string, number>;
}
/**
* Vocabulary create/update --words.
* Accepts the same wordweight object as recognize, or the API entry array
* when per-entry lang is needed. Array form ignores the optional lang param.
*/
export function parseVocabularyEntries(raw: string, lang?: string): VocabularyEntry[] {
const flagName = "--words";
const parsed = decodeVocabularyJson(raw, flagName);
let entries: VocabularyEntry[];
if (Array.isArray(parsed)) {
entries = parsed.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new UsageError(
`${flagName} entry #${index} must be an object with string "text" and number "weight".`,
);
}
const entry = item as Partial<VocabularyEntry>;
if (typeof entry.text !== "string" || typeof entry.weight !== "number") {
throw new UsageError(
`${flagName} entry #${index} must have a string "text" and a number "weight".`,
);
}
if (!Number.isFinite(entry.weight)) {
throw new UsageError(`${flagName} entry #${index} weight must be a finite number.`);
}
return {
text: entry.text,
weight: entry.weight,
...(typeof entry.lang === "string" ? { lang: entry.lang } : {}),
};
});
} else if (!parsed || typeof parsed !== "object") {
throw new UsageError(`${flagName} must decode to a JSON object or array.`);
} else {
entries = [];
for (const [text, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`${flagName} weight for "${text}" must be a number.`);
}
entries.push({ text, weight, ...(lang ? { lang } : {}) });
}
}
if (entries.length === 0) {
throw new UsageError(`${flagName} must contain at least one hot word.`);
}
return entries;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Hot-word vocabulary HTTP API wrappers.
*
* Thin functions over `requestJson`. They return the parsed body verbatim
* (snake_case) so callers can decide how to surface fields.
*/
import { speechVocabularyPath } from "../client/endpoints.ts";
import type { Client } from "../client/client.ts";
/** Fixed model id for the hot-word customization endpoint. */
export const SPEECH_BIASING_MODEL = "speech-biasing";
export interface VocabularyEntry {
text: string;
weight: number;
lang?: string;
}
export interface VocabularyListItem {
vocabulary_id?: string;
gmt_create?: string;
gmt_modified?: string;
/** OK | UNDEPLOYED — UNDEPLOYED vocabularies are silently ignored by ASR. */
status?: string;
}
export interface VocabularyEnvelope<T> {
request_id?: string;
output?: T;
usage?: { count?: number };
}
export interface VocabularyRequest {
model: string;
input: Record<string, unknown>;
}
/** Shared request body builder for dry-run and live calls. */
export function buildVocabularyRequest(
action: string,
input: Record<string, unknown>,
): VocabularyRequest {
return {
model: SPEECH_BIASING_MODEL,
input: { action, ...input },
};
}
async function callVocabularyApi<T>(
client: Client,
action: string,
input: Record<string, unknown>,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<T>> {
return client.requestJson<VocabularyEnvelope<T>>({
path: speechVocabularyPath(),
method: "POST",
body: buildVocabularyRequest(action, input),
signal,
});
}
export function createVocabulary(
client: Client,
params: { targetModel: string; prefix: string; vocabulary: VocabularyEntry[] },
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_id?: string }>> {
return callVocabularyApi(
client,
"create_vocabulary",
{
target_model: params.targetModel,
prefix: params.prefix,
vocabulary: params.vocabulary,
},
signal,
);
}
export function listVocabularies(
client: Client,
params: { prefix?: string; pageIndex?: number; pageSize?: number } = {},
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_list?: VocabularyListItem[] }>> {
const input: Record<string, unknown> = {};
if (params.prefix !== undefined) input.prefix = params.prefix;
if (params.pageIndex !== undefined) input.page_index = params.pageIndex;
if (params.pageSize !== undefined) input.page_size = params.pageSize;
return callVocabularyApi(client, "list_vocabulary", input, signal);
}
export function queryVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<
VocabularyEnvelope<{
gmt_create?: string;
gmt_modified?: string;
status?: string;
target_model?: string;
vocabulary?: VocabularyEntry[];
}>
> {
return callVocabularyApi(client, "query_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
export function updateVocabulary(
client: Client,
vocabularyId: string,
vocabulary: VocabularyEntry[],
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(
client,
"update_vocabulary",
{ vocabulary_id: vocabularyId, vocabulary },
signal,
);
}
export function deleteVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(client, "delete_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
+109 -11
View File
@@ -341,30 +341,62 @@ export interface AppStreamChunk {
// ---- Memory (DashScope v2) ----
/**
* Multimodal message part. The memory API reference only documents `content`
* as `string | array` without pinning the element shape, so this mirrors the
* sibling Knowledge Chat contract (`KnowledgeChatContentPart`).
*/
export type MemoryContentPart =
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } };
export interface MemoryMessage {
role: "user" | "assistant";
content: string;
content: string | MemoryContentPart[];
}
/** 记忆抽取策略版本pro 开启 Reranklite 关闭(单价不同)。 */
export type MemoryPlanVersion = "pro" | "lite";
export interface MemoryAddRequest {
user_id: string;
messages?: MemoryMessage[];
custom_content?: string;
profile_schema?: string;
memory_library_id?: string;
/** 记忆片段规则 ID不传则用记忆库的默认规则 */
project_id?: string;
meta_data?: Record<string, unknown>;
}
/** AddMemory 返回的变更记录:一次调用可能产生多条 ADD/UPDATE/DELETE。 */
export interface MemoryChangedNode {
memory_node_id: string;
content: string;
event?: "ADD" | "UPDATE" | "DELETE";
/** 仅 event 为 UPDATE 时有效 */
old_content?: string;
}
export interface MemoryAddResponse {
request_id: string;
memory_ids?: string[];
memory_nodes?: MemoryChangedNode[];
}
export interface MemorySearchRequest {
user_id: string;
messages?: MemoryMessage[];
query?: string;
top_k?: number;
/** 最小相似度阈值,值域 [0,1] */
min_score?: number;
enable_rerank?: boolean;
/** 优先级高于 enable_rerank传了本字段时 enable_rerank 被忽略 */
plan_version?: MemoryPlanVersion;
enable_judge?: boolean;
enable_rewrite?: boolean;
memory_library_id?: string;
/** 记忆片段规则 ID 数组,可多规则混合检索 */
project_ids?: string[];
}
export interface MemoryNode {
@@ -372,8 +404,10 @@ export interface MemoryNode {
content: string;
user_id?: string;
meta_data?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
/** 秒级 Unix 时间戳 */
created_at?: number;
/** 秒级 Unix 时间戳 */
updated_at?: number;
}
export interface MemorySearchResponse {
@@ -394,20 +428,26 @@ export interface MemoryNodeUpdateRequest {
custom_content: string;
/** 非默认记忆库时必填(与控制台记忆库 ID 一致) */
memory_library_id?: string;
/** 记忆片段对应事件发生时的秒级 Unix 时间戳(默认当前时间) */
timestamp?: number;
/** 用户自定义信息(增量更新) */
meta_data?: Record<string, unknown>;
}
// ---- Memory Profile (DashScope v2) ----
export interface ProfileAttribute {
name: string;
description: string;
value?: string;
description?: string;
default_value?: string;
}
export interface ProfileSchemaCreateRequest {
name: string;
description?: string;
attributes: ProfileAttribute[];
memory_library_id?: string;
plan_version?: MemoryPlanVersion;
}
export interface ProfileSchemaCreateResponse {
@@ -415,12 +455,60 @@ export interface ProfileSchemaCreateResponse {
profile_schema_id: string;
}
export interface ProfileSchemaSummary {
profile_schema_id: string;
name: string;
description?: string;
}
export interface ProfileSchemaListResponse {
request_id: string;
profile_schemas?: ProfileSchemaSummary[];
total?: number;
}
/** GetProfileSchema属性带 attribute_id是 UpdateProfileSchema 的操作句柄 */
export interface ProfileSchemaAttribute extends ProfileAttribute {
attribute_id: string;
}
export interface ProfileSchemaDetailResponse {
request_id: string;
name?: string;
description?: string;
attributes?: ProfileSchemaAttribute[];
}
export interface ProfileSchemaAttributeOperation {
op: "add" | "update" | "delete";
/** op 为 update / delete 时必填 */
attribute_id?: string;
/** op 为 add 时必填 */
name?: string;
description?: string;
default_value?: string | null;
}
export interface ProfileSchemaUpdateRequest {
name?: string;
description?: string;
attributes_operations?: ProfileSchemaAttributeOperation[];
memory_library_id?: string;
}
/** GetUserProfile 的属性value 未提取时字段缺失 */
export interface UserProfileAttribute {
id: string;
name: string;
value?: string;
}
export interface UserProfileResponse {
request_id: string;
profile: {
schema_id: string;
user_id: string;
attributes: ProfileAttribute[];
profile?: {
schema_name?: string;
schema_description?: string;
attributes?: UserProfileAttribute[];
};
}
@@ -580,11 +668,19 @@ export interface DashScopeTTSStreamChunk {
// ---- Speech Recognition / ASR (DashScope) ----
/** Context-enhancement message for async ASR `input.context` / sync Flash `input.messages`. */
export interface AsrContextMessage {
role: "user" | "assistant";
content: Array<{ type: "input_text" | "text"; text: string }>;
}
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
/** Context enhancement for async filetrans (array of chat-style messages). */
context?: AsrContextMessage[];
};
parameters?: {
channel_id?: number[];
@@ -595,6 +691,8 @@ export interface DashScopeASRRequest {
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
/** Instant hot words (word → weight); takes effect on Qwen-Audio-3.0-ASR-Flash series. */
vocabulary?: Record<string, number>;
};
}
+3 -1
View File
@@ -287,8 +287,10 @@ export interface Command<F extends FlagsDef = FlagsDef> {
* Cross-flag validation, after parsing and before run. Return an error message
* UsageError; undefined to pass. Single-flag `required` is enforced by the
* parser use this for rules spanning flags or depending on a flag's *value*.
* May be async for read-only local preflight. Runtime awaits it before auth
* and confirmation. Do not perform remote requests or local writes here.
*/
validate?: (flags: ParsedFlags<F>) => string | undefined;
validate?: (flags: ParsedFlags<F>) => string | undefined | Promise<string | undefined>;
run: (ctx: CommandContext<F>) => Promise<void>;
}
+11
View File
@@ -49,6 +49,7 @@ export type {
ChatRequest,
ChatResponse,
ChatTool,
AsrContextMessage,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
@@ -70,17 +71,27 @@ export type {
KnowledgeSearchResponse,
MemoryAddRequest,
MemoryAddResponse,
MemoryChangedNode,
MemoryContentPart,
MemoryMessage,
MemoryNode,
MemoryNodeListResponse,
MemoryNodeUpdateRequest,
MemoryPlanVersion,
MemorySearchRequest,
MemorySearchResponse,
ProfileAttribute,
ProfileSchemaAttribute,
ProfileSchemaAttributeOperation,
ProfileSchemaCreateRequest,
ProfileSchemaCreateResponse,
ProfileSchemaDetailResponse,
ProfileSchemaListResponse,
ProfileSchemaSummary,
ProfileSchemaUpdateRequest,
StreamChoice,
StreamChunk,
UserProfileAttribute,
UserProfileResponse,
} from "./api.ts";
export type * from "./knowledge-admin.ts";
+3 -3
View File
@@ -34,7 +34,7 @@ export function resolveBooleanFlag(
return defaultWhenUnset;
}
/** Resolve `--watermark` flag; default true when unset. */
export function resolveWatermark(flagValue: unknown): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? true;
/** Resolve `--watermark`; command flag overrides config, then defaults to true. */
export function resolveWatermark(flagValue: unknown, configuredValue?: boolean): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? configuredValue ?? true;
}
+42
View File
@@ -1,5 +1,6 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
@@ -141,6 +142,47 @@ test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
});
});
test("buildAsrContextMessages wraps plain text as a single user input_text message", () => {
expect(buildAsrContextMessages("奋斗者号 鲸落")).toEqual([
{ role: "user", content: [{ type: "input_text", text: "奋斗者号 鲸落" }] },
]);
});
test("buildAsrFlashRequest injects instant vocabulary and prepends context before input_audio", () => {
const body = buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
context: "奋斗者号 鲸落",
flashFamily: "input-audio",
});
expect(body).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落" }],
},
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
},
});
const messages = (body.input as { messages: Array<{ content: Array<{ type?: string }> }> })
.messages;
expect(messages[messages.length - 1]?.content?.[0]?.type).toBe("input_audio");
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
@@ -79,6 +79,14 @@ test("default_speech_recognition_model 从配置文件进入运行时 Settings",
expect(resolve({ file }).defaultSpeechRecognitionModel).toBe("qwen-audio-3.0-asr-flash");
});
test("watermark 从配置文件进入 Settings缺省时保持合规默认值 true", () => {
expect(parseConfigFile({ watermark: false }).watermark).toBe(false);
expect(parseConfigFile({ watermark: true }).watermark).toBe(true);
expect(parseConfigFile({ watermark: "false" }).watermark).toBeUndefined();
expect(resolve({ file: { watermark: false } }).watermark).toBe(false);
expect(resolve({}).watermark).toBe(true);
});
test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" };
const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" };
+1
View File
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
+4
View File
@@ -31,6 +31,7 @@ function testDeps(identity: Partial<Identity> = {}): {
output: "json",
outputExplicit: true,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -290,6 +291,9 @@ test("resolveWatermark uses flag or defaults to true", () => {
expect(resolveWatermark("false")).toBe(false);
expect(resolveWatermark("true")).toBe(true);
expect(resolveWatermark(undefined)).toBe(true);
expect(resolveWatermark(undefined, false)).toBe(false);
expect(resolveWatermark("true", false)).toBe(true);
expect(resolveWatermark("false", true)).toBe(false);
});
test("resolveBooleanFlag uses flag or defaultWhenUnset", () => {
+1
View File
@@ -23,6 +23,7 @@ function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings
output: "json",
outputExplicit: true,
timeout: 5,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vite-plus/test";
import {
parseInstantVocabulary,
parseVocabularyEntries,
buildVocabularyRequest,
SPEECH_BIASING_MODEL,
UsageError,
} from "../src/index.ts";
describe("parseInstantVocabulary", () => {
test("接受 word→weight 对象", () => {
expect(parseInstantVocabulary('{"奋斗者":4,"鲸落":5}')).toEqual({
奋斗者: 4,
鲸落: 5,
});
});
test("拒绝数组", () => {
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(UsageError);
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(/object/i);
});
test("拒绝字符串权重", () => {
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(/must be a number/);
});
test("拒绝非有限数字权重null", () => {
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(/must be a number/);
});
test("拒绝 NaN / Infinity 字面量(非法 JSON", () => {
expect(() => parseInstantVocabulary('{"x":NaN}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":Infinity}')).toThrow(UsageError);
});
test("拒绝顶层 null", () => {
expect(() => parseInstantVocabulary("null")).toThrow(UsageError);
});
test("非法 JSON 抛 UsageError", () => {
expect(() => parseInstantVocabulary("{bad json")).toThrow(UsageError);
expect(() => parseInstantVocabulary("{bad json")).toThrow(/not valid JSON/);
});
});
describe("parseVocabularyEntries", () => {
test("对象形态转为条目数组", () => {
expect(parseVocabularyEntries('{"奋斗者":4,"鲸落":5}')).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 5 },
]);
});
test("对象形态叠加 --lang", () => {
expect(parseVocabularyEntries('{"奋斗者":4}', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态透传 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"zh"}]')).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态忽略第二参 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4 },
]);
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"en"}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "en" },
]);
});
test("数组缺 text 或 weight 拒绝", () => {
expect(() => parseVocabularyEntries('[{"weight":4}]')).toThrow(UsageError);
expect(() => parseVocabularyEntries('[{"text":"x"}]')).toThrow(UsageError);
});
test("非 object 数组元素拒绝", () => {
expect(() => parseVocabularyEntries('["x"]')).toThrow(UsageError);
expect(() => parseVocabularyEntries("[null]")).toThrow(UsageError);
});
test("空对象 / 空数组拒绝", () => {
expect(() => parseVocabularyEntries("{}")).toThrow(UsageError);
expect(() => parseVocabularyEntries("{}")).toThrow(/at least one/);
expect(() => parseVocabularyEntries("[]")).toThrow(UsageError);
expect(() => parseVocabularyEntries("[]")).toThrow(/at least one/);
});
});
describe("buildVocabularyRequest", () => {
test("固定 model 为 speech-biasing", () => {
const body = buildVocabularyRequest("create_vocabulary", {
target_model: "fun-asr",
prefix: "demo",
vocabulary: [{ text: "奋斗者", weight: 4 }],
});
expect(body.model).toBe(SPEECH_BIASING_MODEL);
expect(body.model).toBe("speech-biasing");
expect(body.input.action).toBe("create_vocabulary");
});
});
+15
View File
@@ -75,6 +75,21 @@ export function isConnectorE2EReady(): boolean {
return isKbAdminE2EReady() && process.env.BAILIAN_E2E_CONNECTOR === "1";
}
/**
* fixturememory / memory profile live
*
* API ID
* ServiceNotOpened
* API workspace BAILIAN_WORKSPACE_ID
*/
export function isMemoryE2EReady(): boolean {
return (
isDashScopeE2EReady() &&
!!process.env.BAILIAN_E2E_MEMORY_LIBRARY_ID?.trim() &&
!!process.env.BAILIAN_WORKSPACE_ID?.trim()
);
}
// ---- Long-lived knowledge fixtures (created manually in the console; the CLI
// cannot create table/image-type bases or multimodal services itself) ----
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.21.0",
"version": "1.23.0",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.21.0",
"version": "1.23.0",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1 -1
View File
@@ -217,7 +217,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
parsedFlags,
Object.keys(res.command.flags ?? {}),
) as ParsedFlags<FlagsDef>;
const invalid = res.command.validate?.(ownFlags);
const invalid = await res.command.validate?.(ownFlags);
if (invalid) throw new UsageError(invalid);
// 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。
@@ -16,6 +16,22 @@ export interface PipelineEnv {
settings: Settings;
}
/** Media steps that inherit Profile `watermark` when the YAML omits the field. */
export const PIPELINE_WATERMARK_STEPS = new Set(["image/generate", "image/edit", "video/generate"]);
/**
* Fill Profile watermark into a planned/executed step input.
* Explicit step `watermark` wins; otherwise use Settings (file default true).
*/
export function applyProfileWatermarkToStepInput(
stepType: string,
input: Record<string, unknown>,
settings: Settings,
): Record<string, unknown> {
if (!PIPELINE_WATERMARK_STEPS.has(stepType) || input.watermark !== undefined) return input;
return { ...input, watermark: settings.watermark };
}
/**
* Build the in-process env for pipeline steps. Uses the same source resolution
* as the CLI itself (env vars, config file; no CLI flags), but forces JSON
+9 -4
View File
@@ -1,5 +1,5 @@
import { PipelineError, toPipelineError } from "./errors.ts";
import { buildPipelineEnv } from "./bl-config.ts";
import { applyProfileWatermarkToStepInput, buildPipelineEnv } from "./bl-config.ts";
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import {
evaluateCondition,
@@ -156,12 +156,17 @@ async function executePipelineInternal(
if (options.dryRun) {
for (const planStep of topologicalOrder(plan)) {
const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput);
const plannedInput = applyProfileWatermarkToStepInput(
planStep.step.type,
resolved.redacted,
blEnv.settings,
);
const report: PipelineStepReport = {
id: planStep.step.id,
type: planStep.step.type,
status: "planned",
dependencies: planStep.dependencies,
input: resolved.redacted,
input: plannedInput,
...(planStep.step.when !== undefined ? { condition: "pending" } : {}),
};
reports.push(report);
@@ -170,14 +175,14 @@ async function executePipelineInternal(
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
});
await emit({
type: "step.planned",
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}),
});
}
+25 -8
View File
@@ -12,6 +12,7 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
@@ -192,7 +193,8 @@ export async function imageGenerate(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
const body: DashScopeImageRequest =
@@ -299,7 +301,8 @@ export async function imageEdit(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
let body: DashScopeImageRequest;
@@ -460,7 +463,8 @@ export async function videoGenerate(
ratio: input.ratio || undefined,
duration: input.duration,
prompt_extend: resolveBooleanFlag(input["prompt-extend"], undefined, "prompt-extend"),
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
seed: input.seed,
},
};
@@ -563,6 +567,10 @@ export interface SpeechRecognizeInput {
diarization?: boolean;
"speaker-count"?: number;
"vocabulary-id"?: string;
/** Instant hot words (already structured; no JSON string parse needed). */
vocabulary?: Record<string, number>;
/** Context enhancement plain text. */
context?: string;
"channel-id"?: number;
"poll-interval"?: number;
}
@@ -602,9 +610,11 @@ export async function speechRecognize(
const unsupportedFlags: string[] = [];
if (input.diarization) unsupportedFlags.push("diarization");
if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count");
// input-audio Flash supports vocabulary_id; qwen3 sync Flash does not
if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) {
unsupportedFlags.push("vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (route.flashFamily === "qwen3") {
if (input["vocabulary-id"] !== undefined) unsupportedFlags.push("vocabulary-id");
if (input.vocabulary !== undefined) unsupportedFlags.push("vocabulary");
if (input.context !== undefined) unsupportedFlags.push("context");
}
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
if (unsupportedFlags.length > 0) {
@@ -648,6 +658,8 @@ export async function speechRecognize(
audioUrl: fileUrls[0]!,
language: input.language,
vocabularyId: input["vocabulary-id"],
vocabulary: input.vocabulary,
context: input.context,
flashFamily,
});
const response = await env.client.requestJson<Record<string, unknown>>({
@@ -671,14 +683,19 @@ export async function speechRecognize(
);
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: fileUrls[0]! }
: { file_urls: fileUrls }),
...(input.context ? { context: buildAsrContextMessages(input.context) } : {}),
},
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
...languageFields,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
vocabulary: input.vocabulary,
},
};
stripUndefined(body.parameters as Record<string, unknown>);
@@ -1,5 +1,9 @@
import { registerStep } from "../dispatcher.ts";
import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts";
import {
applyProfileWatermarkToStepInput,
buildPipelineEnv,
type PipelineEnv,
} from "../bl-config.ts";
import { isRecord } from "../utils.ts";
import {
textChat,
@@ -153,9 +157,13 @@ async function executeDirectBlStep(
input: Record<string, unknown>,
ctx: StepContext,
): Promise<StepResult> {
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
if (ctx.dryRun) {
// Surface the Profile-effective watermark in dry-run so plans match runtime.
const plannedInput = applyProfileWatermarkToStepInput(id, input, env.settings);
return {
metadata: { dryRun: true, step: id, plannedInput: input },
metadata: { dryRun: true, step: id, plannedInput },
warnings: [
{ code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` },
],
@@ -167,7 +175,6 @@ async function executeDirectBlStep(
throw new Error(`No direct API handler registered for step: ${id}`);
}
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
const data = await handler(env, input, ctx);
const builder = RESULT_BUILDERS[id];
if (builder) {
@@ -1,8 +1,9 @@
/** Shared --foo <bool> help text; keep wording consistent with actual CLI/request behavior. */
export const BOOL_FLAG_WATERMARK = {
"en-US": "Enable watermark (true/false). Omit flag to use CLI default (true).",
"zh-CN": "是否启用水印true/false。不传时使用 CLI 默认值 true",
"en-US":
"Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true).",
"zh-CN": "是否启用水印true/false。不传时使用 Profile 配置或 CLI 默认值 true。",
};
/** CLI sends prompt_extend=true when flag omitted (qwen-image edit, etc.). */
@@ -68,6 +68,81 @@ test("pipeline speechRecognize routes input-audio flash to sync multimodal endpo
});
});
test("pipeline speechRecognize injects vocabulary and context on sync input-audio", async () => {
const { env, captured } = makeEnv();
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash",
vocabulary: { 奋斗者: 4 },
context: "奋斗者号",
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
parameters: { vocabulary: { 奋斗者: 4 } },
input: {
messages: [
{ role: "user", content: [{ type: "input_text", text: "奋斗者号" }] },
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
});
});
test("pipeline speechRecognize injects vocabulary and context on async filetrans", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
request_id: "r1",
};
});
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash-filetrans",
vocabulary: { 鲸落: 4 },
context: "鲸落 深海勇士",
"poll-interval": 0,
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
input: {
file_urls: ["https://example.com/a.wav"],
context: [{ role: "user", content: [{ type: "input_text", text: "鲸落 深海勇士" }] }],
},
parameters: { vocabulary: { 鲸落: 4 } },
});
});
test("pipeline speechRecognize rejects vocabulary on qwen3 sync flash", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash",
vocabulary: { 奋斗者: 4 },
},
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
@@ -0,0 +1,129 @@
import { expect, test } from "vite-plus/test";
import type { Client } from "bailian-cli-core";
import { applyProfileWatermarkToStepInput, type PipelineEnv } from "../src/pipeline/bl-config.ts";
import { imageEdit, imageGenerate, videoGenerate } from "../src/pipeline/steps/bl-api.ts";
import type { StepContext } from "../src/pipeline/types.ts";
type CapturedRequest = {
path?: string;
method?: string;
body?: {
parameters?: { watermark?: boolean };
};
async?: boolean;
};
function makeEnv(watermark: boolean): {
env: PipelineEnv;
captured: CapturedRequest[];
} {
const captured: CapturedRequest[] = [];
const client = {
uploadFile: async (source: string) => source,
requestJson: async (opts: CapturedRequest) => {
captured.push(opts);
if (opts.async) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
// Sync image response shape (qwen-image / wan2.7-image).
return {
request_id: "req-wm",
output: {
choices: [{ message: { content: [{ image: "https://example.com/out.png" }] } }],
},
};
},
} as unknown as Client;
return {
env: {
client,
settings: {
quiet: true,
output: "json",
outputExplicit: true,
timeout: 300,
watermark,
verbose: false,
dryRun: false,
telemetry: false,
} as PipelineEnv["settings"],
},
captured,
};
}
function makeCtx(): StepContext {
return { dryRun: false, signal: new AbortController().signal, timeoutSeconds: 1 };
}
test("pipeline imageGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline imageGenerate keeps step watermark over Profile", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat", watermark: true }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("pipeline imageEdit inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageEdit(env, { prompt: "Blue sky", image: "https://example.com/in.png" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline videoGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
// First call submits async task; pollTaskWithOptions will keep polling — mock SUCCEEDED quickly.
const client = env.client as unknown as {
requestJson: (opts: CapturedRequest) => Promise<unknown>;
};
let calls = 0;
client.requestJson = async (opts: CapturedRequest) => {
captured.push(opts);
calls += 1;
if (calls === 1) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
return {
output: {
task_id: "task-wm",
task_status: "SUCCEEDED",
video_url: "https://example.com/out.mp4",
},
};
};
await videoGenerate(
env,
{ prompt: "A cat walks", "poll-interval": 0 },
{ ...makeCtx(), timeoutSeconds: 5 },
);
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline defaults watermark to true when Profile leaves the compliance default", async () => {
const { env, captured } = makeEnv(true);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("applyProfileWatermarkToStepInput fills omitted watermark from Settings", () => {
const settings = { watermark: false } as PipelineEnv["settings"];
expect(
applyProfileWatermarkToStepInput("image/generate", { prompt: "A cat" }, settings).watermark,
).toBe(false);
expect(
applyProfileWatermarkToStepInput(
"image/generate",
{ prompt: "A cat", watermark: true },
settings,
).watermark,
).toBe(true);
expect(
applyProfileWatermarkToStepInput("text/chat", { message: "hi" }, settings).watermark,
).toBeUndefined();
});
+5 -5
View File
@@ -189,8 +189,8 @@ importers:
packages/commands:
dependencies:
'@openagentpack/sdk':
specifier: 0.7.0
version: 0.7.0
specifier: 0.7.1
version: 0.7.1
bailian-cli-core:
specifier: workspace:*
version: link:../core
@@ -1124,8 +1124,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@openagentpack/sdk@0.7.0':
resolution: {integrity: sha512-MjyKIFvPi4aT1TIcvGUB0bnD4Tly9aMCncjI9+EYG6CPWpuGO4DGj+4zlfBCx1fvtD1G1w4Zi0m8A8ZodqkK9Q==}
'@openagentpack/sdk@0.7.1':
resolution: {integrity: sha512-OENks8UdfWFanvhJIVbqyUX1+XGavHnrxDiFx4SoJoGRXi45cOgyYtWWbG5VdDI2I5Dpo9eGNaw/JtDnkdxUcg==}
engines: {node: '>=18.17.0'}
'@oxc-project/runtime@0.129.0':
@@ -4287,7 +4287,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@openagentpack/sdk@0.7.0':
'@openagentpack/sdk@0.7.1':
dependencies:
jszip: 3.10.1
yaml: 2.9.0

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