@ai-zen/agents-sdk 1.0.0-alpha.10 → 1.0.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/config/constants.js +4 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/scope/plugins/builtin/BatchEditTool.js +4 -1
- package/dist/scope/plugins/builtin/EditTool.js +6 -1
- package/dist/scope/plugins/builtin/ViewImageTool.d.ts +6 -4
- package/dist/scope/plugins/builtin/ViewImageTool.js +40 -56
- package/dist/scope/plugins/mcp/McpScopePlugin.d.ts +1 -1
- package/dist/scope/plugins/mcp/McpScopePlugin.js +4 -3
- package/dist/scope/plugins/mcp/mcpTools.d.ts +9 -3
- package/dist/scope/plugins/mcp/mcpTools.js +194 -15
- package/dist/shared/content-blocks.d.ts +108 -0
- package/dist/shared/content-blocks.js +256 -0
- package/dist/types/index.d.ts +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.0-alpha.12] - 2026-10-22
|
|
4
|
+
|
|
5
|
+
### 💥 Breaking Changes
|
|
6
|
+
|
|
7
|
+
- **`createReadMcpResourceTool(mcpManager)` → `createReadMcpResourceTool(mcpManager, env: ToolEnv)`** —— 与 alpha.11 的 `createCallMcpTool` 对齐:`env` 用于读取 `maxToolOutput` 并解析当前模型绑定的端点。直接调用该工厂的下游需补第二参。
|
|
8
|
+
- **`view_image` 不再依赖 `deepseek` 端点** —— 此前本地图片固定上传到 `id === "deepseek"` 的端点,未配置时直接报「未配置 deepseek 端点,无法上传本地图片」。现在按能力位决定:声明 `capabilities.filesApi` 的端点走上传,其余走 base64 内联,没有 deepseek 也能查看本地图片。返回文案随之变化(含传输方式与降级说明)。
|
|
9
|
+
- **`Endpoint` 新增可选字段 `capabilities`** —— 类型层面非破坏性(可选),但语义重要:缺省视为「无特殊能力」,即不做上传。
|
|
10
|
+
|
|
11
|
+
### ✨ 新增
|
|
12
|
+
|
|
13
|
+
- **「本地文件 → 内容块」统一入口 `shared/content-blocks.ts`**。这段逻辑此前散落三处各自为政(`ViewImageTool` 硬编码端点 id、MCP 媒体块归一、CLI 侧再实现一遍),现在收敛为一处:
|
|
14
|
+
- `fileToContentSection({ source, endpoint, model, signal, maxInlineBytes, deps })` → `{ section, transport, bytes, warning? }`;`source` 支持 `path` / `url` / `data`(`Uint8Array`)/ `base64` 四种来源。
|
|
15
|
+
- 决策链:http(s) URL → `image_url`;端点有 Files API → 上传得 `file_id`(失败**自动降级**为内联);其余端点 + 图片 → `image_url` + base64 data URL;其余端点 + 非图片 → `file` 块 + `file_data`;非视觉模型 + 图片 → 文本提示;内联超限且无法上传 → 抛错(不静默塞进请求体)。
|
|
16
|
+
- 配套:`resolveModelAndEndpoint()`(按发给 API 的模型名反查模型与端点)、`supportsFilesApi()`、`mimeOf()` / `toDataUrl()` / `base64ByteLength()` / `contentHash()`、`clearUploadCache()`、`DEFAULT_MAX_INLINE_BYTES`(8 MiB)。
|
|
17
|
+
- **已从包入口 `src/index.ts` 导出为公共 API**(`fileToContentSection` / `resolveModelAndEndpoint` / `supportsFilesApi` / `mimeOf` / `toDataUrl` / `base64ByteLength` / `contentHash` / `clearUploadCache` / `MIME_BY_EXT` / `DEFAULT_MAX_INLINE_BYTES`,以及 `FileSource` / `FileToSectionInput` / `FileToSectionResult` / `ContentTransport` / `UploadFn` / `EndpointCapabilities` 类型)。上层应用构造媒体消息时直接复用,不要各自再实现一遍「上传 or 内联」。
|
|
18
|
+
- 上传缓存:路径来源用「端点 id + 绝对路径 + mtime + size」,内存来源用内容 sha256(同一张图不重复上传),上限 200 条。
|
|
19
|
+
- `deps.upload` 可注入,便于测试与替换上传实现。
|
|
20
|
+
- **`Endpoint.capabilities.filesApi` 能力位** —— 出厂仅 `deepseek` 标记为 `true`(description 同步说明)。判据从「端点 id 是不是 deepseek」改为能力位,用户自建端点只要声明即可享受上传。
|
|
21
|
+
|
|
22
|
+
### 🔧 修复
|
|
23
|
+
|
|
24
|
+
- **MCP 媒体不再一律 base64 内联**:`call_mcp_tool` 的 `image` / `audio` / `resource.blob` 改走统一入口,端点有 Files API 能力时上传后以 `file_id` 发送,请求体不再随历史反复膨胀;转换失败**不丢块**,退化为一条文本说明,避免整个工具调用因媒体问题失败。
|
|
25
|
+
- **`read_mcp_resource` 的二进制资源被静默丢弃**:此前只拼 `contents[].text`,图片 / PDF 等 `blob` 内容直接消失。新增 `resourceToMessageSection()`——注意 `readResource` 的 contents 形如 `{ uri, mimeType?, text|blob }`,**没有 `type` 判别字段**,不能复用 `toMessageSection`(否则文本资源会被 JSON 化);`blob` 交给统一入口。
|
|
26
|
+
- **`read_mcp_resource` 接入输出保护**:与 `call_mcp_tool` 共用同一套口径——文本超限落盘 `result.txt` 并返回头尾预览,媒体块原位保留;返回形态同样是「仅文本 → 字符串 / 含媒体 → 内容块数组」。
|
|
27
|
+
- **非视觉模型退化**:`Model.vision !== true` 时图片退化为一行文本提示(本地路径来源与 MCP 内存 base64 来源均生效),避免 `image_url` 被服务端 4xx 拒掉。
|
|
28
|
+
- **`edit` 并发护栏**:`edit` / `batch_edit` 工具描述明确「同一文件禁止并行 `edit`,多处修改改用 `batch_edit`,只有不同文件才能并行」——描述层劝阻(不引入运行时锁)。
|
|
29
|
+
|
|
30
|
+
### ✅ 测试
|
|
31
|
+
|
|
32
|
+
- 新增 `shared/content-blocks.test.ts` 16 例;`mcpTools.test.ts` 扩展覆盖媒体统一入口、非视觉退化、`read` 超限落盘与混排保序;`ViewImageTool` / `EditTool` / `BatchEditTool` 断言同步到新语义。
|
|
33
|
+
- 全量 **55 文件 / 569 用例**通过(6 skipped),`tsc --noEmit` 与 `build` 零错误。
|
|
34
|
+
|
|
35
|
+
### 📄 Docs
|
|
36
|
+
|
|
37
|
+
- `docs/{zh,en}/sdk.md` 新增「本地文件 → 内容块(统一入口)」小节、`Endpoint` 能力位与 MCP 媒体 / 退化 / 输出保护说明。
|
|
38
|
+
- `docs/{zh,en}/mcp.md` 同步 `call_mcp_tool` / `read_mcp_resource` 的媒体传输方式与输出保护口径。
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## [1.0.0-alpha.11] - 2026-10-20
|
|
42
|
+
|
|
43
|
+
### 💥 Breaking Changes
|
|
44
|
+
|
|
45
|
+
- **`createCallMcpTool(mcpManager)` → `createCallMcpTool(mcpManager, env: ToolEnv)`** —— 新增的 `env` 只用于读取 `maxToolOutput`,由 `McpScopePlugin.instantiate` 传入 `scope.env`。直接调用该工厂的下游需补第二参。
|
|
46
|
+
|
|
47
|
+
### ✨ 新增
|
|
48
|
+
|
|
49
|
+
- **`call_mcp_tool` 输出保护**:MCP 工具是最容易一次吐出上百 KB 甚至上 MB JSON 的通道,此前完全裸返回。现在文本部分超过 `maxToolOutput`(默认 32768 字符)时,完整文本落盘为 `result.txt`,只返回警告 + 头尾预览(`outputTooLarge` / `chars` / `head` / `tail` / `files`)。复用内置工具同一套 `outputGuard` 骨架与同一配置口径,不另立阈值。
|
|
50
|
+
- **媒体内容块原样透传**:`image` → `image_url`(`data:<mimeType>;base64,...`)、`audio` → `file`(`file_data` 内联 data URL)、`resource.blob` 按 mimeType 分流到 `image_url` / `file`。媒体**不参与字符计数、不落盘、不丢弃**——只对文本做保护。
|
|
51
|
+
- **返回形态**:仅文本 → 纯字符串(与历史一致);含媒体 → 内容块数组。
|
|
52
|
+
|
|
53
|
+
### 🔧 修复
|
|
54
|
+
|
|
55
|
+
- **非文本内容块被静默丢弃**:旧实现只取 `type === "text"`,`image` / `audio` / `resource` / `resource_link` 全部被扔掉,模型只知道「有个块没给我」。现在媒体透传、`resource.text` 并入正文、`resource_link` 转为 `[资源链接] name · uri · mimeType` 线索、未知类型(如 `tool_use`)退化为 JSON 文本。
|
|
56
|
+
- **内容块顺序**:严格按 MCP `content[]` 原始顺序逐块映射,文本夹在图片中间也保持原位。超限时只保留一个警告块占据原**首个**文本块的位置,其余文本落盘、媒体原位不动——即使文本夹在两图之间,图注与图片的对应关系也不会被打乱。
|
|
57
|
+
- **判定口径**:用全部文本的字符**总数**判超限,不逐块判(否则 N 段各 3 万字符都不超限、合起来却能灌爆)。
|
|
58
|
+
|
|
59
|
+
### ✅ 测试
|
|
60
|
+
|
|
61
|
+
- `mcpTools.test.ts` 38 例(新增内容块映射 8 例、输出保护 4 例、保序 4 例)。全量 **54 文件 / 542 用例**通过,`tsc --noEmit` 与 `build` 零错误。
|
|
62
|
+
|
|
63
|
+
### 📄 Docs
|
|
64
|
+
|
|
65
|
+
- `docs/sdk-design-v1.md` §9 `call_mcp_tool` 补充内容块映射表、输出保护口径与超限保序折叠规则;`../docs/{zh,en}/mcp.md` 及网站镜像同步。
|
|
66
|
+
|
|
67
|
+
|
|
3
68
|
## [1.0.0-alpha.10] - 2026-10-20
|
|
4
69
|
|
|
5
70
|
### 💥 Breaking Changes
|
package/dist/config/constants.js
CHANGED
|
@@ -82,7 +82,10 @@ export const DEFAULT_APP_CONFIG = {
|
|
|
82
82
|
name: "DeepSeek",
|
|
83
83
|
apiKey: "",
|
|
84
84
|
baseUrl: "https://api.deepseek.com/v1",
|
|
85
|
-
description: "DeepSeek API
|
|
85
|
+
description: "DeepSeek API 端点(支持 Files API:本地文件上传后以 file_id 引用)",
|
|
86
|
+
// 唯一声明了 Files API 能力的出厂端点;其余端点本地文件走 base64 内联。
|
|
87
|
+
// 判断依据是能力位而非端点 id,用户自建端点只要声明即可享受上传。
|
|
88
|
+
capabilities: { filesApi: true },
|
|
86
89
|
},
|
|
87
90
|
],
|
|
88
91
|
// 模型清单(2026-10-03 依据各厂商官方来源核对):
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { PermissionPolicy, AgentPermissions, Endpoint, Model, ImageModel, AgentDefinition, AppConfig, ToolEnv, McpIcon, McpAnnotations, McpToolDef, McpResourceDef, McpPromptDef, McpServerManifest, McpServerConfig, McpConnectionState, } from "./types/index.js";
|
|
1
|
+
export type { PermissionPolicy, AgentPermissions, Endpoint, EndpointCapabilities, Model, ImageModel, AgentDefinition, AppConfig, ToolEnv, McpIcon, McpAnnotations, McpToolDef, McpResourceDef, McpPromptDef, McpServerManifest, McpServerConfig, McpConnectionState, } from "./types/index.js";
|
|
2
2
|
export { PermissionEvaluator } from "./scope/PermissionEvaluator.js";
|
|
3
3
|
export type { CandidateSets } from "./scope/PermissionEvaluator.js";
|
|
4
4
|
export { createDisclosureParam } from "./scope/disclosure.js";
|
|
@@ -23,6 +23,8 @@ export { ConfigManager, DEFAULT_AGENT_ID, DEFAULT_AGENT_DEFINITION, DEFAULT_SUBA
|
|
|
23
23
|
export { FACTORY_SKILLS, HANDOFF_SKILL_ID, FALLBACK_HANDOFF_TEMPLATE, isCustomSkill, } from "./config/factory-skills.js";
|
|
24
24
|
export { EntityRepository } from "./shared/EntityRepository.js";
|
|
25
25
|
export { AgentRepository } from "./crud/AgentRepository.js";
|
|
26
|
+
export { fileToContentSection, resolveModelAndEndpoint, supportsFilesApi, mimeOf, toDataUrl, base64ByteLength, contentHash, clearUploadCache, MIME_BY_EXT, DEFAULT_MAX_INLINE_BYTES, } from "./shared/content-blocks.js";
|
|
27
|
+
export type { FileSource, FileToSectionInput, FileToSectionResult, ContentTransport, UploadFn, } from "./shared/content-blocks.js";
|
|
26
28
|
export { createModel } from "./runtime/createModel.js";
|
|
27
29
|
export { SdkAgent } from "./runtime/SdkAgent.js";
|
|
28
30
|
export type { AgentPlugin, SendContext } from "./runtime/SdkAgent.js";
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,8 @@ export { FACTORY_SKILLS, HANDOFF_SKILL_ID, FALLBACK_HANDOFF_TEMPLATE, isCustomSk
|
|
|
22
22
|
// CRUD — 实体仓储(仅 Agent 定义等能力实体)
|
|
23
23
|
export { EntityRepository } from "./shared/EntityRepository.js";
|
|
24
24
|
export { AgentRepository } from "./crud/AgentRepository.js";
|
|
25
|
+
// 文件 → 内容块(统一入口)— 上层应用构造媒体消息时请走这里,不要自己判断上传还是内联
|
|
26
|
+
export { fileToContentSection, resolveModelAndEndpoint, supportsFilesApi, mimeOf, toDataUrl, base64ByteLength, contentHash, clearUploadCache, MIME_BY_EXT, DEFAULT_MAX_INLINE_BYTES, } from "./shared/content-blocks.js";
|
|
25
27
|
// 运行时
|
|
26
28
|
export { createModel } from "./runtime/createModel.js";
|
|
27
29
|
export { SdkAgent } from "./runtime/SdkAgent.js";
|
|
@@ -3,7 +3,10 @@ import { SdkCallbackTool } from "./SdkCallbackTool.js";
|
|
|
3
3
|
export class BatchEditTool extends SdkCallbackTool {
|
|
4
4
|
function = {
|
|
5
5
|
name: "batch_edit",
|
|
6
|
-
description:
|
|
6
|
+
description: [
|
|
7
|
+
"批量编辑文件文本,可以优先使用这个工具对文件进行编辑。",
|
|
8
|
+
"对同一个文件的多处修改应在一次调用中全部提交(replacements 数组),而不是并行多次调用 edit——并行编辑同一文件会互相覆盖。",
|
|
9
|
+
].join("\n"),
|
|
7
10
|
parameters: {
|
|
8
11
|
type: "object",
|
|
9
12
|
properties: {
|
|
@@ -15,7 +15,12 @@ function countOccurrences(content, text) {
|
|
|
15
15
|
export class EditTool extends SdkCallbackTool {
|
|
16
16
|
function = {
|
|
17
17
|
name: "edit",
|
|
18
|
-
description:
|
|
18
|
+
description: [
|
|
19
|
+
"编辑文件中的文本,支持精确替换或替换所有匹配项。",
|
|
20
|
+
"",
|
|
21
|
+
"并发限制(重要):同一个文件禁止并行编辑。不要在同一轮回复中对同一路径发起多个 edit 调用——这些调用会各自读取旧内容再写回,后写者覆盖先写者,导致修改丢失或文件被写坏。",
|
|
22
|
+
"如需对同一个文件施加多处修改,请改用 batch_edit,在一次调用里提交全部替换。只有编辑不同文件时才可以并行调用 edit。",
|
|
23
|
+
].join("\n"),
|
|
19
24
|
parameters: {
|
|
20
25
|
type: "object",
|
|
21
26
|
properties: {
|
|
@@ -7,11 +7,13 @@ import type { AgentDefinition, AppConfig, ToolEnv } from "../../../types/index.j
|
|
|
7
7
|
* buildTools 阶段按声明过滤——非视觉模型不会拿到该工具,故无需运行时重复校验。
|
|
8
8
|
*
|
|
9
9
|
* 输入 `path_or_url`:
|
|
10
|
-
* - http(s) URL → 直接以 `image_url`
|
|
11
|
-
* - 本地路径 →
|
|
10
|
+
* - http(s) URL → 直接以 `image_url` 内容块返回;
|
|
11
|
+
* - 本地路径 → 交给 `fileToContentSection()` 统一处理:
|
|
12
|
+
* 端点声明 `capabilities.filesApi`(出厂仅 deepseek)→ Files API 上传,`file` 块(file_id);
|
|
13
|
+
* 其余端点 → base64 data URL 内联(`image_url`),不再要求必须配置 deepseek。
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* 端点按**当前模型**反查(`ctx.agent.model`),不硬编码端点 id——`file_id` 是端点维度
|
|
16
|
+
* 资源,传错端点会返回 not found。
|
|
15
17
|
*/
|
|
16
18
|
export declare class ViewImageTool extends SdkCallbackTool {
|
|
17
19
|
/** 仅视觉模型可用:从 definition.modelId 解析当前模型,非视觉模型由 buildTools 过滤 */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import OpenAI, { toFile } from "openai";
|
|
2
|
-
import { promises as fs } from "node:fs";
|
|
3
1
|
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
4
3
|
import { SdkCallbackTool } from "./SdkCallbackTool.js";
|
|
5
|
-
|
|
4
|
+
import { fileToContentSection, mimeOf, resolveModelAndEndpoint } from "../../../shared/content-blocks.js";
|
|
6
5
|
/**
|
|
7
6
|
* 是否为"本地 URL"——指向本地/回环地址,API 服务商无法下载访问。
|
|
8
7
|
* 覆盖 file:// 等本地协议,以及指向本机的 http(s) 地址(localhost、127.0.0.1、[::1]、0.0.0.0)。
|
|
@@ -19,11 +18,13 @@ function isLocalUrl(value) {
|
|
|
19
18
|
* buildTools 阶段按声明过滤——非视觉模型不会拿到该工具,故无需运行时重复校验。
|
|
20
19
|
*
|
|
21
20
|
* 输入 `path_or_url`:
|
|
22
|
-
* - http(s) URL → 直接以 `image_url`
|
|
23
|
-
* - 本地路径 →
|
|
21
|
+
* - http(s) URL → 直接以 `image_url` 内容块返回;
|
|
22
|
+
* - 本地路径 → 交给 `fileToContentSection()` 统一处理:
|
|
23
|
+
* 端点声明 `capabilities.filesApi`(出厂仅 deepseek)→ Files API 上传,`file` 块(file_id);
|
|
24
|
+
* 其余端点 → base64 data URL 内联(`image_url`),不再要求必须配置 deepseek。
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* 端点按**当前模型**反查(`ctx.agent.model`),不硬编码端点 id——`file_id` 是端点维度
|
|
27
|
+
* 资源,传错端点会返回 not found。
|
|
27
28
|
*/
|
|
28
29
|
export class ViewImageTool extends SdkCallbackTool {
|
|
29
30
|
/** 仅视觉模型可用:从 definition.modelId 解析当前模型,非视觉模型由 buildTools 过滤 */
|
|
@@ -34,7 +35,7 @@ export class ViewImageTool extends SdkCallbackTool {
|
|
|
34
35
|
}
|
|
35
36
|
function = {
|
|
36
37
|
name: "view_image",
|
|
37
|
-
description: "查看一张图片并分析其内容。传入本地图片路径或 http(s) 图片 URL
|
|
38
|
+
description: "查看一张图片并分析其内容。传入本地图片路径或 http(s) 图片 URL。本地图片优先上传(端点支持 Files API 时)否则以 base64 内联引用,网络图片直接引用。不支持本地 URL(如 file:// 协议、指向本机 localhost/127.0.0.1 的 http(s) 地址),本地图片请使用文件路径。适合识别图片文字、描述图片内容、分析图表等。",
|
|
38
39
|
parameters: {
|
|
39
40
|
type: "object",
|
|
40
41
|
properties: {
|
|
@@ -57,60 +58,43 @@ export class ViewImageTool extends SdkCallbackTool {
|
|
|
57
58
|
return "view_image 需要提供 path_or_url 参数(本地图片路径或 http(s) URL)。";
|
|
58
59
|
}
|
|
59
60
|
const value = target.trim();
|
|
60
|
-
// 拒绝服务商无法访问的"本地 URL":file://
|
|
61
|
+
// 拒绝服务商无法访问的"本地 URL":file:// 等本地协议,或指向本机回环地址的 http(s) 地址
|
|
61
62
|
if (isLocalUrl(value)) {
|
|
62
63
|
return "不支持本地 URL(API 服务商无法下载访问)。本地图片请使用文件路径,而不是本地链接。";
|
|
63
64
|
}
|
|
65
|
+
// 按当前模型定位端点:决定走 Files API 上传还是 base64 内联
|
|
66
|
+
const { model, endpoint } = resolveModelAndEndpoint(this.env.config, ctx?.agent?.model);
|
|
64
67
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
stat = await fs.stat(absPath);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return `本地图片不存在: ${absPath}`;
|
|
85
|
-
}
|
|
86
|
-
if (!stat.isFile()) {
|
|
87
|
-
return `"${absPath}" 不是文件。`;
|
|
68
|
+
const isUrl = /^https?:\/\//i.test(value);
|
|
69
|
+
// 本工具只处理图片:先按扩展名把关,给出比通用入口更贴切的提示
|
|
70
|
+
if (!isUrl) {
|
|
71
|
+
const abs = this.resolve(value);
|
|
72
|
+
// 先 stat 一次,把 ENOENT 变成对人友好的提示(通用入口的报错面向任意文件)
|
|
73
|
+
try {
|
|
74
|
+
await fs.stat(abs);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return `本地图片不存在: ${abs}`;
|
|
78
|
+
}
|
|
79
|
+
const mime = mimeOf(abs);
|
|
80
|
+
if (!mime || !mime.startsWith("image/")) {
|
|
81
|
+
return `不支持的图片格式 "${path.extname(abs) || "(无扩展名)"}"。支持的格式:JPEG、PNG、GIF、WebP。`;
|
|
82
|
+
}
|
|
88
83
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const endpoint = (this.env.config.endpoints ?? []).find((e) => e.id === "deepseek");
|
|
95
|
-
if (!endpoint) {
|
|
96
|
-
return "未配置 deepseek 端点,无法上传本地图片(Files API)。如需查看本地图片,请先配置 deepseek 端点;网络图片 URL 可直接使用。";
|
|
97
|
-
}
|
|
98
|
-
if (!endpoint.apiKey) {
|
|
99
|
-
return `端点 "${endpoint.name}" 的 API Key 未设置,无法上传本地图片。`;
|
|
100
|
-
}
|
|
101
|
-
const client = new OpenAI({
|
|
102
|
-
apiKey: endpoint.apiKey,
|
|
103
|
-
baseURL: endpoint.baseUrl,
|
|
84
|
+
const result = await fileToContentSection({
|
|
85
|
+
source: isUrl ? { url: value } : { path: this.resolve(value) },
|
|
86
|
+
endpoint,
|
|
87
|
+
model,
|
|
88
|
+
signal,
|
|
104
89
|
});
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
];
|
|
90
|
+
const label = isUrl ? `已获取网络图片:${value}` : `已获取本地图片(${path.basename(value)})`;
|
|
91
|
+
const how = result.transport === "files-api"
|
|
92
|
+
? `,已上传至 Files API(file_id: ${result.section.file_id ?? "?"})`
|
|
93
|
+
: result.transport === "inline"
|
|
94
|
+
? `,已按 base64 内联(${(result.bytes / 1024).toFixed(0)} KB)`
|
|
95
|
+
: "";
|
|
96
|
+
const extra = result.warning ? `(降级说明:${result.warning})` : "";
|
|
97
|
+
return [{ type: "text", text: `${label}${how}${extra}` }, result.section];
|
|
114
98
|
}
|
|
115
99
|
catch (error) {
|
|
116
100
|
// 中断(abort)时返回明确的中断结果
|
|
@@ -24,6 +24,6 @@ export declare class McpScopePlugin implements ScopePlugin {
|
|
|
24
24
|
/** 是否配置了 MCP(供外部代码判定,如 UnknownToolHintPlugin) */
|
|
25
25
|
hasConfig(): boolean;
|
|
26
26
|
candidates(): ScopeCandidates;
|
|
27
|
-
instantiate(
|
|
27
|
+
instantiate(scope: Scope, filtered: FilterOutput): Tool[];
|
|
28
28
|
dispose(): Promise<void>;
|
|
29
29
|
}
|
|
@@ -37,7 +37,7 @@ export class McpScopePlugin {
|
|
|
37
37
|
tools: ["load_mcp", "call_mcp_tool", "read_mcp_resource"],
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
instantiate(
|
|
40
|
+
instantiate(scope, filtered) {
|
|
41
41
|
const allow = new Set(filtered.tools);
|
|
42
42
|
const allowedMcps = new Set(filtered.mcps);
|
|
43
43
|
const filteredMcps = this.servers.filter((s) => allowedMcps.has(s.id));
|
|
@@ -47,10 +47,11 @@ export class McpScopePlugin {
|
|
|
47
47
|
result.push(createLoadMcpTool(m, filteredMcps));
|
|
48
48
|
}
|
|
49
49
|
if (allow.has("call_mcp_tool") && m) {
|
|
50
|
-
|
|
50
|
+
// env 供 call_mcp_tool 读取 maxToolOutput,与内置工具共用同一上限口径
|
|
51
|
+
result.push(createCallMcpTool(m, scope.env));
|
|
51
52
|
}
|
|
52
53
|
if (allow.has("read_mcp_resource") && m) {
|
|
53
|
-
result.push(createReadMcpResourceTool(m));
|
|
54
|
+
result.push(createReadMcpResourceTool(m, scope.env));
|
|
54
55
|
}
|
|
55
56
|
return result;
|
|
56
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CallbackTool } from "@ai-zen/agents-core";
|
|
2
2
|
import type { McpConnectionManager } from "./McpConnectionManager.js";
|
|
3
|
-
import type { McpServerConfig } from "../../../types/index.js";
|
|
3
|
+
import type { McpServerConfig, ToolEnv } from "../../../types/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* 创建 load_mcp 工具。
|
|
6
6
|
* server 枚举由 filteredMcps (完整 McpServerConfig[])自动推导,
|
|
@@ -10,9 +10,15 @@ export declare function createLoadMcpTool(mcpManager: McpConnectionManager, filt
|
|
|
10
10
|
/**
|
|
11
11
|
* 创建 call_mcp_tool 工具。
|
|
12
12
|
* 通过官方 Client.callTool() API 调用 MCP 服务器上的工具。
|
|
13
|
+
*
|
|
14
|
+
* 返回形态:
|
|
15
|
+
* - 仅文本 → 纯字符串(与历史行为一致);
|
|
16
|
+
* - 含媒体 → 内容块数组:首块为受保护的文本,其后原样透传 image / file 块。
|
|
17
|
+
*
|
|
18
|
+
* env 仅用于读取 maxToolOutput(文本输出保护上限),与内置工具口径一致。
|
|
13
19
|
*/
|
|
14
|
-
export declare function createCallMcpTool(mcpManager: McpConnectionManager): CallbackTool;
|
|
20
|
+
export declare function createCallMcpTool(mcpManager: McpConnectionManager, env: ToolEnv): CallbackTool;
|
|
15
21
|
/**
|
|
16
22
|
* 创建 read_mcp_resource 工具。
|
|
17
23
|
*/
|
|
18
|
-
export declare function createReadMcpResourceTool(mcpManager: McpConnectionManager): CallbackTool;
|
|
24
|
+
export declare function createReadMcpResourceTool(mcpManager: McpConnectionManager, env: ToolEnv): CallbackTool;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CallbackTool } from "@ai-zen/agents-core";
|
|
2
|
+
import { fileToContentSection, resolveModelAndEndpoint } from "../../../shared/content-blocks.js";
|
|
2
3
|
import { createDisclosureParam } from "../../disclosure.js";
|
|
4
|
+
import { DEFAULT_MAX_TOOL_OUTPUT, guardOutput, headTailPreview } from "../builtin/outputGuard.js";
|
|
3
5
|
import { getLogger } from "../../../shared/logger.js";
|
|
4
6
|
const EMPTY_HINT = "(当前没有可用的 MCP 服务器,请联系用户添加)";
|
|
5
7
|
const log = getLogger();
|
|
@@ -81,15 +83,172 @@ export function createLoadMcpTool(mcpManager, filteredMcps) {
|
|
|
81
83
|
},
|
|
82
84
|
});
|
|
83
85
|
}
|
|
86
|
+
/** MIME → 文件名后缀(仅用于上传时给文件一个好名字) */
|
|
87
|
+
const EXT_BY_MIME = {
|
|
88
|
+
"image/png": "png",
|
|
89
|
+
"image/jpeg": "jpg",
|
|
90
|
+
"image/gif": "gif",
|
|
91
|
+
"image/webp": "webp",
|
|
92
|
+
"audio/wav": "wav",
|
|
93
|
+
"audio/mpeg": "mp3",
|
|
94
|
+
"application/pdf": "pdf",
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* MCP 媒体(base64 字符串)→ SDK 内容块。
|
|
98
|
+
*
|
|
99
|
+
* 统一交给 `fileToContentSection()` 决定传输方式:端点声明 Files API 能力就上传
|
|
100
|
+
* (`file` 块 + `file_id`,请求体小、不随历史反复膨胀),否则保持 base64 内联。
|
|
101
|
+
* 转换失败**不丢块**——退化为一条文本说明,避免整个工具调用因媒体问题失败。
|
|
102
|
+
*/
|
|
103
|
+
async function mediaSection(data, mime, media) {
|
|
104
|
+
const ext = EXT_BY_MIME[mime] ?? "bin";
|
|
105
|
+
try {
|
|
106
|
+
const { section } = await fileToContentSection({
|
|
107
|
+
source: {
|
|
108
|
+
base64: data,
|
|
109
|
+
mime,
|
|
110
|
+
filename: `mcp-media-${Date.now()}.${ext}`,
|
|
111
|
+
},
|
|
112
|
+
endpoint: media.endpoint,
|
|
113
|
+
model: media.model,
|
|
114
|
+
signal: media.signal,
|
|
115
|
+
});
|
|
116
|
+
return section;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return {
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `[媒体] 无法转换(${mime}):${error?.message ?? error}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `read_mcp_resource` 的 resource contents → SDK 内容块。
|
|
127
|
+
*
|
|
128
|
+
* 注意形状差异:`callTool` 的 content 块带 `type` 判别字段,而 `readResource` 的
|
|
129
|
+
* contents 是 `{ uri, mimeType?, text }` 或 `{ uri, mimeType?, blob }`——**没有 type**。
|
|
130
|
+
* 若直接复用 `toMessageSection`,文本资源会掉进 default 分支被 JSON 化(实测踩过)。
|
|
131
|
+
*/
|
|
132
|
+
async function resourceToMessageSection(item, media) {
|
|
133
|
+
if (typeof item?.text === "string")
|
|
134
|
+
return { type: "text", text: item.text };
|
|
135
|
+
if (typeof item?.blob === "string") {
|
|
136
|
+
return mediaSection(item.blob, item.mimeType ?? "application/octet-stream", media);
|
|
137
|
+
}
|
|
138
|
+
return { type: "text", text: JSON.stringify(item) };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* MCP content 块 → SDK 内容块。
|
|
142
|
+
*
|
|
143
|
+
* 文本类(text / resource.text / resource_link / 未知类型)归一为 `text` 块,
|
|
144
|
+
* 由调用方拼接成正文后进入输出保护;
|
|
145
|
+
* 媒体类(image / audio / resource.blob)经 `mediaSection()` → `fileToContentSection()`:
|
|
146
|
+
* 端点支持 Files API 时上传为 `file` 块,否则 base64 内联——图片不进字符闸门,也不丢弃。
|
|
147
|
+
*/
|
|
148
|
+
async function toMessageSection(block, media) {
|
|
149
|
+
switch (block?.type) {
|
|
150
|
+
case "image":
|
|
151
|
+
return await mediaSection(block.data, block.mimeType ?? "image/png", media);
|
|
152
|
+
case "audio":
|
|
153
|
+
// AgentNS 无独立音频块,以 `file` 块承载(支持上传的端点 → file_id,否则 file_data 内联)
|
|
154
|
+
return await mediaSection(block.data, block.mimeType ?? "audio/wav", media);
|
|
155
|
+
case "resource": {
|
|
156
|
+
const r = block.resource ?? {};
|
|
157
|
+
// 内嵌文本资源:本质是纯文本,并入正文(同样受输出保护)
|
|
158
|
+
if (typeof r.text === "string")
|
|
159
|
+
return { type: "text", text: r.text };
|
|
160
|
+
// 内嵌二进制资源:按 mimeType 选块类型,不丢弃
|
|
161
|
+
if (typeof r.blob === "string") {
|
|
162
|
+
const mime = r.mimeType ?? "application/octet-stream";
|
|
163
|
+
return await mediaSection(r.blob, mime, media);
|
|
164
|
+
}
|
|
165
|
+
return { type: "text", text: `[资源] ${r.uri ?? "(未知 URI)"}` };
|
|
166
|
+
}
|
|
167
|
+
case "resource_link": {
|
|
168
|
+
const parts = [block.name, block.uri, block.mimeType].filter(Boolean);
|
|
169
|
+
return { type: "text", text: `[资源链接] ${parts.join(" · ")}` };
|
|
170
|
+
}
|
|
171
|
+
case "text":
|
|
172
|
+
return { type: "text", text: block.text ?? "" };
|
|
173
|
+
default:
|
|
174
|
+
// 未知类型(如 tool_use)退化为 JSON 文本,仍受输出保护
|
|
175
|
+
return { type: "text", text: JSON.stringify(block) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 文本输出保护(保序):
|
|
180
|
+
*
|
|
181
|
+
* - **未超限**:原样返回映射后的块数组,不重排、不合并、不丢块;
|
|
182
|
+
* 纯文本时退化为单个字符串(历史行为)。
|
|
183
|
+
* - **超限**:全部文本落盘为 result.txt,只保留**一个**警告文本块占据
|
|
184
|
+
* 原首个文本块的位置,其余文本块丢弃(内容已落盘),媒体块原位不动。
|
|
185
|
+
* 这样即使文本夹在中间,媒体与位置的对应关系也不会被打乱。
|
|
186
|
+
*
|
|
187
|
+
* 判定用全部文本的字符总数——不能逐块判,否则 N 段各 3 万字符都不超限、合起来却能灌爆。
|
|
188
|
+
* 落盘只写文本正文,不写原始结果——后者可能含 image 的 base64,
|
|
189
|
+
* 落盘等于把几 MB 二进制再抄一遍磁盘。
|
|
190
|
+
*/
|
|
191
|
+
async function protectCallOutput(output, env) {
|
|
192
|
+
const limit = env.config.maxToolOutput ?? DEFAULT_MAX_TOOL_OUTPUT;
|
|
193
|
+
const { sections, fullText, chars, isError } = output;
|
|
194
|
+
const guarded = await guardOutput({
|
|
195
|
+
tool: "call_mcp_tool",
|
|
196
|
+
content: output,
|
|
197
|
+
isOverLimit: (o) => o.chars > limit,
|
|
198
|
+
dump: (_dir, o) => [{ name: "result.txt", content: o.fullText }],
|
|
199
|
+
buildWarning: (ctx) => {
|
|
200
|
+
const preview = headTailPreview(fullText);
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
outputTooLarge: true,
|
|
203
|
+
isError: isError || undefined,
|
|
204
|
+
warning: `文本输出过大:共 ${chars} 字符,超过上限 ${limit} 字符。` +
|
|
205
|
+
`完整文本已落盘(result.txt),可用 read 分批读取。`,
|
|
206
|
+
files: ctx.files,
|
|
207
|
+
chars,
|
|
208
|
+
...preview,
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
// 超限:警告占据首个文本块的位置,其余文本块合并到它身上(内容已落盘),媒体原位
|
|
213
|
+
if (typeof guarded === "string") {
|
|
214
|
+
const collapsed = [];
|
|
215
|
+
let placed = false;
|
|
216
|
+
for (const section of sections) {
|
|
217
|
+
if (section.type === "text") {
|
|
218
|
+
if (!placed) {
|
|
219
|
+
collapsed.push({ type: "text", text: guarded });
|
|
220
|
+
placed = true;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
collapsed.push(section);
|
|
225
|
+
}
|
|
226
|
+
// 仅一个文本块(无媒体):退化为字符串,与历史行为一致
|
|
227
|
+
return collapsed.length === 1 ? collapsed[0].text : collapsed;
|
|
228
|
+
}
|
|
229
|
+
// 未超限:无媒体 → 纯字符串;有媒体 → 原序块数组
|
|
230
|
+
const hasMedia = sections.some((s) => s.type !== "text");
|
|
231
|
+
if (!hasMedia)
|
|
232
|
+
return isError ? `❌ 工具执行出错:\n${fullText}` : fullText;
|
|
233
|
+
return isError
|
|
234
|
+
? [{ type: "text", text: "❌ 工具执行出错:" }, ...sections]
|
|
235
|
+
: sections;
|
|
236
|
+
}
|
|
84
237
|
/**
|
|
85
238
|
* 创建 call_mcp_tool 工具。
|
|
86
239
|
* 通过官方 Client.callTool() API 调用 MCP 服务器上的工具。
|
|
240
|
+
*
|
|
241
|
+
* 返回形态:
|
|
242
|
+
* - 仅文本 → 纯字符串(与历史行为一致);
|
|
243
|
+
* - 含媒体 → 内容块数组:首块为受保护的文本,其后原样透传 image / file 块。
|
|
244
|
+
*
|
|
245
|
+
* env 仅用于读取 maxToolOutput(文本输出保护上限),与内置工具口径一致。
|
|
87
246
|
*/
|
|
88
|
-
export function createCallMcpTool(mcpManager) {
|
|
247
|
+
export function createCallMcpTool(mcpManager, env) {
|
|
89
248
|
return new CallbackTool({
|
|
90
249
|
function: {
|
|
91
250
|
name: "call_mcp_tool",
|
|
92
|
-
description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp
|
|
251
|
+
description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。文本输出过大时会落盘并只返回头尾预览;图片、音频等媒体会保留:端点支持 Files API 时上传后以 file_id 引用,否则 base64 内联。",
|
|
93
252
|
parameters: {
|
|
94
253
|
type: "object",
|
|
95
254
|
properties: {
|
|
@@ -117,15 +276,23 @@ export function createCallMcpTool(mcpManager) {
|
|
|
117
276
|
name: input.tool,
|
|
118
277
|
arguments: input.arguments,
|
|
119
278
|
}, undefined, { signal: ctx?.signal });
|
|
279
|
+
// 按 MCP content 原始顺序映射,文本与媒体的相对位置完整保留
|
|
120
280
|
const contents = result.content ?? [];
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return `❌ 工具执行出错:\n${text || JSON.stringify(result)}`;
|
|
281
|
+
const isError = Boolean(result.isError);
|
|
282
|
+
// 没有 content 时退回原始 JSON(既有兜底)
|
|
283
|
+
if (contents.length === 0) {
|
|
284
|
+
const json = JSON.stringify(result);
|
|
285
|
+
return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError }, env);
|
|
127
286
|
}
|
|
128
|
-
|
|
287
|
+
// 按当前模型定位端点:决定媒体走 Files API 上传还是 base64 内联
|
|
288
|
+
const { model, endpoint } = resolveModelAndEndpoint(env.config, ctx?.agent?.model);
|
|
289
|
+
const mediaContext = { endpoint, model, signal: ctx?.signal };
|
|
290
|
+
// Promise.all 保序:文本与媒体的相对位置不变
|
|
291
|
+
const sections = await Promise.all(contents.map((block) => toMessageSection(block, mediaContext)));
|
|
292
|
+
const textSections = sections.filter((s) => s.type === "text");
|
|
293
|
+
const fullText = textSections.map((s) => s.text).join("\n");
|
|
294
|
+
const chars = textSections.reduce((n, s) => n + s.text.length, 0);
|
|
295
|
+
return await protectCallOutput({ sections, fullText, chars, isError }, env);
|
|
129
296
|
}
|
|
130
297
|
catch (error) {
|
|
131
298
|
return `❌ 调用 "${input.tool}" 失败: ${error?.message ?? error}`;
|
|
@@ -136,11 +303,11 @@ export function createCallMcpTool(mcpManager) {
|
|
|
136
303
|
/**
|
|
137
304
|
* 创建 read_mcp_resource 工具。
|
|
138
305
|
*/
|
|
139
|
-
export function createReadMcpResourceTool(mcpManager) {
|
|
306
|
+
export function createReadMcpResourceTool(mcpManager, env) {
|
|
140
307
|
return new CallbackTool({
|
|
141
308
|
function: {
|
|
142
309
|
name: "read_mcp_resource",
|
|
143
|
-
description: "读取已连接 MCP
|
|
310
|
+
description: "读取已连接 MCP 服务器上的指定资源(文档、数据、图片等)。文本原样返回;图片等媒体按端点能力上传(file_id)或 base64 内联,不会被丢弃。",
|
|
144
311
|
parameters: {
|
|
145
312
|
type: "object",
|
|
146
313
|
properties: {
|
|
@@ -165,10 +332,22 @@ export function createReadMcpResourceTool(mcpManager) {
|
|
|
165
332
|
mcpManager.touch(serverName);
|
|
166
333
|
const result = await client.readResource({ uri: input.uri }, { signal: ctx?.signal });
|
|
167
334
|
const contents = result?.contents ?? [];
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
335
|
+
const { model, endpoint } = resolveModelAndEndpoint(env.config, ctx?.agent?.model);
|
|
336
|
+
const mediaContext = { endpoint, model, signal: ctx?.signal };
|
|
337
|
+
// 与 call_mcp_tool 同一套映射:文本归一为 text 块,媒体按端点能力上传或内联,
|
|
338
|
+
// 不再把二进制资源(图片 / PDF)静默丢掉。
|
|
339
|
+
const sections = await Promise.all(contents.map((content) => resourceToMessageSection(content, mediaContext)));
|
|
340
|
+
// 没有 contents 时退回原始 JSON(与 call_mcp_tool 同一兜底)
|
|
341
|
+
if (sections.length === 0) {
|
|
342
|
+
const json = JSON.stringify(result);
|
|
343
|
+
return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError: false }, env);
|
|
344
|
+
}
|
|
345
|
+
// 与 call_mcp_tool 共用同一套输出保护:文本超限落盘 result.txt,
|
|
346
|
+
// 媒体块原位保留;未超限时纯文本退化为字符串、含媒体返回块数组。
|
|
347
|
+
const textSections = sections.filter((section) => section.type === "text");
|
|
348
|
+
const fullText = textSections.map((section) => section.text).join("\n");
|
|
349
|
+
const chars = textSections.reduce((n, section) => n + section.text.length, 0);
|
|
350
|
+
return await protectCallOutput({ sections, fullText, chars, isError: false }, env);
|
|
172
351
|
}
|
|
173
352
|
catch (error) {
|
|
174
353
|
return `❌ 读取资源 "${input.uri}" 失败: ${error?.message ?? error}`;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地文件 → 消息内容块的唯一转换入口。
|
|
3
|
+
*
|
|
4
|
+
* 背景:这段逻辑原先散落三处,且各自为政 ——
|
|
5
|
+
* 1. `ViewImageTool` 自己判断 `endpoint.id === "deepseek"` 再上传(端点 id 一变就失效);
|
|
6
|
+
* 2. `mcp/mcpTools.ts` 自己把 MCP 的 base64 归一成 `image_url` / `file` 块;
|
|
7
|
+
* 3. CLI 侧又实现了一遍「上传 or base64」。
|
|
8
|
+
*
|
|
9
|
+
* 现在统一到这里,调用方只提供「文件 + 当前端点/模型」,传输方式由本模块决定:
|
|
10
|
+
*
|
|
11
|
+
* | 条件 | 结果 |
|
|
12
|
+
* |------|------|
|
|
13
|
+
* | http(s) URL | 直接 `image_url`(不上传、不内联) |
|
|
14
|
+
* | 端点声明 `capabilities.filesApi` | 上传 Files API → `file` 块(`file_id`),失败降级为内联 |
|
|
15
|
+
* | 其余端点 + 图片 | `image_url` + base64 data URL |
|
|
16
|
+
* | 其余端点 + 非图片 | `file` 块 + `file_data`(base64 data URL) |
|
|
17
|
+
* | 模型明确不支持视觉(`vision === false`)且是图片 | 退化为 `text`,只给路径提示 |
|
|
18
|
+
* | 内联体积超过上限且无法上传 | 抛错(不静默塞进请求体) |
|
|
19
|
+
*/
|
|
20
|
+
import type { AgentNS } from "@ai-zen/agents-core";
|
|
21
|
+
import type { AppConfig, Endpoint, Model } from "../types/index.js";
|
|
22
|
+
/** 扩展名 → MIME。图片走视觉通道,其余走 `file` 块 */
|
|
23
|
+
export declare const MIME_BY_EXT: Record<string, string>;
|
|
24
|
+
/** 按扩展名推断 MIME;未知扩展名返回 undefined */
|
|
25
|
+
export declare function mimeOf(filePath: string): string | undefined;
|
|
26
|
+
/** base64 二进制转 data URL(`image_url` 与 `file.file_data` 均接受) */
|
|
27
|
+
export declare function toDataUrl(base64: string, mime: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* base64 字符串对应的字节数(不含空白)。
|
|
30
|
+
*
|
|
31
|
+
* 用于「只看体积、不想解码」的场景:内联上限检查与日志统计。
|
|
32
|
+
*/
|
|
33
|
+
export declare function base64ByteLength(base64: string): number;
|
|
34
|
+
/** 内联(base64)体积上限,默认 8 MiB;超过且端点不支持上传时直接报错 */
|
|
35
|
+
export declare const DEFAULT_MAX_INLINE_BYTES: number;
|
|
36
|
+
/** 实际采用的传输方式(供调用方组织提示文案 / 日志) */
|
|
37
|
+
export type ContentTransport = "url" | "files-api" | "inline" | "text";
|
|
38
|
+
/** 文件来源:本地路径 / 网络 URL / 已在内存里的字节 */
|
|
39
|
+
export type FileSource = {
|
|
40
|
+
path: string;
|
|
41
|
+
} | {
|
|
42
|
+
url: string;
|
|
43
|
+
} | {
|
|
44
|
+
data: Uint8Array;
|
|
45
|
+
mime: string;
|
|
46
|
+
filename?: string;
|
|
47
|
+
}
|
|
48
|
+
/** 已经是 base64 的字节(MCP 等协议给的就是 base64):内联时原样透传,不做解码再编码 */
|
|
49
|
+
| {
|
|
50
|
+
base64: string;
|
|
51
|
+
mime: string;
|
|
52
|
+
filename?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Files API 上传函数签名(可注入,便于测试与替换实现) */
|
|
55
|
+
export type UploadFn = (input: {
|
|
56
|
+
endpoint: Endpoint;
|
|
57
|
+
data: Uint8Array;
|
|
58
|
+
filename: string;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}) => Promise<string>;
|
|
61
|
+
export interface FileToSectionInput {
|
|
62
|
+
source: FileSource;
|
|
63
|
+
/** 当前模型所用端点:决定能否走 Files API */
|
|
64
|
+
endpoint?: Endpoint | null;
|
|
65
|
+
/** 当前模型:`vision === false` 时图片退化为文本提示 */
|
|
66
|
+
model?: Model | null;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
/** 覆盖内联体积上限 */
|
|
69
|
+
maxInlineBytes?: number;
|
|
70
|
+
/** 依赖注入(测试用),默认走 OpenAI SDK 的 Files API */
|
|
71
|
+
deps?: {
|
|
72
|
+
upload?: UploadFn;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface FileToSectionResult {
|
|
76
|
+
section: AgentNS.MessageContentSection;
|
|
77
|
+
transport: ContentTransport;
|
|
78
|
+
/** 实际处理的字节数(URL 来源为 0) */
|
|
79
|
+
bytes: number;
|
|
80
|
+
/** 降级 / 退化时的人类可读说明,供上层拼提示 */
|
|
81
|
+
warning?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 由「发给 API 的模型名」反查它绑定的模型定义与端点。
|
|
85
|
+
*
|
|
86
|
+
* 工具在 `call()` 阶段拿不到 Agent 定义,但 `ctx.agent.model` 就是发给 API 的
|
|
87
|
+
* 模型名(`Model.modelName ?? Model.id`)。必须按**当前实际使用的模型**定位端点:
|
|
88
|
+
* `file_id` 是端点维度资源,传错端点会得到 not found。
|
|
89
|
+
*/
|
|
90
|
+
export declare function resolveModelAndEndpoint(config: AppConfig, apiModelName?: string): {
|
|
91
|
+
model?: Model;
|
|
92
|
+
endpoint?: Endpoint | null;
|
|
93
|
+
};
|
|
94
|
+
/** 端点是否声明了 Files API 能力 */
|
|
95
|
+
export declare function supportsFilesApi(endpoint?: Endpoint | null): boolean;
|
|
96
|
+
/** 清空上传缓存(测试用;进程内长期运行无需清理,条目有上限) */
|
|
97
|
+
export declare function clearUploadCache(): void;
|
|
98
|
+
/** 内容哈希(sha256),用于给「内存里的字节」做上传缓存键 */
|
|
99
|
+
export declare function contentHash(bytes: Uint8Array): string;
|
|
100
|
+
/**
|
|
101
|
+
* 把文件转成内容块。
|
|
102
|
+
*
|
|
103
|
+
* 抛错场景(均由调用方决定如何呈现给模型):
|
|
104
|
+
* - `source.url` 不是 http(s)(本地 URL 服务商访问不到)
|
|
105
|
+
* - 本地文件不存在 / 不是文件 / 扩展名不支持
|
|
106
|
+
* - 需要内联但体积超过上限,且端点不支持上传
|
|
107
|
+
*/
|
|
108
|
+
export declare function fileToContentSection(input: FileToSectionInput): Promise<FileToSectionResult>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地文件 → 消息内容块的唯一转换入口。
|
|
3
|
+
*
|
|
4
|
+
* 背景:这段逻辑原先散落三处,且各自为政 ——
|
|
5
|
+
* 1. `ViewImageTool` 自己判断 `endpoint.id === "deepseek"` 再上传(端点 id 一变就失效);
|
|
6
|
+
* 2. `mcp/mcpTools.ts` 自己把 MCP 的 base64 归一成 `image_url` / `file` 块;
|
|
7
|
+
* 3. CLI 侧又实现了一遍「上传 or base64」。
|
|
8
|
+
*
|
|
9
|
+
* 现在统一到这里,调用方只提供「文件 + 当前端点/模型」,传输方式由本模块决定:
|
|
10
|
+
*
|
|
11
|
+
* | 条件 | 结果 |
|
|
12
|
+
* |------|------|
|
|
13
|
+
* | http(s) URL | 直接 `image_url`(不上传、不内联) |
|
|
14
|
+
* | 端点声明 `capabilities.filesApi` | 上传 Files API → `file` 块(`file_id`),失败降级为内联 |
|
|
15
|
+
* | 其余端点 + 图片 | `image_url` + base64 data URL |
|
|
16
|
+
* | 其余端点 + 非图片 | `file` 块 + `file_data`(base64 data URL) |
|
|
17
|
+
* | 模型明确不支持视觉(`vision === false`)且是图片 | 退化为 `text`,只给路径提示 |
|
|
18
|
+
* | 内联体积超过上限且无法上传 | 抛错(不静默塞进请求体) |
|
|
19
|
+
*/
|
|
20
|
+
import OpenAI, { toFile } from "openai";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
/** 扩展名 → MIME。图片走视觉通道,其余走 `file` 块 */
|
|
25
|
+
export const MIME_BY_EXT = {
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".gif": "image/gif",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".pdf": "application/pdf",
|
|
32
|
+
".txt": "text/plain",
|
|
33
|
+
".md": "text/markdown",
|
|
34
|
+
".json": "application/json",
|
|
35
|
+
};
|
|
36
|
+
/** 按扩展名推断 MIME;未知扩展名返回 undefined */
|
|
37
|
+
export function mimeOf(filePath) {
|
|
38
|
+
const dot = filePath.lastIndexOf(".");
|
|
39
|
+
if (dot < 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
return MIME_BY_EXT[filePath.slice(dot).toLowerCase()];
|
|
42
|
+
}
|
|
43
|
+
/** base64 二进制转 data URL(`image_url` 与 `file.file_data` 均接受) */
|
|
44
|
+
export function toDataUrl(base64, mime) {
|
|
45
|
+
return `data:${mime};base64,${base64}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* base64 字符串对应的字节数(不含空白)。
|
|
49
|
+
*
|
|
50
|
+
* 用于「只看体积、不想解码」的场景:内联上限检查与日志统计。
|
|
51
|
+
*/
|
|
52
|
+
export function base64ByteLength(base64) {
|
|
53
|
+
const len = base64.replace(/\s/g, "").length;
|
|
54
|
+
if (len === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
const pad = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
57
|
+
return Math.max(0, Math.floor((len * 3) / 4) - pad);
|
|
58
|
+
}
|
|
59
|
+
/** 内联(base64)体积上限,默认 8 MiB;超过且端点不支持上传时直接报错 */
|
|
60
|
+
export const DEFAULT_MAX_INLINE_BYTES = 8 * 1024 * 1024;
|
|
61
|
+
/**
|
|
62
|
+
* 由「发给 API 的模型名」反查它绑定的模型定义与端点。
|
|
63
|
+
*
|
|
64
|
+
* 工具在 `call()` 阶段拿不到 Agent 定义,但 `ctx.agent.model` 就是发给 API 的
|
|
65
|
+
* 模型名(`Model.modelName ?? Model.id`)。必须按**当前实际使用的模型**定位端点:
|
|
66
|
+
* `file_id` 是端点维度资源,传错端点会得到 not found。
|
|
67
|
+
*/
|
|
68
|
+
export function resolveModelAndEndpoint(config, apiModelName) {
|
|
69
|
+
const models = config.models ?? [];
|
|
70
|
+
const endpoints = config.endpoints ?? [];
|
|
71
|
+
const findEndpoint = (id) => (id ? endpoints.find((e) => e.id === id) ?? null : null);
|
|
72
|
+
const byApiName = apiModelName
|
|
73
|
+
? models.find((m) => (m.modelName ?? m.id) === apiModelName)
|
|
74
|
+
: undefined;
|
|
75
|
+
if (byApiName)
|
|
76
|
+
return { model: byApiName, endpoint: findEndpoint(byApiName.endpointId) };
|
|
77
|
+
const fallback = models.find((m) => m.id === config.defaultModel);
|
|
78
|
+
if (fallback)
|
|
79
|
+
return { model: fallback, endpoint: findEndpoint(fallback.endpointId) };
|
|
80
|
+
return { endpoint: null };
|
|
81
|
+
}
|
|
82
|
+
/** 端点是否声明了 Files API 能力 */
|
|
83
|
+
export function supportsFilesApi(endpoint) {
|
|
84
|
+
return endpoint?.capabilities?.filesApi === true;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 上传缓存:`端点id + 绝对路径 + mtime + size` → `file_id`。
|
|
88
|
+
*
|
|
89
|
+
* file_id 是**端点维度**的资源,所以 key 必须带端点 id —— 同一张图传到两个端点
|
|
90
|
+
* 会得到两个不同的 id,混用会让另一端返回 not found。
|
|
91
|
+
*/
|
|
92
|
+
const uploadCache = new Map();
|
|
93
|
+
/** 清空上传缓存(测试用;进程内长期运行无需清理,条目有上限) */
|
|
94
|
+
export function clearUploadCache() {
|
|
95
|
+
uploadCache.clear();
|
|
96
|
+
}
|
|
97
|
+
const UPLOAD_CACHE_LIMIT = 200;
|
|
98
|
+
/** 内容哈希(sha256),用于给「内存里的字节」做上传缓存键 */
|
|
99
|
+
export function contentHash(bytes) {
|
|
100
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
101
|
+
}
|
|
102
|
+
function cacheKey(endpoint, absPath, stat) {
|
|
103
|
+
return `${endpoint.id}\u0000${absPath}\u0000${stat.mtimeMs}\u0000${stat.size}`;
|
|
104
|
+
}
|
|
105
|
+
/** 内存来源的缓存键:没有路径与 mtime,改用内容哈希(同一张图不会重复上传) */
|
|
106
|
+
function contentCacheKey(endpoint, hash) {
|
|
107
|
+
return `${endpoint.id}\u0000sha256:${hash}`;
|
|
108
|
+
}
|
|
109
|
+
function rememberUpload(key, fileId) {
|
|
110
|
+
uploadCache.set(key, fileId);
|
|
111
|
+
while (uploadCache.size > UPLOAD_CACHE_LIMIT) {
|
|
112
|
+
const oldest = uploadCache.keys().next();
|
|
113
|
+
if (oldest.done)
|
|
114
|
+
break;
|
|
115
|
+
uploadCache.delete(oldest.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** 默认上传实现:OpenAI 兼容的 `POST {baseUrl}/files`(`purpose=user_data`) */
|
|
119
|
+
const defaultUpload = async ({ endpoint, data, filename, signal }) => {
|
|
120
|
+
const client = new OpenAI({ apiKey: endpoint.apiKey, baseURL: endpoint.baseUrl });
|
|
121
|
+
const file = await toFile(Buffer.from(data), filename);
|
|
122
|
+
const uploaded = await client.files.create({ file, purpose: "user_data" }, { signal });
|
|
123
|
+
return uploaded.id;
|
|
124
|
+
};
|
|
125
|
+
function isRemoteUrl(value) {
|
|
126
|
+
return /^https?:\/\//i.test(value);
|
|
127
|
+
}
|
|
128
|
+
function textSection(text) {
|
|
129
|
+
return { type: "text", text };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 把文件转成内容块。
|
|
133
|
+
*
|
|
134
|
+
* 抛错场景(均由调用方决定如何呈现给模型):
|
|
135
|
+
* - `source.url` 不是 http(s)(本地 URL 服务商访问不到)
|
|
136
|
+
* - 本地文件不存在 / 不是文件 / 扩展名不支持
|
|
137
|
+
* - 需要内联但体积超过上限,且端点不支持上传
|
|
138
|
+
*/
|
|
139
|
+
export async function fileToContentSection(input) {
|
|
140
|
+
const { source, endpoint, model, signal } = input;
|
|
141
|
+
const maxInline = input.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
|
|
142
|
+
// ---- 1. 网络 URL:直接引用,不经上传/内联 ----
|
|
143
|
+
if ("url" in source) {
|
|
144
|
+
if (!isRemoteUrl(source.url)) {
|
|
145
|
+
throw new Error(`不支持本地 URL(${source.url}):API 服务商无法访问。本地文件请传文件路径,而不是本地链接。`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
section: { type: "image_url", image_url: { url: source.url } },
|
|
149
|
+
transport: "url",
|
|
150
|
+
bytes: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ---- 2. 取得字节与 MIME ----
|
|
154
|
+
// 已经是 base64 的来源(MCP):先不解码,内联时原样透传,只有真要上传/算哈希时才解码
|
|
155
|
+
let base64 = null;
|
|
156
|
+
let bytes = null;
|
|
157
|
+
let byteLength;
|
|
158
|
+
let mime;
|
|
159
|
+
let filename;
|
|
160
|
+
let absPath = null;
|
|
161
|
+
let stat = null;
|
|
162
|
+
if ("path" in source) {
|
|
163
|
+
absPath = path.isAbsolute(source.path) ? source.path : path.resolve(source.path);
|
|
164
|
+
const st = await fs.stat(absPath);
|
|
165
|
+
if (!st.isFile())
|
|
166
|
+
throw new Error(`"${absPath}" 不是文件。`);
|
|
167
|
+
const inferred = mimeOf(absPath);
|
|
168
|
+
if (!inferred) {
|
|
169
|
+
throw new Error(`不支持的文件类型:"${absPath}"(已知类型:${Object.keys(MIME_BY_EXT).join(" ")})`);
|
|
170
|
+
}
|
|
171
|
+
mime = inferred;
|
|
172
|
+
stat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
173
|
+
bytes = new Uint8Array(await fs.readFile(absPath));
|
|
174
|
+
byteLength = bytes.byteLength;
|
|
175
|
+
filename = path.basename(absPath);
|
|
176
|
+
}
|
|
177
|
+
else if ("base64" in source) {
|
|
178
|
+
mime = source.mime;
|
|
179
|
+
base64 = source.base64;
|
|
180
|
+
byteLength = base64ByteLength(base64);
|
|
181
|
+
filename = source.filename ?? "file";
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
mime = source.mime;
|
|
185
|
+
bytes = source.data;
|
|
186
|
+
byteLength = bytes.byteLength;
|
|
187
|
+
filename = source.filename ?? "file";
|
|
188
|
+
}
|
|
189
|
+
/** 需要真实字节时才解码(上传、算内容哈希) */
|
|
190
|
+
const ensureBytes = () => {
|
|
191
|
+
if (!bytes)
|
|
192
|
+
bytes = new Uint8Array(Buffer.from(base64 ?? "", "base64"));
|
|
193
|
+
return bytes;
|
|
194
|
+
};
|
|
195
|
+
const isImage = mime.startsWith("image/");
|
|
196
|
+
// ---- 3. 模型看不见图:退化为文本提示 ----
|
|
197
|
+
// 判定口径与 `ViewImageTool.isAvailable` 一致:只有 `vision === true` 才算视觉模型,
|
|
198
|
+
// 缺省视为不支持(模型未提供 model 时不做判断,交给调用方)。
|
|
199
|
+
// 对任何来源都生效(本地路径给路径,内存来源给文件名):非视觉模型收到 image_url
|
|
200
|
+
// 可能直接 4xx,宁可退化成一行提示。
|
|
201
|
+
if (isImage && model && model.vision !== true) {
|
|
202
|
+
const label = absPath ?? filename;
|
|
203
|
+
return {
|
|
204
|
+
section: textSection(`[图片] ${label}(当前模型不支持图片输入,如需查看请改用视觉模型)`),
|
|
205
|
+
transport: "text",
|
|
206
|
+
bytes: byteLength,
|
|
207
|
+
warning: "当前模型不支持图片输入,已退化为文本提示",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// ---- 4. 端点支持 Files API:上传(带缓存),失败降级内联 ----
|
|
211
|
+
let warning;
|
|
212
|
+
if (supportsFilesApi(endpoint) && endpoint?.apiKey) {
|
|
213
|
+
const key = absPath && stat
|
|
214
|
+
? cacheKey(endpoint, absPath, stat)
|
|
215
|
+
: contentCacheKey(endpoint, contentHash(ensureBytes()));
|
|
216
|
+
const cached = uploadCache.get(key);
|
|
217
|
+
if (cached) {
|
|
218
|
+
return {
|
|
219
|
+
section: { type: "file", file_id: cached },
|
|
220
|
+
transport: "files-api",
|
|
221
|
+
bytes: byteLength,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const upload = input.deps?.upload ?? defaultUpload;
|
|
226
|
+
const fileId = await upload({ endpoint, data: ensureBytes(), filename, signal });
|
|
227
|
+
rememberUpload(key, fileId);
|
|
228
|
+
return {
|
|
229
|
+
section: { type: "file", file_id: fileId },
|
|
230
|
+
transport: "files-api",
|
|
231
|
+
bytes: byteLength,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (signal?.aborted)
|
|
236
|
+
throw error;
|
|
237
|
+
warning = `上传 Files API 失败,已改为 base64 内联:${error?.message ?? error}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ---- 5. 内联(base64 data URL)----
|
|
241
|
+
if (byteLength > maxInline) {
|
|
242
|
+
throw new Error(`文件体积 ${(byteLength / 1024 / 1024).toFixed(1)} MiB 超过内联上限 ` +
|
|
243
|
+
`${(maxInline / 1024 / 1024).toFixed(1)} MiB,且端点未声明 Files API 能力` +
|
|
244
|
+
`(capabilities.filesApi),无法发送。`);
|
|
245
|
+
}
|
|
246
|
+
// base64 来源直接透传,不重新编码(保证字节级一致,也省一次编解码)
|
|
247
|
+
const dataUrl = toDataUrl(base64 ?? Buffer.from(ensureBytes()).toString("base64"), mime);
|
|
248
|
+
return {
|
|
249
|
+
section: isImage
|
|
250
|
+
? { type: "image_url", image_url: { url: dataUrl } }
|
|
251
|
+
: { type: "file", file_data: dataUrl, filename },
|
|
252
|
+
transport: "inline",
|
|
253
|
+
bytes: byteLength,
|
|
254
|
+
warning,
|
|
255
|
+
};
|
|
256
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -12,6 +12,19 @@ export interface AgentPermissions {
|
|
|
12
12
|
mcps?: PermissionPolicy;
|
|
13
13
|
subagents?: PermissionPolicy;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* 端点能力声明 —— 决定「本地文件 → 内容块」时走哪条传输路径。
|
|
17
|
+
*
|
|
18
|
+
* 之前这段判断硬编码在 ViewImageTool 里(`endpoint.id === "deepseek"`),
|
|
19
|
+
* 用户改一下端点 id 就失效;改为显式能力位后,新端点只要声明能力即可享受上传。
|
|
20
|
+
*/
|
|
21
|
+
export interface EndpointCapabilities {
|
|
22
|
+
/**
|
|
23
|
+
* 是否支持 OpenAI 兼容的 Files API(`POST {baseUrl}/files`,`purpose=user_data`)。
|
|
24
|
+
* 为 true 时本地文件优先上传并以 `file` 块(file_id)发送;否则走 base64 内联。
|
|
25
|
+
*/
|
|
26
|
+
filesApi?: boolean;
|
|
27
|
+
}
|
|
15
28
|
/** API 端点 */
|
|
16
29
|
export interface Endpoint {
|
|
17
30
|
id: string;
|
|
@@ -19,6 +32,7 @@ export interface Endpoint {
|
|
|
19
32
|
baseUrl: string;
|
|
20
33
|
apiKey: string;
|
|
21
34
|
description?: string;
|
|
35
|
+
capabilities?: EndpointCapabilities;
|
|
22
36
|
}
|
|
23
37
|
/** 模型配置,绑定一个 Endpoint */
|
|
24
38
|
export interface Model {
|