【DeepSeek Harness 零基础入门 #09】如何给 AI 装上“豆包画图”超能力?手把手开发原生工具与无水印生图插件

本文以dsh-doubao-image插件为例,详解如何在DeepSeek Harness中注册原生AI工具,实现豆包网页版无水印2.7K高清图片生成与多模态回传。涵盖工具Schema契约定义、跨包依赖门禁校验、多候选URL质量评分去重、混合下载引擎及本机WebServer路由扩展等核心技术,聚焦Agent能力扩展的工程化实践。

玄同 765

AI Engineer


  中国传媒大学 | 数字媒体技术(游戏设计技术方向)

  ▍深耕领域  AI Agent 架构设计 Agent Harness 工程 Context Engineering 多智能体协同 & RAG
  ▍技术底座  Python TypeScript · LangChain MCP & Skills · FastAPI Milvus Docker
  ▍工程能力  专注 Agent 运行时架构、长上下文与 Prompt Cache 优化、LLM 全链路评测与工程化落地

     

「 让 AI 交互更智能,让技术落地更高效 」   |   欢迎技术探讨 / 项目合作,关注解锁大模型与智能交互的无限可能!

【DeepSeek Harness 零基础入门 #09】如何给 AI 装上“豆包画图”超能力?手把手开发原生工具与无水印生图插件

📌 一句话精华摘要:不想配付费 API Key,怎么让 Agent 一句话自动调用豆包网页版生成 2.7K 无水印高清配图?本文以开源插件 dsh-doubao-image 为例,详解原生 AI 工具注册与多模态图片回传实战。

📚 《DeepSeek Harness 零基础入门与插件开发实战》十篇连载导航

  • #01:只调大模型 API 就够了?一文看懂 Agent“安全底盘”与积木式插件架构
  • #02:改配置总怕升级被覆盖?揭秘三层配置不丢数据与本地插件挂载秘诀
  • #03:对话发错指令怎么“时光倒流”?拆解聊天记账本与无损分支重发原理
  • #04:长对话越聊越卡还爆窗口?揭秘大模型缓存加速与自动瘦身压缩原理
  • #05:怎么防止 AI 乱删代码或卡死?拆解安全护栏、后台任务与多智能体组队
  • #06:不改官方源码怎么改界面?一文搞懂前端“插槽接管”与防白屏保护
  • #07:10 分钟写出第一个全栈插件!从新建文件夹到设置面板保姆级教程
  • #08:AI 连续调工具闷头当哑巴?手把手写“自动提醒汇报进展”的拦截插件
  • #09(本文):如何给 AI 装上“豆包画图”超能力?手把手开发原生工具与无水印生图插件
  • #10:聊天气泡原位编辑与划词批注怎么做?UI 深度改造与开源上架全流程

想给 Agent 扩展外部能力,做成“原生 AI 工具”比“手动表单面板”好用十倍!本文以开源插件 dsh-doubao-image(豆包网页版 2.7K 无水印生图插件) 为实战蓝本,手把手教你使用 defineTool 注册原生工具、绕过前端风控下载无水印原图并在对话流原位渲染。


一、为什么给 Agent 扩展能力必须首选「注册原生 AI 工具」?(原理拆解篇)

1.1 从一个直觉误区说起:给机械臂外挂手动遥控器 vs 为机械臂安装神经接口

很多初次开发 AI 客户端插件的工程师,在接到“给客户端增加 AI 生图或外部检索能力”的需求时,第一反应是在聊天输入框上方挂一个折叠表单:塞满提示词输入框、比例下拉框、风格单选框和一个「点击执行」按钮。

