mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75699966d4 | |||
| 2220f932b5 | |||
| d9134d6f0a | |||
| 5c390fad74 | |||
| 6218637f7c | |||
| 0c7ba721a7 | |||
| 0dbf071368 | |||
| 75a7100702 | |||
| 16e30686f0 | |||
| 7803d91e3f | |||
| c2d17707c0 | |||
| 1dfb4400cc | |||
| fd2077b373 | |||
| 2090293f85 | |||
| a245359792 | |||
| 19bd5a5ea8 | |||
| e262f2e574 | |||
| 180aac9f28 | |||
| 3477a08dad | |||
| aa1484264e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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." |
|
||||
|
||||
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
@@ -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`;记忆服务需账号单独开通) |
|
||||
|
||||
## 用例类型
|
||||
|
||||
|
||||
@@ -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. 鉴权 / 全局选项
|
||||
|
||||
|
||||
@@ -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." |
|
||||
|
||||
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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":
|
||||
"每个属性必须有 name,description 与 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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/ru;Fun-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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 vocabulary:help / 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 vocabulary(DashScope 媒体)",
|
||||
() => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 word→weight 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 word→weight 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;
|
||||
}
|
||||
@@ -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
@@ -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 开启 Rerank,lite 关闭(单价不同)。 */
|
||||
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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
watermark: true,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,21 @@ export function isConnectorE2EReady(): boolean {
|
||||
return isKbAdminE2EReady() && process.env.BAILIAN_E2E_CONNECTOR === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆库 fixture(memory / 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,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,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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+5
-5
@@ -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
Reference in New Issue
Block a user