dsh-connect-trae 4.5.2 → 4.6.1

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,146 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.1 (2026-10-26)
4
+
5
+ > **4.6.0 说「图片字节插件拿不到」,这一版把这句话纠正过来。** 那条按
6
+ > `?prompt=&image_size=` 再打一次 GET 的路确实不是取图、而是**又一次生成**,但
7
+ > 「uri → 字节」不是没有通道,只是请求体形态特殊。现在图片会被取回来、落成会话文件、
8
+ > 直接显示在聊天里。
9
+
10
+ ### 修复
11
+
12
+ - **文生图把图片真的取回来了**。
13
+ - **取图通道**:`POST /api/ide/v1/get_resource_url`,body 必须正是
14
+ `{"uri_list":["<tos key>"]}`——`{uri}`/`{path}`/`{resource_uri}`/`{uris:[…]}`/
15
+ `{resources:[…]}`/`{scene:'report',uri}` 以及 GET `?uri=` 统统只回 200 + 空
16
+ `url_map`(不报错、也不给地址)。应答是
17
+ `{"url_map":{"<key>":"<byteimg 签名 URL>"}}`,再直接 GET 那个签名地址(**不带任何
18
+ Trae 头**,签名在 query 里)即得字节——实测 `image/jpeg`、1,155,578 字节、头
19
+ `ffd8ffe0`。对已有 key 这一步**零成本**,不会重新生成。
20
+ - **图片落成会话文件并直接显示**:`generate` 返回 `{url, credit?, bytes, mimeType}`,
21
+ `execute` 把字节写进 `<session cwd>/trae-images/trae-YYYYMMDD-HHMMSS-<hex>.<ext>`
22
+ (扩展名跟 mime 走),`output.schema` 恢复 `path` 并进 `required`,`render` 输出
23
+ `![Trae 文生图](trae-images/…)`。DSH 的聊天按 session cwd 解析相对路径、再过宿主的
24
+ `api/file` 鉴权路由取字节,所以图真的显示出来,不需要用户去 Trae 客户端翻历史。
25
+ - **失败语义**:图已生成、积分已扣、但字节取不回来(`url_map` 里没有这个 key / CDN 非
26
+ 2xx / 0 字节 / 落盘失败)→ `IMAGE_UNAVAILABLE`,进 30 秒冷却,文案明说「积分已经扣了」
27
+ 并给出对象 key 供排障,要求模型**不要重试**。
28
+ - 改图与文生视频仍然**不做**,也不留半截入口。
29
+ - **图片现在落在会话目录,不再落到宿主进程的 cwd**。
30
+ - 用户在实际 web 会话里跑了一次:工具返回 `trae-images/trae-20261026-034146-1c0a91.jpg`,
31
+ 聊天里却显示「图片无法预览」。文件确实生成了(789,453 字节真 JPEG),但落在**启动 DSH
32
+ 的那个目录**里,而不是会话目录。
33
+ - 根因:宿主的 `ToolExecution` 不带会话目录。`cwd` 字段 4.15.x 仍标 `Reserved`(不填),
34
+ `agent.session.cwd` 这个字段不存在,`agent.session.header.cwd` 又只是**创建时**目录
35
+ (`/cd` 之后就不对)。插件此前只能回退到 `process.cwd()`。
36
+ - 现在照宿主自己的 `dsh-tool-*` 取法:`ctx.inject(['workingDirectory'])` 后调
37
+ `workingDirectory.ensure(exec.agent, exec.signal)`(它读 session projection
38
+ `workingDirectory`,即实时目录,并确认目录还在)。阶梯:宿主 service →
39
+ `agent.session.header.cwd` → `process.cwd()`。`TraeImageToolDeps` 新增可选
40
+ `directoryOf`,service 读取放在 `src/index.ts` 注入,`image-tool` 保持纯函数可单测。
41
+ - **取图那一跳的 HTTP 错误不再谎报成因**。
42
+ - 此前它复用生图那套状态码映射,取图接口回 403 会报 `AUTH`(「上游拒绝了凭据」)、回
43
+ 429 会报 `RATE_LIMIT`——可这时图早已生成、积分早已扣掉,用户照这句话去重新登录,
44
+ 钱花了图还是拿不到。
45
+ - 现在取图阶段(解析地址、下载字节)的 HTTP 失败一律归 `IMAGE_UNAVAILABLE`,文案保留
46
+ 状态码与上游原文、附上对象 key。冷却判据只看 code,`IMAGE_UNAVAILABLE` 仍在冷却集合里,
47
+ 不会因此变成可重试。
48
+ - **下载回来的东西必须是图片**。签名地址过期时 CDN 常拿 `200` 回一页 HTML,而
49
+ `content-type` 可能仍写着 `image/jpeg`。现在按字节魔数判定
50
+ (JPEG/PNG/GIF/WebP/BMP),认不出就如实报 `IMAGE_UNAVAILABLE`(带前 4 字节),
51
+ 不再落一个打不开的 `.jpg`。
52
+
53
+ ### 变更
54
+
55
+ - **删掉已证明是「又一次生成」的配套代码**:`src/image-gen-trae.ts` 里的
56
+ `traeImageFetchUrl()`、`computeTraeIdeSign()`、`traeImageSignFields()` 与
57
+ `TRAE_IMAGE_SIGN_SALT` 全部移除(连同它们的测试)。取图跟 prompt 没有任何关系,
58
+ 留着只会诱导后人再打一次那条会再扣费的 GET。
59
+
60
+ ### 测试
61
+
62
+ - `tests/image-tool-download.spec.ts` 5 例:stub fetch 断言**三跳**(POST 生图 →
63
+ POST `get_resource_url` → GET 签名地址),且**没有任何**带 `image_size=` 的请求;
64
+ `uri_list` 请求体;CDN 请求不带 `Authorization`、只带 `Accept: image/*`;
65
+ 空 `url_map` → `IMAGE_UNAVAILABLE`;取图跳回 401/403/429/500/502 一律
66
+ `IMAGE_UNAVAILABLE`(断言文案含状态码与对象 key,且**不含**「拒绝了凭据」「限流」);
67
+ 取图跳回 `200` 但字节不是图片(HTML 错误页)→ `IMAGE_UNAVAILABLE`。
68
+ - `tests/image-gen-trae.spec.ts` 21 例:新增 `buildTraeResourceUrlBody`(只产出
69
+ `uri_list`、去空白、空 uri → `INVALID_ARGS`)、`parseTraeResourceUrlMap`(去空白、
70
+ 坏形状回空表、丢弃非字符串/空串)、`signedTraeResourceUrl`(取到地址、缺 key 即
71
+ `IMAGE_UNAVAILABLE` 并带 key)三组纯函数单测。
72
+ - `tests/image-tool-render.spec.ts` 12 例:字节真落盘、文件名与后缀、markdown 用相对路径
73
+ 且不含 key、credit 回显、写盘失败即 `IMAGE_UNAVAILABLE`、`IMAGE_UNAVAILABLE` 进冷却而
74
+ `INVALID_ARGS` 不进;以及会话目录阶梯三条——只有 `agent.session.header.cwd` 时写进它、
75
+ 实时 `workingDirectory` 优先于创建时目录、`directoryOf` 注入(`index.ts` 接 projection
76
+ 的方式)优先于两者。
77
+ - 全量 **92 files / 1180 tests** 通过(16 个 live 探针被 `TRAE_LIVE` 跳过且未提交,
78
+ 跑一次烧一次积分);CI 在 `ubuntu-latest` 与 `windows-latest` 双平台通过。
79
+ - 真机(零成本,复用既有 key):签名 → 1,155,578 字节 JPEG → 落盘同样字节 →
80
+ render 输出 markdown 图片,整条链路跑通。除 POST 之外整条链路都在真账号上验过。
81
+
82
+ ### 文档
83
+
84
+ - `README.md` / `README.en.md` 的「文生图」条目与协议表改成「两步取图 + 落盘 + 相对
85
+ 路径显示」,并保留「那条 GET 是又一次生成」的实测记录作为反面证据。
86
+
87
+ ## 4.6.0 (2026-10-26)
88
+
89
+ > **文生图先只在国内版面世,并且必须由用户亲手勾选。** 企业版的租户网关是另一条
90
+ > 计费面,国际版按另一套套餐计费,两者的生图行为都没有测过——所有取证都在
91
+ > `trae-api-cn.mchost.guru` 上做的。
92
+
93
+ ### 新增
94
+
95
+ - **文生图接进会话:`trae_draw_image`**(`feat(tools)`)。
96
+ - 用登录账号画一张图,返回 Trae 的**图片对象 key**(形如 `tos-cn-i-…/cn/<uid>/gen_image/…`,
97
+ 图在 Trae 客户端的历史里能看到;字节插件拿不到,见下)。默认关闭、**按面板单独开**:
98
+ 卡片模型栏勾上「允许模型自主生图」(`drawImageEnabled`)该面板才注册这个工具,
99
+ 取消勾选立即撤下。
100
+ - **协议是实测出来的,不是从二进制里猜的**:`POST /api/ide/v1/tool_text_to_image`
101
+ (另有 SSE 变体),请求体**只认** `{prompt, image_size?}`——多带 `mode_type`/`session_id`/
102
+ `image_urls` 之类的已知字段直接 400 空 body,未知字段反被忽略;成功响应是**扁平**的
103
+ `{"message":"success","uri":"tos-cn-i-…"}`,没有 `{code,msg,data}` 信封。模型 `seedream_5`。
104
+ - **尺寸边界**:总像素必须落在 **3686400 ~ 16777216**(`1024x1024` 报
105
+ `image size must be at least 3686400 pixels`、`4608x4608` 报 at most,两者都**不扣费**);
106
+ 预设 `2k`/`3k`/`4k` 可用,缺省 `1920x1920`。
107
+ - **计费口径写进工具文案**:实测 `1920x1920` 一张约 **8.8 积分**(国内版价),上游不回传
108
+ 扣费数字。失败后加 **30 秒冷却**(按张计费,不过冷却的重试等于再买一张)。
109
+ - **不取图,只回 key**:同路径的 GET 不是取图,**是又一次生成**。决定性探针(CN 个人版):
110
+ POST 拿到 `…/1792954795741_44f94116_jpeg_1920x1920` 之后,用同一 `(prompt, size)` 带
111
+ 全套网关头 + `x-ide-sign` 打 GET,回来的是**另一个** uri
112
+ (`…/1792954810505_b107ac11_jpeg_1920x1920`),耗时 27 秒、**一个像素都没有**——等于又画
113
+ 一张、又扣一次(两次共约 17.6)。非法尺寸探针(`100x100`)同样印证:报错直接来自 Ark 的
114
+ `[callArkImageGeneration]`。所以发版代码**不再发这条 GET**:`generate` 只发一次 POST,
115
+ 返回 `{url, credit?}`,渲染层把 key 显示成**行内代码**并注明「不能直接打开」,绝不拼进
116
+ `![](...)`(key 不是路径,拼进去只会得到以当前目录为基准的碎图标)。要真正把 uri 变成
117
+ 字节,需要另一条通道(iCube material 的 `get_url` + `x-icube-token`,基址来自运行时
118
+ boot config),目前**没有打通**,代码里也不留假装能取的入口。
119
+ 两例 `tests/image-tool-render.spec.ts` 与两例 `tests/image-tool-download.spec.ts`
120
+ (现已改名口径)用 stub fetch 断言**每次生成只发一次请求**。
121
+ - **只有国内版面板提供**(`DRAW_IMAGE_PANELS = ['cn']`,见 `src/status-paths.ts`)。
122
+ 企业版走租户网关、国际版按另一套套餐计费,两者的生图行为都**没有测过**:企业版原先
123
+ 就因为这一点被拒绝,国际版没有理由凭「面板是消费者版」放行——而且工具文案里的
124
+ 8.8 积分是国内价,放到国际版就是错的。两处的勾选框都**置灰并写明原因**(不隐藏:
125
+ 隐藏会让人以为插件漏了这个功能),理由与「加进白名单必须先补实测证据」写在同一处注释里。
126
+ - **注册开关是一个可测的纯函数**:`drawImagePanelsOf()` 三道门——面板在白名单内、
127
+ 该面板的 provider 开着(opt-out 语义,旧配置仍算开)、用户显式勾选(**只认字面量
128
+ `true`**,`'yes'`、`1` 之类的手改值不算同意)。企业版即使被勾上也**结构性**拒绝,
129
+ 不靠白名单列表的书写顺序。首版把这条规则写在 `apply()` 的闭包里,只能靠读代码确认,
130
+ 现在由 `tests/image-tool-optin.spec.ts` 的 12 例钉住(缺省/非法形状/非布尔真值/关掉的
131
+ provider/企业版/国际版/多面板顺序/整段 section 与裸 `regions` map 同答)。
132
+ - **开关就在卡片里,不是一个只有 schema 的字段**:真值是 `regions.<panel>.drawImageEnabled`
133
+ (Host 的工具解析按面板读它,所以写的是 `regions` 而不是顶层设置键)。勾选即写、不经过
134
+ 「保存」,写后**回读文档**核对——一个谎报授权的勾选框会让「工具没出现」看起来像 bug。
135
+ 4 例卡片测试 `tests/card-draw-image-switch.spec.tsx` 断言写入字段是 `regions`、该槽位的
136
+ 模型目录/图片模型/上下文预算/账号池一个都不能丢。
137
+ - **范围**:改图(需先走 TOS 上传预签名)与文生视频(异步素材轮询)**不做**,也不留半截入口。
138
+
139
+ ### 文档
140
+
141
+ - `README.md` / `README.en.md` 新增「文生图」一节与功能列表条目:开关语义(含只有国内版
142
+ 提供)、实测协议表、计费,以及为什么那条「取图」GET **是又一次生成**、插件只回 key。
143
+
3
144
  ## 4.5.2 (2026-10-25)