这种传统 GUI 思维在 Agentic Workflow(智能体自主工作流) 中存在三个致命短板:

  1. 打断多步自主编排(Breaking Autonomous Chains):当用户下达复合指令(如“帮我写一个 React 落地页,自动生成一张 16:9 赛博朋克主视觉图保存到 /Users/dev/workspace/public/hero.png 并写入组件代码”)时,手动表单会迫使 Agent 停机等待人类点按钮;而注册为原生工具 doubao_generate_image 后,Agent 可在单轮对话中自主串联「生图 → 拿路径 → 写代码」全流程。
  2. 丧失大模型上下文感知 Prompt 扩写能力:人类手填词条往往只有寥寥数字;而大模型在调用 doubao_generate_image 前,会自动结合项目上下文将其扩写为包含构图、光影、材质与景深的专业视觉描述。
  3. 打通多模态视觉自检闭环(Visual Self-Verification):通过 output.render 回传 { type: 'image', attachment } 内容块后,生成的图片不仅展示给用户,还会直接进入大模型的视觉上下文(如支持视觉输入的云端模型或本地 http://127.0.0.1:8000/v1 多模态服务),支持 Agent 自主检视成图效果。

架构跃迁:把外部能力接入 Agent 神经总线

本文方案:ctx.tools.register 原生工具 + 多模态回传闭环

用户下达复合自然语言指令
(写落地页 + 配图 + 插入代码)

Agent 自主扩写摄影级 Prompt
调用 doubao_generate_image

Host 操控浏览器自动化
哈希优选 wm0 无水印 2.7K 原图

注入 ctx.attachments 服务
output.render 回传多模态图像块

传统方案:输入框手动 UI 表单面板(打断智能体编排,人工负担重)

人类手动展开折叠面板
手填简陋词条与下拉框

前端直接发起孤立请求
脱离 Agent 会话上下文

返回图片静态路径
Agent 无法看见或继续编排

1.2 DSH 四种外部能力扩展机制全景对比
扩展方案核心挂载接口多模态附件原生回传 (ContentBlock)调用卡片定制 (presentCall)复用宿主服务 (webServer / systemPrompt)典型适用场景与选型建议
1. 纯前端 Slot 表单插件ctx.slots.register('conversation.input.dock')不支持(仅能改写输入框草稿)无工具调用卡片无法在宿主端注册工具仅适合划词引用等纯前端交互增强
2. 独立脚本 + bash 调用无(让 Agent 调内置 bash 跑 CLI)不支持(仅返回 stdout 纯文本)统一显示为普通 Bash 卡片无法访问 Cordis ctx 与配置表单适合临时一次性脚本,无强类型 Schema 约束
3. 外部 MCP Server标准 stdio / SSE MCP 协议依赖协议转换层,大图传输开销高通用 MCP 卡片,定制受限进程隔离,无法直接挂载本机 Web 路由适合跨 IDE 通用的标准化工具集
4. DSH 原生工具插件(本文方案)ctx.tools.register(defineTool(...))原生支持(直连 ctx.attachments.saveImage)完整支持(定制标题与输入摘要)同进程协同(联动 systemPrompt 与 webServer)深度集成首选:需多模态回传与本机路由的生产级插件

二、基础篇:深度拆解 @deepseek-ai/dsh-tools 的 defineTool 契约与跨包解析

2.1 defineTool 全字段契约解析

在 DSH 宿主端中,ctx.tools.register 接收由 @deepseek-ai/dsh-tools 的 defineTool 构造的工具定义对象,其 7 大核心字段契约如下:

字段路径类型签名必填底层运行机制与工程注意事项
namestring是工具唯一标识符(推荐 snake_case,如 doubao_generate_image),直接映射为 LLM Function Calling 名称
descriptionstring是发送给大模型的工具说明书,需明确描述触发条件、参数语义及 status: "login_required" 等状态的处理指引
parametersRecord<string, Spec> | JsonSchema是入参定义。支持属性内简写 required: true,编译后生成顶层 { type: 'object', properties, required } 标准结构
timeoutMsnumber否单次工具执行最大超时毫秒数。网页生图建议设为 185000(185 秒),避免被默认短超时提前中断
presentCall(args) => CallCardSpec否控制聊天流中工具折叠卡片的 UI 展示,返回 { card: 'generic', title: '豆包网页生图', kind: 'other', rawInput }
executeasync (args, exec) => any是异步执行器。exec.signal 为 AbortSignal,当用户点击停止时触发中断,必须透传给子进程与 fetch
output{ schema, render }否多模态回传核心:schema 声明返回值结构,render(args, value) 将结果转为 ContentBlock[] 图文块数组
2.2 跨包解析深水区:ProfileRuntimeResolution.routeLinked 与 fallbackDefineTool

为什么在 link: 本地软链接或 ~/.dsh/profiles/desktop 模式下,插件内部执行 import('@deepseek-ai/dsh-tools') 经常报错 ERR_MODULE_NOT_FOUND?

原因在于 @deepseek-ai/dsh-tools 封装在 DSH 宿主的 app.asar 内部。当宿主通过 ProfileRuntimeResolution.routeLinked(specifier, parentURL) 解析软链接插件的依赖时,会调用 readPeerNames(directory) 读取插件 package.json 的 peerDependencies——只有显式声明在 peerDependencies 中的 @deepseek-ai/* 包,才会被允许路由到宿主内部依赖树!同时必须声明 "engines": { "dsh": ">=0.1.0-0" } 以通过 evaluatePluginCompatibility() 兼容性门禁。

此外,为了在脱离 DSH 宿主的 node --test 单元测试环境下也能零依赖运行,我们编写了 importHostPackage + fallbackDefineTool 双保险加载器(注意 ToolRuntime 的 assertSupportedJsonSchema() 严禁 output.schema 使用非标 type: 'json',必须规范化为 { type: 'object', additionalProperties: true }):

// 摘自 dsh-doubao-image/lib/index.js:宿主包动态寻址 + 单元测试无损降级编译器
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

async function importHostPackage(specifier) {
  try {
    return await import(specifier);
  } catch {
    const anchors = [
      process.argv[1],
      typeof process.resourcesPath === 'string'
        ? path.join(process.resourcesPath, 'app.asar', 'dsh', 'package.json')
        : undefined,
    ].filter(Boolean);
    for (const anchor of anchors) {
      try {
        return await import(pathToFileURL(createRequire(anchor).resolve(specifier)).href);
      } catch {}
    }
    return null;
  }
}

function fallbackDefineTool(options) {
  const rawParams = options?.parameters || {};
  const properties = {};
  const required = [];
  for (const [key, spec] of Object.entries(rawParams)) {
    if (!spec || typeof spec !== 'object') continue;
    const { required: isReq, ...rest } = spec;
    properties[key] = { ...rest };
    if (isReq === true) required.push(key);
  }
  const outSchema =
    options?.output?.schema?.type === 'json'
      ? { type: 'object', additionalProperties: true }
      : options?.output?.schema || { type: 'object', additionalProperties: true };
  return {
    ...options,
    parameters: { type: 'object', properties, ...(required.length > 0 ? { required } : {}) },
    output: { ...options?.output, schema: outSchema },
  };
}

const dshToolsMod = await importHostPackage('@deepseek-ai/dsh-tools');
const defineTool = typeof dshToolsMod?.defineTool === 'function' ? dshToolsMod.defineTool : fallbackDefineTool;
2.3 核心代码实战一:工具 Schema 编译器与跨包门禁校验器(Python 完整实现)

下面用 Python 完整实现一套 DSH 工具契约编译器与 package.json 跨包门禁静态审计器(共 56 行,可通过 ast.parse 校验):

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Set, Tuple


@dataclass
class ToolContractCompiler:
    """DSH 原生工具 Schema 编译器与 ProfileRuntimeResolution 门禁校验器。"""

    allowed_types: Set[str] = field(
        default_factory=lambda: {"object", "array", "string", "number", "integer", "boolean", "null"}
    )
    required_peers: Set[str] = field(
        default_factory=lambda: {"@deepseek-ai/cordis", "@deepseek-ai/dsh-tools", "@deepseek-ai/schemastery"}
    )

    def compile_shorthand_tool(self, raw_def: Dict[str, Any]) -> Dict[str, Any]:
        """将 defineTool 简写参数字典编译为符合 ToolRuntime 规范的标准 JSON Schema。"""
        raw_params = raw_def.get("parameters") or {}
        if raw_params.get("type") == "object" and isinstance(raw_params.get("properties"), dict):
            compiled_params = dict(raw_params)
        else:
            props: Dict[str, Dict[str, Any]] = {}
            req_list: List[str] = []
            for key, spec in raw_params.items():
                if not isinstance(spec, dict):
                    continue
                props[key] = {k: v for k, v in spec.items() if k != "required"}
                if spec.get("required") is True:
                    req_list.append(key)
            compiled_params = {"type": "object", "properties": props}
            if req_list:
                compiled_params["required"] = req_list

        raw_out = dict(raw_def.get("output") or {})
        out_schema = dict(raw_out.get("schema") or {"type": "object", "additionalProperties": True})
        if out_schema.get("type") == "json":
            out_schema = {"type": "object", "additionalProperties": True}
        raw_out["schema"] = out_schema
        return {**raw_def, "parameters": compiled_params, "output": raw_out}

    def audit_manifest_and_tool(self, pkg_json: Dict[str, Any], tool_def: Dict[str, Any]) -> Tuple[bool, List[str]]:
        """联合校验 package.json 跨包路由声明与编译后的工具 Schema。"""
        errors: List[str] = []
        if not str((pkg_json.get("engines") or {}).get("dsh", "")).strip():
            errors.append("MISSING_ENGINE_DSH: package.json 必须声明 engines.dsh")
        peers = set((pkg_json.get("peerDependencies") or {}).keys())
        for missing in sorted(self.required_peers - peers):
            errors.append(f"MISSING_PEER_DEP: 缺少 {missing},link 模式下 routeLinked 将拒绝解析")
        params = tool_def.get("parameters") or {}
        if params.get("type") != "object" or not isinstance(params.get("properties"), dict):
            errors.append("INVALID_PARAMETERS_ROOT: parameters 根节点必须为 type='object'")
        out_type = ((tool_def.get("output") or {}).get("schema") or {}).get("type")
        if out_type not in self.allowed_types:
            errors.append(f"INVALID_OUTPUT_SCHEMA: output.schema.type={out_type!r} 不受支持")
        return (len(errors) == 0, errors)

三、进阶篇:多候选图片 URL 质量评分去重与混合下载引擎

3.1 豆包网页版「一图三轨」URL 结构与 cgen_wm0 无水印优选

当豆包网页版(doubao.com/chat/)生成图片时,DOM 中针对同一张图会挂载共享同一个 /rc_gen_image/<32位哈希> 的三类 URL:

候选 URL 轨名称URL 后缀特征真实解码尺寸 (naturalWidth × Height)页面 CSS 尺寸 (clientWidth)水印状态质量评分 (qualityScore) 与策略
渐进模糊缩略图-cthumb_lwm3.png192 × 108425 × 239带水印且模糊0 分(直接剔除):命中 -cthumb 立即过滤
标准对话展示图-cgen_lwm3.png2730 × 1536425 × 239右下角带水印1 分(保底候选):仅在未出现 wm0 时兜底
画布无水印原图(WebP)-cgen_wm0.webp2730 × 1536422 × 237 或 18 × 18零水印纯净版2 分(高优候选):2.7K 纯净无水印 WebP
画布无水印母版(PNG)-cgen_wm0.png2730 × 1536422 × 237 或 18 × 18零水印纯净版3 分(最高优先级):同哈希下无条件胜出

关键工程防坑:在画布缩略栏中,cgen_wm0 节点的 CSS 宽度 clientWidth 可能仅有 18px,但真实像素 naturalWidth 高达 2730px!因此过滤规则必须对含 /rc_gen_image/ 的 URL 豁免 clientWidth < 120 限制,统一按 naturalWidth >= 360 判定。此外,豆包前端 bdms-sdk.js 重写了页面内的 window.fetch(直接在 page.evaluate 内请求 byteimg.com 必报 Failed to fetch),因此我们采用 「浏览器内提取签名 URL + Node.js 宿主携带 Referer: https://www.doubao.com/ 直连下载」 的混合架构。

阶段三:宿主直连下载与多模态附件回传

Node.js 宿主带 Referer 直连
绕过页面 bdms-sdk fetch 拦截

调用 ctx.attachments.saveImage
output.render 返回图文 ContentBlock

阶段二:DOM 轮询与 /rc_gen_image 哈希优选

每 2.5s 扫描已完成 img 节点
要求 naturalWidth >= 360

按 /rc_gen_image/哈希 归并
wm0.png(3) > wm0.webp(2) > lwm3(1)

阶段一:Agent 工具调用与极速模式自检

Agent 调用原生工具
doubao_generate_image

检测若处于慢速『工作』模式
自动点击顶栏『豆包』切回极速模式

3.2 核心代码实战二:多候选图片 URL 质量评分去重器(Python 完整实现)

下面给出「基于 /rc_gen_image/<hash> 唯一键的图像候选去重与 wm0 评分优选器」完整 Python 代码(共 54 行,已通过 ast.parse 语法校验):

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Dict, Iterable, List


@dataclass(frozen=True)
class RawImgNode:
    """浏览器页面单次轮询提取的 <img> 节点快照。"""

    src: str
    natural_width: int
    natural_height: int
    client_width: int
    client_height: int
    complete: bool = True


class WatermarkedUrlDeduplicator:
    """基于 /rc_gen_image/<hash> 唯一键对候选图像去重,并按 wm0 > lwm3 质量分优选。"""

    HASH_REGEX = re.compile(r"/rc_gen_image/([^.~/?#]+)", re.IGNORECASE)
    NOISE_MARKERS = ("-cthumb", "/rc/icon/", "/static/image/", "avatar", "qrcode", "favicon")

    @classmethod
    def resolve_content_key(cls, src_url: str) -> str:
        """提取豆包生成图的哈希内容键,确保同图的不同水印后缀归入同一主键。"""
        cleaned = (src_url or "").strip()
        matched = cls.HASH_REGEX.search(cleaned)
        return f"rc_gen_image:{matched.group(1).lower()}" if matched else cleaned.split("?", 1)[0]

    @classmethod
    def score_and_deduplicate(
        cls, candidates: Iterable[RawImgNode], baseline_urls: Iterable[str] = ()
    ) -> List[Dict[str, object]]:
        """过滤历史基准图、模糊缩略图与小图标,同哈希下自动保留最高分 wm0 无水印版本。"""
        base_set = {u.strip() for u in baseline_urls if u and u.strip()}
        base_keys = {cls.resolve_content_key(u) for u in base_set}
        winners: Dict[str, Dict[str, object]] = {}

        for node in candidates:
            src = (node.src or "").strip()
            lower = src.lower()
            if not src.startswith(("http://", "https://")) or src in base_set:
                continue
            if any(m in lower for m in cls.NOISE_MARKERS) or lower.endswith(".svg"):
                continue
            if "/rc_gen_image/" not in lower and (node.client_width < 120 or node.client_height < 120):
                continue
            if not node.complete or node.natural_width < 360 or node.natural_height < 360:
                continue
            key = cls.resolve_content_key(src)
            if key in base_keys:
                continue
            score = 3 if "wm0.png" in lower else (2 if "wm0" in lower else 1)
            prev = winners.get(key)
            if prev is None or int(prev["quality_score"]) < score:
                winners[key] = {
                    "key": key, "src": src, "width": node.natural_width,
                    "height": node.natural_height, "watermark_free": score >= 2, "quality_score": score,
                }
        return list(winners.values())

四、高阶技巧:多模态 ContentBlock 回传与本机 WebServer 路由扩展

4.1 ctx.get('attachments').saveImage 与 ctx.webServer.register 完整实战

在宿主端 apply(ctx, config) 中,为了同时支撑 Agent 原生工具多模态回传与前端设置页画廊预览,我们打通了以下三大机制:

  1. 多模态附件持久化与渲染:图片下载落盘后,调用 ctx.get('attachments').saveImage({ data: new Uint8Array(buffer), mediaType }) 获取 attachmentRef,并在 output.render 中返回 [{ type: 'text', text: summaryJson }, { type: 'image', attachment: attachmentRef }]。
  2. Cordis ctx Proxy 安全访问规范:顶层仅声明 export const inject = ['tools'] 时,切勿直接读取 ctx.locale 或 ctx.webServer(会触发 Proxy 异常 cannot get property without inject),必须使用 ctx.get('locale') 或 ctx.inject(['webServer'], fn) 子 Fiber。
  3. 本机 HTTP 路由扩展:通过 webCtx.webServer.register({ kind: 'exact', path: '/doubao-image/file', handler }) 暴露本地原图流式预览接口,并强制校验 path.isAbsolute 与图片扩展名白名单。
// 摘自 dsh-doubao-image/lib/index.js:多模态渲染 + 原生工具注册 + 本机 Web 路由扩展
export function renderDoubaoToolOutput(value) {
  if (!value || typeof value !== 'object') return [{ type: 'text', text: String(value ?? '') }];
  const cleanImages = (value.images || []).map(({ attachment, ...rest }) => ({
    ...rest,
    attachedToChat: Boolean(attachment),
  }));
  const blocks = [{ type: 'text', text: JSON.stringify({ ...value, images: cleanImages }, null, 2) }];
  for (const img of value.images || []) {
    if (img?.attachment) blocks.push({ type: 'image', attachment: img.attachment });
  }
  return blocks;
}

export function apply(ctx, config) {
  const initial = normalizeConfig(config);
  if (!initial.enabled) return;
  const history = [];

  // 1. 注册原生 AI 工具:doubao_generate_image
  const registerTools = (toolCtx) => {
    toolCtx.tools.register(
      defineTool({
        name: 'doubao_generate_image',
        description: 'Generate watermark-free 2.7K AI images via Doubao Web in Tabbit.',
        parameters: {
          prompt: { type: 'string', required: true, description: 'Visual prompt.' },
          aspectRatio: { type: 'string', enum: [...SUPPORTED_ASPECT_RATIOS] },
          style: { type: 'string', description: 'Style preset id.' },
        },
        output: {
          schema: { type: 'object', additionalProperties: true },
          render: (_args, val) => renderDoubaoToolOutput(val),
        },
        timeoutMs: 185000,
        presentCall: (args) => ({
          card: 'generic',
          title: '豆包网页生图 (Doubao Image)',
          kind: 'other',
          rawInput: `${args.prompt || ''} [${args.aspectRatio || '16:9'}]`,
        }),
        async execute(args, exec) {
          return await executeDoubaoAction(
            { mode: 'generate', ...args, config },
            { ctx, signal: exec?.signal, history },
          );
        },
      }),
    );
  };
  if (ctx.tools?.register) registerTools(ctx);
  else if (typeof ctx.inject === 'function') ctx.inject(['tools'], registerTools);

  // 2. 子 Fiber 扩展本机 WebServer 路由(本地原图安全预览)
  if (typeof ctx.inject === 'function') {
    ctx.inject(['webServer'], (webCtx) => {
      webCtx.webServer.register({
        kind: 'exact',
        path: '/doubao-image/file',
        handler: async (req, res) => {
          const u = new URL(req.url || '/doubao-image/file', 'http://127.0.0.1');
          const fp = u.searchParams.get('path') || '';
          const mime = IMAGE_MEDIA_BY_EXT[path.extname(fp).slice(1).toLowerCase()];
          if (!fp || !path.isAbsolute(fp) || !mime) return sendJson(res, 400, { ok: false });
          const buf = await readFile(fp);
          res.writeHead(200, { 'content-type': mime, 'cache-control': 'private, max-age=3600' });
          res.end(buf);
        },
      });
    });
  }
}
ctx.webServer (/doubao-image/*) ctx.attachments 服务 doubao_generate_image (Host) DSH Agent Loop 用户 / Web GUI ctx.webServer (/doubao-image/*) ctx.attachments 服务 doubao_generate_image (Host) DSH Agent Loop 用户 / Web GUI 1. 自然语言请求:“生成一张 16:9 国风水墨仙鹤图” 1 2. 触发 execute(args, { signal }),渲染 presentCall 卡片 2 3. 操控 Tabbit 豆包网页生图 + 携带 Referer 下载 wm0 原图落盘 3 4. 调用 saveImage({ data: Uint8Array, mediaType }) 4 5. 返回持久化附件句柄 attachmentRef 5 6. 执行 output.render(value) 返回 [TextBlock, ImageBlock] 6 7. 聊天气泡直接渲染 2730×1536 高清大图,模型视觉同步可见 7 8. 设置页请求 GET /doubao-image/file?path=/Users/dev/workspace/... 8 9. 校验绝对路径与图像扩展名白名单,流式返回原图预览 9
4.2 核心代码实战三:混合下载引擎与多模态附件回传组装器(Python 完整实现)

下面给出「携带 Referer 直连下载、注册附件句柄并组装多模态 ContentBlock 数组」的完整 Python 实现(共 56 行,已通过 ast.parse 语法校验):

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.request import Request, urlopen


class HybridImageDownloaderAndRenderer:
    """宿主端带 Referer 直连下载无水印原图,并组装 DSH 多模态 ContentBlock 列表。"""

    MEDIA_TO_EXT: Dict[str, str] = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}

    def __init__(
        self,
        workspace_dir: Path,
        attachment_saver: Optional[Callable[[bytes, str], Dict[str, Any]]] = None,
    ) -> None:
        self.workspace_dir = workspace_dir
        self.attachment_saver = attachment_saver

    @staticmethod
    def sanitize_prefix(raw_prefix: str = "doubao") -> str:
        """清洗文件名前缀,防止路径穿越或非法字符。"""
        cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "_", (raw_prefix or "doubao")).strip("_")[:40]
        return cleaned or "doubao"

    def fetch_with_doubao_referer(self, source_url: str, timeout_sec: float = 30.0) -> Tuple[bytes, str]:
        """在宿主进程携带豆包 Referer 直连拉取 byteimg 签名原图,绕过页面 bdms-sdk 拦截。"""
        req = Request(
            source_url,
            headers={"Referer": "https://www.doubao.com/", "User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/132.0"},
        )
        with urlopen(req, timeout=timeout_sec) as resp:
            mime = (resp.headers.get("Content-Type") or "image/png").split(";")[0].strip().lower()
            return resp.read(), mime

    def save_and_render_blocks(
        self, candidates: List[Dict[str, Any]], prefix: str = "doubao"
    ) -> List[Dict[str, Any]]:
        """将候选图片下载落盘、注册附件句柄,并生成 DSH [TextBlock, ImageBlock...] 数组。"""
        out_dir = self.workspace_dir / "doubao_images"
        out_dir.mkdir(parents=True, exist_ok=True)
        safe_prefix = self.sanitize_prefix(prefix)
        saved_records: List[Dict[str, Any]] = []
        image_blocks: List[Dict[str, Any]] = []

        for idx, item in enumerate(candidates, start=1):
            raw_bytes, mime = self.fetch_with_doubao_referer(str(item["src"]))
            file_path = out_dir / f"{safe_prefix}_{idx}.{self.MEDIA_TO_EXT.get(mime, 'png')}"
            file_path.write_bytes(raw_bytes)
            att_ref = self.attachment_saver(raw_bytes, mime) if self.attachment_saver else None
            saved_records.append({"index": idx, "filePath": str(file_path), "attachedToChat": bool(att_ref)})
            if att_ref:
                image_blocks.append({"type": "image", "attachment": att_ref})
        return [{"type": "text", "text": json.dumps({"images": saved_records}, ensure_ascii=False)}, *image_blocks]

五、实战避坑指南:原生 Agent 工具插件开发高频报错与一键解决速查表

序号遇到的报错或异常现象背后的真正底层原因一句话解决办法
01Error: Cannot find package '@deepseek-ai/dsh-tools'link: 插件未在 package.json 声明 peerDependencies,被 ProfileRuntimeResolution.routeLinked() 拦截在 package.json 声明 "peerDependencies": { "@deepseek-ai/cordis": "*", "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/schemastery": "*" } 与 "engines": { "dsh": ">=0.1.0-0" }
02插件状态为 fiberPhase: "failed",报 cannot get property "locale" without inject在仅声明 inject = ['tools'] 的插件里直接访问 ctx.locale 或 ctx.webServer,触发 Cordis Proxy 异常改用 ctx.get('locale')、ctx.get('attachments'),或使用 ctx.inject(['webServer'], fn) 派生子 Fiber
03注册工具时报错 Unsupported JSON Schemaoutput.schema 误写了非标 { type: 'json' },或属性内 required: true 未提升为顶层 required 数组将 output.schema 设为 { type: 'object', additionalProperties: true },确保 parameters 根节点为 type: 'object'
04page.evaluate 内拉取图片必报 TypeError: Failed to fetch (bdms-sdk.js)豆包网页前端安全 SDK 重写了页面的 window.fetch,阻断了跨子域编程式请求仅从 DOM 提取签名 img.src,回到 Node.js 宿主进程携带 Referer: https://www.doubao.com/ 直连下载
05豆包生图单次耗时高达 140 秒甚至超时豆包网页停留在慢速「豆包·工作(云电脑)」模式,每次生图均需启动云端画布容器发送指令前检测 [data-testid="create_office_task_button"],自动点击顶栏 [data-testid="workspace-switch-chip"] 切回极速「豆包」模式
06画布模式下漏抓 cgen_wm0.png 无水印原图缩略栏中 cgen_wm0 对应 <img> 的 clientWidth 仅为 18px,被 clientWidth < 120 误杀对 URL 含 /rc_gen_image/ 的节点豁免 clientWidth 检查,统一按真实分辨率 naturalWidth >= 360 过滤

结语与下一篇收官预告

在本篇(连载第 09 篇)中,我们以开源插件 dsh-doubao-image 为实战载体,完整拆解了 DeepSeek Harness 原生 Agent 工具插件的研发全链路:从 @deepseek-ai/dsh-tools 的 defineTool 契约、ProfileRuntimeResolution.routeLinked 的 peerDependencies 跨包门禁,到 /rc_gen_image/<hash> 无水印 2.7K 原图优选算法,再到 ctx.attachments.saveImage 多模态回传与 ctx.webServer.register 本机路由扩展。

在下一篇收官之作——第 10 篇《【DeepSeek Harness 架构与插件实战 #10】客户端 UI 深度定制与开源发布全链路:从气泡内联交互到官方插件市场 PR 收录》 中,我们将手把手拆解如何接管 conversation.chat.node 实现用户消息原位编辑与分支重发(dsh-message-edit)、如何实现划词引用与行内批注胶囊(dsh-selection-quote),并完整奉上从 node --test 单元测试到 awesome-dsh-plugin 官方市场 PR 收录的 6 步端到端 SOP!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值