@ai-zen/agents-sdk 1.0.0-alpha.11 → 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 CHANGED
@@ -1,5 +1,43 @@
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
+
3
41
  ## [1.0.0-alpha.11] - 2026-10-20
4
42
 
5
43
  ### 💥 Breaking Changes
@@ -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
- * - 本地路径 → 自动通过 Files API(DeepSeek)上传,以 `file` 内容块(file_id)返回。
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
- * 不使用 base64 内联(本地图片走 Files API,符合 DeepSeek 推荐与 48 MiB 请求体限制)。
14
- * 依赖配置中的 deepseek 端点(Files API 能力);URL 场景不依赖上传。
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
- const SUPPORTED_EXT = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
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
- * - 本地路径 → 自动通过 Files API(DeepSeek)上传,以 `file` 内容块(file_id)返回。
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
- * 不使用 base64 内联(本地图片走 Files API,符合 DeepSeek 推荐与 48 MiB 请求体限制)。
26
- * 依赖配置中的 deepseek 端点(Files API 能力);URL 场景不依赖上传。
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。本地图片会自动上传(Files API)后引用,网络图片直接引用。不支持本地 URL(如 file:// 协议、指向本机 localhost/127.0.0.1 的 http(s) 地址),本地图片请使用文件路径。适合识别图片文字、描述图片内容、分析图表等。",
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:// 等本地协议,或指向本机回环地址(localhost/127.0.0.1/[::1]/0.0.0.0)的 http(s) 地址
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
- // 网络图片:直接返回 image_url 内容块(无需上传)
66
- if (/^https?:\/\//i.test(value)) {
67
- return [
68
- {
69
- type: "text",
70
- text: `已获取网络图片:${value}`,
71
- },
72
- { type: "image_url", image_url: { url: value } },
73
- ];
74
- }
75
- // 本地图片:解析路径 + 校验 + Files API 上传
76
- const absPath = path.isAbsolute(value)
77
- ? value
78
- : path.join(this.env.cwd, value);
79
- let stat;
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 ext = path.extname(absPath).toLowerCase();
90
- if (!SUPPORTED_EXT.includes(ext)) {
91
- return `不支持的图片格式 "${ext || "(无扩展名)"}"。支持的格式:JPEG、PNG、GIF、WebP。`;
92
- }
93
- // 上传端点:deepseek(Files API 能力)
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 file = await toFile(await fs.readFile(absPath), path.basename(absPath));
106
- const uploaded = await client.files.create({ file, purpose: "user_data" }, { signal });
107
- return [
108
- {
109
- type: "text",
110
- text: `图片已上传(${path.basename(absPath)}),file_id: ${uploaded.id}`,
111
- },
112
- { type: "file", file_id: uploaded.id },
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)时返回明确的中断结果
@@ -51,7 +51,7 @@ export class McpScopePlugin {
51
51
  result.push(createCallMcpTool(m, scope.env));
52
52
  }
53
53
  if (allow.has("read_mcp_resource") && m) {
54
- result.push(createReadMcpResourceTool(m));
54
+ result.push(createReadMcpResourceTool(m, scope.env));
55
55
  }
56
56
  return result;
57
57
  }
@@ -21,4 +21,4 @@ export declare function createCallMcpTool(mcpManager: McpConnectionManager, env:
21
21
  /**
22
22
  * 创建 read_mcp_resource 工具。
23
23
  */
24
- export declare function createReadMcpResourceTool(mcpManager: McpConnectionManager): CallbackTool;
24
+ export declare function createReadMcpResourceTool(mcpManager: McpConnectionManager, env: ToolEnv): CallbackTool;
@@ -1,4 +1,5 @@
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";
3
4
  import { DEFAULT_MAX_TOOL_OUTPUT, guardOutput, headTailPreview } from "../builtin/outputGuard.js";
4
5
  import { getLogger } from "../../../shared/logger.js";
@@ -82,31 +83,75 @@ export function createLoadMcpTool(mcpManager, filteredMcps) {
82
83
  },
83
84
  });
84
85
  }
85
- /** base64 二进制转 data URL(image_url / file_data 均接受) */
86
- function toDataUrl(base64, mimeType) {
87
- return `data:${mimeType};base64,${base64}`;
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) };
88
139
  }