4
145
 
5
146
  > **思考档位不再被吞,也不再被谎报。** 插件原先只认识三档,未知档位一律按 `high`
package/README.en.md CHANGED
@@ -42,6 +42,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
42
42
  - **Multiplier in the model name** — model names show the credit multiplier in Trae's own menu format (e.g. `GLM-5.2 · x0.79`), updated with each directory refresh.
43
43
  - **Promotion badge** — Trae publishes its discount rules in the model directory (`activity_discount`: pay ratio, list price, off-peak windows with their time zone). The plugin judges them against **the clock at render time** and paints one capsule beside the model name (`5 折` / `off-peak 5 折` / `limited 5 折`), with the old price, the new price and the window on hover. Judging at render time is what keeps a night-time discount from still hanging there the next morning; when upstream says nothing is discounted right now no badge is painted, the multiplier is never used to guess a promotion, and no campaign without an end time is labelled "limited". It is information only: no request and no billing changes, and the international region (subscription) has no such data and therefore shows no badge. See [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).
44
44
  - **DSH local tool loop** — gets pending structured `tool_calls` from Trae `llm_utils_chat`, lets DSH execute its own local tools, then returns tool results to the model.
45
+ - **Text-to-image (`trae_draw_image`)** — brings the signed-in account's Trae text-to-image service into the conversation: the model draws one image and the plugin **fetches the bytes too**, writes them under the session cwd and shows the picture inline. **Off by default, enabled per panel** — generation really spends credits (measured: ~8.8 for one `1920x1920` on CN personal), so unlike the image-input declaration it only happens when you say so. Protocol, size bounds, the two-step fetch and why the old "fetch" GET is really a second generation are in "Text-to-image" below.
45
46
  - **Dual-region detection (CN / international)** — detects local sign-ins across Trae CN, TRAE SOLO CN, Trae, and TRAE SOLO installs. The region is derived from the credential's own `userRegion` claim (host suffix and edition label as fallbacks), with no manual switch.
46
47
  - **Region-isolated directories and selections** — CN and international each keep their own model directory, enabled picks, image opt-ins, and context budgets, and each provider reads only its own slot.
47
48
  - **Vision follows Trae's own declaration** — when Trae marks a model `multimodal`, the plugin advertises it as such with no manual tick per model (issue #16). The rule is "**models you tick ∪ models Trae marks multimodal**": models Trae marks `false`, or does not mark at all, stay text-only and can still be turned on by hand. A row enabled automatically is shown ticked and read-only, with the reason. See "Image input" below.
@@ -115,6 +116,27 @@ Notes:
115
116
  - **An automatically enabled row is shown ticked and read-only.** Under the union, unticking it would be re-applied immediately, so offering a clickable box would be a lie. There is currently no per-model "force off" escape hatch; if you want one, say so on issue #16.
116
117
  - Manual ticks still work for models Trae marks `false` (or does not mark), which is the escape hatch for "upstream does not say so, but it actually works".
117
118
 
119
+ ## Text-to-image (`trae_draw_image`)
120
+
121
+ The text-to-image service behind TraeWork / SOLO is now reachable from a DSH conversation: the model **draws one image** and the result is written to a local file.
122
+
123
+ **Off by default, switched on per panel.** Generation **really spends credits** (measured: ~8.8 for one `1920x1920` on CN personal), so this is not the image-input kind of declaration — it is an explicit spend gate. Tick "Allow the model to generate images" (`drawImageEnabled`) in the card's model column and that panel's `trae_draw_image` gets registered with DSH; unticking removes it immediately. Each panel stores its own switch; the enterprise and international panels do not offer one — the former's tenant gateway is a separate billing surface, the latter bills under another plan, and neither has been measured, so the plugin does not pretend otherwise.
124
+
125
+ **The protocol (all measured, none of it guessed from the binary):**
126
+
127
+ | Item | Fact |
128
+ |---|---|
129
+ | Endpoint | `POST /api/ide/v1/tool_text_to_image` (plus an SSE variant `..._stream`), model `seedream_5` |
130
+ | Request body | Only `{prompt, image_size?}`. Adding known fields (`mode_type` / `session_id` / `image_urls` / `watermark`) earns a 400 with an empty body; unknown fields are ignored |
131
+ | Success | Flat `{"message":"success","uri":"tos-cn-i-…/cn/<uid>/gen_image/<ts>_<hex>_jpeg_1920x1920"}` — **no** `{code,msg,data}` envelope |
132
+ | Sizes | Total pixels must land in **3686400 ~ 16777216**: `1024x1024` is rejected as too small (`code 3003`, no charge) and `4608x4608` as too large; presets `2k`/`3k`/`4k` work, default is `1920x1920` |
133
+ | Fetch | **Two steps, no extra charge**: `POST /api/ide/v1/get_resource_url` whose body must be exactly `{"uri_list":["<key>"]}` (`{uri}`/`{path}`/`{resource_uri}`/`{uris:[…]}` and GET `?uri=` all answer 200 with an empty `url_map`), replying `{"url_map":{"<key>":"<byteimg signed URL>"}}`; then a plain GET of that signed URL (**no Trae headers** — the signature is in the query) returns the bytes: measured `image/jpeg`, 1,155,578 bytes, header `ffd8ffe0` |
134
+ | What that GET really is | Decisive probe: after the POST returned `…/1792954795741_44f94116_jpeg_1920x1920`, the GET with the same `(prompt, size)` came back with a **different** uri (`…/1792954810505_b107ac11_jpeg_1920x1920`) after 27 s and zero pixels — a second image and a second charge (~17.6 for the pair). The illegal-size probe agreed: the error is Ark's own `[callArkImageGeneration]` |
135
+
136
+ **The bytes really do come back.** On success the tool returns `url` (the Trae object key, kept for diagnostics), `path` (a local file relative to the session cwd, e.g. `trae-images/trae-20261026-042015-ab12cd.jpg`), `model`, `prompt`, `size` and a `hint`; the renderer emits `![Trae 文生图](trae-images/…)` — DSH's chat resolves that relative path against the session cwd and reads the bytes through the host's authenticated `api/file` route, so the picture shows up inline. Only when the bytes genuinely could not be fetched (`IMAGE_UNAVAILABLE`: the image was generated and charged) does it fall back to inline-code "key only", and such a failure still starts the 30-second cooldown — generation is billed per image, so a retry buys another one.
137
+
138
+ **Image editing and text-to-video are not opened up.** Editing needs a TOS upload presign first (this plugin has no such path); video polls a material result — a separate async pipeline. Neither ships, and neither leaves half an entry point behind.
139
+
118
140
  ## Model coverage
119
141
 
120
142
  This plugin serves models from Trae's **SOLO channel** (`DeepSeek-V4-Flash-Official`, `DeepSeek-V4-Pro-Official`, `GLM-5.3`, `GLM-5.2`, `Kimi-K3`, `MiniMax-M3`, `Qwen3.8-Max`, `Doubao-Seed-*`, …), for both the domestic and international sides.
package/README.md CHANGED
@@ -46,6 +46,7 @@
46
46
  - **倍率内嵌模型名** —— 模型名称按 Trae 自身菜单的格式显示积分倍率(如 `GLM-5.2 · x0.79`),倍率随目录刷新更新。
47
47
  - **活动优惠徽标** —— Trae 把促销规则发在目录里(`activity_discount`:折扣比例、原价、闲时时段与时区),插件按**当前时钟**判定后,在模型名旁边画一枚胶囊:`5 折` / `闲时 5 折` / `限时 5 折`,悬停写明原价、现价与时段。判定放在**渲染时刻**而不是解析时刻,所以夜间折扣不会在第二天早上继续挂着;上游说「此刻没打折」就不画,**不从倍率反推**,也不给没有结束时间的活动编「限时」。这是纯提示:不改请求、不改计费,国际区(订阅制)没有这项数据因此不显示。详见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。**同一枚胶囊也画在 DSH 的模型选择器里**(宿主 `conversation.input.model.label` 插槽),挑模型时就能看见——毕竟徽标画在设置卡片里时,你已经选完了。选择器里**倍率只出现一次**:名字里已有的目录倍率会被拆出来,有折扣时换成**折后价**(与卡片同一套时钟判定,同一份数据),打开选择器不会额外触发上游读。
