Remove deprecated tools and related configurations

- Deleted the `tool-image`, `tool-managed-agent`, `tool-vision`, and `web-search-rag` modules from the codebase.
- Updated the Vite configuration to remove entries for the deleted tools and added a new entry for `tokenplan-usage`.
- Adjusted tests to reflect the removal of TokenPlan key handling logic from the now-deleted tools.
This commit is contained in:
lisheng.lisheng
2026-08-16 21:18:59 +08:00
parent 9ba4a9d1a3
commit 186c500ca4
13 changed files with 2752 additions and 1314 deletions
+128 -178
View File
@@ -2,82 +2,43 @@
把阿里云百炼(Model Studio)的能力接入 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的 profile bundle。
一个包提供 5 个插件行,外加对 base bundle 的 `llm-pi-ai` 行做一次配置覆盖:
本包提供两项能力:
| row id | 能力 | 默认 | 依赖 |
| ---------------------------- | --------------------------------------------------------------- | ---- | --------------------- |
| `llm-pi-ai`(覆盖 base 行) | 把百炼 TokenPlan 网关注册成 LLM provider(`bailian-tokenplan`) | 启用 | TokenPlan Key |
| `bailian-tool-vision` | `bailian_vision_describe`:图片/视频理解 | 启用 | `bl` |
| `bailian-tool-image` | `bailian_image_generate`:文生图 | 启用 | `bl` |
| `bailian-tool-managed-agent` | `bailian_run_remote_task`:按需在云端创建 agent 并跑任务 | 启用 | `bl` + 按量付费 Key |
| `bailian-web-search-rag` | 百炼知识库检索,注册为 `web_search` 的后端 | 停用 | 按量付费 Key + 知识库 |
| `bailian-memory` | 跨会话长期记忆(tools + 自动检索/落库) | 停用 | 按量付费 Key |
`web-search-rag` 与 `memory` 默认停用是有意的:它们要么需要部署方特有的资源 ID,要么按次计费,不该在用户没配置时就生效。`tool-managed-agent` 默认启用——它加载时不建任何资源,只有模型真正调用时才在云端创建 agent。
| 能力 | 说明 |
| ------------------ | ------------------------------------------------------------------------------------------------------ |
| **Bailian 设置页** | 通用的百炼凭证配置(AK/SK 存入 `dsh` bl profile + DashScope API Key)+ TokenPlan 用量展示 + 记忆库配置 |
| **跨会话长期记忆** | 自动检索注入 + 自动落库,模型可主动 search/add/list。按量计费,默认停用 |
---
## 1. 前置条件
- Node ≥ 22.19(`dsh` 的要求)
- `bl`(vision / image / 远程任务 三个工具通过子进程调它)
- `bl`(用量展示通过子进程调用 `bl console call`)
```sh
npm install -g bailian-cli
```
- 百炼 API Key。**注意有两类且不可混用**:
- **阿里云 AK/SK**(AccessKey ID + AccessKey Secret)—— 用于控制台鉴权,查询用量信息。在 webui 设置页填入即可,无需环境变量。
- **DashScope API Key**(`sk-` 前缀,按量付费)—— 用于记忆库等 DashScope API 调用。在设置页「凭证配置」填入,与 AK/SK 并列为通用凭证。
| 类型 | 前缀 | 能访问 | 不能访问 |
| --------- | -------- | ----------------------------------------------------------- | ------------------------ |
| TokenPlan | `sk-sp-` | TokenPlan 网关(LLM / vision / 文生图) | 记忆库、知识库、远程任务 |
| 按量付费 | `sk-ws-` | 记忆库、知识库、远程任务(agentstudio)、DashScope 全量接口 | TokenPlan 网关 |
两者互相返回 `401 InvalidApiKey`,所以本包用**两个不同的环境变量**,不会互相踩:
```sh
export BAILIAN_TOKENPLAN_API_KEY=sk-sp-xxx # 只给 bailian-tokenplan provider
export DASHSCOPE_API_KEY=sk-ws-xxx # 给 bl、memory、RAG、远程任务
```
只有一类 Key 也能用,只是能力范围相应缩小。若只有 TokenPlan Key:
```sh
export BAILIAN_TOKENPLAN_API_KEY=sk-sp-xxx
export DASHSCOPE_API_KEY=sk-sp-xxx
export DASHSCOPE_BASE_URL=https://token-plan.cn-beijing.maas.aliyuncs.com
```
这样 LLM / vision / 文生图可用(后两者经 `bl` 走 TokenPlan 网关);memory 与 RAG 保持停用即可。**远程任务仍可注册**,但它的凭证解析会看出这是 TokenPlan Key / 网关,调用 `bailian_run_remote_task` 时直接给出带修复指引的报错,而不是以前的 `Bailian API 404`。
**按量付费 Key 的解析顺序**(memory / RAG / 远程任务三处一致):行内 `config.apiKey` → `$DASHSCOPE_API_KEY`。
**远程任务的端点**另有讲究:managed-agent(agentstudio)API **只**在工作空间前缀主机上提供——`https://{workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`(普通 dashscope 主机与 TokenPlan 网关都 404),且 Key 只能访问**自己归属的工作空间**(不匹配时 403 `Endpoint.AccessDenied`)。端点解析顺序:行内 `baseUrl` → `$DASHSCOPE_BASE_URL` → 行内 `workspaceId` → `$BAILIAN_WORKSPACE_ID`(后两者自动拼成工作空间主机)。workspace ID 在百炼控制台右上角的工作空间下拉里看。
memory / RAG 是显式开启的插件,Key 缺失或误填 `sk-sp-` 会在启动期报错;远程任务默认启用,为避免拖垮 TokenPlan-only 环境,改为调用时报错。
获取方式:[阿里云控制台 → AccessKey 管理](https://ram.console.aliyun.com/manage/ak)
---
## 2. 安装到 `web` profile
`npx @deepseek-ai/dsh web` 是 `dsh --profile web` 的别名,所以要装进**名为 `web` 的 profile**,配置目录是 `~/.dsh/profiles/web/`(`$DSH_HOME` 可覆盖)。
本包尚未发布到 npm,先在本仓库打包:
`npx @deepseek-ai/dsh web` 是 `dsh --profile web` 的别名,配置目录是 `~/.dsh/profiles/web/`。
```sh
pnpm -F bailian-cli-dsh build
cd packages/dsh && pnpm pack # 产出 bailian-cli-dsh-<version>.tgz
cd packages/dsh && pnpm pack
npx @deepseek-ai/dsh plugin --profile web add /absolute/path/to/bailian-cli-dsh-<version>.tgz
```
装入 profile(`dsh plugin` 是 pnpm 的转发器,接受本地路径 / tarball / npm 包名 / git):
```sh
npx @deepseek-ai/dsh plugin --profile web add /absolute/path/to/bailian-cli-dsh-1.14.2.tgz
```
因为 `package.json` 声明了 `dsh.bundle`,安装后会自动加入该 profile 的 bundle 层,无需手动改 `cordis.patch.yml`。
确认 5 个插入行都在,且 TokenPlan provider 已配到 `llm-pi-ai` 上:
确认 TokenPlan provider 和用量展示行都在:
```sh
npx @deepseek-ai/dsh --profile web --dump-config | grep -E 'bailian|tokenplan'
@@ -91,168 +52,130 @@ npx @deepseek-ai/dsh web
Web UI 在 http://127.0.0.1:3080。
> 发布到 npm 后直接 `npx @deepseek-ai/dsh plugin --profile web add bailian-cli-dsh`,跳过打包步骤。
---
## 3. Bailian 设置页
安装后,在 Web UI 左下角 **Settings** 面板会出现 **"Bailian"** 页面。
### 凭证配置(通用)
1. 在「凭证配置」区填入 **AccessKey ID** 和 **AccessKey Secret**
2. 点击 **「保存凭证」**
Host 会执行 `bl auth login --open-api --config dsh`,将 AK/SK 和新生成的 access_token 存入 bl 的 `dsh` 专属 profile。**所有后续百炼插件共用此凭证**,无需重复配置。
### TokenPlan 用量
1. 选择区域和站点
2. 点击 **「查询用量」**
Host 执行 `bl console call --config dsh` 调用 3 个个人版控制台接口,返回:
- **用量百分比** —— 5 小时窗口 / 1 周窗口的用量百分比和重置时间
- **套餐信息** —— 套餐类型(基础版/标准版/高级版)、状态、剩余天数、到期时间、自动续费
- **额外用量包** —— Credits 总量、剩余量、生效中数量
### 凭证解析优先级
凭证保存到 bl 的 `dsh` profile 后,所有百炼插件通过 `--config dsh` 读取。行内 config 的 `accessKeyId`/`accessKeySecret` 作为兜底(未通过 UI 保存时自动使用)。
### 行内配置(可选)
如果不想在 UI 里每次输入,可以在 profile 的 `cordis.patch.yml` 里固化凭证:
```yaml
- id: bailian-tokenplan-usage
config:
# accessKeyId / accessKeySecret: 兜底凭证(未通过 UI 保存时使用)
# consoleRegion: cn-beijing
# consoleSite: domestic
# profile: dsh # 默认用 dsh 专属 profile
```
配置后 UI 表单会留空,但点击「查询用量」会使用行内凭证。
---
## 3. 开箱能用的部分
## 4. 跨会话长期记忆
装完不做任何配置就生效:
默认停用(按量计费)。在 `cordis.patch.yml` 中设 `disabled: false` 启用,然后在设置页配置 API Key 和参数。
**LLM provider** — 模型选择器里出现 `bailian-tokenplan`,可选模型(已逐个实测):
### 功能
| 模型 | 读图 |
| ------------------------ | ------------------ |
| `qwen3.8-max` | 是 |
| `qwen3.7-plus` | 是 |
| `qwen3.6-flash` | 是 |
| `glm-5.2` | 是 |
| `qwen3.7-max` | 否(传图直接 400) |
| `deepseek-v4-pro` | 否 |
| `deepseek-v4-flash-0731` | 否 |
- **自动检索注入**:新会话首轮,用用户消息搜索记忆,将结果注入上下文(`autoInject`,默认开启)
- **自动落库**:每轮结束,将该轮新消息发送到记忆库 add API(`autoPersist`,默认开启)
- **模型工具**:`bailian_memory_search`(检索)、`bailian_memory_add`(存储)、`bailian_memory_list`(浏览)
**三个工具** — `bailian_vision_describe`、`bailian_image_generate`、`bailian_run_remote_task`。
### 触发机制
前两个走 TokenPlan(vision/image)。`bailian_run_remote_task` 见 [§3.1](#31-远程任务-bailian_run_remote_task)——它默认启用但用的是**按量付费 Key + dashscope 端点**,与 TokenPlan 那两个不同。
| 时机 | 触发方式 |
| ---------- | --------------------------------------------------------- |
| 新会话首轮 | 自动检索记忆注入上下文(`agent/pre-step` 事件) |
| 对话中 | 模型主动调用 `bailian_memory_search`/`bailian_memory_add` |
| 轮次结束 | 自动落库新消息(`agent/turn-stopping` 事件) |
### 关于看图,有个坑值得知道
### 凭证与配置
dsh 会在两处**提前**拦截图片:Web UI 粘图前会查当前模型的输入模态,`read_image` 也有同样的门禁。所以主模型选 DeepSeek 时,图片根本进不到对话里。
- **API Key**:DashScope 按量付费 Key(`sk-`),在设置页「凭证配置」填入
- **Base URL**:默认 `https://dashscope.aliyuncs.com/api/v2/apps/memory/`
- **User ID**:记忆归属 ID,默认读系统用户名
- **Plan Version**:`lite`(便宜,关闭 rerank)或 `pro`(开启 rerank,约 50 倍成本)。注意:实际计费由 `enable_rerank` 控制
- **Top K**:检索返回数量(1-100,默认 10)
- **Memory Library ID**:记忆库 ID,留空用默认
- 主模型选 `qwen3.8-max` 等标着"是"的 → 直接粘图,原生看图,不需要任何工具
- 主模型选 DeepSeek → 让它调 `bailian_vision_describe`,工具返回**文字描述**,绕过模态门禁
### 计费
DeepSeek 那两个模型在 TokenPlan 网关上传图**不报错但也看不见**(实测会回答 "None"),所以本包坚决没给它们声明 `input: [image]`——否则会从"明确拒绝"退化成"静默失明",更难排查。
- Add:120 QPM
- Search:300 QPM(Lite ¥0.00002/次,Pro ¥0.001/次)
- 总计不超过 3000 QPM
`bailian_image_generate` 同理:模型能看图时返回内联图片,不能看图时降级为返回落盘路径,你可以接着用 vision 工具读它。文件不会被删除,正是为了这个衔接。
### 3.1 远程任务(`bailian_run_remote_task`)
把一个任务甩到百炼云端的托管 agent 上跑,不占本地会话。**无需预先写 `agents.yaml` 或 `apply`**:工具首次被调用时,`bl managed-agent run` 会在你的账号里幂等创建一个 agent + cloud environment,之后复用。
- 模型自己按用户意图填 `instructions`(远程 agent 的角色),`task` 是要它做的事。例如你说「在云端帮我审计这个依赖树,它该懂安全」→ 模型调 `bailian_run_remote_task(task="审计依赖树", instructions="你是安全专家")`。
- **前提**:这条路走的是 managed-agent(agentstudio)服务,需要**按量付费 Key**(`sk-ws-`)+ **工作空间端点**,且账号已开通 managed-agent。TokenPlan Key 不适用。
- **凭证解析**:Key 为 `config.apiKey` → `$DASHSCOPE_API_KEY`;端点为 `config.baseUrl` → `$DASHSCOPE_BASE_URL` → `config.workspaceId` → `$BAILIAN_WORKSPACE_ID`(后两者自动拼成 `https://{workspaceId}.cn-beijing.maas.aliyuncs.com`)。凡是解析出来的,都会显式下发给 `bl`,不会落到 `bl` 活动 config profile 的端点上——这正是旧版 `Bailian API 404` 的根因:agentstudio **只**在工作空间前缀主机上提供,TokenPlan 网关与普通 dashscope 主机都 404。
- **两个高频报错**:`404`=端点不是工作空间主机;`403 Endpoint.AccessDenied`=主机对了但这个 Key 不属于该工作空间。二者都会附带具体修复指引。Key 归属的工作空间在百炼控制台右上角下拉里看。
- 首次会创建云资源(可能计费、启动有延迟);同名 agent 后续复用。默认 agent 名 `dsh-remote-runner`,可在配置里改。
需要非默认的 agent 名 / 模型 / 凭证时:
```yaml
- id: bailian-tool-managed-agent
config:
agent: my-runner
model: qwen3.8-max
timeoutMs: 600000
# 可选凭证(省略则按上面的解析顺序找):
# apiKey: sk-ws-xxxxxxxx
# workspaceId: llm-xxxxxxxx # 推荐:自动拼成工作空间端点
# baseUrl: https://llm-xxxxxxxx.cn-beijing.maas.aliyuncs.com # 或用完整端点
```
---
## 4. 开启可选插件
用户层配置写在 `~/.dsh/profiles/web/cordis.patch.yml`,按 row `id` 覆盖 bundle 的默认值。
> **一个必须记住的语义**:patch 是按 row **整体替换 `config`**,不是深合并。所以覆盖一行时要把该行完整的 config 重写一遍。
### 知识库检索(RAG)
注册 id 为 `bailian-kb` 的搜索后端,模型用它熟悉的 `web_search` 就能检索私域文档。
```yaml
- id: bailian-web-search-rag
disabled: false
config:
workspaceId: llm-xxxxxxxx # 百炼控制台工作空间 ID
agentId: aid-xxxxxxxx # 知识库"检索服务"ID
maxResults: 10
# apiKey 省略则读 $DASHSCOPE_API_KEY(须为按量付费 sk-ws-;误填 sk-sp- 会在启动期报错)
```
一个实例对一个知识库(`WebSearchRequest` 只带 `query` / `maxResults`,agentId 只能来自配置)。要多个知识库就插多行不同 `id`。
**如果 profile 里还有别的搜索 provider**(base bundle 默认带 `web-search-deepseek`),必须显式指定用哪个,否则 dsh 报 `WEB_PROVIDER_AMBIGUOUS`:
```yaml
- id: web
config:
searchProvider: bailian-kb
```
### 长期记忆
dsh 自身没有跨会话记忆(`ctx.compaction` 只在单会话内压缩上下文)。开启后:两个工具 `bailian_memory_search` / `bailian_memory_add`,加上每个会话首轮自动检索注入、每轮结束自动落库。
### 启用
```yaml
- id: bailian-memory
disabled: false
config:
userId: your-name # 省略则读 $BAILIAN_MEMORY_USER_ID,再退到系统用户名
planVersion: lite
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
injectEveryTurn: false # 开启会变成每轮一次检索,成本相应上升
autoPersist: true
```
**费用**:记忆库自 2026-08-20 起商业化,add 与 search 按次计费,pro 档约为 lite 档的 50 倍。
启用后在设置页「记忆库」section 配置 API Key 和参数。
实测发现一个与文档不符的地方:单独传 `plan_version: lite` 会被服务端忽略、仍按 pro 计费,真正生效的开关是 `enable_rerank: false`。本插件已按此处理——`planVersion: lite`(默认)会同时下发 `enable_rerank: false`,所以默认就是便宜的那档。
不想要自动行为、只保留手动工具:
```yaml
- id: bailian-memory
disabled: false
config:
userId: your-name
autoInject: false
autoPersist: false
```
> 远程任务(`bailian_run_remote_task`)默认启用,配置见 [§3.1](#31-远程任务-bailian_run_remote_task)。
---
> 记忆库调用 DashScope memory v2 API(非 `bl memory`),因为 v2 API 暴露了 `min_score`、`enable_rerank`、`plan_version`、`memory_library_id` 等参数 `bl memory` 不支持。
## 5. 验证
```sh
# 配置是否被正确合成(改完 patch 后先看这个)
npx @deepseek-ai/dsh --profile web --dump-config | grep -A5 bailian-memory
# 配置合成
npx @deepseek-ai/dsh --profile web --dump-config | grep bailian
# bl 是否就绪
# bl 就绪
bl auth status
```
启动后逐项试:
启动后验证:
- **LLM**:切到 `bailian-tokenplan / qwen3.8-max`,随便发一句
- **原生看图**:同上模型,直接粘一张图提问
- **间接看图**:切到 `deepseek-v4-pro`,让它用 `bailian_vision_describe` 读同一张图
- **文生图**:让模型生成一张图
- **RAG**:问一个只有知识库里才有答案的问题
- **记忆**:会话 A 告诉它一个事实 → 关掉 → 新开会话 B 提问,看是否命中
- **远程任务**:说「在云端帮我跑一个任务:<something>,它该擅长 <role>」→ 确认模型调用 `bailian_run_remote_task`(`instructions` 由模型按 role 填)→ 首次触发云端创建 → 返回远程会话结果(需按量付费 Key + 已开通 agentstudio)
- **LLM**:切到 `bailian-tokenplan / qwen3.8-max`,发一句消息
- **凭证配置**:打开 Settings → Bailian → 填入 AK/SK → 保存凭证
- **用量展示**:同页面选择区域 → 查询用量
---
## 6. 常见问题
| 现象 | 原因 |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| LLM 路由 `401 InvalidApiKey` | `BAILIAN_TOKENPLAN_API_KEY` 没设,或误填了 `sk-ws-` 的按量付费 Key |
| memory / RAG 启动期报 TokenPlan Key | `DASHSCOPE_API_KEY` / `apiKey` 误填了 `sk-sp-` 的 TokenPlan Key |
| 远程任务 `Bailian API 404` | 端点不是工作空间前缀主机(TokenPlan 网关 / 普通 dashscope 主机都不提供 agentstudio);给该行配 `workspaceId`(或 `baseUrl`),或导出 `BAILIAN_WORKSPACE_ID` / `DASHSCOPE_BASE_URL` |
| 远程任务 `403 Endpoint.AccessDenied` | 主机是工作空间主机,但这个 Key 不属于该工作空间;换成 Key 归属工作空间的 ID(控制台右上角下拉),或用属于该工作空间的 Key |
| 远程任务调用即报 TokenPlan 提示 | `$DASHSCOPE_API_KEY` 是 `sk-sp-`;换按量付费 Key 或在行内配 `apiKey` |
| `WEB_PROVIDER_AMBIGUOUS` | 有多个搜索 provider,需在 `web` 行 pin `searchProvider` |
| 粘图报 `MODEL_DOES_NOT_SUPPORT_IMAGES` | 当前模型不支持图片输入,换成上表标"是"的,或改用 vision 工具 |
| 工具报找不到 `bl` | `bl` 不在 PATH:`npm install -g bailian-cli` |
| 改了 patch 但没生效 | `config` 是整体替换,检查是否漏写了原有字段;再用 `--dump-config` 确认 |
| `memoryLibraryId does not exist` | 记忆库 ID 属于另一个账号,与当前 Key 不匹配 |
| 现象 | 原因 |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| LLM 路由 `401 InvalidApiKey` | `BAILIAN_TOKENPLAN_API_KEY` 没设,或误填了 `sk-ws-` 的按量付费 Key |
| 用量查询报 `bl auth login failed` | AK/SK 无效或无权限;确认 AK 有百炼控制台访问权限 |
| 用量查询报 `NotLogined` 或 token 过期 | bl 的 access token 已过期;Host 会自动通过 AK/SK 刷新,确认 AK/SK 正确 |
| 用量查询报 `bl console call failed` | 控制台接口调用失败;检查 region/site 是否匹配你的账号 |
| 用量查询报 `Workspace.NotAuthorised` | bl 用了其他 profile 的旧 access_token;Host 默认用 `--config dsh` 专属 profile 隔离,首次 login 会生成新 token |
| 工具报找不到 `bl` | `bl` 不在 PATH:`npm install -g bailian-cli` |
| 设置页看不到 Bailian | 确认 bundle 已装入 web profile,且 `dsh.client` 声明在 package.json 中 |
---
@@ -262,4 +185,31 @@ bl auth status
npx @deepseek-ai/dsh plugin --profile web remove bailian-cli-dsh
```
移除后 bundle 层会自动从 `dsh.profile.bundles` 摘掉;`~/.dsh/profiles/web/cordis.patch.yml` 里你手写的覆盖行需要自己清理。
---
## 架构说明
### Host 半(`src/tokenplan-usage/index.ts`)
- `inject: ['subprocess']` —— 通过 subprocess 服务调用 `bl`
- 所有 bl 命令都带 `--config dsh`,使用专属 profile 隔离凭证
- 两个 webServer 路由:
- `POST /api/bailian/credentials` — 保存 AK/SK(`bl auth login --open-api --config dsh`,生成新 token)
- `POST /api/bailian/tokenplan/usage` — 查询用量(`bl console call --config dsh`,3 个个人版接口)
调用链路:**AK/SK → `bl auth login --open-api --config dsh`(存入 dsh profile)→ `bl console call --config dsh`(读 dsh profile token → 控制台网关)→ 个人版 TokenPlan 接口**
### Client 半(`src/tokenplan-usage/client.ts`)
- 声明 `dsh.client: { platform: "web" }`,被 `client-modules` 扫描并加载
- 注册 `settings.section`(id: `bailian`,label: `Bailian`),渲染通用百炼设置页
- 两个区块:凭证配置(POST /api/bailian/credentials)+ TokenPlan 用量(POST /api/bailian/tokenplan/usage)
- 后续百炼插件可在同一设置页新增区块,共用已保存的凭证
### 共享模块(`src/shared/`)
- `bl.ts` —— `bl` 子进程调用封装(env 转发、stdout/stderr 收集、JSON 解析)
- `credentials.ts` —— TokenPlan / 按量付费 Key 分类工具
- `http.ts` —— DashScope HTTP 客户端
这些模块来自早期版本(vision / image / managed-agent / RAG / memory 工具),已移除工具实现但保留共享逻辑作为参考。
+932
View File
@@ -0,0 +1,932 @@
window.__ModuleLoader__.load({
id: "bailian-cli-dsh",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
var React = require("react");
function insertStyles(css) {
if (typeof document === "undefined") return function () {};
var tag = document.createElement("style");
tag.dataset.plugin = "bailian-cli-dsh";
tag.textContent = css;
document.head.appendChild(tag);
return function () {
if (tag.parentNode) tag.parentNode.removeChild(tag);
};
}
var styles = { insert: insertStyles };
function jfetch(url, opts) {
return fetch(url, opts).then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error((d && d.error) || "HTTP " + r.status);
return d;
});
});
}
styles.insert(
".bl-wrap{padding:20px;max-width:640px}" +
".bl-title{font-size:16px;font-weight:600;margin:0 0 4px;color:var(--dsw-text,#18181b)}" +
".bl-sub{font-size:12px;color:var(--dsw-text-secondary,#71717a);margin:0 0 20px}" +
".bl-section{border:1px solid var(--dsw-border,#e4e4e7);border-radius:8px;padding:16px;margin-bottom:16px}" +
".bl-section-title{font-size:14px;font-weight:600;margin:0 0 12px;color:var(--dsw-text,#18181b)}" +
".bl-field{margin-bottom:12px}" +
".bl-label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--dsw-text,#18181b)}" +
".bl-input{width:100%;padding:7px 10px;border:1px solid var(--dsw-border,#e4e4e7);border-radius:6px;background:var(--dsw-input,#fff);color:var(--dsw-text,#18181b);font-size:13px;box-sizing:border-box}" +
".bl-row{display:flex;gap:12px;align-items:flex-end}" +
".bl-row>.bl-field{flex:1;margin-bottom:0}" +
".bl-btn{padding:7px 18px;border:none;border-radius:6px;background:var(--dsw-accent,#2563eb);color:#fff;cursor:pointer;font-size:13px;font-weight:500;white-space:nowrap}" +
".bl-btn:disabled{opacity:.4;cursor:not-allowed}" +
".bl-card{border:1px solid var(--dsw-border,#e4e4e7);border-radius:8px;padding:14px;margin-top:12px}" +
".bl-card-title{font-size:13px;font-weight:600;margin:0 0 10px;color:var(--dsw-text,#18181b)}" +
".bl-stat{display:flex;justify-content:space-between;align-items:center;padding:5px 0}" +
".bl-stat-label{font-size:13px;color:var(--dsw-text-secondary,#71717a)}" +
".bl-stat-val{font-size:13px;font-weight:600;color:var(--dsw-text,#18181b)}" +
".bl-bar{width:100%;height:6px;background:var(--dsw-surface-hover,#f4f4f5);border-radius:3px;overflow:hidden;margin-top:4px}" +
".bl-bar-fill{height:100%;border-radius:3px}" +
".bl-msg{font-size:13px;margin-top:8px;padding:8px 12px;border-radius:6px}" +
".bl-msg-ok{color:#16a34a;background:#dcfce7}" +
".bl-msg-err{color:var(--dsw-danger,#e5484d);background:var(--dsw-danger-bg,#fef2f2)}" +
".bl-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}" +
".bw-wrap{padding:8px 4px 20px;max-width:1100px;margin:0 auto;width:100%}" +
".bw-head{display:flex;gap:14px;align-items:flex-start;margin-bottom:18px}" +
".bw-logo{width:44px;height:44px;border-radius:12px;background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;flex:none}" +
".bw-title{font-size:20px;font-weight:700;color:var(--dsw-text,#18181b);margin-bottom:4px}" +
".bw-desc{font-size:13px;color:var(--dsw-text-secondary,#71717a);line-height:1.6;max-width:640px}" +
".bw-tabs{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px}" +
".bw-tab{padding:8px 18px;border-radius:999px;border:1px solid var(--dsw-border,#e4e4e7);background:var(--dsw-input,#fff);color:var(--dsw-text-secondary,#71717a);font-size:13px;cursor:pointer}" +
".bw-tab-active{background:#6366f1;border-color:#6366f1;color:#fff}" +
".bw-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}" +
".bw-card{border:1px solid var(--dsw-border,#e4e4e7);border-radius:12px;padding:18px;background:var(--dsw-input,#fff)}" +
".bw-card-title{font-size:15px;font-weight:600;color:var(--dsw-text,#18181b);margin-bottom:8px}" +
".bw-card-desc{font-size:13px;color:var(--dsw-text-secondary,#71717a);line-height:1.6}",
);
function fmtPct(v) {
if (v === undefined || v === null) return "—";
var p = (typeof v === "number" ? v : Number(v)) * 100;
return (isNaN(p) ? 0 : p).toFixed(1) + "%";
}
function barColor(p) {
var n = typeof p === "number" ? p : Number(p);
if (isNaN(n)) n = 0;
if (n >= 0.9) return "#ef4444";
if (n >= 0.7) return "#f59e0b";
return "#22c55e";
}
function fmtTime(t) {
return t ? new Date(t).toLocaleString() : "—";
}
function fmtDays(d) {
return d === undefined || d === null ? "—" : d + " 天";
}
var WELCOME_TABS = [
{
id: "rec",
label: "为我推荐",
cards: [
{
title: "免费额度一键防护",
desc: "一键开启「用完即停」,免费额度耗尽自动停止调用,不再产生意外扣费",
},
{
title: "API Key 配置诊断",
desc: "自动排查 401 报错与配置问题,直接给出可用的正确配置",
},
{
title: "账单消费分析",
desc: "账单按 Key、模型、时间三个维度拆解,钱花在哪一目了然,顺手设置费用告警",
},
{
title: "限流自查与提额",
desc: "被 429 限流?帮你查清用量水位、定位原因,一键申请提额",
},
{
title: "智能配置监控告警",
desc: "不知道告警阈值设多少?根据你的历史调用数据自动算出建议值,批量创建规则",
},
{
title: "模型用量统计",
desc: "各模型的 Token 用量与费用一次查清,自动生成用量分析报告",
},
],
},
{
id: "key",
label: "密钥与接入",
cards: [
{
title: "API Key 配置诊断",
desc: "自动排查 401 报错与配置问题,直接给出可用的正确配置",
},
{
title: "API Key 管理",
desc: "创建、禁用、重置、删除 Key 对话即可完成,全程脱敏展示,不怕泄露",
},
{
title: "第三方工具一键接入",
desc: "不用手动改配置,为 Claude Code、Cursor、OpenClaw 生成开箱即用的接入配置",
},
{
title: "业务空间与成员管理",
desc: "团队多人协作按空间划分:管理成员与权限,各空间 API Key 相互隔离",
},
],
},
{
id: "usage",
label: "用量与费用",
cards: [
{
title: "免费额度一键防护",
desc: "一键开启「用完即停」,免费额度耗尽自动停止调用,不再产生意外扣费",
},
{
title: "账单消费分析",
desc: "账单按 Key、模型、时间三个维度拆解,钱花在哪一目了然,顺手设置费用告警",
},
{
title: "限流自查与提额",
desc: "被 429 限流?帮你查清用量水位、定位原因,一键申请提额",
},
{
title: "模型用量统计",
desc: "各模型的 Token 用量与费用一次查清,自动生成用量分析报告",
},
{
title: "费用告警设置",
desc: "设一条月度消费上限,快超时钉钉/邮件自动提醒,不用天天盯账单",
},
],
},
{
id: "ops",
label: "运维监控",
cards: [
{
title: "智能配置监控告警",
desc: "不知道告警阈值设多少?根据你的历史调用数据自动算出建议值,批量创建规则",
},
{
title: "监控总览与失败日志",
desc: "一屏总览调用量、失败率和延时,出现异常时可直接定位到具体的失败请求",
},
{ title: "告警规则管理", desc: "已有告警规则统一查看、修改、停用,触发历史随时回溯" },
],
},
{
id: "task",
label: "任务与部署",
cards: [
{ title: "批量推理任务", desc: "大批量数据离线跑推理,费用只要一半,完成后一键下载结果" },
{ title: "模型部署管理", desc: "一键部署模型,快速实现扩/缩容,无需手动运维" },
{
title: "图像/视频生成任务",
desc: "一句话生成图片或视频(通义万相等模型),任务进度随时可查",
},
],
},
{
id: "model",
label: "模型管理",
cards: [
{ title: "模型对比选型", desc: "选型拿不准?直接问 agent,帮你推荐最适合的模型" },
{ title: "模型评测", desc: "用自己的业务数据检验模型效果,自动生成评测报告与错例分析" },
{
title: "模型调优",
desc: "让通用模型学会你的业务:对话式引导 SFT 微调,费用与耗时提前算清",
},
],
},
];
exports.inject = ["slots"];
exports.apply = function (ctx) {
var slots = ctx.get("slots");
if (slots === undefined) return;
// ── Bailian settings section ──
slots.inject("settings.section", function () {
return slots.register(
{ name: "settings.section", id: "bailian", label: "Bailian", order: 90 },
function () {
var cs = React.useState({ id: "", secret: "", apiKey: "" });
var cm = React.useState({ type: "", text: "" });
var cl = React.useState(false);
var us = React.useState({ region: "cn-beijing", site: "domestic" });
var ud = React.useState(null);
var ul = React.useState(false);
var ue = React.useState("");
var mc = React.useState({
userId: "",
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/",
planVersion: "lite",
topK: 10,
autoInject: true,
autoPersist: true,
memoryLibraryId: "",
});
var mm = React.useState({ type: "", text: "" });
var ml = React.useState(false);
var ms = React.useState(null);
var cred = cs[0],
setCred = cs[1],
cmsg = cm[0],
setCmsg = cm[1],
cLoading = cl[0],
setCLoading = cl[1];
var uForm = us[0],
setUform = us[1],
result = ud[0],
setResult = ud[1],
uLoading = ul[0],
setULoading = ul[1],
uErr = ue[0],
setUErr = ue[1];
var memCfg = mc[0],
setMemCfg = mc[1],
memMsg = mm[0],
setMemMsg = mm[1],
mLoading = ml[0],
setMLoading = ml[1],
memStatus = ms[0],
setMemStatus = ms[1];
function updC(f, v) {
var o = {};
o[f] = v;
setCred(Object.assign({}, cred, o));
}
function updU(f, v) {
var o = {};
o[f] = v;
setUform(Object.assign({}, uForm, o));
}
function updM(f, v) {
var o = {};
o[f] = v;
setMemCfg(Object.assign({}, memCfg, o));
}
function saveCred() {
if (!cred.id || !cred.secret) return;
setCLoading(true);
setCmsg({ type: "", text: "" });
jfetch("/api/bailian/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessKeyId: cred.id, accessKeySecret: cred.secret }),
})
.then(function (d) {
var p = ["AK/SK 已存入 bl " + (d.profile || "dsh") + " profile"];
if (cred.apiKey) {
return jfetch("/api/bailian/memory/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: cred.apiKey }),
})
.then(function () {
p.push("API Key 已同步到记忆库");
return;
})
.catch(function () {
return;
});
}
})
.then(function () {
setCmsg({ type: "ok", text: p.join(";") + "。" });
})
.catch(function (e) {
setCmsg({ type: "err", text: e && e.message ? e.message : String(e) });
})
.then(function () {
setCLoading(false);
});
}
function fetchUsage() {
setULoading(true);
setUErr("");
setResult(null);
jfetch("/api/bailian/tokenplan/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ region: uForm.region, site: uForm.site }),
})
.then(function (r) {
setResult(r);
})
.catch(function (e) {
setUErr(e && e.message ? e.message : String(e));
})
.then(function () {
setULoading(false);
});
}
function fetchMemStatus() {
jfetch("/api/bailian/memory/status")
.then(function (s) {
setMemStatus(s);
setMemCfg(
Object.assign({}, memCfg, {
userId: s.userId || memCfg.userId,
baseUrl: s.baseUrl || memCfg.baseUrl,
planVersion: s.planVersion || memCfg.planVersion,
topK: s.topK || memCfg.topK,
autoInject: s.autoInject !== undefined ? s.autoInject : memCfg.autoInject,
autoPersist: s.autoPersist !== undefined ? s.autoPersist : memCfg.autoPersist,
}),
);
})
.catch(function () {});
}
React.useEffect(function () {
fetchMemStatus();
}, []);
function saveMemCfg() {
setMLoading(true);
setMemMsg({ type: "", text: "" });
jfetch("/api/bailian/memory/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(memCfg),
})
.then(function (r) {
setMemMsg({
type: "ok",
text: "记忆库配置已保存。API Key " + (r.configured ? "已配置" : "未配置"),
});
})
.catch(function (e) {
setMemMsg({ type: "err", text: e && e.message ? e.message : String(e) });
})
.then(function () {
setMLoading(false);
});
}
var children = [
React.createElement("h3", { className: "bl-title", key: "t" }, "阿里云百炼"),
React.createElement(
"p",
{ className: "bl-sub", key: "s" },
"配置阿里云 AK/SK 和 DashScope API Key 后,所有百炼插件共用此凭证。",
),
];
var cc = [
React.createElement("div", { className: "bl-section-title", key: "ct" }, "凭证配置"),
];
cc.push(
React.createElement(
"div",
{ className: "bl-field", key: "ci" },
React.createElement("label", { className: "bl-label" }, "AccessKey ID"),
React.createElement("input", {
className: "bl-input",
value: cred.id,
onChange: function (e) {
updC("id", e.target.value);
},
placeholder: "LTAI...",
}),
),
);
cc.push(
React.createElement(
"div",
{ className: "bl-field", key: "cs" },
React.createElement("label", { className: "bl-label" }, "AccessKey Secret"),
React.createElement("input", {
className: "bl-input",
type: "password",
value: cred.secret,
onChange: function (e) {
updC("secret", e.target.value);
},
placeholder: "••••••••",
}),
),
);
cc.push(
React.createElement(
"div",
{ className: "bl-field", key: "cak" },
React.createElement("label", { className: "bl-label" }, "DashScope API Key"),
React.createElement("input", {
className: "bl-input",
type: "password",
value: cred.apiKey,
onChange: function (e) {
updC("apiKey", e.target.value);
},
placeholder: "sk-xxxx(按量付费,记忆库共用)",
}),
),
);
cc.push(
React.createElement(
"button",
{
className: "bl-btn",
key: "cb",
onClick: saveCred,
disabled: cLoading || !cred.id || !cred.secret,
},
cLoading ? "保存中..." : "保存凭证",
),
);
if (cmsg.text)
cc.push(
React.createElement(
"div",
{ className: "bl-msg bl-msg-" + cmsg.type, key: "cm" },
cmsg.text,
),
);
children.push(React.createElement("div", { className: "bl-section", key: "cred" }, cc));
var tp = [
React.createElement(
"div",
{ className: "bl-section-title", key: "tt" },
"TokenPlan 用量",
),
];
tp.push(
React.createElement(
"div",
{ className: "bl-row", key: "tr" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "区域"),
React.createElement(
"select",
{
className: "bl-input",
value: uForm.region,
onChange: function (e) {
updU("region", e.target.value);
},
},
React.createElement("option", { value: "cn-beijing" }, "cn-beijing"),
React.createElement("option", { value: "ap-southeast-1" }, "ap-southeast-1"),
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "站点"),
React.createElement(
"select",
{
className: "bl-input",
value: uForm.site,
onChange: function (e) {
updU("site", e.target.value);
},
},
React.createElement("option", { value: "domestic" }, "国内站"),
React.createElement("option", { value: "international" }, "国际站"),
),
),
React.createElement(
"button",
{ className: "bl-btn", onClick: fetchUsage, disabled: uLoading },
uLoading ? "查询中..." : "查询用量",
),
),
);
if (uErr)
tp.push(
React.createElement("div", { className: "bl-msg bl-msg-err", key: "te" }, uErr),
);
if (uLoading)
tp.push(
React.createElement(
"div",
{ key: "tl", className: "bl-sub" },
"正在调用控制台接口...",
),
);
if (result) {
var u = result.usage;
if (
u &&
typeof u === "object" &&
(u.per5HourPercentage !== undefined || u.per1WeekPercentage !== undefined)
) {
var uc = [
React.createElement(
"div",
{ className: "bl-card-title", key: "ut" },
"用量百分比",
),
];
if (u.per5HourPercentage !== undefined)
uc.push(
React.createElement(
"div",
{ key: "u5" },
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "5 小时窗口"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtPct(u.per5HourPercentage),
),
),
React.createElement(
"div",
{ className: "bl-bar" },
React.createElement("div", {
className: "bl-bar-fill",
style: {
width: fmtPct(u.per5HourPercentage),
background: barColor(u.per5HourPercentage),
},
}),
),
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "重置时间"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtTime(u.per5HourResetTime),
),
),
),
);
if (u.per1WeekPercentage !== undefined)
uc.push(
React.createElement(
"div",
{ key: "u1w" },
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "1 周窗口"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtPct(u.per1WeekPercentage),
),
),
React.createElement(
"div",
{ className: "bl-bar" },
React.createElement("div", {
className: "bl-bar-fill",
style: {
width: fmtPct(u.per1WeekPercentage),
background: barColor(u.per1WeekPercentage),
},
}),
),
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "重置时间"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtTime(u.per1WeekResetTime),
),
),
),
);
tp.push(React.createElement("div", { className: "bl-card", key: "uc" }, uc));
}
var sub = result.subscription;
if (sub && typeof sub === "object" && sub.instanceCode) {
var sm = { lite: "基础版", standard: "标准版", pro: "高级版" };
var sc = [
React.createElement("div", { className: "bl-card-title", key: "st" }, "套餐信息"),
];
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s1" },
React.createElement("span", { className: "bl-stat-label" }, "套餐类型"),
React.createElement(
"span",
{ className: "bl-stat-val" },
sm[sub.specCode] || sub.specCode,
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s2" },
React.createElement("span", { className: "bl-stat-label" }, "状态"),
React.createElement(
"span",
{
className: "bl-tag",
style: {
background: sub.status === "VALID" ? "#dcfce7" : "#fee2e2",
color: sub.status === "VALID" ? "#16a34a" : "#dc2626",
},
},
sub.status === "VALID" ? "有效" : "无效",
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s3" },
React.createElement("span", { className: "bl-stat-label" }, "剩余天数"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtDays(sub.remainingDays),
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s4" },
React.createElement("span", { className: "bl-stat-label" }, "到期时间"),
React.createElement("span", { className: "bl-stat-val" }, fmtTime(sub.endTime)),
),
);
tp.push(React.createElement("div", { className: "bl-card", key: "sc" }, sc));
}
var ad = result.addonSummary;
if (ad && typeof ad === "object" && ad.totalCredits !== undefined) {
var ac = [
React.createElement(
"div",
{ className: "bl-card-title", key: "at" },
"额外用量包",
),
];
ac.push(
React.createElement(
"div",
{ className: "bl-stat", key: "a1" },
React.createElement("span", { className: "bl-stat-label" }, "Credits 总量"),
React.createElement("span", { className: "bl-stat-val" }, ad.totalCredits),
),
);
ac.push(
React.createElement(
"div",
{ className: "bl-stat", key: "a2" },
React.createElement("span", { className: "bl-stat-label" }, "Credits 剩余"),
React.createElement("span", { className: "bl-stat-val" }, ad.remainingCredits),
),
);
tp.push(React.createElement("div", { className: "bl-card", key: "ac" }, ac));
}
if (result.errors && result.errors.length > 0)
tp.push(
React.createElement(
"div",
{ className: "bl-card", key: "ec" },
result.errors.map(function (e, i) {
return React.createElement(
"div",
{ key: "e" + i, style: { fontSize: "12px" } },
"[" + e.api + "] " + e.message,
);
}),
),
);
}
children.push(React.createElement("div", { className: "bl-section", key: "tp" }, tp));
var mem = [
React.createElement("div", { className: "bl-section-title", key: "mt" }, "记忆库"),
];
if (memStatus)
mem.push(
React.createElement(
"div",
{ className: "bl-stat", key: "ms" },
React.createElement("span", { className: "bl-stat-label" }, "API Key"),
React.createElement(
"span",
{
className: "bl-tag",
style: {
background: memStatus.configured ? "#dcfce7" : "#fee2e2",
color: memStatus.configured ? "#16a34a" : "#dc2626",
},
},
memStatus.configured ? "已配置" : "未配置",
),
),
);
mem.push(
React.createElement(
"div",
{ className: "bl-field", key: "mu" },
React.createElement("label", { className: "bl-label" }, "User ID"),
React.createElement("input", {
className: "bl-input",
value: memCfg.userId,
onChange: function (e) {
updM("userId", e.target.value);
},
placeholder: "留空用系统用户名",
}),
),
);
mem.push(
React.createElement(
"div",
{ className: "bl-field", key: "mb" },
React.createElement("label", { className: "bl-label" }, "Base URL"),
React.createElement("input", {
className: "bl-input",
value: memCfg.baseUrl,
onChange: function (e) {
updM("baseUrl", e.target.value);
},
}),
),
);
mem.push(
React.createElement(
"div",
{ className: "bl-row", key: "mr" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "策略版本"),
React.createElement(
"select",
{
className: "bl-input",
value: memCfg.planVersion,
onChange: function (e) {
updM("planVersion", e.target.value);
},
},
React.createElement("option", { value: "lite" }, "Lite(便宜)"),
React.createElement("option", { value: "pro" }, "Pro(贵50倍)"),
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "Top K"),
React.createElement("input", {
className: "bl-input",
type: "number",
value: memCfg.topK,
onChange: function (e) {
updM("topK", Number(e.target.value));
},
}),
),
),
);
mem.push(
React.createElement(
"div",
{ className: "bl-row", key: "ms2" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement(
"label",
{ className: "bl-label" },
React.createElement("input", {
type: "checkbox",
checked: memCfg.autoInject,
onChange: function (e) {
updM("autoInject", e.target.checked);
},
style: { marginRight: "6px" },
}),
"自动检索注入",
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement(
"label",
{ className: "bl-label" },
React.createElement("input", {
type: "checkbox",
checked: memCfg.autoPersist,
onChange: function (e) {
updM("autoPersist", e.target.checked);
},
style: { marginRight: "6px" },
}),
"自动落库",
),
),
),
);
mem.push(
React.createElement(
"button",
{ className: "bl-btn", key: "mbtn", onClick: saveMemCfg, disabled: mLoading },
mLoading ? "保存中..." : "保存记忆配置",
),
);
if (memMsg.text)
mem.push(
React.createElement(
"div",
{ className: "bl-msg bl-msg-" + memMsg.type, key: "mmsg" },
memMsg.text,
),
);
children.push(React.createElement("div", { className: "bl-section", key: "mem" }, mem));
return React.createElement("div", { className: "bl-wrap" }, children);
},
);
});
// ── Welcome page on blank sessions ──
slots.inject("conversation.input.dock", function () {
return slots.register(
{ name: "conversation.input.dock", id: "bailian-welcome", order: -100 },
function (props) {
var session = props && props.session;
if (!session || session.blank !== true) return null;
var st = React.useState("rec");
var tab = st[0],
setTab = st[1];
var active = null;
for (var i = 0; i < WELCOME_TABS.length; i++)
if (WELCOME_TABS[i].id === tab) active = WELCOME_TABS[i];
if (!active) active = WELCOME_TABS[0];
return React.createElement(
"div",
{ className: "bw-wrap" },
React.createElement(
"div",
{ className: "bw-head" },
React.createElement("div", { className: "bw-logo" }, "B"),
React.createElement(
"div",
null,
React.createElement("div", { className: "bw-title" }, "百炼 Agent"),
React.createElement(
"div",
{ className: "bw-desc" },
"百炼 Agent 是您的智能百炼控制台,通过 Agent + CLI 帮助您高效管理百炼平台,覆盖密钥接入、用量费用、运维监控、任务部署、模型管理等场景。",
),
),
),
React.createElement(
"div",
{ className: "bw-tabs" },
WELCOME_TABS.map(function (t) {
return React.createElement(
"button",
{
key: t.id,
className: "bw-tab" + (t.id === tab ? " bw-tab-active" : ""),
onClick: function () {
setTab(t.id);
},
},
t.label,
);
}),
),
React.createElement(
"div",
{ className: "bw-cards" },
active.cards.map(function (c) {
return React.createElement(
"div",
{ key: c.title, className: "bw-card" },
React.createElement("div", { className: "bw-card-title" }, c.title),
React.createElement("div", { className: "bw-card-desc" }, c.desc),
);
}),
),
);
},
);
});
};
return module.exports;
},
});
+31 -146
View File
@@ -1,160 +1,45 @@
# bailian-cli-dsh — Aliyun Model Studio (Bailian) as a dsh profile bundle.
#
# Applied over whatever the earlier layers composed: one config override of the
# base bundle's dormant pi-ai adapter, then one insert of the Bailian tool rows.
# Inserts Bailian plugin rows: TokenPlan usage display + cross-session memory.
# Every inserted id is `bailian-`-prefixed so a user profile can address,
# reconfigure, or disable any single capability without touching the others.
# Remember that a later patch REPLACES a row's whole `config` rather than
# merging into it, so restate the complete config when overriding.
# TokenPlan configures the base bundle's existing `llm-pi-ai` row instead of
# inserting a second @deepseek-ai/dsh-llm-pi-ai instance. That plugin declares
# pi-ai's entire built-in provider catalog to
# `ctx.llm.registerConfigurableProviders` on every apply, and that registry
# refuses an already-declared provider, so a second instance always fails the
# whole tree with DUPLICATE_DIRECTORY on `amazon-bedrock`. Only adapter routes
# are per-instance; the configurable-provider directory is global.
#
# Replacing this row's whole `config` costs nothing: the base mounts it dormant
# with no config of its own. A profile that needs to drop or re-aim TokenPlan
# restates this row's config rather than disabling an id.
- id: llm-pi-ai
config:
providers:
bailian-tokenplan:
displayName: Aliyun Bailian TokenPlan
api: openai-completions
baseURL: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
# A dedicated name, not DASHSCOPE_API_KEY. TokenPlan keys (sk-sp-)
# and pay-as-you-go keys (sk-ws-) are not interchangeable: this
# gateway 401s a pay-as-you-go key, and the memory / knowledge-base
# endpoints 401 a TokenPlan key. Sharing one variable would make
# whichever plugin loses silently fail to authenticate.
apiKeyEnv: BAILIAN_TOKENPLAN_API_KEY
compat:
thinkingFormat: qwen
supportsReasoningEffort: true
# Verified against GET /compatible-mode/v1/models plus a per-model
# image probe on 2026-08-14. Only `id` is required; context window
# and max tokens fall back to this provider's defaults.
#
# `input: [text, image]` is a claim about the endpoint, not a
# checked fact, and it is what opens the Web UI paste path and
# `read_image`. Every entry carrying it answered a colour question
# about a test PNG correctly. The DeepSeek routes deliberately do
# NOT carry it: they accept image content without erroring and then
# answer "None", so declaring vision would turn a clean refusal
# into a silently blind reply. Use `bailian_vision_describe` there.
#
# Omitted on purpose: wan2.7-image / wan2.7-image-pro are
# generation-only (reach them through `bailian_image_generate`) and
# qwen-audio-3.0-* are audio endpoints, not chat completions.
models:
- id: qwen3.8-max
input: [text, image]
- id: qwen3.7-plus
input: [text, image]
- id: qwen3.6-flash
input: [text, image]
# Rejects image content outright with HTTP 400.
- id: qwen3.7-max
- id: glm-5.2
input: [text, image]
compat:
thinkingFormat: deepseek
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
- id: deepseek-v4-flash-0731
compat:
thinkingFormat: deepseek
# Responses-API route for the TokenPlan Qwen thinking models: same
# gateway and key as bailian-tokenplan, but OpenAI Responses protocol
# (`/responses`) instead of chat/completions. Reasoning arrives as
# native `reasoning` output items; the gateway's
# `response.reasoning_text.delta` SSE event is one pi-ai's
# openai-responses adapter explicitly consumes. The three models stay
# listed on the completions provider above, so both routes remain
# selectable; delete them there to make Responses the only route.
#
# No `thinkingFormat: qwen` here — that compat maps the completions
# `reasoning_content` field, pi-ai types it only for
# openai-completions, and resolution rejects such switches on this
# protocol. Reasoning effort instead goes through each model's
# `reasoningEfforts`, whose wire spellings land in the gateway's
# `reasoning.effort` request parameter. The four levels below were the
# only ones probed; xhigh/max were not offered to the gateway.
#
# Verified on 2026-08-15 per model against /responses: non-stream and
# stream bodies, one function call, and a colour question about a test
# PNG. qwen3.7-max rejects image content with HTTP 400, so it carries
# no `input: [text, image]`.
bailian-tokenplan-responses:
displayName: Aliyun Bailian TokenPlan (Responses)
api: openai-responses
baseURL: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
apiKeyEnv: BAILIAN_TOKENPLAN_API_KEY
models:
- id: qwen3.8-max
input: [text, image]
reasoningEfforts:
minimal: minimal
low: low
medium: medium
high: high
- id: qwen3.7-plus
input: [text, image]
reasoningEfforts:
minimal: minimal
low: low
medium: medium
high: high
- id: qwen3.7-max
reasoningEfforts:
minimal: minimal
low: low
medium: medium
high: high
- insert:
- id: bailian-tool-vision
name: bailian-cli-dsh/tool-vision
- id: bailian-tool-image
name: bailian-cli-dsh/tool-image
# Enabled by default: the tool creates no resources at load time. It only
# provisions a cloud agent when the model actually calls it, and reuses it
# after — no deployment-specific ID to configure up front.
# TokenPlan usage display (dual-face: Host provides two webServer routes,
# Client renders a general "Bailian" settings.section page). All bl commands
# use `--config dsh` to isolate credentials in a dedicated bl profile.
#
# Credentials: needs a pay-as-you-go key (sk-ws-), resolved from row config
# `apiKey`, then $DASHSCOPE_API_KEY. The agentstudio API is served only on
# the workspace-scoped host, so the endpoint resolves from row `baseUrl`,
# then $DASHSCOPE_BASE_URL, then row `workspaceId` / $BAILIAN_WORKSPACE_ID
# composed into https://{workspace}.cn-beijing.maas.aliyuncs.com — whatever
# resolves ships to bl explicitly, never leaving the endpoint to bl's
# active-profile base_url (a TokenPlan or bare model-domain origin 404s
# agentstudio). The key must belong to that workspace. A resolved TokenPlan
# key or TokenPlan endpoint rejects at call time with guidance (this row is
# enabled by default and must not break boot for TokenPlan-only setups).
- id: bailian-tool-managed-agent
name: bailian-cli-dsh/tool-managed-agent
# Disabled by default: the knowledge base to query is deployment-specific,
# and an enabled provider with no agentId would make `web_search` ambiguous
# for everyone. Set workspaceId + agentId and flip `disabled` to use it.
# Key: row config `apiKey`, then $DASHSCOPE_API_KEY (pay-as-you-go sk-ws-;
# a TokenPlan key is rejected at boot).
- id: bailian-web-search-rag
name: bailian-cli-dsh/web-search-rag
disabled: true
# Two routes:
# POST /api/bailian/credentials — saves AK/SK to dsh profile
# (bl auth login --open-api --config dsh). Generates fresh access_token.
# POST /api/bailian/tokenplan/usage — fetches personal-edition usage
# using the dsh profile (no AK/SK in body; credentials already saved).
#
# Users configure AK/SK once on the settings page; all future Bailian
# plugins reuse the same dsh profile credentials.
#
# Config fields:
# accessKeyId / accessKeySecret: fallback when not provided via UI.
# consoleRegion: default region (cn-beijing).
# consoleSite: domestic | international (default: domestic).
# profile: bl config profile name (default: dsh).
- id: bailian-tokenplan-usage
name: bailian-cli-dsh/tokenplan-usage
config: {}
# Disabled by default: memory add/search are billed per call.
# Key: row config `apiKey`, then $DASHSCOPE_API_KEY (pay-as-you-go sk-ws-;
# a missing key or a TokenPlan key fails the boot with an actionable message).
# Disabled by default: memory add/search are billed per call. Enable in
# the profile patch and configure API Key + parameters on the Bailian
# settings page. Calls DashScope memory v2 API directly (not bl memory)
# for full parameter control (min_score, enable_rerank, plan_version,
# memory_library_id, enable_judge, enable_rewrite).
- id: bailian-memory
name: bailian-cli-dsh/memory
disabled: true
config: {}
config:
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
autoPersist: true
+13 -22
View File
@@ -1,7 +1,7 @@
{
"name": "bailian-cli-dsh",
"version": "1.14.2",
"description": "Aliyun Model Studio (Bailian) plugin bundle for DeepSeek Harness (dsh): TokenPlan LLM provider, knowledge-base RAG search, vision, image generation, long-term memory, and managed-agent subagents.",
"description": "Aliyun Model Studio (Bailian) plugin bundle for DeepSeek Harness (dsh): TokenPlan LLM provider and personal-edition TokenPlan usage display in the webui.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
@@ -16,6 +16,7 @@
"files": [
"README.md",
"dist",
"client.bundle.js",
"cordis.patch.yml"
],
"type": "module",
@@ -23,28 +24,17 @@
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"default": "./dist/index.mjs"
},
"./tool-vision": {
"types": "./src/tool-vision/index.ts",
"default": "./src/tool-vision/index.ts"
},
"./tool-image": {
"types": "./src/tool-image/index.ts",
"default": "./src/tool-image/index.ts"
},
"./tool-managed-agent": {
"types": "./src/tool-managed-agent/index.ts",
"default": "./src/tool-managed-agent/index.ts"
},
"./web-search-rag": {
"types": "./src/web-search-rag/index.ts",
"default": "./src/web-search-rag/index.ts"
"./tokenplan-usage": {
"types": "./src/tokenplan-usage/index.ts",
"default": "./dist/tokenplan-usage/index.mjs"
},
"./memory": {
"types": "./src/memory/index.ts",
"default": "./src/memory/index.ts"
"default": "./dist/memory/index.mjs"
},
"./client": "./client.bundle.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
@@ -52,11 +42,9 @@
"access": "public",
"exports": {
".": "./dist/index.mjs",
"./tool-vision": "./dist/tool-vision/index.mjs",
"./tool-image": "./dist/tool-image/index.mjs",
"./tool-managed-agent": "./dist/tool-managed-agent/index.mjs",
"./web-search-rag": "./dist/web-search-rag/index.mjs",
"./tokenplan-usage": "./dist/tokenplan-usage/index.mjs",
"./memory": "./dist/memory/index.mjs",
"./client": "./client.bundle.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
@@ -106,6 +94,9 @@
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web"
}
}
}
+352 -110
View File
@@ -1,20 +1,25 @@
/**
* `bailian-cli-dsh/memory`: cross-session long-term memory backed by Bailian's
* hosted memory library.
* `bailian-cli-dsh/memory` (Host half): cross-session long-term memory backed
* by Bailian's hosted memory library (DashScope memory v2 API).
*
* dsh has no memory seam — `ctx.compaction` only summarizes within one
* session's context window and never writes across sessions — so this plugin
* supplies the whole capability: two tools for deliberate reads and writes,
* plus automatic retrieval and persistence around each turn.
* Provides:
* - Two model tools: `bailian_memory_search` (recall) + `bailian_memory_add`
* (store), plus `bailian_memory_list` (browse).
* - Auto-inject: on the first turn of each session (or every turn if
* configured), search memory and inject relevant facts into context.
* - Auto-persist: when a turn closes, send new user/assistant messages to
* the add API so future sessions can recall them.
* - webServer routes for the Client settings page to configure memory
* parameters (apiKey, baseUrl, userId, planVersion, etc.).
*
* Calls go straight to DashScope rather than through `bl memory`, because the
* v2 API exposes retrieval controls (`min_score`, `plan_version`,
* `enable_rerank`, `meta_data`) the CLI does not surface.
* Calls go straight to DashScope rather than through `bl memory`, because
* the v2 API exposes retrieval controls (`min_score`, `plan_version`,
* `enable_rerank`, `memory_library_id`, `enable_judge`, `enable_rewrite`)
* the CLI does not surface.
*
* BILLING: add and search are charged per call, and `pro` costs roughly fifty
* times `lite` per search. Automatic behaviour therefore defaults to `lite`,
* retrieves once per session rather than once per turn, and never requests
* profile extraction unless a schema is configured.
* BILLING: add and search are charged per call. `pro` costs ~50x `lite` per
* search. The `enable_rerank` flag is what actually selects the billing tier
* (verified: sending `plan_version: lite` alone still bills `pro`).
*
* @module bailian-cli-dsh/memory
*/
@@ -26,51 +31,49 @@ import type { ContentBlock, Message } from "@deepseek-ai/dsh-llm";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import { defineTool } from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import type { IncomingMessage, ServerResponse } from "node:http";
import { isTokenPlanKey, tokenPlanKeyRejection } from "../shared/credentials.ts";
import { dashScopeFetch, resolveApiKey, resolveBaseUrl } from "../shared/http.ts";
import { dashScopeFetch } from "../shared/http.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-memory";
/** Seams this plugin registers into. */
export const inject = ["tools", "agents"];
export const inject = ["tools", "agents", "webServer"];
export interface Config {
/** DashScope API key (pay-as-you-go sk-ws-). Falls back to $DASHSCOPE_API_KEY. */
apiKey?: string;
/** Memory API base URL (default: https://dashscope.aliyuncs.com/api/v2/apps/memory/). */
baseUrl?: string;
/** Memory entity id. Falls back to `$BAILIAN_MEMORY_USER_ID`, then the OS user. */
/** Memory entity id. Falls back to $BAILIAN_MEMORY_USER_ID, then OS user. */
userId?: string;
/** Memory library id; defaults to the account default. */
memoryLibraryId?: string;
/** Memory extraction rule id. */
projectId?: string;
/** Profile template id; omitting it skips profile extraction (and its cost). */
/** Profile template id; omitting skips profile extraction (and its cost). */
profileSchema?: string;
/** `lite` disables rerank and is ~50x cheaper per search. */
/** Search strategy; pro enables rerank at ~50x the cost. */
planVersion?: "lite" | "pro";
topK?: number;
minScore?: number;
/** Retrieve relevant memories and inject them into the conversation. */
/** Retrieve relevant memories and inject into the conversation. */
autoInject?: boolean;
/** Retrieve on every turn instead of once per session. Costs one search per turn. */
/** Retrieve every turn instead of once per session. */
injectEveryTurn?: boolean;
/** Persist each turn's new messages when the turn closes. */
autoPersist?: boolean;
}
export const Config: z<Config> = z.object({
apiKey: z
.string()
.role("secret")
.description(
"Pay-as-you-go DashScope key (sk-ws-); defaults to $DASHSCOPE_API_KEY. TokenPlan keys are rejected.",
),
baseUrl: z.string().description("DashScope base URL override."),
apiKey: z.string().role("secret").description("Pay-as-you-go DashScope API key (sk-)."),
baseUrl: z.string().description("Memory API base URL."),
userId: z.string().description("Memory entity id owning these memories."),
memoryLibraryId: z.string().description("Memory library id; defaults to the account default."),
memoryLibraryId: z.string().description("Memory library id."),
projectId: z.string().description("Memory extraction rule id."),
profileSchema: z.string().description("Profile template id; enables profile extraction."),
planVersion: z
.union(["lite", "pro"] as const)
.description("Search strategy; pro enables rerank at ~50x the cost."),
planVersion: z.union(["lite", "pro"] as const).description("Search strategy; pro ~50x cost."),
topK: z.natural().description("Maximum memories to recall (1-100)."),
minScore: z.number().description("Minimum similarity score, 0-1."),
autoInject: z.boolean().description("Inject recalled memories automatically."),
@@ -78,9 +81,12 @@ export const Config: z<Config> = z.object({
autoPersist: z.boolean().description("Persist new messages when a turn closes."),
});
const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/api/v2/apps/memory/";
const DEFAULT_TOP_K = 10;
const DEFAULT_PLAN_VERSION = "lite";
const MEMORY_PATH = "/api/v2/apps/memory";
const CONFIG_ROUTE = "/api/bailian/memory/config";
const STATUS_ROUTE = "/api/bailian/memory/status";
interface MemoryNode {
memory_node_id?: string;
@@ -89,11 +95,16 @@ interface MemoryNode {
old_content?: string;
created_at?: number;
updated_at?: number;
meta_data?: Record<string, unknown>;
}
interface MemoryResponse {
request_id?: string;
memory_nodes?: readonly MemoryNode[];
total?: number;
page_num?: number;
page_size?: number;
billing_plan?: string;
}
interface ChatTurn {
@@ -101,7 +112,23 @@ interface ChatTurn {
content: string;
}
/** Resolution order: explicit config, then environment, then the OS user. */
/** Mutable runtime config — updated via webServer route, initialized from Cordis config. */
interface MemoryRuntimeConfig {
apiKey: string | undefined;
baseUrl: string;
userId: string;
memoryLibraryId: string | undefined;
projectId: string | undefined;
profileSchema: string | undefined;
planVersion: "lite" | "pro";
topK: number;
minScore: number | undefined;
autoInject: boolean;
injectEveryTurn: boolean;
autoPersist: boolean;
}
/** Resolution order: explicit config, then env, then OS user. */
function resolveUserId(ctx: Context, config: Config): string {
if (config.userId !== undefined && config.userId.length > 0) return config.userId;
const fromEnv = ctx.get("launchEnvironment")?.get("BAILIAN_MEMORY_USER_ID")?.value;
@@ -129,19 +156,52 @@ function conversationTurns(messages: readonly Message[]): ChatTurn[] {
return turns;
}
/** Read a UTF-8 POST body up to a size limit. */
function readJsonBody(req: IncomingMessage, maxBytes: number = 16384): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
req.destroy();
reject(new Error("body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
if (text.length === 0) return resolve({});
try {
resolve(JSON.parse(text));
} catch {
reject(new Error("invalid JSON"));
}
});
req.on("error", reject);
});
}
function sendJson(res: ServerResponse, status: number, data: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
class MemoryClient {
constructor(
private readonly apiKey: string,
private readonly baseUrl: string,
private readonly config: Config,
private readonly cfg: MemoryRuntimeConfig,
private readonly userId: string,
) {}
private shared(): Record<string, unknown> {
return {
user_id: this.userId,
...(this.config.memoryLibraryId !== undefined
? { memory_library_id: this.config.memoryLibraryId }
...(this.cfg.memoryLibraryId !== undefined
? { memory_library_id: this.cfg.memoryLibraryId }
: {}),
};
}
@@ -152,7 +212,7 @@ class MemoryClient {
overrides?: { customContent?: string; metaData?: Record<string, unknown> },
): Promise<MemoryResponse> {
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}${MEMORY_PATH}/add`,
url: `${this.baseUrl}add`,
method: "POST",
apiKey: this.apiKey,
signal,
@@ -161,10 +221,8 @@ class MemoryClient {
...(overrides?.customContent !== undefined
? { custom_content: overrides.customContent }
: { messages }),
...(this.config.projectId !== undefined ? { project_id: this.config.projectId } : {}),
...(this.config.profileSchema !== undefined
? { profile_schema: this.config.profileSchema }
: {}),
...(this.cfg.projectId !== undefined ? { project_id: this.cfg.projectId } : {}),
...(this.cfg.profileSchema !== undefined ? { profile_schema: this.cfg.profileSchema } : {}),
...(overrides?.metaData !== undefined ? { meta_data: overrides.metaData } : {}),
},
});
@@ -175,47 +233,64 @@ class MemoryClient {
signal: AbortSignal | undefined,
overrides?: { topK?: number; minScore?: number; planVersion?: "lite" | "pro" },
): Promise<MemoryResponse> {
const planVersion = overrides?.planVersion ?? this.config.planVersion ?? DEFAULT_PLAN_VERSION;
const planVersion = overrides?.planVersion ?? this.cfg.planVersion ?? DEFAULT_PLAN_VERSION;
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}${MEMORY_PATH}/memory_nodes/search`,
url: `${this.baseUrl}memory_nodes/search`,
method: "POST",
apiKey: this.apiKey,
signal,
body: {
...this.shared(),
messages,
top_k: overrides?.topK ?? this.config.topK ?? DEFAULT_TOP_K,
...((overrides?.minScore ?? this.config.minScore) !== undefined
? { min_score: overrides?.minScore ?? this.config.minScore }
top_k: overrides?.topK ?? this.cfg.topK ?? DEFAULT_TOP_K,
...((overrides?.minScore ?? this.cfg.minScore) !== undefined
? { min_score: overrides?.minScore ?? this.cfg.minScore }
: {}),
// `enable_rerank` is what actually selects the billing tier. Sending
// `plan_version: lite` alone still bills `pro` (verified against the
// live API), despite the documented precedence, and pro costs ~50x
// more per search. Send both: the flag that works, plus the
// documented field in case the server-side precedence is fixed.
// enable_rerank is what actually selects the billing tier (verified:
// plan_version alone still bills pro). Send both for safety.
enable_rerank: planVersion === "pro",
plan_version: planVersion,
...(this.config.projectId !== undefined ? { project_ids: [this.config.projectId] } : {}),
...(this.cfg.projectId !== undefined ? { project_ids: [this.cfg.projectId] } : {}),
},
});
}
async list(
signal: AbortSignal | undefined,
overrides?: { pageNum?: number; pageSize?: number },
): Promise<MemoryResponse> {
const params = new URLSearchParams({
user_id: this.userId,
page_num: String(overrides?.pageNum ?? 1),
page_size: String(overrides?.pageSize ?? 10),
...(this.cfg.memoryLibraryId !== undefined
? { memory_library_id: this.cfg.memoryLibraryId }
: {}),
});
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}memory_nodes?${params.toString()}`,
method: "GET",
apiKey: this.apiKey,
signal,
});
}
}
function renderMemories(nodes: readonly MemoryNode[]): string {
const lines = nodes
function formatMemories(nodes: readonly MemoryNode[]): string {
const items = nodes
.map((node) => node.content?.trim())
.filter((content): content is string => content !== undefined && content.length > 0)
.map((content) => `- ${content}`);
return `What you remember about this user from earlier sessions:\n${lines.join("\n")}`;
.filter((content): content is string => content !== undefined && content.length > 0);
if (items.length === 0) return "";
return `What you remember about this user from earlier sessions:\n${items.map((item) => `- ${item}`).join("\n")}`;
}
function registerTools(ctx: Context, client: MemoryClient): void {
/** Register model tools for deliberate memory operations. */
function registerTools(ctx: Context, client: () => MemoryClient | undefined): void {
ctx.tools.register(
defineTool({
name: "bailian_memory_search",
description:
"Recall facts stored about this user in earlier sessions. Use when the user refers " +
"to prior context, preferences, or decisions you have no record of in this session.",
"Recall facts stored about this user in earlier sessions. Use when the user refers to prior context, preferences, or decisions you have no record of in this session.",
parameters: {
query: { type: "string", required: true, description: "What to recall." },
top_k: { type: "integer", description: "Maximum memories to return (1-100)." },
@@ -246,18 +321,23 @@ function registerTools(ctx: Context, client: MemoryClient): void {
text:
value.memories.length === 0
? "No relevant memories."
: value.memories.map((memory) => `- ${memory.content}`).join("\n"),
: value.memories.map((m: any) => `- ${m.content}`).join("\n"),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const response = await client.search([{ role: "user", content: args.query }], exec.signal, {
const mem = client();
if (mem === undefined)
throw new Error(
"bailian-memory: not configured. Set apiKey in the Bailian settings page or config.",
);
const result = await mem.search([{ role: "user", content: args.query }], exec.signal, {
...(args.top_k !== undefined ? { topK: args.top_k } : {}),
...(args.min_score !== undefined ? { minScore: args.min_score } : {}),
});
return {
memories: (response.memory_nodes ?? []).map((node) => ({
memories: (result.memory_nodes ?? []).map((node) => ({
id: node.memory_node_id ?? "",
content: node.content ?? "",
})),
@@ -270,8 +350,7 @@ function registerTools(ctx: Context, client: MemoryClient): void {
defineTool({
name: "bailian_memory_add",
description:
"Store a durable fact about this user so later sessions can recall it. Use for stable " +
"preferences, decisions, and context — not for transient task state.",
"Store a durable fact about this user so later sessions can recall it. Use for stable preferences, decisions, and context — not for transient task state.",
parameters: {
content: { type: "string", required: true, description: "The fact to remember." },
},
@@ -286,40 +365,117 @@ function registerTools(ctx: Context, client: MemoryClient): void {
],
},
async execute(args, exec) {
const response = await client.add([], exec.signal, { customContent: args.content });
return { stored: (response.memory_nodes ?? []).length };
const mem = client();
if (mem === undefined)
throw new Error(
"bailian-memory: not configured. Set apiKey in the Bailian settings page or config.",
);
const result = await mem.add([], exec.signal, { customContent: args.content });
return { stored: (result.memory_nodes ?? []).length };
},
}),
);
ctx.tools.register(
defineTool({
name: "bailian_memory_list",
description:
"List all stored memory fragments for this user. Use to review what the system already knows.",
parameters: {
page_size: { type: "integer", description: "Results per page (default 10)." },
page_num: { type: "integer", description: "Page number, starting from 1." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
memories: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string", required: true },
content: { type: "string", required: true },
},
},
},
total: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `${value.total} memory fragment(s):\n${value.memories.map((m: any) => `- ${m.content}`).join("\n")}`,
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const mem = client();
if (mem === undefined) throw new Error("bailian-memory: not configured.");
const result = await mem.list(exec.signal, {
...(args.page_size !== undefined ? { pageSize: args.page_size } : {}),
...(args.page_num !== undefined ? { pageNum: args.page_num } : {}),
});
return {
memories: (result.memory_nodes ?? []).map((node) => ({
id: node.memory_node_id ?? "",
content: node.content ?? "",
})),
total: result.total ?? 0,
};
},
}),
);
}
function registerLifecycle(ctx: Context, client: MemoryClient, config: Config): void {
const injected = new WeakSet<Agent>();
const persistedUpTo = new WeakMap<Agent, number>();
/** Auto-inject (search on first turn) + auto-persist (add on turn end). */
function registerAutoBehavior(
ctx: Context,
client: () => MemoryClient | undefined,
cfg: () => MemoryRuntimeConfig,
): void {
const injectedSessions = new WeakSet<Agent>();
const persistedCursor = new WeakMap<Agent, number>();
if (config.autoInject !== false) {
const currentCfg = cfg();
if (currentCfg.autoInject !== false) {
ctx.on(
"agent/pre-step",
async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
async (
{
agent,
messages,
signal,
}: { agent: Agent; messages: readonly Message[]; signal: AbortSignal },
next: () => Promise<PreStepDecision>,
) => {
const decision = await next();
if (decision.kind !== "enter") return decision;
if (injected.has(agent) && config.injectEveryTurn !== true) return decision;
if (injectedSessions.has(agent) && currentCfg.injectEveryTurn !== true) return decision;
const query = textOf(messages.flatMap((message) => message.content));
const query = textOf(messages.flatMap((m) => m.content));
if (query.length === 0) return decision;
let nodes: readonly MemoryNode[];
const mem = client();
if (mem === undefined) return decision;
let nodes: readonly MemoryNode[] = [];
try {
const response = await client.search([{ role: "user", content: query }], signal);
nodes = response.memory_nodes ?? [];
const result = await mem.search([{ role: "user", content: query }], signal);
nodes = result.memory_nodes ?? [];
} catch {
// Recall is an enhancement; a memory-service outage must not stop the turn.
return decision;
}
injected.add(agent);
injectedSessions.add(agent);
if (nodes.length === 0) return decision;
const text = renderMemories(nodes);
const text = formatMemories(nodes);
return {
...decision,
messages: [
@@ -340,40 +496,126 @@ function registerLifecycle(ctx: Context, client: MemoryClient, config: Config):
);
}
if (config.autoPersist !== false) {
ctx.on("agent/turn-stopping", async ({ agent, signal }): Promise<void> => {
const turns = conversationTurns(agent.session.deriveMessages());
const from = persistedUpTo.get(agent) ?? 0;
const fresh = turns.slice(from);
if (fresh.length === 0) return;
persistedUpTo.set(agent, turns.length);
try {
await client.add(fresh, signal);
} catch {
// Persistence is best-effort; never fail a turn over it.
persistedUpTo.set(agent, from);
}
});
if (currentCfg.autoPersist !== false) {
ctx.on(
"agent/turn-stopping",
async ({ agent, signal }: { agent: Agent; signal: AbortSignal }) => {
const mem = client();
if (mem === undefined) return;
const turns = conversationTurns(agent.session.deriveMessages());
const cursor = persistedCursor.get(agent) ?? 0;
const newTurns = turns.slice(cursor);
if (newTurns.length === 0) return;
persistedCursor.set(agent, turns.length);
try {
await mem.add(newTurns, signal);
} catch {
persistedCursor.set(agent, cursor);
}
},
);
}
}
export function apply(ctx: Context, config: Config): void {
const apiKey = resolveApiKey(ctx, config.apiKey);
if (apiKey === undefined) {
throw new Error(
"bailian-memory: no DashScope API key. Set `apiKey` in this row's config or export " +
"$DASHSCOPE_API_KEY (a pay-as-you-go sk-ws- key; the memory API 401s TokenPlan keys).",
const webServer = ctx.get("webServer");
// Mutable runtime config — initialized from Cordis config, updatable via webServer route.
let runtime: MemoryRuntimeConfig = {
apiKey: config.apiKey,
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
userId: resolveUserId(ctx, config),
memoryLibraryId: config.memoryLibraryId,
projectId: config.projectId,
profileSchema: config.profileSchema,
planVersion: config.planVersion ?? DEFAULT_PLAN_VERSION,
topK: config.topK ?? DEFAULT_TOP_K,
minScore: config.minScore,
autoInject: config.autoInject ?? true,
injectEveryTurn: config.injectEveryTurn ?? false,
autoPersist: config.autoPersist ?? true,
};
/** Build a MemoryClient from the current runtime config, or undefined if no API key. */
function buildClient(): MemoryClient | undefined {
if (runtime.apiKey === undefined || runtime.apiKey.length === 0) return undefined;
if (isTokenPlanKey(runtime.apiKey)) {
throw new Error(tokenPlanKeyRejection(name, "the memory API"));
}
return new MemoryClient(runtime.apiKey, runtime.baseUrl, runtime, runtime.userId);
}
// Register tools + auto behavior.
registerTools(ctx, buildClient);
registerAutoBehavior(ctx, buildClient, () => runtime);
// webServer routes for the Client settings page.
if (webServer !== undefined) {
ctx.effect(() =>
webServer.register({
kind: "exact",
path: STATUS_ROUTE,
handler: async (_req: IncomingMessage, res: ServerResponse) => {
sendJson(res, 200, {
configured: runtime.apiKey !== undefined && runtime.apiKey.length > 0,
userId: runtime.userId,
baseUrl: runtime.baseUrl,
planVersion: runtime.planVersion,
topK: runtime.topK,
autoInject: runtime.autoInject,
injectEveryTurn: runtime.injectEveryTurn,
autoPersist: runtime.autoPersist,
memoryLibraryId: runtime.memoryLibraryId,
});
},
}),
);
ctx.effect(() =>
webServer.register({
kind: "exact",
path: CONFIG_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
// Update mutable fields from the request body.
if (typeof body.apiKey === "string") runtime.apiKey = body.apiKey || undefined;
if (typeof body.baseUrl === "string" && body.baseUrl.length > 0)
runtime.baseUrl = body.baseUrl;
if (typeof body.userId === "string" && body.userId.length > 0)
runtime.userId = body.userId;
if (typeof body.memoryLibraryId === "string")
runtime.memoryLibraryId = body.memoryLibraryId || undefined;
if (typeof body.projectId === "string") runtime.projectId = body.projectId || undefined;
if (typeof body.profileSchema === "string")
runtime.profileSchema = body.profileSchema || undefined;
if (body.planVersion === "lite" || body.planVersion === "pro")
runtime.planVersion = body.planVersion;
if (typeof body.topK === "number") runtime.topK = body.topK;
if (typeof body.minScore === "number") runtime.minScore = body.minScore;
if (typeof body.autoInject === "boolean") runtime.autoInject = body.autoInject;
if (typeof body.injectEveryTurn === "boolean")
runtime.injectEveryTurn = body.injectEveryTurn;
if (typeof body.autoPersist === "boolean") runtime.autoPersist = body.autoPersist;
sendJson(res, 200, {
ok: true,
configured: runtime.apiKey !== undefined && runtime.apiKey.length > 0,
});
},
}),
);
}
if (isTokenPlanKey(apiKey)) {
throw new Error(tokenPlanKeyRejection(name, "the memory API"));
}
const client = new MemoryClient(
apiKey,
resolveBaseUrl(ctx, config.baseUrl),
config,
resolveUserId(ctx, config),
);
registerTools(ctx, client);
registerLifecycle(ctx, client, config);
}
+967
View File
@@ -0,0 +1,967 @@
/**
* `bailian-cli-dsh/tokenplan-usage` (Client half): renders a general
* "Bailian" settings.section page with:
*
* 1. **凭证配置** — AK/SK input + "保存凭证" button. Saves to the `dsh`
* bl profile via `POST /api/bailian/credentials`. All future Bailian
* plugins reuse these credentials.
* 2. **TokenPlan 用量** — region/site selectors + "查询用量" button.
* Fetches via `POST /api/bailian/tokenplan/usage` using the dsh profile.
*
* This module runs in the browser. `React` and `styles` are provided by
* the DSH client runtime evaluator; `fetch` is the standard browser API.
*
* @module bailian-cli-dsh/tokenplan-usage/client
*/
// Globals provided by the DSH client runtime evaluator (not imported).
declare const React: {
createElement: (type: any, props?: any, ...children: any[]) => any;
useState: <T>(initial: T) => [T, (value: T | ((prev: T) => T)) => void];
useEffect: (effect: () => void | (() => void), deps?: any[]) => void;
};
declare const styles: { insert: (css: string) => () => void };
/** Services required by the client plugin. */
export const inject = ["slots"];
/** Browser Cordis plugin: registers the "Bailian" settings.section page. */
export function apply(ctx: any): void {
const slots = ctx.get("slots");
if (slots === undefined) return;
styles.insert(`
.bl-wrap { padding: 20px; max-width: 640px; }
.bl-title { font-size: 16px; font-weight: 600; margin: 0 0 4px; color: var(--dsw-text, #18181b); }
.bl-sub { font-size: 12px; color: var(--dsw-text-secondary, #71717a); margin: 0 0 20px; }
.bl-section { border: 1px solid var(--dsw-border, #e4e4e7); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.bl-section-title { font-size: 14px; font-weight: 600; margin: 0 0 12px; color: var(--dsw-text, #18181b); }
.bl-field { margin-bottom: 12px; }
.bl-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--dsw-text, #18181b); }
.bl-input { width: 100%; padding: 7px 10px; border: 1px solid var(--dsw-border, #e4e4e7); border-radius: 6px; background: var(--dsw-input, #fff); color: var(--dsw-text, #18181b); font-size: 13px; box-sizing: border-box; }
.bl-input:focus { outline: none; border-color: var(--dsw-accent, #2563eb); }
.bl-row { display: flex; gap: 12px; align-items: flex-end; }
.bl-row > .bl-field { flex: 1; margin-bottom: 0; }
.bl-btn { padding: 7px 18px; border: none; border-radius: 6px; background: var(--dsw-accent, #2563eb); color: #fff; cursor: pointer; font-size: 13px; font-weight: 500; white-space: nowrap; }
.bl-btn:hover:not(:disabled) { opacity: 0.9; }
.bl-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.bl-card { border: 1px solid var(--dsw-border, #e4e4e7); border-radius: 8px; padding: 14px; margin-top: 12px; }
.bl-card-title { font-size: 13px; font-weight: 600; margin: 0 0 10px; color: var(--dsw-text, #18181b); }
.bl-stat { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; }
.bl-stat-label { font-size: 13px; color: var(--dsw-text-secondary, #71717a); }
.bl-stat-val { font-size: 13px; font-weight: 600; color: var(--dsw-text, #18181b); }
.bl-bar { width: 100%; height: 6px; background: var(--dsw-surface-hover, #f4f4f5); border-radius: 3px; overflow: hidden; margin-top: 4px; }
.bl-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.bl-msg { font-size: 13px; margin-top: 8px; padding: 8px 12px; border-radius: 6px; }
.bl-msg-ok { color: #16a34a; background: #dcfce7; }
.bl-msg-err { color: var(--dsw-danger, #e5484d); background: var(--dsw-danger-bg, #fef2f2); }
.bl-loading { font-size: 13px; color: var(--dsw-text-secondary, #71717a); padding: 12px 0; }
.bl-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--dsw-border, #e4e4e7); border-top-color: var(--dsw-accent, #2563eb); border-radius: 50%; animation: bl-spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
@keyframes bl-spin { to { transform: rotate(360deg); } }
.bl-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
`);
function fmtPct(val: unknown): string {
if (val === undefined || val === null) return "—";
const pct = (typeof val === "number" ? val : Number(val)) * 100;
return (isNaN(pct) ? 0 : pct).toFixed(1) + "%";
}
function barColor(pct: unknown): string {
let n = typeof pct === "number" ? pct : Number(pct);
if (isNaN(n)) n = 0;
if (n >= 0.9) return "#ef4444";
if (n >= 0.7) return "#f59e0b";
return "#22c55e";
}
function fmtTime(ts: unknown): string {
return ts ? new Date(ts as number).toLocaleString() : "—";
}
function fmtDays(days: unknown): string {
if (typeof days === "number") return `${days} 天`;
if (typeof days === "string" && days.length > 0) return `${days} 天`;
return "—";
}
/**
* Host route payloads. `fetch().json()` is `unknown`, and every one of these
* routes answers either its success shape or `{ error }`, so the reads below
* stay optional rather than asserting a discriminated union that the error
* branch would violate.
*/
interface ErrorPayload {
error?: string;
}
interface CredentialsPayload extends ErrorPayload {
ok?: boolean;
profile?: string;
}
interface MemoryStatusPayload {
configured?: boolean;
userId?: string;
baseUrl?: string;
planVersion?: string;
topK?: number;
autoInject?: boolean;
autoPersist?: boolean;
}
slots.inject("settings.section", () =>
slots.register(
{ name: "settings.section", id: "bailian", label: "Bailian", order: 90 },
function () {
const [cred, setCred] = React.useState({ id: "", secret: "", apiKey: "" });
const [credMsg, setCredMsg] = React.useState({ type: "", text: "" });
const [cLoading, setCLoading] = React.useState(false);
const [uForm, setUForm] = React.useState({ region: "cn-beijing", site: "domestic" });
const [result, setResult] = React.useState<any>(null);
const [uLoading, setULoading] = React.useState(false);
const [uErr, setUErr] = React.useState("");
const [memCfg, setMemCfg] = React.useState({
userId: "",
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/",
planVersion: "lite",
topK: 10,
autoInject: true,
autoPersist: true,
memoryLibraryId: "",
});
const [memMsg, setMemMsg] = React.useState({ type: "", text: "" });
const [mLoading, setMLoading] = React.useState(false);
const [memStatus, setMemStatus] = React.useState<any>(null);
function updateCred(field: string, val: string): void {
const patch: Record<string, string> = {};
patch[field] = val;
setCred(Object.assign({}, cred, patch));
}
function updateU(field: string, val: string): void {
const patch: Record<string, string> = {};
patch[field] = val;
setUForm(Object.assign({}, uForm, patch));
}
async function saveCred(): Promise<void> {
if (!cred.id || !cred.secret) return;
setCLoading(true);
setCredMsg({ type: "", text: "" });
try {
const resp = await fetch("/api/bailian/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessKeyId: cred.id, accessKeySecret: cred.secret }),
});
const data = (await resp.json()) as CredentialsPayload;
if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`);
// Also save API Key to memory config if provided
if (cred.apiKey) {
try {
await fetch("/api/bailian/memory/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: cred.apiKey }),
});
} catch {
/* non-fatal: memory module may not be enabled */
}
}
const parts = [`AK/SK 已存入 bl ${data.profile ?? "dsh"} profile`];
if (cred.apiKey) parts.push("API Key 已同步到记忆库配置");
setCredMsg({ type: "ok", text: parts.join(";") + "。" });
} catch (error) {
setCredMsg({
type: "err",
text: error instanceof Error ? error.message : String(error),
});
} finally {
setCLoading(false);
}
}
async function fetchUsage(): Promise<void> {
setULoading(true);
setUErr("");
setResult(null);
try {
const resp = await fetch("/api/bailian/tokenplan/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ region: uForm.region, site: uForm.site }),
});
const data = (await resp.json()) as ErrorPayload;
if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`);
setResult(data);
} catch (error) {
setUErr(error instanceof Error ? error.message : String(error));
} finally {
setULoading(false);
}
}
const children: any[] = [
React.createElement("h3", { className: "bl-title", key: "t" }, "阿里云百炼"),
React.createElement(
"p",
{ className: "bl-sub", key: "s" },
"配置阿里云 AK/SK 后,所有百炼插件共用此凭证调用控制台接口。",
),
];
// ── 凭证配置 ──
const credChildren: any[] = [
React.createElement("div", { className: "bl-section-title", key: "ct" }, "凭证配置"),
];
credChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "ci" },
React.createElement("label", { className: "bl-label" }, "AccessKey ID"),
React.createElement("input", {
className: "bl-input",
value: cred.id,
onChange: (e: any) => updateCred("id", e.target.value),
placeholder: "LTAI...",
}),
),
);
credChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "cs" },
React.createElement("label", { className: "bl-label" }, "AccessKey Secret"),
React.createElement("input", {
className: "bl-input",
type: "password",
value: cred.secret,
onChange: (e: any) => updateCred("secret", e.target.value),
placeholder: "••••••••",
}),
),
);
credChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "cak" },
React.createElement("label", { className: "bl-label" }, "DashScope API Key"),
React.createElement("input", {
className: "bl-input",
type: "password",
value: cred.apiKey,
onChange: (e: any) => updateCred("apiKey", e.target.value),
placeholder: "sk-xxxx(按量付费,记忆库等共用)",
}),
),
);
credChildren.push(
React.createElement(
"button",
{
className: "bl-btn",
key: "cb",
onClick: saveCred,
disabled: cLoading || !cred.id || !cred.secret,
},
cLoading ? "保存中..." : "保存凭证",
),
);
if (credMsg.text) {
credChildren.push(
React.createElement(
"div",
{ className: "bl-msg bl-msg-" + credMsg.type, key: "cm" },
credMsg.text,
),
);
}
children.push(
React.createElement("div", { className: "bl-section", key: "cred" }, credChildren),
);
// ── TokenPlan 用量 ──
const tpChildren: any[] = [
React.createElement(
"div",
{ className: "bl-section-title", key: "tt" },
"TokenPlan 用量",
),
];
tpChildren.push(
React.createElement(
"div",
{ className: "bl-row", key: "tr" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "区域"),
React.createElement(
"select",
{
className: "bl-input",
value: uForm.region,
onChange: (e: any) => updateU("region", e.target.value),
},
React.createElement("option", { value: "cn-beijing" }, "cn-beijing"),
React.createElement("option", { value: "ap-southeast-1" }, "ap-southeast-1"),
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "站点"),
React.createElement(
"select",
{
className: "bl-input",
value: uForm.site,
onChange: (e: any) => updateU("site", e.target.value),
},
React.createElement("option", { value: "domestic" }, "国内站"),
React.createElement("option", { value: "international" }, "国际站"),
),
),
React.createElement(
"button",
{
className: "bl-btn",
onClick: fetchUsage,
disabled: uLoading,
},
uLoading ? "查询中..." : "查询用量",
),
),
);
if (uErr) {
tpChildren.push(
React.createElement("div", { className: "bl-msg bl-msg-err", key: "te" }, uErr),
);
}
if (uLoading) {
tpChildren.push(
React.createElement(
"div",
{ className: "bl-loading", key: "tl" },
React.createElement("span", { className: "bl-spinner" }),
"正在调用控制台接口...",
),
);
}
if (result) {
// Usage card
const usage = result.usage;
if (
usage &&
typeof usage === "object" &&
(usage.per5HourPercentage !== undefined || usage.per1WeekPercentage !== undefined)
) {
const uc: any[] = [
React.createElement("div", { className: "bl-card-title", key: "ut" }, "用量百分比"),
];
if (usage.per5HourPercentage !== undefined) {
uc.push(
React.createElement(
"div",
{ key: "u5" },
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "5 小时窗口"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtPct(usage.per5HourPercentage),
),
),
React.createElement(
"div",
{ className: "bl-bar" },
React.createElement("div", {
className: "bl-bar-fill",
style: {
width: fmtPct(usage.per5HourPercentage),
background: barColor(usage.per5HourPercentage),
},
}),
),
React.createElement(
"div",
{ className: "bl-stat", style: { marginTop: "4px" } },
React.createElement(
"span",
{ className: "bl-stat-label", style: { fontSize: "11px" } },
"重置时间",
),
React.createElement(
"span",
{ className: "bl-stat-val", style: { fontSize: "11px" } },
fmtTime(usage.per5HourResetTime),
),
),
),
);
}
if (usage.per1WeekPercentage !== undefined) {
uc.push(
React.createElement(
"div",
{ key: "u1w", style: { marginTop: "12px" } },
React.createElement(
"div",
{ className: "bl-stat" },
React.createElement("span", { className: "bl-stat-label" }, "1 周窗口"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtPct(usage.per1WeekPercentage),
),
),
React.createElement(
"div",
{ className: "bl-bar" },
React.createElement("div", {
className: "bl-bar-fill",
style: {
width: fmtPct(usage.per1WeekPercentage),
background: barColor(usage.per1WeekPercentage),
},
}),
),
React.createElement(
"div",
{ className: "bl-stat", style: { marginTop: "4px" } },
React.createElement(
"span",
{ className: "bl-stat-label", style: { fontSize: "11px" } },
"重置时间",
),
React.createElement(
"span",
{ className: "bl-stat-val", style: { fontSize: "11px" } },
fmtTime(usage.per1WeekResetTime),
),
),
),
);
}
tpChildren.push(React.createElement("div", { className: "bl-card", key: "uc" }, uc));
}
// Subscription card
const sub = result.subscription;
if (sub && typeof sub === "object" && sub.instanceCode) {
const sm: Record<string, string> = {
lite: "基础版",
standard: "标准版",
pro: "高级版",
};
const sc: any[] = [
React.createElement("div", { className: "bl-card-title", key: "st" }, "套餐信息"),
];
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s1" },
React.createElement("span", { className: "bl-stat-label" }, "套餐类型"),
React.createElement(
"span",
{ className: "bl-stat-val" },
sm[sub.specCode] || sub.specCode,
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s2" },
React.createElement("span", { className: "bl-stat-label" }, "状态"),
React.createElement(
"span",
{
className: "bl-tag",
style: {
background: sub.status === "VALID" ? "#dcfce7" : "#fee2e2",
color: sub.status === "VALID" ? "#16a34a" : "#dc2626",
},
},
sub.status === "VALID" ? "有效" : "无效",
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s3" },
React.createElement("span", { className: "bl-stat-label" }, "剩余天数"),
React.createElement(
"span",
{ className: "bl-stat-val" },
fmtDays(sub.remainingDays),
),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s4" },
React.createElement("span", { className: "bl-stat-label" }, "到期时间"),
React.createElement("span", { className: "bl-stat-val" }, fmtTime(sub.endTime)),
),
);
sc.push(
React.createElement(
"div",
{ className: "bl-stat", key: "s5" },
React.createElement("span", { className: "bl-stat-label" }, "自动续费"),
React.createElement(
"span",
{ className: "bl-stat-val" },
sub.autoRenewFlag ? "已开启" : "未开启",
),
),
);
tpChildren.push(React.createElement("div", { className: "bl-card", key: "sc" }, sc));
}
// Addon summary card
const addon = result.addonSummary;
if (addon && typeof addon === "object" && addon.totalCredits !== undefined) {
const ac: any[] = [
React.createElement("div", { className: "bl-card-title", key: "at" }, "额外用量包"),
];
ac.push(
React.createElement(
"div",
{ className: "bl-stat", key: "a1" },
React.createElement("span", { className: "bl-stat-label" }, "Credits 总量"),
React.createElement("span", { className: "bl-stat-val" }, addon.totalCredits),
),
);
ac.push(
React.createElement(
"div",
{ className: "bl-stat", key: "a2" },
React.createElement("span", { className: "bl-stat-label" }, "Credits 剩余"),
React.createElement("span", { className: "bl-stat-val" }, addon.remainingCredits),
),
);
if (addon.activeCount !== undefined) {
ac.push(
React.createElement(
"div",
{ className: "bl-stat", key: "a3" },
React.createElement("span", { className: "bl-stat-label" }, "生效中"),
React.createElement("span", { className: "bl-stat-val" }, addon.activeCount),
),
);
}
tpChildren.push(React.createElement("div", { className: "bl-card", key: "ac" }, ac));
}
// Errors
if (result.errors && result.errors.length > 0) {
tpChildren.push(
React.createElement(
"div",
{
className: "bl-card",
key: "ec",
style: { borderColor: "var(--dsw-danger, #e5484d)" },
},
result.errors.map((e: any, i: number) =>
React.createElement(
"div",
{ key: "e" + i, style: { fontSize: "12px", marginBottom: "4px" } },
"[" + e.api + "] " + e.message,
),
),
),
);
}
}
children.push(
React.createElement("div", { className: "bl-section", key: "tp" }, tpChildren),
);
// ── 记忆库 ──
function updateMemCfg(field: string, val: any): void {
const patch: Record<string, any> = {};
patch[field] = val;
setMemCfg(Object.assign({}, memCfg, patch));
}
async function saveMemCfg(): Promise<void> {
setMLoading(true);
setMemMsg({ type: "", text: "" });
try {
const resp = await fetch("/api/bailian/memory/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(memCfg),
});
const data = (await resp.json()) as ErrorPayload;
if (!resp.ok) throw new Error(data.error ?? `HTTP ${resp.status}`);
setMemMsg({ type: "ok", text: "记忆库配置已保存。" });
} catch (error) {
setMemMsg({
type: "err",
text: error instanceof Error ? error.message : String(error),
});
} finally {
setMLoading(false);
}
}
async function fetchMemStatus(): Promise<void> {
try {
const resp = await fetch("/api/bailian/memory/status");
const data = (await resp.json()) as MemoryStatusPayload;
setMemStatus(data);
if (data.userId) updateMemCfg("userId", data.userId);
if (data.baseUrl) updateMemCfg("baseUrl", data.baseUrl);
if (data.planVersion) updateMemCfg("planVersion", data.planVersion);
if (data.topK) updateMemCfg("topK", data.topK);
if (data.autoInject !== undefined) updateMemCfg("autoInject", data.autoInject);
if (data.autoPersist !== undefined) updateMemCfg("autoPersist", data.autoPersist);
} catch {
/* memory module may not be enabled */
}
}
React.useEffect(() => {
void fetchMemStatus();
}, []);
const memChildren: any[] = [
React.createElement("div", { className: "bl-section-title", key: "mt" }, "记忆库"),
];
if (memStatus) {
memChildren.push(
React.createElement(
"div",
{ className: "bl-stat", key: "ms" },
React.createElement("span", { className: "bl-stat-label" }, "API Key"),
React.createElement(
"span",
{
className: "bl-tag",
style: {
background: memStatus.configured ? "#dcfce7" : "#fee2e2",
color: memStatus.configured ? "#16a34a" : "#dc2626",
},
},
memStatus.configured ? "已配置" : "未配置",
),
),
);
}
memChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "mu" },
React.createElement("label", { className: "bl-label" }, "User ID"),
React.createElement("input", {
className: "bl-input",
value: memCfg.userId,
onChange: (e: any) => updateMemCfg("userId", e.target.value),
placeholder: "留空用系统用户名",
}),
),
);
memChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "mb" },
React.createElement("label", { className: "bl-label" }, "Base URL"),
React.createElement("input", {
className: "bl-input",
value: memCfg.baseUrl,
onChange: (e: any) => updateMemCfg("baseUrl", e.target.value),
}),
),
);
memChildren.push(
React.createElement(
"div",
{ className: "bl-row", key: "mr" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "策略版本"),
React.createElement(
"select",
{
className: "bl-input",
value: memCfg.planVersion,
onChange: (e: any) => updateMemCfg("planVersion", e.target.value),
},
React.createElement("option", { value: "lite" }, "Lite(便宜)"),
React.createElement("option", { value: "pro" }, "Pro(贵50倍)"),
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement("label", { className: "bl-label" }, "Top K"),
React.createElement("input", {
className: "bl-input",
type: "number",
value: memCfg.topK,
onChange: (e: any) => updateMemCfg("topK", Number(e.target.value)),
}),
),
),
);
memChildren.push(
React.createElement(
"div",
{ className: "bl-row", key: "ms2" },
React.createElement(
"div",
{ className: "bl-field" },
React.createElement(
"label",
{ className: "bl-label" },
React.createElement("input", {
type: "checkbox",
checked: memCfg.autoInject,
onChange: (e: any) => updateMemCfg("autoInject", e.target.checked),
style: { marginRight: "6px" },
}),
"自动检索注入",
),
),
React.createElement(
"div",
{ className: "bl-field" },
React.createElement(
"label",
{ className: "bl-label" },
React.createElement("input", {
type: "checkbox",
checked: memCfg.autoPersist,
onChange: (e: any) => updateMemCfg("autoPersist", e.target.checked),
style: { marginRight: "6px" },
}),
"自动落库",
),
),
),
);
memChildren.push(
React.createElement(
"div",
{ className: "bl-field", key: "ml" },
React.createElement("label", { className: "bl-label" }, "Memory Library ID(可选)"),
React.createElement("input", {
className: "bl-input",
value: memCfg.memoryLibraryId,
onChange: (e: any) => updateMemCfg("memoryLibraryId", e.target.value),
placeholder: "留空用默认",
}),
),
);
memChildren.push(
React.createElement(
"button",
{ className: "bl-btn", key: "mbtn", onClick: saveMemCfg, disabled: mLoading },
mLoading ? "保存中..." : "保存记忆配置",
),
);
if (memMsg.text) {
memChildren.push(
React.createElement(
"div",
{ className: "bl-msg bl-msg-" + memMsg.type, key: "mmsg" },
memMsg.text,
),
);
}
children.push(
React.createElement("div", { className: "bl-section", key: "mem" }, memChildren),
);
return React.createElement("div", { className: "bl-wrap" }, children);
},
),
);
// ── 欢迎页:每个新会话(blank)显示,开始对话后自动隐藏 ──
const WELCOME_TABS: Array<{
id: string;
label: string;
cards: Array<{ title: string; desc: string }>;
}> = [
{
id: "rec",
label: "为我推荐",
cards: [
{
title: "免费额度一键防护",
desc: "一键开启「用完即停」,免费额度耗尽自动停止调用,不再产生意外扣费",
},
{ title: "API Key 配置诊断", desc: "自动排查 401 报错与配置问题,直接给出可用的正确配置" },
{
title: "账单消费分析",
desc: "账单按 Key、模型、时间三个维度拆解,钱花在哪一目了然,顺手设置费用告警",
},
{ title: "限流自查与提额", desc: "被 429 限流?帮你查清用量水位、定位原因,一键申请提额" },
{
title: "智能配置监控告警",
desc: "不知道告警阈值设多少?根据你的历史调用数据自动算出建议值,批量创建规则",
},
{ title: "模型用量统计", desc: "各模型的 Token 用量与费用一次查清,自动生成用量分析报告" },
],
},
{
id: "key",
label: "密钥与接入",
cards: [
{ title: "API Key 配置诊断", desc: "自动排查 401 报错与配置问题,直接给出可用的正确配置" },
{
title: "API Key 管理",
desc: "创建、禁用、重置、删除 Key 对话即可完成,全程脱敏展示,不怕泄露",
},
{
title: "第三方工具一键接入",
desc: "不用手动改配置,为 Claude Code、Cursor、OpenClaw 生成开箱即用的接入配置",
},
{
title: "业务空间与成员管理",
desc: "团队多人协作按空间划分:管理成员与权限,各空间 API Key 相互隔离",
},
],
},
{
id: "usage",
label: "用量与费用",
cards: [
{
title: "免费额度一键防护",
desc: "一键开启「用完即停」,免费额度耗尽自动停止调用,不再产生意外扣费",
},
{
title: "账单消费分析",
desc: "账单按 Key、模型、时间三个维度拆解,钱花在哪一目了然,顺手设置费用告警",
},
{ title: "限流自查与提额", desc: "被 429 限流?帮你查清用量水位、定位原因,一键申请提额" },
{ title: "模型用量统计", desc: "各模型的 Token 用量与费用一次查清,自动生成用量分析报告" },
{
title: "费用告警设置",
desc: "设一条月度消费上限,快超时钉钉/邮件自动提醒,不用天天盯账单",
},
],
},
{
id: "ops",
label: "运维监控",
cards: [
{
title: "智能配置监控告警",
desc: "不知道告警阈值设多少?根据你的历史调用数据自动算出建议值,批量创建规则",
},
{
title: "监控总览与失败日志",
desc: "一屏总览调用量、失败率和延时,出现异常时可直接定位到具体的失败请求",
},
{ title: "告警规则管理", desc: "已有告警规则统一查看、修改、停用,触发历史随时回溯" },
],
},
{
id: "task",
label: "任务与部署",
cards: [
{ title: "批量推理任务", desc: "大批量数据离线跑推理,费用只要一半,完成后一键下载结果" },
{ title: "模型部署管理", desc: "一键部署模型,快速实现扩/缩容,无需手动运维" },
{
title: "图像/视频生成任务",
desc: "一句话生成图片或视频(通义万相等模型),任务进度随时可查",
},
],
},
{
id: "model",
label: "模型管理",
cards: [
{ title: "模型对比选型", desc: "选型拿不准?直接问 agent,帮你推荐最适合的模型" },
{ title: "模型评测", desc: "用自己的业务数据检验模型效果,自动生成评测报告与错例分析" },
{
title: "模型调优",
desc: "让通用模型学会你的业务:对话式引导 SFT 微调,费用与耗时提前算清",
},
],
},
];
styles.insert(`
.bw-wrap{padding:8px 4px 20px;max-width:1100px;margin:0 auto;width:100%}
.bw-head{display:flex;gap:14px;align-items:flex-start;margin-bottom:18px}
.bw-logo{width:44px;height:44px;border-radius:12px;background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;flex:none}
.bw-title{font-size:20px;font-weight:700;color:var(--dsw-text,#18181b);margin-bottom:4px}
.bw-desc{font-size:13px;color:var(--dsw-text-secondary,#71717a);line-height:1.6;max-width:640px}
.bw-tabs{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px}
.bw-tab{padding:8px 18px;border-radius:999px;border:1px solid var(--dsw-border,#e4e4e7);background:var(--dsw-input,#fff);color:var(--dsw-text-secondary,#71717a);font-size:13px;cursor:pointer}
.bw-tab-active{background:#6366f1;border-color:#6366f1;color:#fff}
.bw-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.bw-card{border:1px solid var(--dsw-border,#e4e4e7);border-radius:12px;padding:18px;background:var(--dsw-input,#fff);cursor:pointer;transition:box-shadow .15s}
.bw-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.06)}
.bw-card-title{font-size:15px;font-weight:600;color:var(--dsw-text,#18181b);margin-bottom:8px}
.bw-card-desc{font-size:13px;color:var(--dsw-text-secondary,#71717a);line-height:1.6}
`);
slots.inject("conversation.input.dock", () =>
slots.register(
{ name: "conversation.input.dock", id: "bailian-welcome", order: -100 },
function (props: any) {
const session = props && props.session;
if (!session || session.blank !== true) return null;
const [tab, setTab] = React.useState("rec");
let active = WELCOME_TABS.find((t) => t.id === tab);
if (!active) active = WELCOME_TABS[0];
return React.createElement(
"div",
{ className: "bw-wrap" },
React.createElement(
"div",
{ className: "bw-head" },
React.createElement("div", { className: "bw-logo" }, "B"),
React.createElement(
"div",
null,
React.createElement("div", { className: "bw-title" }, "百炼 Agent"),
React.createElement(
"div",
{ className: "bw-desc" },
"百炼 Agent 是您的智能百炼控制台,通过 Agent + CLI 帮助您高效管理百炼平台,覆盖密钥接入、用量费用、运维监控、任务部署、模型管理等场景。",
),
),
),
React.createElement(
"div",
{ className: "bw-tabs" },
WELCOME_TABS.map((t) =>
React.createElement(
"button",
{
key: t.id,
className: "bw-tab" + (t.id === tab ? " bw-tab-active" : ""),
onClick: () => setTab(t.id),
},
t.label,
),
),
),
React.createElement(
"div",
{ className: "bw-cards" },
active.cards.map((c) =>
React.createElement(
"div",
{ key: c.title, className: "bw-card" },
React.createElement("div", { className: "bw-card-title" }, c.title),
React.createElement("div", { className: "bw-card-desc" }, c.desc),
),
),
),
);
},
),
);
}
+322
View File
@@ -0,0 +1,322 @@
/**
* `bailian-cli-dsh/tokenplan-usage` (Host half): provides two webServer
* routes for the Client's "Bailian" settings page:
*
* 1. `POST /api/bailian/credentials` — saves AK/SK to the dedicated `dsh`
* bl profile via `bl auth login --open-api --config dsh`. This generates
* a fresh access_token and stores AK/SK + token in the profile. All
* subsequent console calls read this profile.
*
* 2. `POST /api/bailian/tokenplan/usage` — fetches personal-edition
* TokenPlan usage (3 console APIs) using the `dsh` profile credentials.
* Takes only `{ region, site }`; AK/SK are already saved in the profile.
*
* Configuration UX: users save AK/SK once on the settings page. All future
* Bailian plugins reuse the same `dsh` profile credentials.
*
* @module bailian-cli-dsh/tokenplan-usage
*/
import type { Context } from "@deepseek-ai/cordis";
import type { IncomingMessage, ServerResponse } from "node:http";
import { runBl } from "../shared/bl.ts";
import z from "@deepseek-ai/schemastery";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tokenplan-usage";
/** Hard dependency: bl runs through the subprocess service. */
export const inject = ["subprocess"];
export interface Config {
/** Alibaba Cloud Access Key ID. Fallback when not provided via UI. */
accessKeyId?: string;
/** Alibaba Cloud Access Key Secret. Fallback when not provided via UI. */
accessKeySecret?: string;
/** Console gateway region (default: cn-beijing). */
consoleRegion?: string;
/** Console site: domestic or international (default: domestic). */
consoleSite?: "domestic" | "international";
/** Dedicated bl config profile name (default: dsh). */
profile?: string;
}
export const Config = z.object({
accessKeyId: z.string().description("Alibaba Cloud Access Key ID (fallback)."),
accessKeySecret: z.string().description("Alibaba Cloud Access Key Secret (fallback)."),
consoleRegion: z.string().description("Console gateway region (default: cn-beijing)."),
consoleSite: z.string().description("Console site: domestic or international."),
profile: z.string().description("Dedicated bl config profile name (default: dsh)."),
});
/** Personal-edition console API names (from bailian-tokenplan frontend). */
const PERSONAL_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
const PERSONAL_SUBSCRIPTION_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription";
const PERSONAL_ADDON_SUMMARY_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/addon/summary";
const PERSONAL_SUB_COMMODITY_CN = "sfm_tokenplansolo_public_cn";
const PERSONAL_SUB_COMMODITY_INTL = "sfm_tokenplansolo_public_intl";
const PERSONAL_ADDON_COMMODITY_CN = "sfm_tokenplansoloaddon_public_cn";
const PERSONAL_ADDON_COMMODITY_INTL = "sfm_tokenplansoloaddon_public_intl";
const CREDENTIALS_ROUTE = "/api/bailian/credentials";
const USAGE_ROUTE = "/api/bailian/tokenplan/usage";
const BL_LOGIN_TIMEOUT_MS = 30_000;
const BL_CALL_TIMEOUT_MS = 90_000;
const BL_LOGIN_GRACE_MS = 20_000;
const BL_CALL_GRACE_MS = 60_000;
const DEFAULT_PROFILE = "dsh";
interface FetchResult {
usage: unknown;
subscription: unknown;
addonSummary: unknown;
errors: Array<{ api: string; message: string }>;
}
/** Extract the business payload from a console gateway response. */
function extractData(response: unknown): unknown {
if (response === null || typeof response !== "object") return response;
const outer = (response as Record<string, unknown>).data;
if (outer !== null && typeof outer === "object") {
const dataV2 = (outer as Record<string, unknown>).DataV2;
if (dataV2 !== null && typeof dataV2 === "object") {
const inner = (dataV2 as Record<string, unknown>).data;
if (inner !== null && typeof inner === "object") {
const payload = (inner as Record<string, unknown>).data;
if (payload !== undefined) return payload;
return inner;
}
}
const fallback = (outer as Record<string, unknown>).data;
if (fallback !== undefined) return fallback;
}
return response;
}
/** Read a UTF-8 POST body up to a size limit. */
function readJsonBody(req: IncomingMessage, maxBytes: number = 8192): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
req.destroy();
reject(new Error("request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
if (text.length === 0) return resolve({});
try {
resolve(JSON.parse(text));
} catch {
reject(new Error("invalid JSON body"));
}
});
req.on("error", reject);
});
}
/** Send a JSON response with a status code. */
function sendJson(res: ServerResponse, status: number, data: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
export function apply(ctx: Context, config: Config): void {
const webServer = ctx.get("webServer");
if (webServer === undefined) return;
const profile = config.profile || DEFAULT_PROFILE;
/** Save AK/SK to the dsh profile (bl auth login --open-api --config dsh). */
async function saveCredentials(accessKeyId: string, accessKeySecret: string): Promise<void> {
const loginArgs = [
"auth",
"login",
"--open-api",
"--config",
profile,
"--access-key-id",
accessKeyId,
"--access-key-secret",
accessKeySecret,
];
const loginOutcome = await runBl(ctx, loginArgs, {
cwd: process.cwd(),
signal: AbortSignal.timeout(BL_LOGIN_TIMEOUT_MS),
graceMs: BL_LOGIN_GRACE_MS,
});
if (loginOutcome.exitCode !== 0) {
const reason =
loginOutcome.stderr.trim() || loginOutcome.stdout.trim() || `exit ${loginOutcome.exitCode}`;
throw new Error(`bl auth login failed: ${reason}`);
}
}
/** Call a console API using the dsh profile (credentials already saved). */
async function consoleCall(
region: string,
site: string,
api: string,
data: Record<string, unknown>,
): Promise<unknown> {
const callArgs = [
"console",
"call",
"--config",
profile,
"--api",
api,
"--data",
JSON.stringify(data),
"--console-region",
region,
"--console-site",
site,
"--output",
"json",
];
const callOutcome = await runBl(ctx, callArgs, {
cwd: process.cwd(),
signal: AbortSignal.timeout(BL_CALL_TIMEOUT_MS),
graceMs: BL_CALL_GRACE_MS,
});
if (callOutcome.exitCode !== 0) {
const reason =
callOutcome.stderr.trim() || callOutcome.stdout.trim() || `exit ${callOutcome.exitCode}`;
throw new Error(`bl console call failed (${api}): ${reason}`);
}
try {
return JSON.parse(callOutcome.stdout);
} catch {
return { raw: callOutcome.stdout };
}
}
/** Fetch all personal-edition TokenPlan usage (3 console calls). */
async function fetchUsage(region: string, site: string): Promise<FetchResult> {
const isIntl = site === "international";
const subCommodity = isIntl ? PERSONAL_SUB_COMMODITY_INTL : PERSONAL_SUB_COMMODITY_CN;
const addonCommodity = isIntl ? PERSONAL_ADDON_COMMODITY_INTL : PERSONAL_ADDON_COMMODITY_CN;
const errors: Array<{ api: string; message: string }> = [];
let usage = null;
let subscription = null;
let addonSummary = null;
try {
usage = extractData(await consoleCall(region, site, PERSONAL_USAGE_API, {}));
} catch (error) {
errors.push({
api: "usage",
message: error instanceof Error ? error.message : String(error),
});
}
try {
subscription = extractData(
await consoleCall(region, site, PERSONAL_SUBSCRIPTION_API, {
queryInstanceInfoRequest: { commodityCode: subCommodity },
}),
);
} catch (error) {
errors.push({
api: "subscription",
message: error instanceof Error ? error.message : String(error),
});
}
try {
addonSummary = extractData(
await consoleCall(region, site, PERSONAL_ADDON_SUMMARY_API, {
commodityCode: addonCommodity,
}),
);
} catch (error) {
errors.push({
api: "addonSummary",
message: error instanceof Error ? error.message : String(error),
});
}
return { usage, subscription, addonSummary, errors };
}
// Route 1: Save credentials to the dsh bl profile.
ctx.effect(() =>
webServer.register({
kind: "exact",
path: CREDENTIALS_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "method not allowed, use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
const accessKeyId = (body.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (body.accessKeySecret as string) || config.accessKeySecret;
if (!accessKeyId || !accessKeySecret) {
sendJson(res, 400, { error: "accessKeyId and accessKeySecret are required." });
return;
}
try {
await saveCredentials(accessKeyId, accessKeySecret);
sendJson(res, 200, { ok: true, profile });
} catch (error) {
sendJson(res, 500, { error: error instanceof Error ? error.message : "internal error" });
}
},
}),
);
// Route 2: Fetch TokenPlan usage using the dsh profile credentials.
ctx.effect(() =>
webServer.register({
kind: "exact",
path: USAGE_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "method not allowed, use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
// If AK/SK are provided in the body, save them first (auto-provision).
const bodyKeyId = (body.accessKeyId as string) || undefined;
const bodyKeySecret = (body.accessKeySecret as string) || undefined;
if (bodyKeyId && bodyKeySecret) {
try {
await saveCredentials(bodyKeyId, bodyKeySecret);
} catch (error) {
sendJson(res, 500, {
error: error instanceof Error ? error.message : "credential save failed",
});
return;
}
}
const region = (body.region as string) || config.consoleRegion || "cn-beijing";
const site = (body.site as string) || config.consoleSite || "domestic";
try {
const result = await fetchUsage(region, site);
sendJson(res, 200, result);
} catch (error) {
sendJson(res, 500, { error: error instanceof Error ? error.message : "internal error" });
}
},
}),
);
}
-239
View File
@@ -1,239 +0,0 @@
/**
* `bailian-cli-dsh/tool-image`: image generation through `bl image generate`.
*
* Delegating to the CLI keeps the async-task polling, model-to-endpoint
* routing, and artifact download in one place rather than restating them here.
*
* Generated files are committed to `ctx.attachments` and returned as
* `ImageBlock`s when the calling route declares image input. When it does not
* — DeepSeek routes never do — the tool degrades to reporting the saved paths
* instead of failing, so the model can hand one to `bailian_vision_describe`.
* For that fallback to work the files must survive the call, so this tool
* deliberately does not delete what the CLI wrote.
*
* @module bailian-cli-dsh/tool-image
*/
import type { Context } from "@deepseek-ai/cordis";
import type { ImageAttachmentRef, ImageMediaType } from "@deepseek-ai/dsh-attachment";
import { AttachmentId } from "@deepseek-ai/dsh-attachment";
import type { ContentBlock } from "@deepseek-ai/dsh-llm";
import type {} from "@deepseek-ai/dsh-fs";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type { ToolExecution } from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-image";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess", "fs"];
export interface Config {
/** Image model passed to `bl image generate --model`. */
model?: string;
/** Directory for generated files; defaults to the CLI's own output dir. */
outDir?: string;
/** Cooperative budget; async models poll until the task succeeds. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
model: z.string().description("Image model; defaults to the CLI's own default."),
outDir: z.string().description("Directory for generated files."),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 300_000;
const MAX_IMAGES = 6;
const MEDIA_TYPE_BY_EXTENSION: Readonly<Record<string, ImageMediaType>> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
};
interface ImageGenerateResponse {
urls?: readonly string[];
saved?: readonly string[];
total?: number;
}
/** One committed image, stored as plain JSON so `render` stays pure. */
interface CommittedImage {
attachmentId: string;
mediaType: ImageMediaType;
bytes: number;
width: number;
height: number;
path: string;
}
function mediaTypeOf(path: string): ImageMediaType | undefined {
const extension = path.split(".").pop()?.toLowerCase();
return extension === undefined ? undefined : MEDIA_TYPE_BY_EXTENSION[extension];
}
function attachmentRefOf(image: CommittedImage): ImageAttachmentRef {
return {
attachmentId: AttachmentId(image.attachmentId),
mediaType: image.mediaType,
bytes: image.bytes,
width: image.width,
height: image.height,
};
}
/**
* Whether the calling route declares image input. Unlike `read_image`'s hard
* gate this only reports, because an unroutable or text-only model is a reason
* to fall back to paths rather than to refuse generating anything.
*/
async function routeAcceptsImages(ctx: Context, exec: ToolExecution): Promise<boolean> {
const routed = exec.agent?.session.requestHeader()?.config;
const provider = routed?.provider ?? exec.agent?.options.provider;
const model = routed?.model ?? exec.agent?.options.model;
const llm = ctx.get("llm");
if (provider === undefined || model === undefined || llm === undefined) return false;
try {
const active = await llm.resolveModelInfo(provider, model, exec.signal);
return active.inputModalities?.includes("image") === true;
} catch {
return false;
}
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(
defineTool({
name: "bailian_image_generate",
description:
"Generate images from a text prompt using Aliyun Bailian (Qwen-Image / Wan). " +
"Files are written to disk and returned inline when the active model can view " +
"images; otherwise the saved paths are reported and you can inspect one with " +
"`bailian_vision_describe`.",
parameters: {
prompt: {
type: "string",
required: true,
description: "What to depict. Be specific about subject, style, and composition.",
},
model: { type: "string", description: "Override the configured image model." },
size: {
type: "string",
description: 'Aspect ratio such as "1:1" / "16:9", or explicit pixels as "1024*1024".',
},
n: {
type: "integer",
description: `How many images to generate (1-${MAX_IMAGES}).`,
},
negative_prompt: { type: "string", description: "What to avoid depicting." },
seed: { type: "integer", description: "Seed for reproducible generation." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
images: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
attachmentId: { type: "string", required: true },
mediaType: { type: "string", required: true },
bytes: { type: "integer", required: true },
width: { type: "integer", required: true },
height: { type: "integer", required: true },
path: { type: "string", required: true },
},
},
},
paths: { type: "array", required: true, items: { type: "string" } },
urls: { type: "array", required: true, items: { type: "string" } },
},
},
render: (_args, value) => {
const paths = value.paths.join("\n");
if (value.images.length === 0) {
return [
{
type: "text",
text:
`Generated ${value.paths.length} image(s); the active model cannot view ` +
`images, so they are on disk only. Use bailian_vision_describe to inspect ` +
`one.\n${paths}`,
},
];
}
const blocks: ContentBlock[] = [
{ type: "text", text: `Generated ${value.images.length} image(s):\n${paths}` },
];
for (const image of value.images) {
blocks.push({ type: "image", attachment: attachmentRefOf(image as CommittedImage) });
}
return blocks;
},
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
async execute(args, exec) {
if (args.n !== undefined && (args.n < 1 || args.n > MAX_IMAGES)) {
throw new Error(`bailian_image_generate accepts n between 1 and ${MAX_IMAGES}.`);
}
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
const argv = ["image", "generate", "--prompt", args.prompt];
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
if (args.size !== undefined) argv.push("--size", args.size);
if (args.n !== undefined) argv.push("--n", String(args.n));
if (args.negative_prompt !== undefined)
argv.push("--negative-prompt", args.negative_prompt);
if (args.seed !== undefined) argv.push("--seed", String(args.seed));
if (config.outDir !== undefined) argv.push("--out-dir", config.outDir);
const response = await runBlJson<ImageGenerateResponse>(ctx, argv, {
cwd,
signal: exec.signal,
});
const paths = [...(response.saved ?? [])];
const urls = [...(response.urls ?? [])];
if (paths.length === 0) {
throw new Error("bl image generate reported no saved files.");
}
const attachments = ctx.get("attachments");
const images: CommittedImage[] = [];
if (attachments !== undefined && (await routeAcceptsImages(ctx, exec))) {
const byteCap = Math.min(
attachments.imageLimits.maxImageBytes,
attachments.imageLimits.maxMessageImageBytes,
);
for (const path of paths) {
const mediaType = mediaTypeOf(path);
if (mediaType === undefined || !attachments.imageLimits.mediaTypes.includes(mediaType))
continue;
const target = await ctx.fs.resolve(path, { cwd, signal: exec.signal });
const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
const ref = await attachments.saveImage({ data, mediaType, name: target.displayPath });
images.push({
attachmentId: ref.attachmentId,
mediaType: ref.mediaType,
bytes: ref.bytes,
width: ref.width,
height: ref.height,
path,
});
}
}
return { images, paths, urls };
},
}),
);
}
@@ -1,304 +0,0 @@
/**
* `bailian-cli-dsh/tool-managed-agent`: run a task on a Bailian-hosted managed
* agent, provisioned on demand, through `bl managed-agent run`.
*
* A plain tool rather than a `SubagentProvider`: in dsh's `web` profile every
* `tool-subagent` row is disabled in the host plane (delegation tools live in
* agent presets), and a subagent provider fixes one agent identity in config —
* neither fits "the model describes an intent and a remote agent is created for
* it". As a tool the model calls it directly and fills `instructions` from the
* user's intent, so the remote agent's role is defined per task.
*
* The CLI does ensure+run in one step: it materializes (idempotently) a cloud
* agent + environment under the given `agent` name on first use and reuses them
* after, so no `agents.yaml` or prior `apply` is required. First use provisions
* cloud resources — it may incur cost and take longer to start.
*
* Credentials: agentstudio is a pay-as-you-go DashScope API served ONLY on the
* workspace-scoped host `https://{workspace}.cn-beijing.maas.aliyuncs.com`
* (the plain dashscope origin and the TokenPlan gateway both 404 it, and a key
* only unlocks its own workspace's host). `bl` resolves the key as
* `--api-key` > `$DASHSCOPE_API_KEY` > the active config profile, but a
* profile's `base_url` is NOT paired with an env-resolved key — an active
* TokenPlan profile therefore aims agentstudio at the TokenPlan gateway. So
* whenever this plugin resolves a key or an endpoint (row config, then launch
* env), it passes them explicitly; see {@link credentialFlags}. Endpoint
* resolution is `baseUrl`, then `$DASHSCOPE_BASE_URL`, then `workspaceId`
* composed into the workspace host (same for `$BAILIAN_WORKSPACE_ID`). With
* nothing resolvable here both halves are left to bl's own auth chain.
*
* @module bailian-cli-dsh/tool-managed-agent
*/
import type { Context } from "@deepseek-ai/cordis";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type {} from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
import {
credentialFlags,
isTokenPlanEndpoint,
isTokenPlanKey,
tokenPlanKeyRejection,
workspaceEndpoint,
} from "../shared/credentials.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-managed-agent";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess"];
/** Default agent identity provisioned and reused across calls. */
const DEFAULT_AGENT = "dsh-remote-runner";
export interface Config {
/** Agent identity to create/reuse; distinct names get distinct remote agents. */
agent?: string;
/** Model for the remote agent. */
model?: string;
/** Pay-as-you-go DashScope key; defaults to `$DASHSCOPE_API_KEY`. */
apiKey?: string;
/**
* Workspace id the key belongs to; composed into the agentstudio host.
* Read from the console's top-right workspace switcher.
*/
workspaceId?: string;
/** Full agentstudio origin; wins over `workspaceId`. */
baseUrl?: string;
/** Cooperative budget; first-run provisioning of a cloud environment is slow. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
agent: z.string().description("Remote agent identity to create/reuse."),
model: z.string().description("Model for the remote agent."),
apiKey: z
.string()
.role("secret")
.description(
"Pay-as-you-go DashScope key (sk-ws-); defaults to $DASHSCOPE_API_KEY. TokenPlan keys are rejected.",
),
workspaceId: z
.string()
.description(
"Workspace the key belongs to (console top-right switcher); defaults to $BAILIAN_WORKSPACE_ID. " +
"Composed into https://{workspaceId}.cn-beijing.maas.aliyuncs.com.",
),
baseUrl: z
.string()
.description(
"Full agentstudio origin; overrides workspaceId. Defaults to $DASHSCOPE_BASE_URL.",
),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 600_000;
/**
* Resolve the managed-agent credentials: row config first, then the launch
* environment. Endpoint resolution: `baseUrl` (explicit origin) beats
* `workspaceId` (composed into the workspace-scoped host); env names mirror
* the same split. Agentstudio is only served on the workspace-scoped host, so
* an unresolved endpoint is left unset for bl to resolve (and the failure
* hints below explain the gap when bl cannot either).
*/
function resolveCredentials(ctx: Context, config: Config): { apiKey?: string; baseUrl?: string } {
const launchEnvironment = launchEnvironmentOf(ctx);
const env = (varName: string): string | undefined => {
const value = launchEnvironment.get(varName)?.value;
return value !== undefined && value.length > 0 ? value : undefined;
};
const apiKey = config.apiKey ?? env("DASHSCOPE_API_KEY");
const workspaceId = config.workspaceId ?? env("BAILIAN_WORKSPACE_ID");
const baseUrl =
config.baseUrl ??
env("DASHSCOPE_BASE_URL") ??
(workspaceId !== undefined ? workspaceEndpoint(workspaceId) : undefined);
return {
...(apiKey !== undefined ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
};
}
/** The `bl managed-agent run --output json` envelope: a session-event list. */
interface SessionRunResponse {
session_id?: string;
agent?: string;
events?: readonly { type?: string; content?: unknown; role?: string }[];
}
/**
* Text of one sanitized envelope event. `bl --output json` emits the SDK's
* sanitized `SessionEvent` shape, where `content` is an ARRAY of content
* blocks (`[{ type: "text", text }]`) — never a plain string — and `type` is
* the provider's raw event type. Tolerate a legacy string `content` too.
*/
function eventText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((block) =>
block !== null &&
typeof block === "object" &&
typeof (block as { text?: unknown }).text === "string"
? (block as { text: string }).text
: "",
)
.join("");
}
/**
* Assistant-visible text of a finished remote session. The envelope echoes
* the user prompt as `type: "message", role: "user"`, so keep only non-user
* message events and join their text blocks.
*/
function assistantText(response: SessionRunResponse): string {
return (response.events ?? [])
.filter((event) => event.type === "message" && event.role !== "user")
.map((event) => eventText(event.content))
.filter((text) => text.length > 0)
.join("\n")
.trim();
}
export function apply(ctx: Context, config: Config): void {
// Resolve credentials at boot so misconfigurations surface as one clear
// message instead of a cryptic 401/404 mid-task. This row is ENABLED BY
// DEFAULT, though, and TokenPlan-only setups legitimately keep
// $DASHSCOPE_API_KEY / $DASHSCOPE_BASE_URL aimed at the TokenPlan gateway
// for the vision/image tools — so a TokenPlan key or endpoint is not a boot
// error here: it becomes a per-call rejection with guidance, and everything
// else keeps working. (Opt-in plugins like bailian-memory reject at boot.)
const credentials = resolveCredentials(ctx, config);
const rejection =
credentials.apiKey !== undefined && isTokenPlanKey(credentials.apiKey)
? tokenPlanKeyRejection(name, "the managed-agent (agentstudio) API")
: credentials.baseUrl !== undefined && isTokenPlanEndpoint(credentials.baseUrl)
? `${name}: the resolved endpoint ${credentials.baseUrl} is the TokenPlan gateway, ` +
"which does not serve /api/v1/agentstudio (requests 404). Agentstudio lives on the " +
"workspace-scoped host: set `workspaceId` (the workspace your key belongs to, from " +
"the console's top-right switcher) or `baseUrl` in this row's config, or export " +
"BAILIAN_WORKSPACE_ID / DASHSCOPE_BASE_URL."
: undefined;
const credentialArgv = credentialFlags(credentials.apiKey, credentials.baseUrl);
ctx.tools.register(
defineTool({
name: "bailian_run_remote_task",
description:
"Run a task on a Bailian-hosted cloud agent. Use for long-running or isolated work you " +
"want executed remotely rather than in this session. A remote agent is created on demand " +
"(and reused) — describe the role it should play through `instructions`, and the concrete " +
"task through `task`. Returns the remote agent's final answer.",
parameters: {
task: {
type: "string",
required: true,
description: "The concrete task for the remote agent to carry out.",
},
instructions: {
type: "string",
description:
"Role/system instructions defining what the remote agent is good at. " +
"Defaults to a generic assistant.",
},
model: {
type: "string",
description: "Override the configured model for this task.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
answer: { type: "string", required: true },
sessionId: { type: "string", required: true },
},
},
render: (_args, value) => [{ type: "text", text: value.answer }],
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
async execute(args, exec) {
if (rejection !== undefined) throw new Error(rejection);
const argv = [
"managed-agent",
"run",
"--prompt",
args.task,
"--agent",
config.agent ?? DEFAULT_AGENT,
];
if (args.instructions !== undefined) argv.push("--instructions", args.instructions);
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
// Atomic credential pair: never let bl pair a key with its active
// profile's base_url (a TokenPlan profile 404s agentstudio).
argv.push(...credentialArgv);
let response: SessionRunResponse;
try {
response = await runBlJson<SessionRunResponse>(ctx, argv, {
cwd: exec.agent?.session.header.cwd ?? process.cwd(),
signal: exec.signal,
});
} catch (error) {
throw enrichProvisioningError(error, {
fellThroughToBlChain: credentials.apiKey === undefined,
endpointResolved: credentials.baseUrl !== undefined,
});
}
const answer = assistantText(response);
if (answer.length === 0) {
throw new Error("the remote agent produced no assistant output.");
}
return { answer, sessionId: response.session_id ?? "" };
},
}),
);
}
/**
* Attach an actionable hint to the classic misconfiguration signatures.
* Agentstudio is only served on the workspace-scoped host, and a key only
* unlocks its own workspace, so the three failure modes each get targeted
* guidance: 404 = endpoint is not a workspace host; 403 `Endpoint.
* AccessDenied` = right shape of host but the wrong workspace for this key;
* 401 = TokenPlan key on a pay-as-you-go API. Anything else passes through.
*/
function enrichProvisioningError(
error: unknown,
context: { fellThroughToBlChain: boolean; endpointResolved: boolean },
): unknown {
if (!(error instanceof Error)) return error;
const message = error.message;
const workspaceHint =
"Agentstudio is served only on the workspace-scoped host " +
"https://{workspaceId}.cn-beijing.maas.aliyuncs.com, and a key only unlocks its own " +
"workspace. Set `workspaceId` (the workspace your key belongs to, from the console's " +
"top-right switcher) or `baseUrl` on the bailian-tool-managed-agent row, or export " +
"BAILIAN_WORKSPACE_ID / DASHSCOPE_BASE_URL.";
let hint: string | undefined;
if (message.includes("Endpoint.AccessDenied") || message.includes("403")) {
hint = `The host is workspace-scoped but this key belongs to a different workspace. ${workspaceHint}`;
} else if (message.includes("404")) {
hint = context.endpointResolved
? `The endpoint rejected /api/v1/agentstudio. ${workspaceHint}`
: context.fellThroughToBlChain
? "No key/endpoint resolved from this row's config or the environment, so bl used its " +
"own auth chain — its active profile endpoint is not the workspace host agentstudio " +
`needs. ${workspaceHint}`
: `The endpoint rejected /api/v1/agentstudio. ${workspaceHint}`;
} else if (message.includes("401")) {
hint =
"The managed-agent API rejected the key. It needs a pay-as-you-go key (sk-ws-); " +
"TokenPlan keys (sk-sp-) only serve the TokenPlan LLM gateway.";
}
if (hint === undefined) return error;
error.message = `${error.message}\n${hint}`;
return error;
}
-135
View File
@@ -1,135 +0,0 @@
/**
* `bailian-cli-dsh/tool-vision`: image and video understanding through
* `bl vision describe` (Qwen-VL).
*
* The tool returns TEXT, never an `ImageBlock` — that is deliberate. dsh gates
* image content on the active route's declared input modalities in two places
* before a plugin ever sees it (the Web UI paste pre-check and `read_image`),
* so a text-only main model such as DeepSeek cannot receive pictures at all.
* Handing back a description instead gives those routes vision indirectly.
* A genuinely multimodal route does not need this tool and should paste images
* directly.
*
* @module bailian-cli-dsh/tool-vision
*/
import type { Context } from "@deepseek-ai/cordis";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type {} from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-vision";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess"];
export interface Config {
/** Vision model passed to `bl vision describe --model`. */
model?: string;
/** Cooperative budget; video understanding uploads and is slow. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
model: z.string().description("Vision model; defaults to the CLI's own default."),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 180_000;
/** The OpenAI-shaped body `bl vision describe --output json` passes through. */
interface VisionResponse {
model?: string;
request_id?: string;
choices?: readonly {
message?: { content?: unknown };
}[];
}
/** Chat content is a string or an array of typed parts; keep only the text. */
function readContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) =>
typeof part === "object" && part !== null && "text" in part
? String((part as { text: unknown }).text)
: "",
)
.join("")
.trim();
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(
defineTool({
name: "bailian_vision_describe",
description:
"Understand an image or video using Aliyun Bailian's Qwen-VL models. " +
"Accepts a local file path or a URL and returns a text description, so it works " +
"even when the active model cannot take image input. Ask a specific question " +
"through `prompt` (for example OCR, chart reading, or object identification) " +
"instead of relying on the generic default.",
parameters: {
image: {
type: "string",
description: "Local image path or http(s)/oss URL. Provide this or `video`.",
},
video: {
type: "array",
items: { type: "string" },
description:
"Video file paths or URLs (mp4/mov/avi/mkv/webm). Local files are uploaded first.",
},
prompt: {
type: "string",
description: "Question about the content. Defaults to a plain description request.",
},
model: {
type: "string",
description: "Override the configured vision model.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
description: { type: "string", required: true },
model: { type: "string", required: true },
},
},
render: (_args, value) => [{ type: "text", text: value.description }],
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
isConcurrencySafe: () => true,
async execute(args, exec) {
const videos = args.video ?? [];
if (args.image === undefined && videos.length === 0) {
throw new Error("bailian_vision_describe requires `image` or `video`.");
}
const argv = ["vision", "describe"];
if (args.image !== undefined) argv.push("--image", args.image);
for (const video of videos) argv.push("--video", video);
if (args.prompt !== undefined) argv.push("--prompt", args.prompt);
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
const response = await runBlJson<VisionResponse>(ctx, argv, {
cwd: exec.agent?.session.header.cwd ?? process.cwd(),
signal: exec.signal,
});
const description = readContent(response.choices?.[0]?.message?.content);
if (description.length === 0) {
throw new Error("Qwen-VL returned an empty description.");
}
return { description, model: response.model ?? model ?? "" };
},
}),
);
}
-171
View File
@@ -1,171 +0,0 @@
/**
* `bailian-cli-dsh/web-search-rag`: registers a Bailian knowledge-base
* `WebSearchProvider` with `ctx.web`.
*
* Retrieval is modelled as a search provider rather than a bespoke tool so the
* model reaches private corpora through the `web_search` it already knows —
* no new tool, no new prompting. Calls go straight to DashScope because the
* seam needs per-call control the CLI does not surface.
*
* One instance serves one knowledge base: `WebSearchRequest` carries only
* `query` and `maxResults`, so the agent id has to come from config. Insert
* additional rows with distinct ids to expose more than one.
*
* @module bailian-cli-dsh/web-search-rag
*/
import type { Context } from "@deepseek-ai/cordis";
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from "@deepseek-ai/dsh-web";
import { WebError } from "@deepseek-ai/dsh-web";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import z from "@deepseek-ai/schemastery";
import { isTokenPlanKey, tokenPlanKeyRejection } from "../shared/credentials.ts";
import { dashScopeFetch, resolveApiKey } from "../shared/http.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-web-search-rag";
/** The web seam this provider registers into. */
export const inject = ["web"];
/** Stable provider id; pin it as `searchProvider` to disambiguate. */
export const BAILIAN_KB_PROVIDER_ID = "bailian-kb";
export interface Config {
/** DashScope key; falls back to `DASHSCOPE_API_KEY`. */
apiKey?: string;
/** Workspace id; also the retrieval host prefix. Falls back to `BAILIAN_WORKSPACE_ID`. */
workspaceId?: string;
/** Retrieval service id from the console's knowledge retrieval page. */
agentId?: string;
/** Default upper bound when the caller sets none. */
maxResults?: number;
}
export const Config: z<Config> = z.object({
apiKey: z
.string()
.role("secret")
.description(
"Pay-as-you-go DashScope key (sk-ws-); defaults to $DASHSCOPE_API_KEY. TokenPlan keys are rejected.",
),
workspaceId: z.string().description("Bailian workspace id; defaults to $BAILIAN_WORKSPACE_ID."),
agentId: z.string().description("Retrieval service (agent) id identifying the knowledge base."),
maxResults: z.natural().description("Default source cap when the caller sets none."),
});
const DEFAULT_MAX_RESULTS = 10;
interface KnowledgeSearchNode {
score?: number;
text?: string;
metadata?: {
title?: string;
doc_id?: string;
doc_name?: string;
doc_url?: string;
page_number?: number;
};
}
interface KnowledgeSearchResponse {
data?: { total?: number; nodes?: readonly KnowledgeSearchNode[] };
}
export interface BailianKbProviderOptions {
apiKey: string;
workspaceId: string;
agentId: string;
maxResults: number;
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
/** The seam requires a URL; documents without one still deserve a stable identity. */
function sourceUrl(node: KnowledgeSearchNode, index: number): string {
const url = node.metadata?.doc_url;
if (url !== undefined && url.length > 0) return url;
return `bailian-kb://${node.metadata?.doc_id ?? `node-${index}`}`;
}
export class BailianKbSearchProvider implements WebSearchProvider {
readonly id = BAILIAN_KB_PROVIDER_ID;
constructor(private readonly options: BailianKbProviderOptions) {}
available(): boolean {
return (
this.options.apiKey.length > 0 &&
this.options.workspaceId.length > 0 &&
this.options.agentId.length > 0
);
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const limit = request.maxResults ?? this.options.maxResults;
let response: KnowledgeSearchResponse;
try {
response = await dashScopeFetch<KnowledgeSearchResponse>({
url: `https://${this.options.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search`,
method: "POST",
apiKey: this.options.apiKey,
body: { query: request.query, agent_id: this.options.agentId },
signal,
});
} catch (error) {
if (isAbort(error))
throw new WebError("knowledge base search aborted", "WEB_ABORTED", { cause: error });
const reason = error instanceof Error ? error.message : String(error);
throw new WebError(`knowledge base search failed: ${reason}`, "WEB_PROVIDER_ERROR", {
cause: error,
});
}
const nodes = (response.data?.nodes ?? []).slice(0, limit);
const sources: WebSearchSource[] = nodes.map((node, index) => {
const metadata = node.metadata ?? {};
const title = metadata.doc_name ?? metadata.title;
const text = node.text ?? "";
return {
url: sourceUrl(node, index),
...(title !== undefined ? { title } : {}),
...(text.length > 0 ? { snippet: text } : {}),
};
});
const content = nodes
.map((node) => node.text ?? "")
.filter((text) => text.length > 0)
.join("\n\n");
// Truncation is the seam's job; report what this provider returned.
return { ...(content.length > 0 ? { content } : {}), sources, truncated: false };
}
}
export function apply(ctx: Context, config: Config): void {
const apiKey = resolveApiKey(ctx, config.apiKey);
// A TokenPlan key would register a provider that looks available and then 401s
// on every search; reject it at boot instead. An absent key stays soft:
// `available()` returns false and dsh falls back to another provider.
if (apiKey !== undefined && isTokenPlanKey(apiKey)) {
throw new Error(tokenPlanKeyRejection(name, "the knowledge-base API"));
}
const workspaceId =
config.workspaceId ?? launchEnvironmentOf(ctx).get("BAILIAN_WORKSPACE_ID")?.value ?? "";
ctx.web.registerSearchProvider(
new BailianKbSearchProvider({
apiKey: apiKey ?? "",
workspaceId,
agentId: config.agentId ?? "",
maxResults: config.maxResults ?? DEFAULT_MAX_RESULTS,
}),
);
}
+5 -3
View File
@@ -7,12 +7,14 @@ import {
workspaceEndpoint,
} from "../src/shared/credentials.ts";
// 行为锁定:两类 Key(sk-sp- TokenPlan / sk-ws- 按量付费)不可混用,三个直连服务
// 模块(memory / RAG / managed-agent)都会拦下 TokenPlan Key,而不是等请求时
// 拿到难懂的 401/404。managed-agent 的凭证两半独立下发:解析出 key 就显式
// 行为锁定:两类 Key(sk-sp- TokenPlan / sk-ws- 按量付费)不可混用。
// TokenPlan 网关 401 按量付费 Key,TokenPlan 网关只提供模型推理,不提供
// 服务 API。managed-agent 的凭证两半独立下发:解析出 key 就显式
// --api-key(不让 bl 用活动 profile 的 key),解析出端点就显式 --base-url
// (不让 bl 用活动 profile 的端点)。agentstudio 只在工作空间前缀主机上提供,
// 因此绝不存在"默认端点"——工作空间未知就是配置缺口,该报错而不是猜。
// 这些共享函数来自早期版本(vision / image / managed-agent 等工具),
// 工具已移除但凭证分类逻辑保留作为参考。
test("isTokenPlanKey classifies by prefix", () => {
expect(isTokenPlanKey("sk-sp-abc123")).toBe(true);
+2 -6
View File
@@ -2,14 +2,10 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
pack: {
// One entry per `exports` subpath: each dsh plugin row imports its own
// module specifier, so they cannot share a bundle.
entry: [
"src/index.ts",
"src/tool-vision/index.ts",
"src/tool-image/index.ts",
"src/tool-managed-agent/index.ts",
"src/web-search-rag/index.ts",
"src/tokenplan-usage/index.ts",
"src/tokenplan-usage/client.ts",
"src/memory/index.ts",
],
minify: true,