89
140
  /**
90
141
  * MCP content 块 → SDK 内容块。
91
142
  *
92
143
  * 文本类(text / resource.text / resource_link / 未知类型)归一为 `text` 块,
93
144
  * 由调用方拼接成正文后进入输出保护;
94
- * 媒体类(image / audio / resource.blob)归一为 `image_url` / `file` 块,
95
- * 原样透传给模型——图片不进字符闸门,也不丢弃。
145
+ * 媒体类(image / audio / resource.blob)经 `mediaSection()` → `fileToContentSection()`:
146
+ * 端点支持 Files API 时上传为 `file` 块,否则 base64 内联——图片不进字符闸门,也不丢弃。
96
147
  */
97
- function toMessageSection(block) {
148
+ async function toMessageSection(block, media) {
98
149
  switch (block?.type) {
99
150
  case "image":
100
- return {
101
- type: "image_url",
102
- image_url: { url: toDataUrl(block.data, block.mimeType ?? "image/png") },
103
- };
151
+ return await mediaSection(block.data, block.mimeType ?? "image/png", media);
104
152
  case "audio":
105
- // AgentNS 无独立音频块,以 Files API 的 file_data(base64 data URL)内联承载
106
- return {
107
- type: "file",
108
- file_data: toDataUrl(block.data, block.mimeType ?? "audio/wav"),
109
- };
153
+ // AgentNS 无独立音频块,以 `file` 块承载(支持上传的端点 → file_id,否则 file_data 内联)
154
+ return await mediaSection(block.data, block.mimeType ?? "audio/wav", media);
110
155
  case "resource": {
111
156
  const r = block.resource ?? {};
112
157
  // 内嵌文本资源:本质是纯文本,并入正文(同样受输出保护)
@@ -115,9 +160,7 @@ function toMessageSection(block) {
115
160
  // 内嵌二进制资源:按 mimeType 选块类型,不丢弃
116
161
  if (typeof r.blob === "string") {
117
162
  const mime = r.mimeType ?? "application/octet-stream";
118
- return mime.startsWith("image/")
119
- ? { type: "image_url", image_url: { url: toDataUrl(r.blob, mime) } }
120
- : { type: "file", file_data: toDataUrl(r.blob, mime) };
163
+ return await mediaSection(r.blob, mime, media);
121
164
  }
122
165
  return { type: "text", text: `[资源] ${r.uri ?? "(未知 URI)"}` };
123
166
  }
@@ -205,7 +248,7 @@ export function createCallMcpTool(mcpManager, env) {
205
248
  return new CallbackTool({
206
249
  function: {
207
250
  name: "call_mcp_tool",
208
- description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。文本输出过大时会落盘并只返回头尾预览;图片、音频等媒体原样返回。",
251
+ description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。文本输出过大时会落盘并只返回头尾预览;图片、音频等媒体会保留:端点支持 Files API 时上传后以 file_id 引用,否则 base64 内联。",
209
252
  parameters: {
210
253
  type: "object",
211
254
  properties: {
@@ -241,7 +284,11 @@ export function createCallMcpTool(mcpManager, env) {
241
284
  const json = JSON.stringify(result);
242
285
  return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError }, env);
243
286
  }
244
- const sections = contents.map(toMessageSection);
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)));
245
292
  const textSections = sections.filter((s) => s.type === "text");
246
293
  const fullText = textSections.map((s) => s.text).join("\n");
247
294
  const chars = textSections.reduce((n, s) => n + s.text.length, 0);
@@ -256,11 +303,11 @@ export function createCallMcpTool(mcpManager, env) {
256
303
  /**
257
304
  * 创建 read_mcp_resource 工具。
258
305
  */
259
- export function createReadMcpResourceTool(mcpManager) {
306
+ export function createReadMcpResourceTool(mcpManager, env) {
260
307
  return new CallbackTool({
261
308
  function: {
262
309
  name: "read_mcp_resource",
263
- description: "读取已连接 MCP 服务器上的指定资源(文档、数据等)。",
310
+ description: "读取已连接 MCP 服务器上的指定资源(文档、数据、图片等)。文本原样返回;图片等媒体按端点能力上传(file_id)或 base64 内联,不会被丢弃。",
264
311
  parameters: {
265
312
  type: "object",
266
313
  properties: {
@@ -285,10 +332,22 @@ export function createReadMcpResourceTool(mcpManager) {
285
332
  mcpManager.touch(serverName);
286
333
  const result = await client.readResource({ uri: input.uri }, { signal: ctx?.signal });
287
334
  const contents = result?.contents ?? [];
288
- const textParts = contents
289
- .filter((c) => c.text)
290
- .map((c) => c.text);
291
- return textParts.join("\n") || JSON.stringify(result);
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);
292
351
  }
293
352
  catch (error) {
294
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
+ }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-zen/agents-sdk",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "AI-Zen Agents SDK — shared business logic for CLI and Desktop",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",