48
48
  - **DSH 本地工具循环** —— 通过 Trae `llm_utils_chat` 获取待执行的结构化 `tool_calls`,交由 DSH 自带的本地工具执行,再将工具结果回传模型。
49
+ - **文生图(`trae_draw_image`)** —— 把登录账号的 Trae 文生图能力接进会话:模型画一张图,插件把**图片字节一起取回来**、写进会话目录再显示给你。**默认关闭、按面板单独开**——生图真实消耗积分(实测 CN 个人版 `1920x1920` 一张约 8.8),所以它和识图那种「让不让看图」的开关不同,只能由你点头。协议、尺寸边界、取图那两步,以及为什么「取图」那条 GET 其实是又一次生成,见下方「[文生图](#文生图trae_draw_image)」。
49
50
  - **国内国际双区域自动识别** —— 自动发现 Trae CN / TRAE SOLO CN / Trae / TRAE SOLO 四个本地安装的登录账号;区域由凭证自带的 `userRegion` 声明自动判定(host 后缀与 edition 标签兜底),无需手动指定。
50
51
  - **目录与勾选按面板隔离** —— 三个面板各一套模型目录、勾选、图片开关与上下文预算,各自读各自的槽位。名录从**该面板所钉的那条网关**读取——原本钉在企业池的客户端去读消费池名录,会永远发现不了自己池的独占模型。
51
52
  - **识图能力跟随 Trae 自身的标注** —— Trae 把某个模型标为 `multimodal` 时,插件直接按多模态对外声明,无需你在卡片上逐一手勾(issue #16)。判据是「**你手动勾选的 ∪ Trae 标注多模态的**」:Trae 明确标 `false` 或未标注的模型保持纯文本,你可以照旧手动为它们打开图片输入。卡片上被自动开启的那一行显示为已勾选且只读,并说明原因。详见下方「图片输入(识图)」。
@@ -120,6 +121,27 @@ DSH PiAiAdapter(每个 provider 一套)
120
121
  - **被自动开启的行在卡片上显示为已勾选且只读。** 因为并集语义下取消勾选会被立刻重新加上,给它一个可点的框是在撒谎。目前不为单个模型提供「强制关闭」的出口;如果你需要这个开关,可以在 issue #16 下提出。
121
122
  - 手动勾选对 Trae 标注 `false`(或未标注)的模型依然有效,这是为那些「上游没标但实际可用」的情况留的口子。
122
123
 
124
+ ## 文生图(`trae_draw_image`)
125
+
126
+ TraeWork / SOLO 侧那套文生图服务带出来了:在会话里让模型**画一张图**,插件把图片字节取回来、落成会话目录里的文件,再按相对路径显示在聊天里。取图走 `get_resource_url` 解析签名地址,不会重新生成、不额外计费——见下表。
127
+
128
+ **默认关闭,按面板单独开。** 生图**真实消耗积分**(实测 CN 个人版 `1920x1920` 一张约 8.8 积分),所以它不是识图那种「让不让看图」的声明,而是一个要你点头的支出口:卡片模型栏勾上「允许模型自主生图」(`drawImageEnabled`),该面板的 `trae_draw_image` 才注册进 DSH;取消勾选立即撤下。面板各存各的开关;企业版与国际版面板不提供——企业版租户网关是另一条计费面,国际版按另一套套餐计费,两者的生图行为都没有测过,不在这里假装支持。
129
+
130
+ **口径(全部实测,不是从二进制里猜的):**
131
+
132
+ | 项目 | 事实 |
133
+ |---|---|
134
+ | 端点 | `POST /api/ide/v1/tool_text_to_image`(另有 SSE 变体 `..._stream`),模型 `seedream_5` |
135
+ | 请求体 | 只有 `{prompt, image_size?}`。多带已知字段(`mode_type` / `session_id` / `image_urls` / `watermark`)网关直接回 400 空 body——未知字段反而被忽略 |
136
+ | 成功响应 | 扁平结构 `{"message":"success","uri":"tos-cn-i-…/cn/<uid>/gen_image/<ts>_<hex>_jpeg_1920x1920"}`,**没有** `{code,msg,data}` 信封 |
137
+ | 尺寸 | 总像素必须落在 **3686400 ~ 16777216**:`1024x1024` 太小被拒(`code 3003`,不扣费)、`4608x4608` 太大被拒;预设 `2k`/`3k`/`4k` 可用,缺省 `1920x1920` |
138
+ | 取图 | **两步、零额外计费**:`POST /api/ide/v1/get_resource_url`,body 只能是 `{"uri_list":["<key>"]}`(`{uri}`/`{path}`/`{resource_uri}`/`{uris:[…]}`、GET `?uri=` 都只回 200 + 空 `url_map`),应答 `{"url_map":{"<key>":"<byteimg 签名 URL>"}}`;再直接 GET 那个签名地址(**不带任何 Trae 头**,签名在 query 里)拿字节——实测 `image/jpeg`、1,155,578 字节、头 `ffd8ffe0` |
139
+ | 那条 GET 的真实身份 | 决定性探针:POST 拿到 `…/1792954795741_44f94116_jpeg_1920x1920` 后,用同一 `(prompt, size)` 打 GET,回来的是**另一个** uri(`…/1792954810505_b107ac11_jpeg_1920x1920`),耗时 27 秒、一个像素都没有——等于又画一张、再扣一次(两次共约 17.6)。非法尺寸探针(`100x100`)也印证:报错直接来自 Ark 的 `[callArkImageGeneration]` |
140
+
141
+ **图是真的取回来了。** 工具成功时返回 `url`(Trae 对象 key,留作排障)、`path`(相对会话目录的本地文件,如 `trae-images/trae-20261026-042015-ab12cd.jpg`)、`model`、`prompt`、`size` 和一句 `hint`;渲染层输出 `![Trae 文生图](trae-images/…)`——DSH 的聊天按 session cwd 解析这个相对路径,再过宿主的 `api/file` 鉴权路由取字节,所以图直接显示出来。只有字节真的没拿到时(`IMAGE_UNAVAILABLE`:图已生成、积分已扣)才退化成「只有对象 key」的行内代码;那种失败照样进 30 秒冷却——生图按张计费,重试等于再买一张。
142
+
143
+ **改图与文生视频没有放开。** 改图要先走 TOS 上传预签名(本插件没有这条链路);视频要轮询素材结果,是另一条异步管线。两者都不做,也不留半截入口。
144
+
123
145
  ## 模型覆盖范围
124
146
 
125
147
  插件提供的是 Trae **SOLO 通道**的模型(`DeepSeek-V4-Flash-Official`、`DeepSeek-V4-Pro-Official`、`GLM-5.3`、`GLM-5.2`、`Kimi-K3`、`MiniMax-M3`、`Qwen3.8-Max`、`Doubao-Seed-*` 等),三个面板均走这条通道。企业池另有一份名录(例如 `DeepSeek-V4.1-Flash` 只有企业池列),所以两边勾选互不覆盖。
package/lib/client.js CHANGED
@@ -369,6 +369,48 @@ window.__ModuleLoader__.load({
369
369
  });
370
370
  }
371
371
  /**
372
+ * Panels whose text-to-image path this plugin has actually MEASURED, in
373
+ * `TRAE_PANELS` tab order.
374
+ *
375
+ * ONE entry today. The other two are refused for the SAME reason — no evidence
376
+ * — which is exactly the asymmetry the review of #62 held the PR on:
377
+ *
378
+ * - `enterprise` bills through the tenant gateway, a second billing surface
379
+ * whose image behaviour has never been probed;
380
+ * - `ai` answers on `coresg-normal.trae.ai`, where no `tool_text_to_image`
381
+ * request has ever run either — every measurement behind this tool was
382
+ * taken against `trae-api-cn.mchost.guru`. Refusing one unverified gateway
383
+ * while serving another is not a rule, it is an accident.
384
+ *
385
+ * Adding a panel means adding its evidence in the same change (the precedent
386
+ * for international claims is `docs/INTL_SG_EVIDENCE.md`). Order is
387
+ * significant: the tool's resolver walks `TRAE_PANELS` order and takes the
388
+ * FIRST consenting panel, so `cn` — the only measured one — always answers
389
+ * first.
390
+ */
391
+ const DRAW_IMAGE_PANELS = ["cn"];
392
+ /**
393
+ * Build the next `regions` settings value for one panel's 「允许模型自主生图」
394
+ * switch (`drawImageEnabled`, `trae_draw_image`).
395
+ *
396
+ * Same merge rule as every sibling region write: only the addressed panel's
397
+ * `drawImageEnabled` changes, so flipping the switch carries that slot's model
398
+ * directory, selection, budgets, pool and credit seat through, and leaves the
399
+ * other panels alone. The flag is a PANEL's consent, not the account's: cn and
400
+ * enterprise bill the same Trae CN account but route through different gateways,
401
+ * so consent must not cross the boundary (the tool resolver reads it per panel).
402
+ *
403
+ * `value` may be the whole settings section or the `regions` map; the RETURN
404
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
405
+ * 'regions', ...)` needs.
406
+ */
407
+ function nextRegionDrawImage(value, panel, enabled) {
408
+ return nextRegionSlots(regionsMapOf(value), panel, {
409
+ ...regionSlotOf(value, panel),
410
+ drawImageEnabled: enabled
411
+ });
412
+ }
413
+ /**
372
414
  * Build the next `regions` settings value for one panel's MODEL DIRECTORY.
373
415
  *
374
416
  * The model-directory save REPLACES the whole region slot, so every field of
@@ -1195,6 +1237,14 @@ window.__ModuleLoader__.load({
1195
1237
  capability note. Wraps on narrow cards; keeps the two buttons visually a
1196
1238
  pair and distinct from the save footer below. */
1197
1239
  .dsm-trae-context-bulk{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
1240
+ /* Per-panel 「自主生图」 consent (trae_draw_image). Sits under the bulk-context
1241
+ row, above the capability note, because it is a per-REGION runtime choice like
1242
+ the model picks above it — not a global preference. The input stays a real
1243
+ checkbox so the card's tests can find it by its accessible name. */
1244
+ .dsm-trae-draw-image{display:flex;align-items:flex-start;gap:8px;margin-top:10px;font-size:11px;line-height:16px;cursor:pointer}
1245
+ .dsm-trae-draw-image input{flex:none;margin:2px 0 0}
1246
+ .dsm-trae-draw-image[data-disabled="true"]{cursor:not-allowed;opacity:.6}
1247
+ .dsm-trae-draw-image-hint{color:var(--dsw-alias-label-tertiary,#999)}
1198
1248
  /* Verdict badge. Only a real refusal is coloured as a warning; an unknown
1199
1249
  verdict stays neutral because an inconclusive probe is not evidence against
1200
1250
  the model. */
@@ -4133,6 +4183,8 @@ window.__ModuleLoader__.load({
4133
4183
  const optimisticSettings = (0, react.useRef)({});
4134
4184
  /** True while a preference is crossing the wire; serializes both writers. */
4135
4185
  const [savingSettings, setSavingSettings] = (0, react.useState)(false);
4186
+ /** The in-flight flip of the per-panel 「自主生图」 switch (undefined = read the document). */
4187
+ const [drawImageDraft, setDrawImageDraft] = (0, react.useState)(void 0);
4136
4188
  /**
4137
4189
  * Per-account status (check-in / balances / subscription) shown in each row of
4138
4190
  * the account table, keyed by region then account id. Loaded per account via
@@ -4250,6 +4302,37 @@ window.__ModuleLoader__.load({
4250
4302
  if (mounted.current) setSavingSettings(false);
4251
4303
  }
4252
4304
  };
4305
+ /**
4306
+ * Flip ONE panel's 「允许模型自主生图」 switch (`drawImageEnabled`).
4307
+ *
4308
+ * Not a top-level preference like the two above: the flag lives INSIDE
4309
+ * `regions.<panel>`, because it is per-panel consent and the Host's tool
4310
+ * resolver reads it per panel. `nextRegionDrawImage` carries the rest of that
4311
+ * slot through, so flipping this cannot drop the model directory, budgets,
4312
+ * pool or credit seat — the failure #44 fixed for the pool.
4313
+ *
4314
+ * Serialized by `savingSettings` with the sibling writers: they all write the
4315
+ * same settings document. The read-back re-reads the document, so a write that
4316
+ * silently reverted cannot leave the box claiming a grant the Host never got —
4317
+ * the one failure that matters here, because a box that lies about consent
4318
+ * makes the tool's absence look like a bug.
4319
+ */
4320
+ const writeDrawImage = async (value) => {
4321
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
4322
+ setSavingSettings(true);
4323
+ setWriteError(void 0);
4324
+ setDrawImageDraft(value);
4325
+ try {
4326
+ await writeSettingsField(settingsScope, "regions", nextRegionDrawImage(settingsScope.getSnapshot().value, activeRegion, value), (readBack) => regionSlotsOf(readBack)[activeRegion]?.drawImageEnabled === value);
4327
+ } catch (error) {
4328
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
4329
+ } finally {
4330
+ if (mounted.current) {
4331
+ setDrawImageDraft(void 0);
4332
+ setSavingSettings(false);
4333
+ }
4334
+ }
4335
+ };
4253
4336
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
4254
4337
  setSettingsRevision((value) => value + 1);
4255
4338
  }), [settingsScope]);
@@ -4854,6 +4937,26 @@ window.__ModuleLoader__.load({
4854
4937
  const slotImageIds = Array.isArray(regionSlot?.imageModelIds) ? regionSlot.imageModelIds.filter((id) => typeof id === "string") : void 0;
4855
4938
  const legacyImageIds = legacySlot && typeof settingsValue === "object" && settingsValue !== null && Array.isArray(settingsValue.imageModelIds) ? settingsValue.imageModelIds.filter((id) => typeof id === "string") : [];
4856
4939
  const savedImageIds = new Set(slotImageIds ?? legacyImageIds);
4940
+ /**
4941
+ * The active panel's 「自主生图」 consent, read from the SAME place the Host
4942
+ * reads it (`regions.<panel>.drawImageEnabled`, see regionStateOf) with any
4943
+ * in-flight flip laid over it. There is deliberately no legacy flat fallback:
4944
+ * this flag was born after the region split, and reading a top-level key here
4945
+ * would show consent the Host's per-panel resolver never grants.
4946
+ */
4947
+ const committedDrawImage = regionSlot?.drawImageEnabled === true;
4948
+ const drawImageOn = drawImageDraft ?? committedDrawImage;
4949
+ /**
4950
+ * Whether the active panel can serve text-to-image at all. This is the SAME
4951
+ * list the Host registers from (`DRAW_IMAGE_PANELS`, imported rather than
4952
+ * restated), so the box can never offer a grant the Host will then refuse:
4953
+ * a switch that ticks and does nothing is the exact failure this whole
4954
+ * opt-in must not have. Two panels are out — `enterprise` bills through a
4955
+ * tenant gateway, and `ai` has never been probed at all (every measurement
4956
+ * behind the credit figures is CN), so neither may spend credits.
4957
+ */
4958
+ const drawImageServable = DRAW_IMAGE_PANELS.includes(activeRegion);
4959
+ const drawImageUnavailableHint = activeRegion === "ai" ? t("row.drawImageHintAi") : t("row.drawImageHintEnterprise");
4857
4960
  const draft = drafts[activeRegion];
4858
4961
  const visibleModels = draft?.models ?? (status.status === "signed-in" ? status.models : []);
4859
4962
  /**
@@ -5731,6 +5834,22 @@ window.__ModuleLoader__.load({
5731
5834
  className: "dsm-trae-model-capability-note",
5732
5835
  children: t("row.contextBudgetHint")
5733
5836
  }),
5837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5838
+ className: "dsm-trae-draw-image",
5839
+ "data-disabled": drawImageServable ? void 0 : "true",
5840
+ title: drawImageServable ? t("row.drawImageHint") : drawImageUnavailableHint,
5841
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5842
+ type: "checkbox",
5843
+ checked: drawImageOn,
5844
+ disabled: !drawImageServable || busy || saving || savingSettings || settingsScope?.getSnapshot().writable !== true,
5845
+ onChange: (event) => {
5846
+ writeDrawImage(event.target.checked);
5847
+ }
5848
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [t("row.drawImage"), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5849
+ className: "dsm-trae-draw-image-hint",
5850
+ children: [" ", drawImageServable ? t("row.drawImageHint") : drawImageUnavailableHint]
5851
+ })] })]
5852
+ }),
5734
5853
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5735
5854
  className: "dsm-trae-model-capability-note",
5736
5855
  children: t("row.modelCapabilityPending")
@@ -6838,6 +6957,10 @@ window.__ModuleLoader__.load({
6838
6957
  "row.modelsTestAllHint": "The「test all」button probes every model in the catalog, enabled or not, so it spends noticeably more quota than the enabled-only run.",
6839
6958
  "row.showPointsInMainUi": "Show credits in the composer row",
6840
6959
  "row.showPointsInMainUiHint": "A clickable readout in the composer tool row while a model from this plugin is selected: Trae CN · <balance> on the CN side, Trae Global · <plan> on the international one.",
6960
+ "row.drawImage": "Let the model draw images (trae_draw_image)",
6961
+ "row.drawImageHint": "Adds a trae_draw_image tool to this panel: the model can generate one image when you ask for one, at this account's real credit cost (measured ~8.8 for 1920x1920 on Trae CN). Off by default — while this is unticked the tool does not exist, so the model cannot spend credits on its own.",
6962
+ "row.drawImageHintEnterprise": "Not available on the enterprise panel: that path bills through the tenant gateway and has not been verified.",
6963
+ "row.drawImageHintAi": "Not available on the international panel: image generation has only been measured on Trae CN, and this path bills on a different plan, so the credit figures shown here would be wrong. Switch to the CN panel to use it.",
6841
6964
  "row.showPointsInMainUiHintAi": "A clickable readout in the composer tool row while a model from this plugin is selected: Trae Global · <plan>.",
6842
6965
  "row.creditPositionLabel": "position",
6843
6966
  "row.creditPositionComposer": "composer toolbar",
@@ -7219,6 +7342,10 @@ window.__ModuleLoader__.load({
7219
7342
  "row.showPointsInMainUi": "在输入框工具栏显示积分读数",
7220
7343
  "row.showPointsInMainUiHint": "在输入框工具栏显示可点击读数(选中本插件模型时出现):国内版为「Trae CN · 余额」,国际版为「Trae 国际版 · 套餐」。",
7221
7344
  "row.showPointsInMainUiHintAi": "在输入框工具栏显示可点击读数(选中本插件模型时出现):国际版为「Trae 国际版 · 套餐」。",
7345
+ "row.drawImage": "允许模型自主生图(trae_draw_image)",
7346
+ "row.drawImageHint": "为本面板挂上 trae_draw_image 工具:你要求时模型能生成一张图,按本账号真实扣积分(国内版实测 1920x1920 约 8.8)。默认关闭——不勾选时工具根本不存在,模型无法自己花额度。",
7347
+ "row.drawImageHintEnterprise": "企业面板不提供:那条路径走租户网关计费,未经验证。",
7348
+ "row.drawImageHintAi": "国际版面板不提供:生图只在 Trae CN 实测过,国际版按另一套套餐计费,这里标注的积分价格会对不上。要用请切到国内版面板。",
7222
7349
  "row.creditPositionLabel": "显示位置",
7223
7350
  "row.creditPositionComposer": "输入框工具栏",
7224
7351
  "row.creditPositionStats": "输入框下方",
package/lib/index.d.ts CHANGED
@@ -4427,6 +4427,13 @@ interface TraeRegionState {
4427
4427
  enabledModelIds?: string[];
4428
4428
  /** Models the user explicitly enabled for image input in this region. */
4429
4429
  imageModelIds?: string[];
4430
+ /**
4431
+ * Whether the model may call trae_draw_image for this panel. Opt-IN:
4432
+ * image generation spends real credits, so an absent value is never consent
4433
+ * (the same rule workbuddy's imageToolEnabled follows). Declared in the
4434
+ * schema below — a field missing from the schema is dropped on every save.
4435
+ */
4436
+ drawImageEnabled?: boolean;
4430
4437
  /** Local DSH context budget per model in this region. */
4431
4438
  contextBudgets?: Record<string, number>;
4432
4439
  /**
package/lib/index.js CHANGED
@@ -6,7 +6,7 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { basename, dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { copyFile, readFile, readdir, rename, rm, stat } from "node:fs/promises";
9
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
11
11
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
12
12
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
@@ -1560,6 +1560,64 @@ function nextRegionCreditPosition(value, panel, position) {
1560
1560
  creditPosition: position
1561
1561
  });
1562
1562
  }
1563
+ /**
1564
+ * Panels whose text-to-image path this plugin has actually MEASURED, in
1565
+ * `TRAE_PANELS` tab order.
1566
+ *
1567
+ * ONE entry today. The other two are refused for the SAME reason — no evidence
1568
+ * — which is exactly the asymmetry the review of #62 held the PR on:
1569
+ *
1570
+ * - `enterprise` bills through the tenant gateway, a second billing surface
1571
+ * whose image behaviour has never been probed;
1572
+ * - `ai` answers on `coresg-normal.trae.ai`, where no `tool_text_to_image`
1573
+ * request has ever run either — every measurement behind this tool was
1574
+ * taken against `trae-api-cn.mchost.guru`. Refusing one unverified gateway
1575
+ * while serving another is not a rule, it is an accident.
1576
+ *
1577
+ * Adding a panel means adding its evidence in the same change (the precedent
1578
+ * for international claims is `docs/INTL_SG_EVIDENCE.md`). Order is
1579
+ * significant: the tool's resolver walks `TRAE_PANELS` order and takes the
1580
+ * FIRST consenting panel, so `cn` — the only measured one — always answers
1581
+ * first.
1582
+ */
1583
+ const DRAW_IMAGE_PANELS = ["cn"];
1584
+ /**
1585
+ * Whether ONE panel's 「允许模型自主生图」 grant is in force, i.e. its box was
1586
+ * ticked. ONLY the literal `true` counts: a hand-edited `'yes'` or a `1` is a
1587
+ * typo, not consent to spend credits. (A `{ get(): true }` live reference — the
1588
+ * DSH 0.1.7 slot shape — IS decoded to the true it holds first, see
1589
+ * {@link unwrapVolatileDeep}: that is the Host's own document, not a hand edit.)
1590
+ *
1591
+ * `value` may be the whole settings section or the `regions` map (see
1592
+ * {@link regionsMapOf}).
1593
+ */
1594
+ function drawImageEnabledOf(value, panel) {
1595
+ return regionSlotOf(value, panel)["drawImageEnabled"] === true;
1596
+ }
1597
+ /**
1598
+ * The panels that may register `trae_draw_image` RIGHT NOW — the single
1599
+ * judgment behind both the tool's registration and its account resolver, and
1600
+ * therefore the only place a user's credits can be exposed by accident.
1601
+ *
1602
+ * Pure and exported on purpose (review of #62, item 3): the first version kept
1603
+ * this rule inside `apply()`'s closure, so "does ticking the box actually
1604
+ * grant anything" could only be code-reviewed, never tested.
1605
+ *
1606
+ * THREE gates, all required: the panel must be measured
1607
+ * ({@link DRAW_IMAGE_PANELS}), its provider must be switched ON (opt-out rule,
1608
+ * so a config written before the switch existed still counts), and the panel
1609
+ * must carry an explicit grant. A tick on a switched-off provider is STORED
1610
+ * consent, not a live grant: flipping the provider back on restores the tool
1611
+ * without asking again, which is why both gates are read here rather than at
1612
+ * the checkbox.
1613
+ *
1614
+ * `enterprise` is refused structurally, even if someone later adds it to
1615
+ * {@link DRAW_IMAGE_PANELS}: consent must never cross into the tenant billing
1616
+ * surface by editing a list.
1617
+ */
1618
+ function drawImagePanelsOf(value) {
1619
+ return TRAE_PANELS.filter((panel) => panel !== "enterprise" && DRAW_IMAGE_PANELS.includes(panel) && regionEnabledOf(value, panel) && drawImageEnabledOf(value, panel));
1620
+ }
1563
1621
  //#endregion
1564
1622
  //#region src/decrypt.ts
1565
1623
  const TRAE_AUTH_STORAGE_KEY = "iCubeAuthInfo://icube.cloudide";
@@ -9209,7 +9267,7 @@ async function checkinOne(target, deps) {
9209
9267
  return {
9210
9268
  ...base,
9211
9269
  status: "failed",
9212
- message: messageOf(error)
9270
+ message: messageOf$1(error)
9213
9271
  };
9214
9272
  }
9215
9273
  if (credential === void 0) return {
@@ -9224,7 +9282,7 @@ async function checkinOne(target, deps) {
9224
9282
  return {
9225
9283
  ...base,
9226
9284
  status: "failed",
9227
- message: messageOf(error)
9285
+ message: messageOf$1(error)
9228
9286
  };
9229
9287
  }
9230
9288
  if (!status.enabled) return {
@@ -9260,7 +9318,7 @@ async function checkinOne(target, deps) {
9260
9318
  return {
9261
9319
  ...base,
9262
9320
  status: "failed",
9263
- message: messageOf(error)
9321
+ message: messageOf$1(error)
9264
9322
  };
9265
9323
  }
9266
9324
  }
@@ -9312,7 +9370,7 @@ async function testOne(target, modelId, probe, deps) {
9312
9370
  return {
9313
9371
  ...base,
9314
9372
  outcome: "auth",
9315
- detail: messageOf(error)
9373
+ detail: messageOf$1(error)
9316
9374
  };
9317
9375
  }
9318
9376
  if (credential === void 0) return {
@@ -9380,7 +9438,7 @@ function defaultWait(ms) {
9380
9438
  return new Promise((resolve) => setTimeout(resolve, ms));
9381
9439
  }
9382
9440
  /** A safe message from an unknown throwable, bounded in length. */
9383
- function messageOf(error) {
9441
+ function messageOf$1(error) {
9384
9442
  return (error instanceof Error ? error.message : String(error)).slice(0, 300);
9385
9443
  }
9386
9444
  //#endregion
@@ -9847,6 +9905,565 @@ var UnconfiguredTraeUpstreamClient = class {
9847
9905
  }
9848
9906
  };
9849
9907
  //#endregion
9908
+ //#region src/image-gen-trae.ts
9909
+ /**
9910
+ * Trae 文生图的纯逻辑层:请求体、响应解析、尺寸校验、取图地址解析、SSE 帧。
9911
+ *
9912
+ * 与 dsh-connect-workbuddy 的 image-gen.ts 同一分工——本文件只做纯函数与
9913
+ * 常量,不发网络请求;网络与工具注册在 image-tool.ts。
9914
+ *
9915
+ * 协议事实来自 2026-10-25 对 Trae CN 账号的活体探测(tests/live-probe*.spec.ts,
9916
+ * 结果存 probe2/3/4/5-results.jsonl):
9917
+ * - POST {chat}/api/ide/v1/tool_text_to_image,请求体只有 {prompt, image_size?}
9918
+ * 被接受;多发 mode_type/request_type/chat_mode/session_id/access_type/
9919
+ * image_urls/watermark 等任何已知字段都会被网关 400 拒绝(空 body)。
9920
+ * 未知字段被忽略(camelCase 字段能过)。
9921
+ * - 成功应答是扁平 JSON {message: "success", uri: "<tos 对象 key>"};
9922
+ * 业务失败是扁平 {code: 3003, message: "…"}。没有 {code:0,data} envelope。
9923
+ * - image_size 校验在 Ark 侧:格式 'WIDTHxHEIGHT' 或预设 2k/3k/4k,
9924
+ * 总像素 ∈ [3686400, 16777216](512x512 与 1024x1024 会被拒);
9925
+ * 缺省时输出 1920x1920。
9926
+ * - 同一路径的 GET(?prompt=<P>&image_size=<S> + x-ide-sign 头)**不是取图**,
9927
+ * 而是又一次生成:2026-10-26 决定性实测,同一 (prompt, size) 紧接着 GET,
9928
+ * 回来的是另一个 uri、耗时 27 秒、零像素(等于多扣一张)。曾为它写下的
9929
+ * traeImageFetchUrl / computeTraeIdeSign / traeImageSignFields 与签名盐已删除——
9930
+ * 取图跟 prompt 没有任何关系。
9931
+ * - 真正的取图是两步、不再生成(同日实测,零成本):POST
9932
+ * /api/ide/v1/get_resource_url { uri_list: [<tos key>] } →
9933
+ * { url_map: { "<key>": "<byteimg 签名 URL>" } },再直接 GET 那个签名 URL
9934
+ * (不带任何 Trae 头)即得图片字节(实测 image/jpeg、1,155,578 字节、
9935
+ * 头 ffd8ffe0)。签名 URL 带 x-expires,过期重新解析即可。
9936
+ * - SSE 端点 tool_text_to_image_stream 的事件名是小写:heartbeat(data:
9937
+ * {timestamp_ms, status})、result(data: {uri})、done(data:
9938
+ * {finish_reason})。
9939
+ * - 计费锚点:1920x1920 一张 ≈ 8.8 积分。
9940
+ *
9941
+ * @module dsh-connect-trae/image-gen-trae
9942
+ */
9943
+ /** 文生图(同步):一次 POST 拿回结果。Trae 客户端的 ToolText2Image 用的就是它。 */
9944
+ const TRAE_TOOL_TEXT_TO_IMAGE_PATH = "/api/ide/v1/tool_text_to_image";
9945
+ /** 上游为图像工具固定的模型 id;请求体里没有 model 字段,服务端自选。 */
9946
+ const TRAE_IMAGE_MODEL = "seedream_5";
9947
+ /** 尺寸预设:活体确认 '2k','3k','4k' 被 Ark 接受,square/square_hd 等
9948
+ * 客户端预设名服务端也会翻译(square_hd 实测出 1920x1920)。 */
9949
+ const TRAE_IMAGE_SIZE_PRESETS = [
9950
+ "2k",
9951
+ "3k",
9952
+ "4k",
9953
+ "square",
9954
+ "square_hd",
9955
+ "landscape",
9956
+ "portrait"
9957
+ ];
9958
+ var TraeImageError = class extends Error {
9959
+ code;
9960
+ /** 上游业务码(响应里的 code 字段),只在 UPSTREAM 类失败上有值。 */
9961
+ serverCode;
9962
+ constructor(message, code, serverCode) {
9963
+ super(message);
9964
+ this.name = "TraeImageError";
9965
+ this.code = code;
9966
+ this.serverCode = typeof serverCode === "number" ? serverCode : void 0;
9967
+ }
9968
+ };
9969
+ /** 尺寸合法性:预设名,或 宽x高 且总像素在 Ark 的 [下限,上限] 区间。 */
9970
+ function isTraeImageSize(value) {
9971
+ if (TRAE_IMAGE_SIZE_PRESETS.includes(value)) return true;
9972
+ const m = /^([0-9]{3,5})x([0-9]{3,5})$/.exec(value);
9973
+ if (m === null) return false;
9974
+ const pixels = Number(m[1]) * Number(m[2]);
9975
+ return pixels >= 3686400 && pixels <= 16777216;
9976
+ }
9977
+ /** 组一次文生图请求体;参数毛病全在这里拦下(INVALID_ARGS,不花积分)。 */
9978
+ function buildTraeImageBody(input) {
9979
+ const prompt = input.prompt.trim();
9980
+ if (prompt === "") throw new TraeImageError("trae image: prompt 必须是非空字符串。", "INVALID_ARGS");
9981
+ const out = { prompt };
9982
+ if (input.size !== void 0 && input.size !== "") {
9983
+ const size = input.size.trim().toLowerCase();
9984
+ if (!isTraeImageSize(size)) throw new TraeImageError("trae image: 尺寸「" + size + "」不被接受(需 宽x高 且总像素 ∈ [3686400,16777216],或预设 " + TRAE_IMAGE_SIZE_PRESETS.join("/") + ")。", "INVALID_ARGS");
9985
+ out.image_size = size;
9986
+ }
9987
+ return out;
9988
+ }
9989
+ /**
9990
+ * uri → 可下载 URL 的解析端点。2026-10-26 活体确认:body 必须正是
9991
+ * `{ uri_list: [<tos key>] }`;其它所有形态({uri}、{path}、{resource_uri}、
9992
+ * {uris:[…]}、{resources:[…]}、{scene:'report',uri}、GET ?uri=)都回
9993
+ * HTTP 200 + 空 url_map——不报错,只是什么都不给。
9994
+ */
9995
+ const TRAE_RESOURCE_URL_PATH = "/api/ide/v1/get_resource_url";
9996
+ /** 组一次解析请求体;空 uri 在这里拦下(INVALID_ARGS,一分钱没花)。 */
9997
+ function buildTraeResourceUrlBody(uri) {
9998
+ const key = typeof uri === "string" ? uri.trim() : "";
9999
+ if (key === "") throw new TraeImageError("trae image: 解析图片地址需要非空的 uri。", "INVALID_ARGS");
10000
+ return { uri_list: [key] };
10001
+ }
10002
+ /**
10003
+ * 解析应答里的 url_map。服务端对认不出的 key 就回空表(不报错),
10004
+ * 所以非字符串/空串条目一律丢弃,由调用方按"缺地址"处理。
10005
+ */
10006
+ function parseTraeResourceUrlMap(json) {
10007
+ const raw = (json !== null && typeof json === "object" ? json : {})["url_map"];
10008
+ const map = {};
10009
+ if (raw === null || typeof raw !== "object") return map;
10010
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "string" && value.trim() !== "") map[key] = value.trim();
10011
+ return map;
10012
+ }
10013
+ /**
10014
+ * 取该 uri 的签名下载地址。空表 = 服务端认不出这个 key,此时图片已经
10015
+ * 生成并计费,却拿不到字节——如实抛 IMAGE_UNAVAILABLE,别谎报成功。
10016
+ */
10017
+ function signedTraeResourceUrl(json, uri) {
10018
+ const map = parseTraeResourceUrlMap(json);
10019
+ const signed = map[uri] ?? map[uri.trim()];
10020
+ if (signed === void 0) throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但服务端没给出可下载地址(url_map 里没有这个 key):" + uri, "IMAGE_UNAVAILABLE");
10021
+ return signed;
10022
+ }
10023
+ /** 应答里图片地址的字段名。活体看到的就是 uri;其余字段是 Rust 端
10024
+ * ToolcallResultImage 的字段池(tos_uri/display_url/…),留给未来形态。 */
10025
+ const TRAE_IMAGE_URL_FIELDS = [
10026
+ "uri",
10027
+ "tos_uri",
10028
+ "display_url",
10029
+ "image_url",
10030
+ "original_url",
10031
+ "preview_url",
10032
+ "internal_url"
10033
+ ];
10034
+ /** 深度不超过 4 地扫第一个可用 URL 字段;再深的嵌套不是这个 API 的形态。 */
10035
+ function findImageUrl(node, depth = 0) {
10036
+ if (depth > 4 || node === null || typeof node !== "object") return void 0;
10037
+ const record = node;
10038
+ for (const field of TRAE_IMAGE_URL_FIELDS) {
10039
+ const value = record[field];
10040
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
10041
+ }
10042
+ for (const value of Object.values(record)) if (value !== null && typeof value === "object") {
10043
+ const found = findImageUrl(value, depth + 1);
10044
+ if (found !== void 0) return found;
10045
+ }
10046
+ }
10047
+ function findCredit(node, depth = 0) {
10048
+ if (depth > 4 || node === null || typeof node !== "object") return void 0;
10049
+ const record = node;
10050
+ const credit = record.credit;
10051
+ if (typeof credit === "number" && Number.isFinite(credit)) return credit;
10052
+ for (const value of Object.values(record)) if (value !== null && typeof value === "object") {
10053
+ const found = findCredit(value, depth + 1);
10054
+ if (found !== void 0) return found;
10055
+ }
10056
+ }
10057
+ /**
10058
+ * 解析 tool_text_to_image 的扁平 JSON 应答。
10059
+ *
10060
+ * 实测形态:成功 {message:"success", uri:"…"};失败 {code:3003, message:"…"}。
10061
+ * code 存在且非 0 → UPSTREAM(原样抛出 code/message——余额不足、风控、
10062
+ * Ark 参数错误全靠它浮出);无 code 但 uri 缺失 → EMPTY_RESULT。
10063
+ */
10064
+ function parseTraeImageResponse(json) {
10065
+ const record = json !== null && typeof json === "object" ? json : {};
10066
+ const code = typeof record.code === "number" ? record.code : void 0;
10067
+ const message = typeof record.message === "string" ? record.message : void 0;
10068
+ if (code !== void 0 && code !== 0) throw new TraeImageError("trae image: 上游业务错误 code=" + code + (message === void 0 || message === "" ? "" : ": " + message), "UPSTREAM", code);
10069
+ const url = findImageUrl(record);
10070
+ if (url === void 0) throw new TraeImageError("trae image: 应答里没有图片地址" + (message === void 0 ? "" : "(message=" + message + ")"), "EMPTY_RESULT", code);
10071
+ const credit = findCredit(record);
10072
+ return credit === void 0 ? { url } : {
10073
+ url,
10074
+ credit
10075
+ };
10076
+ }
10077
+ //#endregion
10078
+ //#region src/image-tool.ts
10079
+ /**
10080
+ * trae_draw_image:把 Trae 的文生图能力(客户端与 SOLO 共用的
10081
+ * /api/ide/v1/tool_text_to_image 端点)暴露成一个 DSH agent 工具。
10082
+ *
10083
+ * 与 dsh-connect-workbuddy 的 image-tool.ts 同一注册形态:手写 ToolDefinition、
10084
+ * 不 import 宿主类型(宿主 Tools.register 只校验 output.render 是函数、
10085
+ * output.schema 是受支持的 JSON Schema;parameters 原样投影给模型)。
10086
+ * 这样 bin 可达图不新增任何非 node: 裸说明符(tests/host-peers.spec.ts 的约束)。
10087
+ *
10088
+ * 作用域(v1,有意为之):
10089
+ * - 只做文生图。改图(image_urls 传参考图)需要先把本地文件经
10090
+ * get_resource_upload_url 预签名上传到 TOS,那是另一条流水线,未接。
10091
+ * - 只做文生图同步端点。SSE 端点与 video 端点的常量在 image-gen-trae.ts
10092
+ * 留档(tool_text_to_image_stream / tool_text_to_video_stream),
10093
+ * Trae 的视频结果还要轮询 query_material_result,等真机探针确认后再加。
10094
+ *
10095
+ * @module dsh-connect-trae/image-tool
10096
+ */
10097
+ /** 工具名。与其它 connect 插件的命名习惯一致。 */
10098
+ const TRAE_IMAGE_TOOL_NAME = "trae_draw_image";
10099
+ /** 单次生图上限。Trae 客户端里一张图 15-45 秒,留足余量。 */
10100
+ const IMAGE_TOOL_TIMEOUT_MS = 18e4;
10101
+ /** 失败后的冷却:生图按张扣积分,连续重试会白白烧积分。 */
10102
+ const FAILURE_COOLDOWN_MS = 3e4;
10103
+ /** 图片落地目录名,相对会话工作目录。 */
10104
+ const TRAE_IMAGE_DIR = "trae-images";
10105
+ function messageOf(error) {
10106
+ return error instanceof Error ? error.message : String(error);
10107
+ }
10108
+ /** HTTP 层的状态码映射。语义与 workbuddy 侧一致。 */
10109
+ function mapHttpToImageError(status, detail) {
10110
+ const tail = detail === "" ? "" : ":" + detail.slice(0, 200);
10111
+ if (status === 401 || status === 403) return new TraeImageError("trae image: 上游拒绝了凭据(HTTP " + status + ")" + tail, "AUTH");
10112
+ if (status === 429) return new TraeImageError("trae image: 上游限流(HTTP 429)" + tail, "RATE_LIMIT");
10113
+ return new TraeImageError("trae image: 上游 HTTP " + status + tail, "UPSTREAM");
10114
+ }
10115
+ /**
10116
+ * 取图阶段(解析签名地址 / 下载字节)的 HTTP 失败。
10117
+ *
10118
+ * 这里**不能**复用 mapHttpToImageError:那套映射是给「生图那一跳」用的,401/403 报
10119
+ * AUTH、429 报 RATE_LIMIT 都还有意义。到了取图这一跳,图早已生成、积分早已扣掉,
10120
+ * 再把 403 说成「上游拒绝了凭据」就是谎报——用户会去重新登录,而钱已经花了、图还是
10121
+ * 拿不到。所以一律归成 IMAGE_UNAVAILABLE,文案保留状态码与原文便于排障,并附上对象
10122
+ * key。冷却判据只看 code,IMAGE_UNAVAILABLE 同样在冷却集合里,不会因为换了 code 就
10123
+ * 变成可重试(重试等于再买一张)。
10124
+ */
10125
+ function mapRetrievalHttpError(status, detail, uri) {
10126
+ const tail = detail === "" ? "" : ":" + detail.slice(0, 200);
10127
+ return new TraeImageError("trae image: 图片已经生成、积分已经扣了,但取图接口回了 HTTP " + status + tail + "\n图片对象 key:" + uri, "IMAGE_UNAVAILABLE");
10128
+ }
10129
+ /**
10130
+ * 发一次真机文生图请求,并把图片字节一起取回来。所有可预期的失败都归一成
10131
+ * TraeImageError,让上层的冷却判据只看 code(见 createTraeDrawImageTool 的注释)。
10132
+ */
10133
+ var TraeImageClient = class {
10134
+ options;
10135
+ constructor(options = {}) {
10136
+ this.options = options;
10137
+ }
10138
+ async generate(account, input, signal) {
10139
+ const body = buildTraeImageBody(input);
10140
+ const endpoint = traeEndpoint(account.baseUrl, TRAE_TOOL_TEXT_TO_IMAGE_PATH);
10141
+ const headers = buildTraeHeaders(account.credential, account.identity, { profile: "model-detail" });
10142
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
10143
+ const timeoutMs = this.options.timeoutMs ?? IMAGE_TOOL_TIMEOUT_MS;
10144
+ const requestSignal = signal ?? AbortSignal.timeout(timeoutMs);
10145
+ let response;
10146
+ try {
10147
+ response = await fetchImpl(endpoint, {
10148
+ method: "POST",
10149
+ headers,
10150
+ body: JSON.stringify(body),
10151
+ signal: requestSignal
10152
+ });
10153
+ } catch (error) {
10154
+ throw new TraeImageError("trae image: 生图请求发出失败:" + messageOf(error), "NO_ROUTE");
10155
+ }
10156
+ let payload;
10157
+ try {
10158
+ payload = await response.text();
10159
+ } catch (error) {
10160
+ throw new TraeImageError("trae image: 读取应答失败:" + messageOf(error), "UPSTREAM");
10161
+ }
10162
+ if (!response.ok) throw mapHttpToImageError(response.status, payload);
10163
+ let json;
10164
+ try {
10165
+ json = JSON.parse(payload);
10166
+ } catch {
10167
+ throw new TraeImageError("trae image: 应答不是 JSON(HTTP 200):" + payload.slice(0, 200), "UPSTREAM");
10168
+ }
10169
+ const outcome = parseTraeImageResponse(json);
10170
+ const signed = await this.resolveSignedUrl(account, outcome.url, requestSignal);
10171
+ const image = await this.download(signed, requestSignal, outcome.url);
10172
+ const result = {
10173
+ url: outcome.url,
10174
+ bytes: image.bytes,
10175
+ mimeType: image.mimeType
10176
+ };
10177
+ if (outcome.credit !== void 0) result.credit = outcome.credit;
10178
+ return result;
10179
+ }
10180
+ /**
10181
+ * 把 tos key 解析成一个可直接 GET 的签名地址(byteimg CDN,带 x-expires)。
10182
+ * 这一步只是向服务端要地址,不会重新画图。
10183
+ */
10184
+ async resolveSignedUrl(account, uri, signal) {
10185
+ const body = buildTraeResourceUrlBody(uri);
10186
+ const endpoint = traeEndpoint(account.baseUrl, TRAE_RESOURCE_URL_PATH);
10187
+ const headers = buildTraeHeaders(account.credential, account.identity, { profile: "model-detail" });
10188
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
10189
+ const requestSignal = signal ?? AbortSignal.timeout(IMAGE_TOOL_TIMEOUT_MS);
10190
+ let response;
10191
+ try {
10192
+ response = await fetchImpl(endpoint, {
10193
+ method: "POST",
10194
+ headers,
10195
+ body: JSON.stringify(body),
10196
+ signal: requestSignal
10197
+ });
10198
+ } catch (error) {
10199
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但解析下载地址的请求发不出去:" + messageOf(error), "IMAGE_UNAVAILABLE");
10200
+ }
10201
+ let payload;
10202
+ try {
10203
+ payload = await response.text();
10204
+ } catch (error) {
10205
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但读取解析应答失败:" + messageOf(error), "IMAGE_UNAVAILABLE");
10206
+ }
10207
+ if (!response.ok) throw mapRetrievalHttpError(response.status, payload, uri);
10208
+ let json;
10209
+ try {
10210
+ json = JSON.parse(payload);
10211
+ } catch {
10212
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但解析应答不是 JSON(HTTP 200):" + payload.slice(0, 200), "IMAGE_UNAVAILABLE");
10213
+ }
10214
+ if (json === null || typeof json !== "object") throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但解析应答里没有 url_map:" + payload.slice(0, 200), "IMAGE_UNAVAILABLE");
10215
+ return signedTraeResourceUrl(json, uri);
10216
+ }
10217
+ /** 下载签名地址上的图片字节。CDN 不需要任何 Trae 头——签名就在 query 里。 */
10218
+ async download(signed, signal, uri) {
10219
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
10220
+ const requestSignal = signal ?? AbortSignal.timeout(IMAGE_TOOL_TIMEOUT_MS);
10221
+ let response;
10222
+ try {
10223
+ response = await fetchImpl(signed, {
10224
+ method: "GET",
10225
+ headers: { Accept: "image/*" },
10226
+ signal: requestSignal
10227
+ });
10228
+ } catch (error) {
10229
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但下载字节失败:" + messageOf(error), "IMAGE_UNAVAILABLE");
10230
+ }
10231
+ if (!response.ok) {
10232
+ const key = uri === void 0 || uri === "" ? "" : "\n图片对象 key:" + uri;
10233
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但下载地址回了 HTTP " + response.status + "。" + key, "IMAGE_UNAVAILABLE");
10234
+ }
10235
+ let buffer;
10236
+ try {
10237
+ buffer = await response.arrayBuffer();
10238
+ } catch (error) {
10239
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但读取图片字节失败:" + messageOf(error), "IMAGE_UNAVAILABLE");
10240
+ }
10241
+ if (buffer.byteLength === 0) throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但下载回来是 0 字节。", "IMAGE_UNAVAILABLE");
10242
+ const mimeType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
10243
+ const sniffed = imageMimeTypeOf(new Uint8Array(buffer));
10244
+ if (sniffed === void 0) throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但下载回来的不是图片(content-type " + (mimeType === "" ? "缺失" : mimeType) + ",前 4 字节 " + hexPrefix(new Uint8Array(buffer)) + ")。" + (uri === void 0 || uri === "" ? "" : "\n图片对象 key:" + uri), "IMAGE_UNAVAILABLE");
10245
+ return {
10246
+ bytes: new Uint8Array(buffer),
10247
+ mimeType: sniffed
10248
+ };
10249
+ }
10250
+ };
10251
+ function asRecord(value) {
10252
+ return typeof value === "object" && value !== null ? value : {};
10253
+ }
10254
+ /**
10255
+ * 从字节魔数判断图片类型(JPEG / PNG / GIF / WebP / BMP)。
10256
+ *
10257
+ * 为什么不信 content-type:签名地址过期或被劫持时,CDN 常拿 200 回一页 HTML。
10258
+ * 那样写下去的文件扩展名是 .jpg 却打不开,用户只看到碎图标,而积分已经扣了。
10259
+ * 认不出就返回 undefined,由调用方如实报 IMAGE_UNAVAILABLE。
10260
+ */
10261
+ function imageMimeTypeOf(bytes) {
10262
+ const at = (index) => bytes[index] ?? -1;
10263
+ if (at(0) === 255 && at(1) === 216 && at(2) === 255) return "image/jpeg";
10264
+ if (at(0) === 137 && at(1) === 80 && at(2) === 78 && at(3) === 71) return "image/png";
10265
+ if (at(0) === 71 && at(1) === 73 && at(2) === 70) return "image/gif";
10266
+ if (at(0) === 82 && at(1) === 73 && at(2) === 70 && at(3) === 70 && at(8) === 87 && at(9) === 69 && at(10) === 66 && at(11) === 80) return "image/webp";
10267
+ if (at(0) === 66 && at(1) === 77) return "image/bmp";
10268
+ }
10269
+ /** 前若干字节的十六进制,只用于排障文案。 */
10270
+ function hexPrefix(bytes) {
10271
+ return [...bytes.slice(0, 4)].map((value) => value.toString(16).padStart(2, "0")).join("");
10272
+ }
10273
+ function asNonEmptyString(value) {
10274
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
10275
+ }
10276
+ /**
10277
+ * 这些失败码都发生在「生图请求已经发出去」之后或尝试发出它——上游可能已经
10278
+ * 扣了积分或排了队,连续重试只会把它们再花一遍,所以进冷却。
10279
+ * INVALID_ARGS / NOT_LOGGED_IN 一分钱没花,不冷却(判据同 workbuddy)。
10280
+ */
10281
+ const BILLABLE_FAILURE_CODES = /* @__PURE__ */ new Set([
10282
+ "AUTH",
10283
+ "RATE_LIMIT",
10284
+ "NO_ROUTE",
10285
+ "UPSTREAM",
10286
+ "EMPTY_RESULT",
10287
+ "IMAGE_UNAVAILABLE"
10288
+ ]);
10289
+ function isBillableFailure(error) {
10290
+ if (error instanceof TraeImageError) return BILLABLE_FAILURE_CODES.has(error.code);
10291
+ return true;
10292
+ }
10293
+ /** 造出工具定义。每次 apply() 只造一个实例,冷却状态挂在闭包里。 */
10294
+ function createTraeDrawImageTool(deps) {
10295
+ let cooldownUntil = 0;
10296
+ return {
10297
+ name: TRAE_IMAGE_TOOL_NAME,
10298
+ description: "用当前登录的 Trae 账号文生图(TraeWork/SOLO 同款服务,模型 seedream_5)。只在用户明确要一张图时调用:每张真实扣该账号积分,实测 CN 个人版 1920x1920 一张约 8.8 积分(尺寸越大越贵),而上游不回传扣费数字,以 Trae 额度面板为准。不是常开能力:只有插件设置里为该区打开「允许模型自主生图」(drawImageEnabled) 时本工具才存在,没打开就没得调——不要用报错去试探它开没开。取图口径:生成成功后本工具会把图片字节取回来、写进会话工作目录的 trae-images/ 下,结果里给相对路径 path,渲染层已经据此把图显示给用户了——不需要你再写 markdown。取图走 get_resource_url 解析签名地址(只是要地址,不会重新生成、不额外计费)。不要为了\"取图\"或\"换张\"重跑同一条 prompt(那会再扣一张)。失败码 IMAGE_UNAVAILABLE 表示图已生成、积分已扣、但字节没落地:同样不要重跑。失败后有 30 秒冷却,冷却内重试不会打到上游。尺寸:默认 1920x1920,也可给 宽x高(总像素需在 3686400~16777216 之间,如 2048x2048)或预设 2k/3k/4k;过小(如 1024x1024)会被上游直接拒绝。(改图与文生视频暂未开放:改图需要先走 TOS 上传预签名,视频要轮询素材结果。)",
10299
+ parameters: {
10300
+ type: "object",
10301
+ properties: {
10302
+ prompt: {
10303
+ type: "string",
10304
+ description: "画面描述(中文或英文都可以)。写清楚主体、风格、构图、光线。"
10305
+ },
10306
+ size: {
10307
+ type: "string",
10308
+ description: "图片尺寸:默认 1920x1920;也可给 宽x高(总像素 3686400~16777216,如 2048x2048)或预设 2k/3k/4k。过小(如 1024x1024)会被上游拒绝。"
10309
+ }
10310
+ },
10311
+ required: ["prompt"],
10312
+ additionalProperties: false
10313
+ },
10314
+ output: {
10315
+ schema: {
10316
+ type: "object",
10317
+ properties: {
10318
+ url: {
10319
+ type: "string",
10320
+ description: "Trae 图片对象 key(tos uri),留作记录与排障,不是可直接打开的 URL。"
10321
+ },
10322
+ path: {
10323
+ type: "string",
10324
+ description: "图片文件相对会话工作目录的路径(如 trae-images/trae-20261026-041500-ab12cd.jpg);渲染层已据此显示图片。"
10325
+ },
10326
+ model: {
10327
+ type: "string",
10328
+ description: "实际使用的生图模型 id。"
10329
+ },
10330
+ prompt: {
10331
+ type: "string",
10332
+ description: "实际提交的画面描述。"
10333
+ },
10334
+ size: {
10335
+ type: "string",
10336
+ description: "实际提交的尺寸。"
10337
+ },
10338
+ hint: {
10339
+ type: "string",
10340
+ description: "给模型的后续提示(图片位置、扣费口径)。"
10341
+ },
10342
+ credit: {
10343
+ type: "number",
10344
+ description: "上游若回传扣费数字,这里给出积分数。"
10345
+ }
10346
+ },
10347
+ required: [
10348
+ "url",
10349
+ "path",
10350
+ "model",
10351
+ "prompt",
10352
+ "size",
10353
+ "hint"
10354
+ ],
10355
+ additionalProperties: false
10356
+ },
10357
+ render(_args, value) {
10358
+ const record = asRecord(value);
10359
+ const url = asNonEmptyString(record["url"]) ?? "";
10360
+ const path = asNonEmptyString(record["path"]) ?? "";
10361
+ const model = asNonEmptyString(record["model"]) ?? "unknown";
10362
+ const size = asNonEmptyString(record["size"]) ?? "";
10363
+ const credit = typeof record["credit"] === "number" ? record["credit"] : void 0;
10364
+ const hint = asNonEmptyString(record["hint"]) ?? "";
10365
+ const lines = path === "" ? ["图片已生成,但字节没取回来(只有对象 key):`" + url + "`"] : ["![Trae 文生图](" + path + ")"];
10366
+ lines.push("", "文生图 · 模型:" + model + (size === "" ? "" : " · 尺寸:" + size) + (credit === void 0 ? "" : " · 消耗 " + credit + " 积分"));
10367
+ if (hint !== "") lines.push("", hint);
10368
+ return [{
10369
+ type: "text",
10370
+ text: lines.join("\n")
10371
+ }];
10372
+ }
10373
+ },
10374
+ timeoutMs: IMAGE_TOOL_TIMEOUT_MS,
10375
+ async execute(args, exec) {
10376
+ const record = asRecord(args);
10377
+ const prompt = asNonEmptyString(record["prompt"]);
10378
+ if (prompt === void 0) throw new TraeImageError("trae_draw_image: 参数 prompt 必填,且必须是非空字符串。", "INVALID_ARGS");
10379
+ const size = asNonEmptyString(record["size"]) ?? "1920x1920";
10380
+ const now = Date.now();
10381
+ if (now < cooldownUntil) throw new TraeImageError("trae image: 上一次生图刚失败过,请 " + Math.ceil((cooldownUntil - now) / 1e3) + " 秒后再试(失败冷却是为了避免连续重试白白烧积分)。", "COOLDOWN");
10382
+ const account = await deps.resolveAccount();
10383
+ if (account === void 0) throw new TraeImageError("trae image: 没有可用的 Trae 账号——要么相关区都没登录,要么已登录的那个区没有打开「允许模型自主生图」(drawImageEnabled)。请先登录 Trae 并在插件设置里打开那个开关。", "NOT_LOGGED_IN");
10384
+ const signal = exec.signal === void 0 ? AbortSignal.timeout(IMAGE_TOOL_TIMEOUT_MS) : AbortSignal.any([exec.signal, AbortSignal.timeout(IMAGE_TOOL_TIMEOUT_MS)]);
10385
+ try {
10386
+ const outcome = await deps.generate(account, {
10387
+ prompt,
10388
+ size
10389
+ }, signal);
10390
+ const saved = await saveTraeImage(await deps.directoryOf?.(exec) ?? sessionCwdOf(exec) ?? process.cwd(), outcome);
10391
+ const value = {
10392
+ url: outcome.url,
10393
+ path: saved.relativePath,
10394
+ model: TRAE_IMAGE_MODEL,
10395
+ prompt,
10396
+ size,
10397
+ hint: "这张图来自 Trae 文生图(模型 seedream_5),按张计入该账号积分(实测 1920x1920 约 8.8,上游不回传数字,以 Trae 额度面板为准)。图片已保存到会话目录下的 " + saved.relativePath + "(绝对路径 " + saved.absolutePath + "),渲染层已经把它显示给用户了,不需要你再写 markdown。不要为了取图或换张重跑同一条 prompt——那会再扣一张。"
10398
+ };
10399
+ if (outcome.credit !== void 0) value.credit = outcome.credit;
10400
+ return value;
10401
+ } catch (error) {
10402
+ if (isBillableFailure(error)) cooldownUntil = Date.now() + FAILURE_COOLDOWN_MS;
10403
+ throw error;
10404
+ }
10405
+ }
10406
+ };
10407
+ }
10408
+ /** 从 exec 里读会话工作目录(宿主没给就返回 undefined)。 */
10409
+ function sessionCwdOf(exec) {
10410
+ const session = exec.agent?.session;
10411
+ return nonEmptyPath(session?.workingDirectory) ?? nonEmptyPath(session?.header?.cwd) ?? nonEmptyPath(session?.cwd) ?? nonEmptyPath(exec.cwd);
10412
+ }
10413
+ /** 只在真拿到非空字符串时返回它(空串/空白/null 一律当「不知道」)。 */
10414
+ function nonEmptyPath(value) {
10415
+ if (typeof value !== "string") return void 0;
10416
+ return value.trim() === "" ? void 0 : value;
10417
+ }
10418
+ /** mime → 文件后缀(上游目前只回 image/jpeg)。 */
10419
+ function imageExtensionFor(mimeType) {
10420
+ const type = mimeType.toLowerCase();
10421
+ if (type.includes("png")) return "png";
10422
+ if (type.includes("webp")) return "webp";
10423
+ if (type.includes("gif")) return "gif";
10424
+ return "jpg";
10425
+ }
10426
+ /** 文件名:本地时间的可排序时间戳 + 随机后缀,同一秒两张图也不会互相覆盖。 */
10427
+ function imageFileName(mimeType, now) {
10428
+ const date = new Date(now);
10429
+ const pad = (value) => String(value).padStart(2, "0");
10430
+ const stamp = String(date.getFullYear()) + pad(date.getMonth() + 1) + pad(date.getDate()) + "-" + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds());
10431
+ const rand = Math.random().toString(16).slice(2, 8);
10432
+ return "trae-" + stamp + "-" + rand + "." + imageExtensionFor(mimeType);
10433
+ }
10434
+ /**
10435
+ * 把图片字节写成 <cwd>/trae-images/<name>,返回绝对路径与相对路径。
10436
+ * 相对路径给 render 用:DSH 的聊天按 session cwd 解析 markdown 图片地址,
10437
+ * 再过 api/file 鉴权路由取字节,所以相对路径就能显示。
10438
+ */
10439
+ async function saveTraeImage(cwd, outcome) {
10440
+ const name = imageFileName(outcome.mimeType, Date.now());
10441
+ const directory = join(cwd, TRAE_IMAGE_DIR);
10442
+ try {
10443
+ await mkdir(directory, { recursive: true });
10444
+ const absolutePath = join(directory, name);
10445
+ await writeFile(absolutePath, outcome.bytes);
10446
+ return {
10447
+ absolutePath,
10448
+ relativePath: "trae-images/" + name
10449
+ };
10450
+ } catch (error) {
10451
+ throw new TraeImageError("trae image: 图片已经生成、积分已经扣了,但写入本地文件失败(目录 " + directory + "):" + messageOf(error) + "\n图片对象 key:" + outcome.url, "IMAGE_UNAVAILABLE");
10452
+ }
10453
+ }
10454
+ /**
10455
+ * 把工具注册进宿主。返回 disposer;调用方负责在插件卸载时调用它。
10456
+ * 注册失败只记警告——生图工具缺失不该让整个插件起不来(同 workbuddy)。
10457
+ */
10458
+ function registerTraeDrawImageTool(tools, deps) {
10459
+ try {
10460
+ return tools.register(createTraeDrawImageTool(deps));
10461
+ } catch (error) {
10462
+ deps.logger.warn("dsh-connect-trae: 注册 trae_draw_image 失败: " + messageOf(error));
10463
+ return;
10464
+ }
10465
+ }
10466
+ //#endregion
9850
10467
  //#region src/index.ts
9851
10468
  const name = "dsh-connect-trae";
9852
10469
  const inject = ["llm"];
@@ -9978,6 +10595,7 @@ const regionStateConfig = z.object({
9978
10595
  lastCatalog: z.array(modelConfig).default([]),
9979
10596
  enabledModelIds: z.array(z.string()).default([]),
9980
10597
  imageModelIds: z.array(z.string()).default([]),
10598
+ drawImageEnabled: z.boolean().default(false).description("Allow the model to generate images with trae_draw_image on this panel (consumes credits; default off)"),
9981
10599
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
9982
10600
  creditPosition: z.union([z.const("composer"), z.const("stats")]).default("composer").description("积分读数显示位置:\"composer\" = 输入框工具栏(默认),\"stats\" = 输入框下方(与「轮次 / 步数 / token」统计同一行)。装了别的插件后读数不显示时改成 stats。"),
9983
10601
  pool: z.object({
@@ -11120,8 +11738,68 @@ function apply(ctx, config) {
11120
11738
  const settings = settingsCtx.settings;
11121
11739
  ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
11122
11740
  });
11741
+ const drawImageClient = new TraeImageClient({ logger: ctx.logger });
11742
+ let drawImageTools;
11743
+ let disposeDrawImageTool;
11744
+ const syncTraeDrawImageTool = (value) => {
11745
+ if (drawImagePanelsOf(value).length === 0) {
11746
+ disposeDrawImageTool?.();
11747
+ disposeDrawImageTool = void 0;
11748
+ return;
11749
+ }
11750
+ if (disposeDrawImageTool !== void 0 || drawImageTools === void 0) return;
11751
+ disposeDrawImageTool = registerTraeDrawImageTool(drawImageTools, {
11752
+ resolveAccount: async () => {
11753
+ for (const panel of drawImagePanelsOf(current())) {
11754
+ const stack = stacks[panel];
11755
+ try {
11756
+ return {
11757
+ credential: await stack.store.resolve(),
11758
+ identity: await stack.identity(),
11759
+ baseUrl: REGION_GATEWAYS[stack.region].chat
11760
+ };
11761
+ } catch {
11762
+ continue;
11763
+ }
11764
+ }
11765
+ },
11766
+ generate: (account, request, signal) => drawImageClient.generate(account, request, signal),
11767
+ directoryOf: (exec) => ensureSessionDirectory === void 0 ? void 0 : ensureSessionDirectory(exec),
11768
+ logger: ctx.logger
11769
+ });
11770
+ };
11771
+ let ensureSessionDirectory;
11772
+ ctx.inject(["workingDirectory"], (directoryCtx) => {
11773
+ const service = directoryCtx.workingDirectory;
11774
+ if (service === void 0 || typeof service.ensure !== "function") return;
11775
+ const ensure = service.ensure.bind(service);
11776
+ ensureSessionDirectory = async (exec) => {
11777
+ const agent = exec.agent;
11778
+ if (agent === void 0) return void 0;
11779
+ try {
11780
+ const directory = await ensure(agent, exec.signal);
11781
+ return typeof directory === "string" && directory.trim() !== "" ? directory : void 0;
11782
+ } catch {
11783
+ return;
11784
+ }
11785
+ };
11786
+ directoryCtx.effect(() => () => {
11787
+ ensureSessionDirectory = void 0;
11788
+ });
11789
+ });
11790
+ ctx.inject(["tools"], (toolsCtx) => {
11791
+ drawImageTools = toolsCtx.tools;
11792
+ if (drawImageTools === void 0) return;
11793
+ syncTraeDrawImageTool(current());
11794
+ toolsCtx.effect(() => () => {
11795
+ disposeDrawImageTool?.();
11796
+ disposeDrawImageTool = void 0;
11797
+ drawImageTools = void 0;
11798
+ });
11799
+ });
11123
11800
  ctx.on("loader/volatile-update", () => {
11124
11801
  applySelection(current());
11802
+ syncTraeDrawImageTool(current());
11125
11803
  });
11126
11804
  let stopped = false;
11127
11805
  ctx.effect(() => () => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-trae",
3
3
  "displayName": "DSH Connect Trae",
4
4
  "description": "把本机登录的 Trae 模型接入 DeepSeek Harness:国内版与国际版双供应商并行,提供用量/积分概览与每日签到领取。",
5
- "version": "4.5.2",
5
+ "version": "4.6.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",