mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
+128
-178
@@ -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 工具),已移除工具实现但保留共享逻辑作为参考。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "" };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user