diff --git a/packages/dsh/README.md b/packages/dsh/README.md index ced4aef..27ae27d 100644 --- a/packages/dsh/README.md +++ b/packages/dsh/README.md @@ -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-.tgz +cd packages/dsh && pnpm pack + +npx @deepseek-ai/dsh plugin --profile web add /absolute/path/to/bailian-cli-dsh-.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 提问,看是否命中 -- **远程任务**:说「在云端帮我跑一个任务:,它该擅长 」→ 确认模型调用 `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 工具),已移除工具实现但保留共享逻辑作为参考。 diff --git a/packages/dsh/client.bundle.js b/packages/dsh/client.bundle.js new file mode 100644 index 0000000..5aba9c9 --- /dev/null +++ b/packages/dsh/client.bundle.js @@ -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; + }, +}); diff --git a/packages/dsh/cordis.patch.yml b/packages/dsh/cordis.patch.yml index 4d515f8..8059cf4 100644 --- a/packages/dsh/cordis.patch.yml +++ b/packages/dsh/cordis.patch.yml @@ -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 diff --git a/packages/dsh/package.json b/packages/dsh/package.json index 4312144..ef22c5b 100644 --- a/packages/dsh/package.json +++ b/packages/dsh/package.json @@ -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" } } } diff --git a/packages/dsh/src/memory/index.ts b/packages/dsh/src/memory/index.ts index 5193dc5..b08e262 100644 --- a/packages/dsh/src/memory/index.ts +++ b/packages/dsh/src/memory/index.ts @@ -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 = 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 = 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; } 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 { + 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 { 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 }, ): Promise { return dashScopeFetch({ - 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 { - const planVersion = overrides?.planVersion ?? this.config.planVersion ?? DEFAULT_PLAN_VERSION; + const planVersion = overrides?.planVersion ?? this.cfg.planVersion ?? DEFAULT_PLAN_VERSION; return dashScopeFetch({ - 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 { + 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({ + 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(); - const persistedUpTo = new WeakMap(); +/** 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(); + const persistedCursor = new WeakMap(); - if (config.autoInject !== false) { + const currentCfg = cfg(); + + if (currentCfg.autoInject !== false) { ctx.on( "agent/pre-step", - async ({ agent, messages, signal }, next): Promise => { + async ( + { + agent, + messages, + signal, + }: { agent: Agent; messages: readonly Message[]; signal: AbortSignal }, + next: () => Promise, + ) => { 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 => { - 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; + try { + body = (await readJsonBody(req)) as Record; + } 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); } diff --git a/packages/dsh/src/tokenplan-usage/client.ts b/packages/dsh/src/tokenplan-usage/client.ts new file mode 100644 index 0000000..2fdb7a5 --- /dev/null +++ b/packages/dsh/src/tokenplan-usage/client.ts @@ -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: (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(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(null); + + function updateCred(field: string, val: string): void { + const patch: Record = {}; + patch[field] = val; + setCred(Object.assign({}, cred, patch)); + } + function updateU(field: string, val: string): void { + const patch: Record = {}; + patch[field] = val; + setUForm(Object.assign({}, uForm, patch)); + } + + async function saveCred(): Promise { + 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 { + 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 = { + 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 = {}; + patch[field] = val; + setMemCfg(Object.assign({}, memCfg, patch)); + } + async function saveMemCfg(): Promise { + 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 { + 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), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/packages/dsh/src/tokenplan-usage/index.ts b/packages/dsh/src/tokenplan-usage/index.ts new file mode 100644 index 0000000..06fc48e --- /dev/null +++ b/packages/dsh/src/tokenplan-usage/index.ts @@ -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).data; + if (outer !== null && typeof outer === "object") { + const dataV2 = (outer as Record).DataV2; + if (dataV2 !== null && typeof dataV2 === "object") { + const inner = (dataV2 as Record).data; + if (inner !== null && typeof inner === "object") { + const payload = (inner as Record).data; + if (payload !== undefined) return payload; + return inner; + } + } + const fallback = (outer as Record).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 { + 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 { + 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, + ): Promise { + 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 { + 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; + try { + body = (await readJsonBody(req)) as Record; + } 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; + try { + body = (await readJsonBody(req)) as Record; + } 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" }); + } + }, + }), + ); +} diff --git a/packages/dsh/src/tool-image/index.ts b/packages/dsh/src/tool-image/index.ts deleted file mode 100644 index 6c1982f..0000000 --- a/packages/dsh/src/tool-image/index.ts +++ /dev/null @@ -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 = 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> = { - 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 { - 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(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 }; - }, - }), - ); -} diff --git a/packages/dsh/src/tool-managed-agent/index.ts b/packages/dsh/src/tool-managed-agent/index.ts deleted file mode 100644 index 931c65f..0000000 --- a/packages/dsh/src/tool-managed-agent/index.ts +++ /dev/null @@ -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 = 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(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; -} diff --git a/packages/dsh/src/tool-vision/index.ts b/packages/dsh/src/tool-vision/index.ts deleted file mode 100644 index f703179..0000000 --- a/packages/dsh/src/tool-vision/index.ts +++ /dev/null @@ -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 = 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(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 ?? "" }; - }, - }), - ); -} diff --git a/packages/dsh/src/web-search-rag/index.ts b/packages/dsh/src/web-search-rag/index.ts deleted file mode 100644 index d66dc1e..0000000 --- a/packages/dsh/src/web-search-rag/index.ts +++ /dev/null @@ -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 = 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 { - const limit = request.maxResults ?? this.options.maxResults; - let response: KnowledgeSearchResponse; - try { - response = await dashScopeFetch({ - 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, - }), - ); -} diff --git a/packages/dsh/tests/credentials.test.ts b/packages/dsh/tests/credentials.test.ts index acd97b7..53d14d3 100644 --- a/packages/dsh/tests/credentials.test.ts +++ b/packages/dsh/tests/credentials.test.ts @@ -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); diff --git a/packages/dsh/vite.config.ts b/packages/dsh/vite.config.ts index c7ae8a5..fab7fdc 100644 --- a/packages/dsh/vite.config.ts +++ b/packages/dsh/vite.config.ts @@ -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,