diff --git a/README.md b/README.md index c69852a..d6b78bf 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [本机大盘 · Local Market Board](plugins/jzlikewei/kurumi-monitor/) | Watch your own machine as a stock terminal: CPU, GPU, temperature, memory, disk, network and every running process become listed stocks with live candlesticks, moving averages, a watchlist, a leaderboard and a trade tape, on macOS and Windows. Requires minimax Code 3.1.2 or later | [jzlikewei](https://github.com/jzlikewei) | | [Quota Bottle](plugins/7723qqq/quota-bottle/) | An octopus-shaped glass bottle that floats on your desktop and shows the remaining Token Plan quota from a login you configure yourself, with the liquid level and opacity following the remaining share, and a device-code login plus an opt-in local token-usage estimate as fallbacks | [7723qqq](https://github.com/7723qqq) | | [Site QA](plugins/lonelyguy-se1/site-qa/) | Check local HTML/robots/sitemap for SEO and accessibility gaps, then ask the agent to fix them (Mini App + skill/CLI). Requires MiniMax Code 3.1.6 or later | [LonelyGuySE1](https://github.com/LonelyGuy-SE1) | +| [H3-Image-Executor](plugins/liddalidd/h3-image-executor/) | Turn one sentence into verified 4K images: it plans the shot list, searches for reference images, generates, reviews each image by looking at it, corrects colour cast, runs a bounded fix loop and delivers the result with an acceptance report; multi-image runs generate a character sheet first so the same subject stays the same subject. Needs an H3-Image endpoint and key you configure yourself. Requires MiniMax Code 3.1.6 or later | [liddalidd](https://github.com/liddalidd) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 608a00f..fba637a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,6 +90,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [本机大盘](plugins/jzlikewei/kurumi-monitor/README.zh-CN.md) | 把本机当成行情终端来看:CPU、GPU、温度、内存、磁盘、网络与每个运行中的进程都是一只个股,实时 K 线、均线、自选行情、主力动向与逐笔成交,支持 macOS 与 Windows。需要 minimax Code 3.1.2 或更高版本 | [jzlikewei](https://github.com/jzlikewei) | | [额度瓶](plugins/7723qqq/quota-bottle/README.zh-CN.md) | 一只悬浮在桌面上的章鱼形玻璃瓶,用你自己配置的登录读取 Token Plan 剩余额度;液面与透明度按剩余占比走,拿不到实时额度时回退到设备码登录或手填总额与可选的本机本月用量 | [7723qqq](https://github.com/7723qqq) | | [Site QA](plugins/lonelyguy-se1/site-qa/) | 检查本地 HTML/robots/sitemap 的 SEO / 无障碍问题,再让 Agent 修复(Mini App + skill/CLI)。需要 MiniMax Code 3.1.6 或更高版本(说明文档为英文) | [LonelyGuySE1](https://github.com/LonelyGuy-SE1) | +| [H3-Image-Executor](plugins/liddalidd/h3-image-executor/README.zh-CN.md) | 把一句话需求跑成经过校验的 4K 图片:规划分镜、检索参考图、生成、看图评审、色偏校正、有限次修正,最后交付图片和验收报告;多图任务会先生成角色设定图锁定主体外观。需要自备 H3-Image 接口地址和 Key。需要 MiniMax Code 3.1.6 或更高版本 | [liddalidd](https://github.com/liddalidd) |
预览:Cutline diff --git a/plugins/liddalidd/h3-image-executor/.minimax-plugin/plugin.json b/plugins/liddalidd/h3-image-executor/.minimax-plugin/plugin.json new file mode 100644 index 0000000..a930e30 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/.minimax-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "name": "h3-image-executor", + "displayName": "H3-Image-Executor", + "version": "1.8.1", + "description": "把一句话需求跑成经过校验的 4K 图片:自动检索参考图、规划分镜、生成、看图评审、色偏校正与修正循环,最后交付验证过的图和验收报告。多图任务会先生成角色设定图来锁定主体外观,全部出完后再做一次跨图一致性对比。在聊天里直接说「画一张…」就会把需求送进面板自动开跑,不需要自己调生图工具。", + "author": "liddalidd", + "icon": "icon.png", + "category": "Studio", + "exampleQueries": [ + "画一张橘猫趴在窗台上晒太阳的图,午后暖光,胶片质感。", + "用 H3-Image-Executor 画一组四格漫画,注意角色前后要长得一样。", + "接着刚才那张图,把背景换成夜景。" + ], + "apps": [], + "mcpServers": [], + "skills": [ + "skills/h3-image-executor/SKILL.md", + "skills/h3-image-review/SKILL.md" + ] +} diff --git a/plugins/liddalidd/h3-image-executor/LICENSE b/plugins/liddalidd/h3-image-executor/LICENSE new file mode 100644 index 0000000..1cf15c2 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 liddalidd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/liddalidd/h3-image-executor/README.md b/plugins/liddalidd/h3-image-executor/README.md new file mode 100644 index 0000000..ce6844e --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/README.md @@ -0,0 +1,284 @@ +# H3-Image-Executor (`h3-image-executor`) + +[简体中文](README.zh-CN.md) + +A Mini App that turns one sentence into verified 4K images. You describe the picture; the app +searches for reference images, plans the shot list, generates, reviews each image by looking at +it, corrects colour cast, runs a bounded fix loop, picks the best candidate, and delivers the +result together with an acceptance report. + +The interface is in Chinese. + +![The panel after a four-image run: the request, the folded run details, and the delivered images with preview, share and public-link controls](docs/dashboard.jpg) + +It is built around the **H3-Image** generation API. That API is not part of the public MiniMax +Open Platform image endpoint, so you need an H3-Image endpoint address and key of your own +before the app can generate anything. See [Configure the endpoint](#configure-the-endpoint). + +## What it does + +- **Plans before it draws.** A planner turns the sentence into a shot list: one spec per image, + each with its own aspect ratio, hard constraints and soft preferences. +- **Finds reference images by itself.** When a named subject has to look a specific way, it + searches for a reference, downloads it, and judges whether the candidate is usable. +- **Locks character appearance across a set.** For multi-image requests, it first generates a + character sheet and binds it to every image as a reference, so the same subject does not turn + into two different creatures halfway through the set. +- **Reviews by looking.** Each candidate is checked against its constraints by a vision model that + never sees the prompt that produced it, so the review cannot be talked into agreeing. +- **Measures colour.** Red cast is detected programmatically rather than by eye, and a cast image + is redrawn instead of shipped. +- **Fixes in a bounded loop.** At most 5 rounds; two rounds without improvement switches approach; + three ends early. Content-safety rejections stop immediately instead of retrying. +- **Compares across images.** After every image is done, it builds one side-by-side sheet and asks + whether the same subject is still the same subject. It reports; it does not redraw. +- **Ships an acceptance report.** Every delivery carries a `report.json` with per-image verdicts, + the `request_id` of each generation call, cost, and anything it could not verify. + +## Requirements + +- MiniMax Code 3.1.6 or later (the app declares `toolAccess` and `agentAccess`). +- An H3-Image endpoint address and API key. + +## Install + +1. Copy this whole directory, including the hidden `.minimax-plugin` directory, into the MiniMax + Code plugins directory as `h3-image-executor/` (`~/.minimax/plugins/h3-image-executor/` by + default), so the manifest ends up at + `~/.minimax/plugins/h3-image-executor/.minimax-plugin/plugin.json`. +2. Restart MiniMax Code and confirm the plugin is enabled. +3. Say "打开 H3-Image-Executor" to the Agent, or open it from the plugin list. + +## Configure the endpoint + +The app ships **no default endpoint address**. Until you configure one, the engine line at the +bottom of the sidebar reads 未配置生图接口. + +Click that line to open the settings dialog and fill in: + +| Field | Meaning | +| --- | --- | +| API Key | Your H3-Image key. Sent only to the endpoint you configure. | +| 接口地址 | Base URL of the H3-Image endpoint, must start with `https://`. | + +The key is written to `settings.json` inside the private `dataDir` the Host creates for this +plugin, with mode `0600`. It is never written into the package and never printed to a log. The +stored key is never returned to the page: the page only receives its first 6 characters and its +length. + +Alternatively, export the values in your shell and restart MiniMax Code: + +```sh +export MINIMAX_API_KEY="..." +export MINIMAX_BASE_URL="https://..." +``` + +`MCODE_API_KEY` is accepted as another name for the key; in the process environment +`MINIMAX_API_KEY` wins when both are set. `H3_IMAGE_MODEL` overrides the model name, which defaults +to `MiniMax-H3-Image`. + +Resolution order is: what you saved in the page, then the process environment, then +`~/.zshrc` / `~/.zprofile` / `~/.bash_profile` / `~/.profile`. **The page wins**, so a value you +typed deliberately is not overridden by an environment variable left over from something else. + +## Usage + +Type what you want into the composer and press Enter. The app runs the whole pipeline and +delivers images plus a report. Useful things to know: + +- **Default output is 4K.** Aspect ratio comes from the plan unless you state one. +- **Each generation shows its `request_id`** in the timeline as a `req …` chip. Click it to copy + the full value; quote it when reporting a problem. +- **A failed image says why.** Interface errors, including content-safety rejections, appear in + the delivery area with the raw message, not as a silent "0 images". +- **Follow up in the same session.** Sending another message edits the delivered images rather + than starting over. +- **History is in the sidebar.** Each row can be rerun or deleted; deleting moves the session to + `sessions/.trash/` inside `dataDir` instead of erasing it. The app never empties that folder; + delete its contents yourself to free disk space. +- **Stopping a run.** The **停止** button aborts the session's Agent turns and ends the run at the + next step. A generation request already sent to the endpoint still finishes, but nothing new + starts after it. +- **You can just ask in chat.** With the plugin enabled, "draw …" in a conversation hands the request + to this app instead of generating a picture in the chat, unless you name another tool or say you + do not want the panel. The bundled `h3-image-executor` Skill tells the Agent to open the app with + `?prompt=…&auto=1`, and the page fills the composer and submits it. + +### Launch parameters + +The dashboard reads these from its own query string. This is how the Agent hands work over, and you +can use them yourself to deep-link into a painting: + +| Parameter | Meaning | +| --- | --- | +| `prompt` (or `q`) | The request text, used verbatim. | +| `auto` | `1` (default) fills the composer and submits; `0` fills it and waits for you to press Send. | +| `session` | A session id such as `20261109-877482`. Present means "keep editing that one"; absent starts a new painting. | +| `len` | Optional. The number of characters in `prompt` as the sender counted them. When it does not match what arrived, the page warns that the request may have been damaged in transit; it still submits. | + +The page strips those parameters from the address bar as soon as it has read them, so reloading does +not submit the same request twice. If no endpoint is configured yet, the request is filled in but not +submitted, and the composer says so. + +## Tested environment + +- MiniMax Code 3.1.12-preview.5165 on macOS 15.0.1 (Apple Silicon) + +Verified in that environment with this build (1.8.1): publishing a public link, including the +`website_deploy` approval in the page, and a drawing request in chat being handed to the panel. +Verified there with earlier builds: single-image and multi-image runs end to end, reference search +and download, character-sheet generation and cross-image comparison, the bounded fix loop, colour +measurement, session restore after a runtime restart, deletion and rerun, share page generation, +and failure reporting. + +Checked only with automated tests against a simulated Host (the Node service and the page in +headless Chrome), not in MiniMax Code: end-to-end runs with this build, the 2048 px copies in the +share page, the **停止** button, the messages after switching `session.run` or `tool:call` off, +questionnaires, and view restore after the Host reloads the page. Not verified: Windows, MiniMax +Code releases other than the one above, and any H3-Image endpoint other than the one used during +development. + +## Data & access + +**Files read outside the package** + +- `~/.zshrc`, `~/.zprofile`, `~/.bash_profile`, `~/.profile` — read as plain text, never executed, + only to find `MINIMAX_API_KEY`, `MCODE_API_KEY` and `MINIMAX_BASE_URL` when you have not + configured them in the page. This relies on shell configuration layout that is not part of any + published contract. +- Images you pick as references through the file picker. + +**Files written** + +- Everything the app keeps is written under the Host-provided `dataDir`: `settings.json` (mode + `0600`), `sessions//` (plans, images, previews, references, review sheets, deliveries, + reports, the share page `share/index.html` with the larger copies it embeds in `share/large/`, + `session.json`), `refcache/` (downloaded reference images, plus a one-day record of downloads + that failed), and `sessions/.trash/` (deleted sessions). +- The two JSON state files — `settings.json` and `sessions//session.json` — are written + atomically: a temporary file in the same directory, then `rename`. Image and HTML files are + written directly. +- Outside `dataDir`, only two things write files: + - The publish turn (see **Tools called**) copies the share page to `h3-share-publish/index.html` + in this Mini App's own Agent working directory, because `website_deploy` only accepts paths + inside that directory. Each publish overwrites it. + - The `h3-review` command-line tool bundled for the `h3-image-review` Skill writes PNG review + sheets when an Agent runs it: next to the input image for `sheet`, in the current working + directory for `refs` and `grid`, or wherever `-o` points. The app itself never runs it. +- The page keeps view state in its own browser storage: whether the sidebar is collapsed, and, + saved each time the page is hidden, the open session ID, the unsent text in the composer (up to + 4,000 characters) and how many unsent reference images you had picked, so a reload by the Host + restores them. + +**Network** + +- The H3-Image endpoint you configure. It receives your key as a bearer token, your prompts, and + any reference images you supply. There is no default: it is whatever you typed. +- Image URLs returned by that endpoint, which the app downloads from. +- Image URLs found by the reference-image search (see **Tools called**) or written into the plan + by the planning turn: any third-party site, over `https` or `http`. The app only downloads these + images; the search queries themselves go through the tool, not from this app. + +The app refuses to download images from `169.254.0.0/16` — the link-local range where cloud +instance metadata services live — and from wildcard addresses such as `0.0.0.0`, and it checks +every redirect again before following it. For image URLs that come from the search tool it also +refuses localhost and private ranges (`10.*`, `172.16-31.*`, `192.168.*`, `100.64-127.*`, +`fc00::/7`), including host names that resolve to them, because a third-party search result should +never make the app read from your own network. Private ranges and localhost stay allowed for your +endpoint's image URLs and for image URLs written into the plan from your request, because your +endpoint may itself sit on an internal network. Each download is capped: 25 MB for a reference +image, 100 MB for a generated image. + +**Processes spawned** + +Clipboard commands only, and nothing else. Each one uses a fixed argument list, no shell, a 2 s +timeout and an output cap. + +- **Reading** — MiniMax Code denies clipboard reads to Mini App pages, so the **clipboard** button + in the settings panel asks the Node service to run your platform's paste command once: `pbpaste` + on macOS, `powershell.exe -NoProfile -NonInteractive -Command Get-Clipboard` on Windows, + `wl-paste` or `xclip` on Linux, capped at 8 KB. What is read only fills the input field: it is + never logged and never written to disk, and it is handed back to the page only when it looks + like a key or an `https://` endpoint address. +- **Writing** — the page copies with the browser clipboard first (`navigator.clipboard.writeText`, + then `document.execCommand('copy')`). Both need the panel to have focus, which it often does not, + so the Node service can also run your platform's copy command: `pbcopy` on macOS, + `powershell.exe -NoProfile -NonInteractive -Command Set-Clipboard -Value ([Console]::In.ReadToEnd())` + on Windows, `wl-copy` or `xclip` on Linux, capped at 2 KB. It does so in two cases: once, + automatically, when a publish you started finishes, to put the new public link on your + clipboard; and when you click **Share** for a session that already has a public link and the + page could not copy it. The Node service rejects anything that is not a single-line + `https://.space.mcode.cn` address, so this cannot be turned into a general "write anything + to your clipboard" endpoint. + +Nothing is spawned at load, on a timer, or on save, and no other child process is ever started. + +**Agent turns** + +The app declares `agentAccess: ["session.run"]` and runs Agent turns to plan, review and correct. +Those turns run on the model service your MiniMax Code session is configured to use, so your +prompt text, your reference images and the generated images themselves are sent there for +planning, review, correction and selection. Those turns can read and write files, run shell +commands and call MCP tools, subject to your permission rules. Every tool-permission request is +shown in the page for you to answer, including the `website_deploy` request of the publish turn +described under **Tools called**; the app never allows one on your behalf. It only denies, on its +own, the other tool requests of the publish turn. If you switch the Agent permission +(`session.run`) off in the plugin details, the run stops and the page says where to switch it back +on. It does not declare `session.read` and cannot read your other conversations. + +**Tools called** + +The app declares `toolAccess: ["call"]` and calls one tool: the first installed tool whose name +contains `search_images`. On the tested setup that is `connector__matrix__search_images`, the +`search_images` tool of the `matrix` Connector. It receives the subject names from the plan with a +short note on what kind of picture to find, and returns image URLs. Each call goes through your +permission rules. When your rules say "ask", the app shows the Host's prompt to always allow this +one tool before the first search. If you switch tool calls (`tool:call`) off in the plugin details, +the run continues without searched references and the page says why. + +The **publish public link** button is separate and worth reading twice: it starts one Agent turn +whose only job is to call the Host's `website_deploy` tool, which publishes the self-contained +share page to a public `https://.space.mcode.cn` URL that **anyone with the link can open**. +The page contains your request and follow-ups, the reference images you attached or the app found +online, thumbnails of the other search results, the plan, downscaled copies of every candidate and +delivered image, a larger copy (long edge 2048 px) of each delivered image that opens when you +click it, the review sheets, the full prompts the app sent to the model and the model's replies, +and the run log with `request_id`s and costs. The larger copies are capped at 4 MB in total; +delivered images past that cap open at preview size. Nothing is published until you press that button and then +click **允许一次** (allow once) on the `website_deploy` request that appears in the page. A +permanent line under the button says that anyone with the link can open the page and what it +contains. Before the page is written, local paths are replaced with placeholders: the session +folder and the plugin's `dataDir` keep the relative part after them, and any other path in your +home folder, or under `/Users/` or `X:\Users\`, is replaced as a whole. Every other tool request +from the publish turn is denied, and a questionnaire request aborts the turn. + +**Bundled Skills** + +- `h3-image-executor` tells the chat Agent to hand drawing requests to this app instead of other + image tools, unless you name another tool or say you do not want the panel. When you ask about a + finished run, it tells the Agent to read `sessions//session.json` in this plugin's data + directory. The Skill does not know where MiniMax Code keeps that directory, so finding it relies + on unspecified layout. +- `h3-image-review` tells the Agent how to run the bundled `h3-review` tool (see **Files written**) + when you ask it to look at generated images itself. + +**Device use** + +No microphone, camera or system notifications. The app declares no `deviceAccess`. +It does not read the MiniMax Code page you are currently viewing, nor your browser history. The +page writes to the clipboard only when you click a copy control (a `req …` chip, or **Share** for +an existing link) and once after a publish you started finishes; the clipboard commands described +above run in the Node service. The app only ever sees its own panel. + +## Known limitations + +- A character sheet improves consistency but does not guarantee it. Large pose or aspect-ratio + differences between the sheet and a story image still drift. +- Content-safety rejections are deterministic, so the app stops at the first one instead of + retrying. Change the request itself and send it again. +- The reference-image search depends on the search tool and inherits its availability. When the + tool rejects or fails a query, that subject gets no searched reference; attach one yourself. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/plugins/liddalidd/h3-image-executor/README.zh-CN.md b/plugins/liddalidd/h3-image-executor/README.zh-CN.md new file mode 100644 index 0000000..09d6bdc --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/README.zh-CN.md @@ -0,0 +1,228 @@ +# H3-Image-Executor(`h3-image-executor`) + +[English](README.md) + +一个把一句话需求跑成经过校验的 4K 图片的 Mini App。你描述画面,它负责检索参考图、规划分镜、 +生成、看图评审、色偏校正、有限次修正、选优,最后交付图片和一份验收报告。 + +![一次四张图任务完成后的面板:需求、折叠的过程详情,以及带预览、分享和公开链接按钮的交付图](docs/dashboard.jpg) + +它围绕 **H3-Image** 生成接口构建。该接口不属于 MiniMax 开放平台的公开图片接口,因此在使用前 +你需要自备 H3-Image 的接口地址和 Key,详见[配置接口](#配置接口)。 + +## 它做什么 + +- **先规划再动笔。** 规划节点把一句话拆成分镜:每张图一份规格,各自的画幅、硬约束和偏好。 +- **自己找参考图。** 当画面里的主体必须长成特定样子时,它会去检索参考图、下载,并判定候选能不能用。 +- **多图锁定角色外观。** 多图任务会先生成一张角色设定图,并把它绑到每一张图上作为参考, + 避免同一个主体画到一半变成另一只。 +- **看图评审。** 每张候选都由视觉模型按约束逐条核对,而且评审看不到送进模型的那段 prompt, + 不会被 prompt 说服。 +- **量色偏。** 偏红由程序实测判定而不是靠眼睛看,判偏红的图会重画而不是直接交付。 +- **有限次修正。** 最多 5 轮;连续 2 轮没改善就换改法;连续 3 轮提前收手; + 内容安全审核拒绝会立即停止,不再重试。 +- **跨图对比。** 所有图出完后拼一张并排对比图,判断「同一个主体是不是同一个东西」。只报告,不自动重画。 +- **交付验收报告。** 每次交付都带一份 `report.json`:逐图判定、每次生图的 `request_id`、成本, + 以及它没能验证的部分。 + +## 环境要求 + +- MiniMax Code 3.1.6 或更高版本(本应用声明了 `toolAccess` 与 `agentAccess`)。 +- 一个 H3-Image 接口地址和 API Key。 + +## 安装 + +1. 把整个目录(含隐藏的 `.minimax-plugin`)复制到 MiniMax Code 的插件目录,目录名为 + `h3-image-executor/`(默认是 `~/.minimax/plugins/h3-image-executor/`),也就是清单最终位于 + `~/.minimax/plugins/h3-image-executor/.minimax-plugin/plugin.json`。 +2. 重启 MiniMax Code,确认插件已启用。 +3. 对 Agent 说「打开 H3-Image-Executor」,或从插件列表里打开。 + +## 配置接口 + +本应用**不内置任何默认接口地址**。在配置之前,侧边栏底部的引擎状态会显示「未配置生图接口」。 + +点击那行打开设置面板,填写: + +| 字段 | 含义 | +| --- | --- | +| API Key | 你的 H3-Image Key,只会发往你配置的接口地址。 | +| 接口地址 | H3-Image 接口的基础地址,必须以 `https://` 开头。 | + +Key 写入 Host 为本插件创建的私有 `dataDir` 下的 `settings.json`,权限 `0600`。它不会写进包体、 +不会打进日志。保存下来的 Key 不会回传给页面:页面只能拿到前 6 位和 Key 长度。 + +也可以在 shell 里导出后重启 MiniMax Code: + +```sh +export MINIMAX_API_KEY="..." +export MINIMAX_BASE_URL="https://..." +``` + +`MCODE_API_KEY` 也会被当作 Key 读取;进程环境变量里两个都有时以 `MINIMAX_API_KEY` 为准。 +`H3_IMAGE_MODEL` 可以覆盖模型名,默认是 `MiniMax-H3-Image`。 + +解析顺序是:页面里保存的配置 → 进程环境变量 → `~/.zshrc` / `~/.zprofile` / +`~/.bash_profile` / `~/.profile`。**页面优先**,你在页面上明确填过的值不会被别处遗留的环境变量覆盖。 + +## 使用 + +在输入框里写下你要的图,回车发送。应用会跑完整条链路并交付图片和报告。几点值得知道: + +- **默认输出 4K。** 除非你指定画幅,否则由规划决定。 +- **每次生图都会显示 `request_id`**,在过程时间线上是一个 `req …` 小标签,点一下复制完整值; + 反馈问题时请附上它。 +- **失败的图会说清原因。** 接口报错(包括内容安全审核拒绝)会带着原始报文出现在交付区, + 而不是一句没有下文的「0 张」。 +- **可以在同一会话里追问。** 再发一条消息是在已交付的图上继续改,而不是从头再来。 +- **历史在侧边栏。** 每条可以重跑或删除;删除是把会话移进 `dataDir` 下的 `sessions/.trash/`,不是抹掉。 + 应用从不清空这个目录,要释放磁盘空间请自己删除里面的内容。 +- **停止任务。** 「停止」按钮会中止这个会话的 Agent 轮次,并在下一步结束任务。已经发给接口的 + 生图请求仍会跑完,但之后不会再启动新的步骤。 +- **也可以直接在聊天里说。** 启用插件后,在会话里说「画一张…」会把需求交给本应用, + 而不是在聊天里另生成一张图;你点名要用别的工具、或者说不想用这个面板时除外。随包发布的 + `h3-image-executor` Skill 会引导 Agent 用 `?prompt=…&auto=1` 打开面板,页面自己填好输入框并提交。 + +### 启动参数 + +面板会从自己的 query string 里读这些参数。Agent 就是靠它把需求递进来的,你也可以自己用来做深链: + +| 参数 | 含义 | +| --- | --- | +| `prompt`(或 `q`) | 需求原文,逐字使用。 | +| `auto` | `1`(默认)填入并直接提交;`0` 只填入,等你自己按发送。 | +| `session` | 会话 ID,例如 `20261109-877482`。带上表示「接着调那一张」,不带表示开新的一张。 | +| `len` | 可选。发送方数出来的 `prompt` 字符数。和实际收到的对不上时,页面会提示需求可能在传输中损坏,但仍然提交。 | + +页面读完就立刻把这些参数从地址栏抹掉,所以刷新不会把同一条需求再提交一遍。 +如果还没配接口,需求会填进输入框但不会自动提交,输入框上方会说明原因。 + +## 已验证环境 + +- macOS 15.0.1(Apple Silicon)上的 MiniMax Code 3.1.12-preview.5165 + +用当前版本(1.8.1)在该环境下验证过:发布公开链接(含页面上的 `website_deploy` 确认)、在聊天里说 +画图需求会交给面板。用更早的版本在该环境下验证过:单图与多图任务端到端跑通、参考图检索与下载、 +角色设定图生成与跨图对比、有限次修正循环、色偏实测、运行时重启后会话恢复、删除与重跑、分享页生成、 +失败信息透传。 + +只用模拟 Host 的自动化测试(Node 服务,加上无头 Chrome 里的页面)验证过、没有在 MiniMax Code 里实测的: +当前版本的端到端出图、分享页里 2048px 的大图、「停止」按钮、关闭 `session.run` 或 `tool:call` 后的提示、 +问卷、Host 重新加载页面后的视图恢复。未验证:Windows、除上述版本外的 MiniMax Code、以及开发时所用 +之外的任何 H3-Image 接口。 + +## 数据与访问 + +**读取包体之外的文件** + +- `~/.zshrc`、`~/.zprofile`、`~/.bash_profile`、`~/.profile`——按纯文本读取、从不执行,仅在你没有 + 在页面里配置时,用于查找 `MINIMAX_API_KEY`、`MCODE_API_KEY` 和 `MINIMAX_BASE_URL`。这依赖 shell + 配置的文件布局,不属于任何已公布的契约。 +- 你通过文件选择器挑的参考图。 + +**写入的文件** + +- 应用保存的所有内容都写在 Host 提供的 `dataDir` 下:`settings.json`(权限 `0600`)、 + `sessions//`(规划、图片、预览、参考图、评审拼图、交付物、报告、分享页 `share/index.html` + 及其内嵌大图的缓存 `share/large/`、`session.json`)、`refcache/`(下载的参考图,以及保留一天的下载失败记录)、`sessions/.trash/` + (已删除的会话)。 +- 两个 JSON 状态文件——`settings.json` 和 `sessions//session.json`——是原子写入:同目录临时 + 文件再 `rename`。图片和 HTML 文件直接写。 +- `dataDir` 之外只有两处会写文件: + - 发布轮次(见「调用的工具」)会把分享页复制到本 Mini App 自己的 Agent 工作目录下的 + `h3-share-publish/index.html`,因为 `website_deploy` 只接受这个目录里的路径。每次发布都会覆盖它。 + - 随 `h3-image-review` Skill 发布的 `h3-review` 命令行工具,在 Agent 运行它时写 PNG 看图材料: + `sheet` 写在输入图旁边,`refs` 和 `grid` 写在当前工作目录,或者写到 `-o` 指定的位置。 + 应用自己从不运行它。 +- 页面在自己的浏览器存储里保存视图状态:侧边栏是否收起;以及每次页面被隐藏时记下的当前会话 ID、 + 输入框里还没发出去的文字(最多 4000 字)和已选但未发送的参考图张数,这样 Host 重新加载页面后能恢复。 + +**网络** + +- 你配置的 H3-Image 接口。它会收到你的 Key(作为 bearer token)、你的 prompt,以及你提供的参考图。 + 没有默认值——就是你填的那个。 +- 该接口返回的图片地址,应用会从那里下载图片。 +- 参考图检索(见「调用的工具」)找到的图片地址,以及规划轮次写进规划里的图片地址:可能是任何第三方 + 网站,`https` 或 `http` 都有。应用只下载这些图片;检索查询本身经由工具发出,不是本应用直接发出。 + +本应用拒绝从 `169.254.0.0/16`(云主机元数据服务所在的链路本地段)以及 `0.0.0.0` 这类通配地址 +下载图片,跟随每一次重定向之前都会重新检查一遍地址。对检索工具给出的图片地址,还会拒绝 localhost +和私有段(`10.*`、`172.16-31.*`、`192.168.*`、`100.64-127.*`、`fc00::/7`),包括解析到这些地址的 +域名:第三方的检索结果不应该让应用去读你自己网络里的东西。对你的接口返回的图片地址、以及按你的需求 +写进规划里的图片地址,私有段和 localhost 仍然允许,因为你的接口本身可能就在内网。每次下载都有上限: +参考图 25 MB,生成图 100 MB。 + +**启动的进程** + +只有剪贴板命令,没有别的。每一条都是参数固定、不拼 shell、2 秒超时、输出有上限。 + +- **读**——MiniMax Code 禁止 Mini App 页面读剪贴板,所以设置面板里的「剪贴板」按钮会让 Node 服务 + 调一次本机的粘贴命令:macOS 是 `pbpaste`,Windows 是 + `powershell.exe -NoProfile -NonInteractive -Command Get-Clipboard`,Linux 是 `wl-paste` 或 + `xclip`,上限 8 KB。读到的内容只用于回填输入框:不写日志、不落盘,而且只有「像 Key」或 + 「像 `https://` 接口地址」时才会回传给页面。 +- **写**——页面先用浏览器剪贴板复制(`navigator.clipboard.writeText`,不行再用 + `document.execCommand('copy')`)。这两条都要求面板处于聚焦状态,而面板经常没有焦点,所以 Node 服务 + 也可以调本机的复制命令:macOS 是 `pbcopy`,Windows 是 + `powershell.exe -NoProfile -NonInteractive -Command Set-Clipboard -Value ([Console]::In.ReadToEnd())`, + Linux 是 `wl-copy` 或 `xclip`,上限 2 KB。它只在两种情况下执行:你发起的一次发布完成时自动执行 + 一次,把新的公开链接放进剪贴板;以及会话已有公开链接、你点「分享」而页面没能复制时。Node 服务只接受 + 单行的 `https://.space.mcode.cn` 地址,其余一律拒绝——所以这个接口没法被当成「往你剪贴板里 + 写任意内容」的后门。 + +加载时、定时轮询时、保存时都不会启动任何进程,除此之外不会启动任何子进程。 + +**Agent 轮次** + +本应用声明 `agentAccess: ["session.run"]`,会运行 Agent 轮次来做规划、评审和修正。这些轮次运行在 +你 MiniMax Code 会话所配置的模型服务上,因此你的 prompt 原文、参考图以及生成出来的图本身都会送到 +该模型服务,用于规划、评审、修正和选优。这些轮次可以读写文件、执行 shell 命令、调用 MCP 工具, +均受你的权限规则约束。每一个工具权限请求都会展示在页面上由你决定,包括下面「调用的工具」里说的 +发布轮次发起的 `website_deploy` 请求;应用从不替你放行。它自己只会拒绝发布轮次发起的其他工具请求。 +如果你在插件详情里关闭了 Agent 权限(`session.run`),任务会停下,页面会说明去哪里重新打开。 +它没有声明 `session.read`,读不到你的其他会话。 + +**调用的工具** + +本应用声明 `toolAccess: ["call"]`,只调用一个工具:已安装工具里第一个名字包含 `search_images` +的工具。在测试环境里它是 `connector__matrix__search_images`,即 `matrix` Connector 的 +`search_images` 工具。它收到的是规划里的主体名,以及一句说明要找什么样的图,返回图片地址。每次调用 +都走你的权限规则;规则为「询问」时,应用会在第一次检索前弹出 Host 的确认框,请你始终允许这一个工具。 +如果你在插件详情里关闭了工具调用(`tool:call`),任务会在没有检索参考图的情况下继续,页面会说明原因。 + +「发布公开链接」按钮是另一回事,值得单独说清楚:它会起一个 Agent 轮次,这轮唯一的任务是调用 +Host 的 `website_deploy` 工具,把自包含分享页发布到一个公开的 `https://.space.mcode.cn` +地址,**任何拿到链接的人都能打开**。页面里包含:你的需求和追问、你上传的或应用在网上找到的参考图、 +其余检索结果的缩略图、规划、所有候选图和交付图的缩小版、每张交付图一份长边 2048px 的大图(点开时显示)、 +评审拼图、应用发给模型的完整提示词和模型的回复,以及带 `request_id` 和成本的执行记录。大图总量上限 4 MB, +超出的交付图点开时仍是预览大小。你点了这个按钮、并且在页面上对出现的 `website_deploy` +请求点了「允许一次」之后,才会真正发布。按钮下面常驻一行提示,说明任何拿到链接的人都能打开,以及页面包含 +哪些内容。写盘前,本机路径会被替换成占位符:会话目录和插件的 `dataDir` 保留其后的相对部分,用户 +主目录下的其他路径、以及 `/Users/` 或 `X:\Users\` 下的路径整段替换。发布轮次的其他工具请求一律 +拒绝;如果它发起提问,整轮直接中断。 + +**随包发布的 Skill** + +- `h3-image-executor` 让聊天里的 Agent 把画图需求交给本应用,而不是用别的生图工具;你点名要用别的 + 工具、或者说不想用这个面板时除外。你问起某次跑完的任务时,它会让 Agent 去读本插件数据目录下的 + `sessions//session.json`。Skill 并不知道 MiniMax Code 把这个目录放在哪里,所以找到它依赖未公开的 + 目录布局。 +- `h3-image-review` 告诉 Agent 在你要它亲自看生成图时,怎么运行随包的 `h3-review` 工具(见「写入的文件」)。 + +**设备使用** + +不使用麦克风、摄像头和系统通知,未声明任何 `deviceAccess`。 +不读取你当前所在的 MiniMax Code 页面,也不读取浏览器历史。页面只在你点击复制控件时(`req …` 标签, +或已有链接时的「分享」)以及你发起的发布完成后写一次剪贴板;上面那些剪贴板命令跑在 Node 服务里。 +应用只看得见它自己的面板。 + +## 已知限制 + +- 角色设定图能改善一致性,但不保证。设定图与故事图之间姿势或画幅差异大时仍会漂移。 +- 内容安全审核拒绝是确定性的,所以应用在第一次被拒时就停下,不再重试。请修改需求本身后重新发送。 +- 参考图检索依赖检索工具,可用性随它而定。工具拒绝或检索失败时,这个主体就没有检索到的参考图, + 请自己上传一张。 + +## 许可证 + +MIT,见 [LICENSE](LICENSE)。 diff --git a/plugins/liddalidd/h3-image-executor/docs/dashboard.jpg b/plugins/liddalidd/h3-image-executor/docs/dashboard.jpg new file mode 100644 index 0000000..b56c234 Binary files /dev/null and b/plugins/liddalidd/h3-image-executor/docs/dashboard.jpg differ diff --git a/plugins/liddalidd/h3-image-executor/icon.png b/plugins/liddalidd/h3-image-executor/icon.png new file mode 100644 index 0000000..8eae8ef Binary files /dev/null and b/plugins/liddalidd/h3-image-executor/icon.png differ diff --git a/plugins/liddalidd/h3-image-executor/miniapp/client/app.js b/plugins/liddalidd/h3-image-executor/miniapp/client/app.js new file mode 100644 index 0000000..f21435d --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/client/app.js @@ -0,0 +1,2021 @@ +'use strict'; + +// 页面是真正的多轮对话:一轮 = 一句用户消息 → 一段过程 → 一次交付。 +// 追问会新开一轮,所以整个线程按轮数重建;重建后所有渲染签名都要作废, +// 否则"这一轮的内容没变"会把刚清空的 DOM 跳过不渲染。 + +const state = { + sessionId: null, + timer: null, + clockTimer: null, + refs: [], + snapshot: null, + // 轮询每 1.5s 会重建时间线,展开状态必须自己记住,否则一直自动收起。 + openEvents: new Set(), + clockOffset: 0, + // 渲染签名。键是 'u0'/'h0'/'s0'/'t0'/'d0' 这种"哪一轮的哪一块",外加 sessions/interactions/images。 + sigs: {}, + sessionsAll: false, + // 对话骨架是按"会话 + 轮数"重建的。 + threadFor: '', + // 每一轮的 DOM 节点:渲染函数都从这里取节点,不再靠全局 id, + // 因为同一个页面上现在有 N 份"过程 / 交付"。 + turnNodes: [], + turns: [], + // 输入口只有一个:底部 composer。mode 决定这次是开新会话,还是接着调已经打开的这张图。 + mode: 'new', + // 用户主动点了「新建绘画」:这时页面是刻意留白的,不能被"自动打开最新会话"顶掉。 + fresh: false, + // 滚到哪儿:'auto' 还没决定,'deliver' 钉在交付图上,'bottom' 跟随最新进展。 + anchor: 'auto', + deliverSeen: false, + // 需求字符数对不上时的警告:{ expected, actual, prompt }。只在从 MCode 带需求进来时可能非空。 + launchLenWarn: null, + // 分享页落盘后等 MCode 发布,链接回填要几十秒;交付态已经停轮询,靠这个观察器接住。 + linkTimer: null, + // 最近一次成功拉到的会话列表:加载失败时靠它保住屏幕上的内容,也是「用时」自增的数据源。 + sessionList: [], + // 服务端 now 与本机时钟的差值,避免本机时间偏差把「用时」算歪。 + sessionsSkew: 0, + // 列表加载失败后的自动重试句柄,以及失败提示的去重标记。 + sessionsRetry: null, + sessionsFailed: false, + // 列表里有进行中的会话时,才需要每秒刷新那一行的时间。 + sessionsClock: null, + // 页面被 Host 回收后重新加载时,要回到的那个会话(见文件末尾的「视图状态」)。 + restoreSessionId: null, +}; + +const el = (id) => document.getElementById(id); + +function clearSigs(keepSessions) { + const sessions = state.sigs.sessions || ''; + state.sigs = keepSessions ? { sessions } : {}; +} + +// 统一请求封装。这里最重要的一条约束:失败必须让调用方看得见。 +// 之前一律 catch 成 {},于是「Host 网关回了 502 HTML 错误页」「响应体被截断」都被伪装成 +// 「服务端返回了一个空对象」;调用方再把空对象读成「一条记录都没有」,侧边栏 24 条历史会被 +// 当场清空,而且全程不报错——看起来就像数据被删光了。 +function api(path, options) { + return fetch(path, Object.assign({ headers: { 'content-type': 'application/json' } }, options)) + .then((response) => + response.text().then((text) => { + let parsed = null; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + parsed = null; + } + // 业务错误(404 no_session、409 busy)本来就带 error,原样交回去,调用方的文案才准。 + if (parsed && parsed.error) return parsed; + if (!response.ok) return { error: 'http_' + response.status, failed: true }; + // 2xx 但 body 不是 JSON:网关错误页或被截断的响应,绝不能当成「空数据」。 + if (!parsed) return { error: 'bad_response', failed: true }; + return parsed; + }), + ) + .catch(() => ({ error: 'network', failed: true })); +} + +function fileUrl(kind, name, sub) { + if (!state.sessionId || !name) return ''; + const params = new URLSearchParams({ session: state.sessionId, kind, name }); + if (sub) params.set('sub', sub); + return '/api/file?' + params.toString(); +} + +// 事件详情里的缩略图:后端只给寻址键(cache/xxx.jpg、refs/R1.png),这里翻译成实际请求地址。 +function thumbUrl(key) { + const parts = String(key || '').split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return ''; + return fileUrl(parts[0], parts[1]); +} + +// ---------------------------------------------------------------- 共享渲染原语 +const { fmtDuration, fmtSeconds, fmtClock, fmtDateTime, STAGES, renderBlocks, renderPromptCompare } = window.H3Blocks; + +function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +// ---------------------------------------------------------------- 引擎状态 + +async function loadConfig() { + const config = await api('/api/config'); + const target = el('engine'); + // 请求失败和「没配 Key」是两回事,混在一起会把排查方向带偏。失败就过几秒再问一次。 + if (!config || config.failed) { + target.textContent = '引擎状态读取失败,稍后自动重试'; + target.className = 'engine bad'; + setTimeout(loadConfig, 5000); + return; + } + if (config.ready) { + // 侧边栏只放得下一行,Key 明细收进 title:既不挤掉状态,也不丢可排查信息。 + target.textContent = '引擎就绪 · ' + config.model; + target.title = + 'Key ' + config.keyPreview + '(' + config.keyLength + ' 位)· 来源 ' + sourceLabel(config.source) + + ' · ' + config.baseUrl + '\n点击可修改接口设置'; + target.className = 'engine ok'; + } else if (config.hasKey && !config.hasBaseUrl) { + // 只有 Key 没有地址,请求根本发不出去。这两件事要分开说, + // 混成一句「没配好」会让人反复检查一个本来就没问题的 Key。 + target.textContent = '已取到 Key,但缺少接口地址 · 点击填写'; + target.title = '来源 ' + sourceLabel(config.source) + '\n点击填写接口地址'; + target.className = 'engine bad'; + } else { + target.textContent = '未配置生图接口 · 点击填写 Key 与地址'; + target.title = '也可以在 shell 配置里导出 MINIMAX_API_KEY 与 MINIMAX_BASE_URL'; + target.className = 'engine bad'; + } + state.config = config; +} + +function sourceLabel(source) { + const map = { app: '本页面配置', env: '环境变量', none: '未配置' }; + if (!source) return '未配置'; + return map[source] || source; +} + +// ---------------------------------------------------------------- 接口设置 + +const SETTINGS_ERRORS = { + api_key_required: 'API Key 不能为空', + api_key_has_space: 'API Key 里不能有空格或换行', + api_key_too_long: 'API Key 太长了,请确认粘贴完整且没有多余内容', + base_url_required: '接口地址不能为空', + base_url_must_be_https: '接口地址必须以 https:// 开头', + base_url_incomplete: '接口地址不完整,请补上域名', + base_url_invalid: '接口地址不是合法的 URL', + clipboard_empty: '剪贴板是空的', + clipboard_multiline: '剪贴板里有多行或空格,请只复制 Key 本身', + clipboard_too_long: '剪贴板内容太长了,请只复制 Key 本身', + clipboard_not_key: '剪贴板里既不像 Key 也不像接口地址,请只复制其中一段', + clipboard_unavailable: '这台机器上没有可用的剪贴板命令,请手动粘贴(Cmd+V)', + clipboard_timeout: '读剪贴板超时了,请手动粘贴(Cmd+V)', + network: '请求没发出去,请检查 Mini App 服务是否还在运行', + bad_response: '服务端返回了无法解析的内容', +}; + +function openSettings() { + const box = el('settings'); + const config = state.config || {}; + el('settings-error').hidden = true; + el('settings-key').value = ''; + // 地址可以回显,Key 不行:页面上永远只留一个空的密码框。 + el('settings-url').value = config.stored ? config.baseUrl || '' : ''; + el('settings-note').textContent = config.ready + ? '当前配置可用(来源:' + sourceLabel(config.source) + ')。重新填写会覆盖本页配置。' + : '还没有可用配置。填好 Key 和接口地址后才能开始生图。'; + box.hidden = false; + el('settings-key').focus(); +} + +// 剪贴板按钮:省掉「切到别处复制 → 切回来 Cmd+V」这一圈。 +// +// 真正去读的是服务端,不是这里:Mini App 页面的 deviceAccess 不含 clipboard, +// navigator.clipboard.readText() 在面板里必然被拒(实测过,只会拿到 NotAllowedError)。 +// 只填进输入框,不替用户点保存——Key 存不存、存哪个,用户自己按。 +async function pasteKeyFromClipboard() { + const button = el('settings-paste'); + button.disabled = true; + const result = await api('/api/clipboard', { method: 'POST', body: '{}' }); + button.disabled = false; + if (!result || result.error) { + showSettingsError(SETTINGS_ERRORS[result && result.error] || '读不到剪贴板,请手动粘贴(Cmd+V)'); + return; + } + // 剪贴板里也可能是接口地址,那就填到地址栏去,别让用户自己分辨该放哪。 + const isUrl = result.field === 'baseUrl'; + const target = el(isUrl ? 'settings-url' : 'settings-key'); + target.value = result.value; + el('settings-error').hidden = true; + target.focus(); + flash(isUrl ? '已从剪贴板读入接口地址' : '已从剪贴板读入 Key,确认无误后点保存'); +} + +function closeSettings() { + el('settings').hidden = true; +} + +async function saveSettings() { + const error = el('settings-error'); + const apiKey = el('settings-key').value.trim(); + const baseUrl = el('settings-url').value.trim(); + error.hidden = true; + if (!apiKey) return showSettingsError('API Key 不能为空'); + if (!baseUrl) return showSettingsError('接口地址不能为空'); + const save = el('settings-save'); + save.disabled = true; + const result = await api('/api/config', { method: 'POST', body: JSON.stringify({ apiKey, baseUrl }) }); + save.disabled = false; + if (!result || result.error) { + showSettingsError(SETTINGS_ERRORS[result && result.error] || '保存失败:' + ((result && result.error) || '未知错误')); + return; + } + closeSettings(); + await loadConfig(); + flash('接口设置已保存'); +} + +async function clearSettings() { + const result = await api('/api/config', { method: 'POST', body: JSON.stringify({ clear: true }) }); + if (!result || result.error) { + showSettingsError('清除失败:' + ((result && result.error) || '未知错误')); + return; + } + closeSettings(); + await loadConfig(); + flash('已清除本页配置'); +} + +function showSettingsError(message) { + const error = el('settings-error'); + error.textContent = message; + error.hidden = false; +} + +// ---------------------------------------------------------------- 参考图 + +el('refs').addEventListener('change', (event) => { + const files = Array.from(event.target.files || []).slice(0, 8); + state.refs = []; + const box = el('ref-thumbs'); + box.textContent = ''; + box.hidden = files.length === 0; + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = () => { + state.refs.push({ name: file.name, dataUrl: String(reader.result) }); + const img = node('img'); + img.src = String(reader.result); + img.alt = file.name; + box.appendChild(img); + }; + reader.readAsDataURL(file); + }); +}); + +// ---------------------------------------------------------------- 历史会话 + +const SESSION_PREVIEW = 3; + +async function loadSessions() { + const result = await api('/api/sessions'); + // 只有拿到真的数组才允许动 DOM。一次网关抖动就清空列表,等于告诉用户「你的历史没了」, + // 而磁盘上一条都没少——这种假象比报错还糟,所以失败时保留上一次渲染的内容并自动重试。 + if (!result || !Array.isArray(result.sessions)) { + if (!state.sessionsFailed) { + state.sessionsFailed = true; + flash('历史列表没加载出来,正在重试…'); + } + scheduleSessionsRetry(); + return; + } + state.sessionsFailed = false; + const list = result.sessions; + state.sessionList = list; + if (typeof result.now === 'number') state.sessionsSkew = result.now - Date.now(); + const box = el('sessions'); + // 签名只认结构变化。elapsedMs 对进行中的会话每秒都在走,让它参与签名会让整张列表每 1.5s + // 被推倒重建一次,用户刚点开的删除确认条会被一起冲掉。「用时」交给 tickSessionMeta 单独刷新。 + const signature = + list + .map((item) => item.id + ':' + item.status + ':' + (item.busy ? 1 : 0) + ':' + item.delivered + ':' + item.images) + .join('|') + + '|' + (state.sessionsAll ? 'all' : 'few') + '|' + (state.sessionId || ''); + if (signature === state.sigs.sessions) return; + state.sigs.sessions = signature; + + el('sessions-count').textContent = list.length ? list.length + ' 条' : ''; + box.textContent = ''; + if (!list.length) { + box.appendChild(node('p', 'session-empty', '还没有历史会话')); + } + const shown = state.sessionsAll ? list : list.slice(0, SESSION_PREVIEW); + shown.forEach((item) => { + // 行本身是 button,操作按钮不能嵌在 button 里(HTML 不允许嵌套),所以套一层 wrapper, + // 操作区绝对定位浮在行右侧,悬停才显形。 + const wrap = node('div', 'session-item'); + const row = node('button', 'session-row' + (item.id === state.sessionId ? ' current' : '')); + row.type = 'button'; + row.title = item.id; + const left = node('div', 'session-main'); + left.append(node('strong', null, item.instruction || '(空需求)'), node('span', 'meta', sessionMeta(item))); + // 已交付是常态,列表里只留异常状态,标题才不会被标签挤掉。 + const quiet = item.status === 'delivered' && !item.busy ? ' quiet' : ''; + const pill = node('span', 'pill ' + statusClass(item.status) + quiet, sessionStatusLabel(item)); + row.append(left, pill); + row.onclick = () => openSession(item.id); + + const actions = node('div', 'session-actions'); + const rerun = node('button', 'row-icon'); + rerun.type = 'button'; + rerun.title = '用同样的需求重画一次'; + rerun.setAttribute('aria-label', '重跑'); + rerun.appendChild(node('span', 'icon-rerun')); + rerun.onclick = (event) => { + event.stopPropagation(); + rerunSession(item.id, rerun); + }; + const remove = node('button', 'row-icon'); + remove.type = 'button'; + remove.title = '删除这条会话'; + remove.setAttribute('aria-label', '删除'); + remove.appendChild(node('span', 'icon-trash')); + remove.onclick = (event) => { + event.stopPropagation(); + askDelete(item, wrap); + }; + actions.append(rerun, remove); + wrap.append(row, actions); + box.appendChild(wrap); + }); + + if (list.length > SESSION_PREVIEW) { + const more = node('button', 'session-more', state.sessionsAll ? '收起' : '显示全部 ' + list.length + ' 条'); + more.type = 'button'; + more.onclick = () => { + state.sessionsAll = !state.sessionsAll; + loadSessions(); + }; + box.appendChild(more); + } + + if (!state.sessionId && list.length && !state.fresh) { + // 页面重新加载后回到用户离开时看的那条;它已经被删掉就退回最新一条。 + const wanted = state.restoreSessionId && list.some((item) => item.id === state.restoreSessionId) + ? state.restoreSessionId + : list[0].id; + state.restoreSessionId = null; + openSession(wanted); + } + ensureSessionsClock(); +} + +// 加载失败不能就这么空着:4s 后自己再试一次,通了就把列表补回来,用户不用手动刷新。 +function scheduleSessionsRetry() { + if (state.sessionsRetry) return; + state.sessionsRetry = setTimeout(() => { + state.sessionsRetry = null; + state.sigs.sessions = ''; + loadSessions(); + }, 4000); +} + +// 进行中的会话「用时」一直在走,但不该因此重建整张列表:只改已渲染那一行的时间文本。 +// 重建一次就会把用户点开的删除确认条冲掉,那正是「点了删除没反应」的来源。 +function tickSessionMeta() { + const list = state.sessionList; + if (!list.length) return; + el('sessions').querySelectorAll('.session-item').forEach((wrap, index) => { + const item = list[index]; + if (!item || !item.busy) return; + const meta = wrap.querySelector('.meta'); + if (!meta) return; + item.elapsedMs = Date.now() + state.sessionsSkew - item.createdAt; + meta.textContent = sessionMeta(item); + }); +} + +// 只有列表里真的有人在跑时才开这个 1s 定时器,全跑完就关掉,不空转。 +function ensureSessionsClock() { + const busy = state.sessionList.some((item) => item.busy); + if (busy && !state.sessionsClock) { + state.sessionsClock = setInterval(tickSessionMeta, 1000); + } else if (!busy && state.sessionsClock) { + clearInterval(state.sessionsClock); + state.sessionsClock = null; + } +} + +function sessionMeta(item) { + const created = Number(item.createdAt) || 0; + const date = new Date(created); + const now = new Date(); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const when = sameDay ? fmtClock(created) : fmtDateTime(created).slice(5, 16); + return ( + when + ' · 用时 ' + fmtDuration(item.elapsedMs) + ' · ' + item.delivered + '/' + item.images + ' 张' + + (item.busy ? ' · 进行中' : '') + ); +} + +function sessionStatusLabel(item) { + if (item.busy) return '进行中'; + const map = { delivered: '已交付', failed: '失败', cancelled: '已取消', running: '进行中' }; + return map[item.status] || item.status; +} + +function statusClass(status) { + if (status === 'delivered') return 'pass'; + if (status === 'failed') return 'fail'; + if (status === 'cancelled') return 'warn'; + return ''; +} + +function openSession(id) { + if (!id || id === state.sessionId) return; + state.sessionId = id; + state.fresh = false; + resetThread(); + state.mode = 'followup'; + applyComposerMode(); + startPolling(); +} + +// 顶部浮一条短提示,复用分享浮层的样式,不再为每种消息各画一个。 +function flash(text) { + const app = document.querySelector('.app') || document.body; + const old = app.querySelector('.share-toast'); + if (old) old.remove(); + const box = node('div', 'share-toast'); + box.appendChild(node('strong', null, text)); + app.appendChild(box); + setTimeout(() => { box.remove(); }, 2400); +} + +// 重跑:服务端拿原需求和参考图开一个新会话,回来直接切过去,原会话原封不动留着。 +async function rerunSession(id, button) { + if (button) button.disabled = true; + const result = await api('/api/session/rerun', { method: 'POST', body: JSON.stringify({ id }) }); + if (button) button.disabled = false; + if (result.error || !result.id) { + flash(result.error === 'busy' ? '这条还在跑,等它结束再重跑' : '重跑失败'); + return; + } + state.sigs.sessions = ''; + await loadSessions(); + openSession(result.id); +} + +// 删除要二次确认:目录虽然只是挪进 .trash,但误点一次就得自己去磁盘翻,不值得。 +function askDelete(item, wrap) { + if (wrap.querySelector('.session-confirm')) return; + const bar = node('div', 'session-confirm'); + bar.appendChild(node('span', null, '删除这条?')); + const yes = node('button', 'link-btn danger', '删除'); + yes.type = 'button'; + yes.onclick = (event) => { + event.stopPropagation(); + confirmDelete(item.id); + }; + const no = node('button', 'link-btn', '取消'); + no.type = 'button'; + no.onclick = (event) => { + event.stopPropagation(); + bar.remove(); + }; + bar.append(yes, no); + wrap.appendChild(bar); +} + +async function confirmDelete(id) { + const result = await api('/api/session/delete', { method: 'POST', body: JSON.stringify({ id }) }); + // 请求失败不等于删掉了。以前这里只看 result.error,而失败时拿到的 {} 里没有 error, + // 于是会弹「已删除」并清空当前页面,服务端其实什么都没做。 + if (!result || result.error) { + flash(result && result.error === 'busy' ? '这条还在跑,先停掉再删' : '删除没成功,历史还在'); + state.sigs.sessions = ''; + loadSessions(); + return; + } + // 删掉的正好是当前打开的这条,就回到空白页,别留着一个已经不存在的会话内容。 + // startNewPainting 内部已经在刷列表,这里不能再刷:两次并发渲染会互相覆盖,还白打一次接口。 + if (state.sessionId === id) { + startNewPainting(); + } else { + state.sigs.sessions = ''; + await loadSessions(); + } + flash('已删除'); +} + +function resetThread() { + state.openEvents = new Set(); + state.threadFor = ''; + state.anchor = 'auto'; + state.deliverSeen = false; + if (state.linkTimer) { + clearInterval(state.linkTimer); + state.linkTimer = null; + } + clearSigs(true); +} + +// 底部输入区是唯一输入口,文案跟着当前意图变,用户不用猜这次是新建还是接着调。 +function applyComposerMode() { + const follow = state.mode === 'followup' && Boolean(state.sessionId); + el('instruction').placeholder = follow ? '继续调整这张图…' : '描述你要的图…'; + el('brief-hint').textContent = follow ? '接着调 ' + state.sessionId : '默认输出 4K'; +} + +// 「新建绘画」要给一个真正干净的页面:线程清空、轮询停掉、侧边栏取消选中, +// 而不是只把输入框的提示文案换一下、对话记录还留在屏幕上。 +function startNewPainting(options) { + const opts = options || {}; + // 当前这张还在跑的话,只是页面切走了,服务端不会停:得说清楚,别让人以为白等了。 + const wasRunning = !el('cancel').disabled; + state.mode = 'new'; + state.fresh = true; + state.sessionId = null; + for (const key of ['timer', 'clockTimer', 'linkTimer']) { + if (state[key]) { + clearInterval(state[key]); + state[key] = null; + } + } + // 列表加载失败的重试也要收:否则它会在新页面上再触发一次无意义的刷新。 + if (state.sessionsRetry) { + clearTimeout(state.sessionsRetry); + state.sessionsRetry = null; + } + resetThread(); + state.threadFor = ''; + state.turns = []; + state.turnNodes = []; + showEmptyThread(); + // 参考图也一起清掉:既然是全新一页,就不该带着上一张的输入。 + state.refs = []; + el('ref-thumbs').textContent = ''; + el('ref-thumbs').hidden = true; + el('refs').value = ''; + el('topbar-title').textContent = '新建绘画'; + el('cancel').disabled = true; + el('brief-form').classList.remove('compact'); + applyComposerMode(); + if (wasRunning) el('brief-hint').textContent = '上一张还在后台画,完成后到历史里看'; + // 侧边栏要立刻取消选中态,否则列表里还高亮着上一个会话。 + state.sigs.sessions = ''; + // skipLoad:带着需求启动时,启动代码紧接着就会自己调一次 loadSessions,这里再来一次是白打一个接口。 + if (!opts.skipLoad) loadSessions(); + el('instruction').focus(); +} + +function showEmptyThread() { + const thread = el('thread'); + thread.textContent = ''; + const box = node('div', 'empty-state'); + box.id = 'empty-state'; + box.append( + node('p', 'empty-title', '开始一次新的绘画'), + node('p', 'empty-desc', '写下你要的图。检索参考图、规划、生成、校验、修正都会自动做完。'), + ); + thread.appendChild(box); +} + +el('new-session').addEventListener('click', startNewPainting); + +// ---------------------------------------------------------------- 提交与轮询 + +el('brief-form').addEventListener('submit', (event) => { + event.preventDefault(); + sendInstruction(); +}); + +// 已经打开一个会话就接着调它,否则开新会话。同一个输入框,不让用户挑。 +async function sendInstruction() { + const input = el('instruction'); + const instruction = input.value.trim(); + if (!instruction) return; + const follow = state.mode === 'followup' && Boolean(state.sessionId); + el('start').disabled = true; + el('brief-hint').textContent = follow ? '正在提交调整…' : '正在启动…'; + const result = follow + ? await api('/api/session/followup', { method: 'POST', body: JSON.stringify({ id: state.sessionId, instruction }) }) + : await api('/api/session', { method: 'POST', body: JSON.stringify({ instruction, refs: state.refs }) }); + if (result.error || (!follow && !result.id)) { + el('start').disabled = false; + el('brief-hint').textContent = result.error === 'busy' ? '这个会话还在跑,等它结束' : '发送失败,请看引擎状态'; + return; + } + if (!follow) { + state.sessionId = result.id; + state.refs = []; + el('ref-thumbs').textContent = ''; + el('ref-thumbs').hidden = true; + el('refs').value = ''; + } + input.value = ''; + autoGrow(); + state.mode = 'followup'; + state.fresh = false; + // 服务端在 POST 返回之前就已经开好了新一轮,所以这里不用做乐观展示: + // 紧接着的第一次轮询就能拿到新轮次,直接重建线程即可。 + resetThread(); + applyComposerMode(); + startPolling(); +} + +el('examples').addEventListener('click', (event) => { + const target = event.target.closest('.example'); + if (!target) return; + el('instruction').value = target.dataset.text || target.textContent; + el('instruction').focus(); +}); + +el('cancel').addEventListener('click', async () => { + if (!state.sessionId) return; + await api('/api/session/cancel', { method: 'POST', body: JSON.stringify({ id: state.sessionId }) }); +}); + +el('sessions-refresh').addEventListener('click', () => { + state.sigs.sessions = ''; + loadSessions(); +}); + +// 引擎状态就是设置入口:没配好时它是唯一显眼的红字,点它最直接。 +el('engine').addEventListener('click', openSettings); +el('settings-close').addEventListener('click', closeSettings); +el('settings-paste').addEventListener('click', pasteKeyFromClipboard); +// 报错要随输入消失:改完输入框还挂着一句上一次的错误,会让人以为没改对。 +const hideSettingsError = () => { el('settings-error').hidden = true; }; +el('settings-key').addEventListener('input', hideSettingsError); +el('settings-url').addEventListener('input', hideSettingsError); +el('settings-cancel').addEventListener('click', closeSettings); +el('settings-save').addEventListener('click', saveSettings); +el('settings-clear').addEventListener('click', clearSettings); +el('settings').addEventListener('click', (event) => { + if (event.target === el('settings')) closeSettings(); +}); +el('settings-key').addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.isComposing) saveSettings(); +}); +el('settings-url').addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.isComposing) saveSettings(); +}); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !el('settings').hidden) closeSettings(); +}); + +// ---------------------------------------------------------------- 侧边栏折叠 + +// 收起只是视觉偏好,记在本地:下次打开保持用户上次的选择。 +const SIDEBAR_KEY = 'h3.sidebar.collapsed'; + +function applySidebar(collapsed) { + document.body.classList.toggle('collapsed', collapsed); + el('sidebar-toggle').hidden = collapsed; + el('sidebar-open').hidden = !collapsed; +} + +function setSidebar(collapsed) { + try { + localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0'); + } catch (error) { + // 隐私模式下写不了,那就只在本次生效。 + } + applySidebar(collapsed); +} + +el('sidebar-toggle').addEventListener('click', () => setSidebar(true)); +el('sidebar-open').addEventListener('click', () => setSidebar(false)); + +try { + applySidebar(localStorage.getItem(SIDEBAR_KEY) === '1'); +} catch (error) { + applySidebar(false); +} + +// ---------------------------------------------------------------- 输入框 + +// 高度跟着内容走,到上限就自己滚,不再让用户拖右下角。 +function autoGrow() { + const input = el('instruction'); + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 180) + 'px'; +} + +el('instruction').addEventListener('input', autoGrow); +el('instruction').addEventListener('keydown', (event) => { + // isComposing 必须判:中文输入法选词时按回车不是发送。 + if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return; + event.preventDefault(); + el('brief-form').requestSubmit(); +}); + +function startPolling() { + if (state.timer) clearInterval(state.timer); + poll(); + state.timer = setInterval(poll, 1500); + startClock(); +} + +async function poll() { + if (!state.sessionId) return; + const snapshot = await api('/api/session?id=' + encodeURIComponent(state.sessionId)); + // render() 是整线程重建,喂它一个空对象等于把对话记录清空。没有 id 的一律不认。 + if (!snapshot || snapshot.error || !snapshot.id) return; + state.snapshot = snapshot; + const timing = snapshot.timing || {}; + state.clockOffset = Date.now() - (timing.now || Date.now()); + render(snapshot); + loadSessions(); + if (!snapshot.busy && snapshot.status !== 'running') { + clearInterval(state.timer); + state.timer = null; + el('start').disabled = false; + el('cancel').disabled = true; + } else { + el('cancel').disabled = false; + } +} + +function startClock() { + if (state.clockTimer) return; + state.clockTimer = setInterval(tickClock, 1000); +} + +// 发布是异步的:服务端起一个 Agent turn 去部署,量级在分钟级。交付态的会话 poll() 已经停了, +// 所以这里单开一个观察器盯 shareState: +// - 还是 publishing 就继续等 +// - 变成 done 就重画(亮出「公开链接」)并把链接自动写进剪贴板——用户点一次「分享」, +// 拿到的就是「剪贴板里已经有链接了」,不用再点第二次复制 +// - 变成 failed 就停下,按钮回到可重试状态 +function watchSharePublish() { + if (state.linkTimer) return; + let tries = 0; + state.linkTimer = setInterval(async () => { + tries += 1; + // 4s 一次、10 分钟为限:一个 turn 加上传 3MB,实测分钟级;给足余量,失败也不至于空转。 + if (tries > 150 || !state.sessionId) { + clearInterval(state.linkTimer); + state.linkTimer = null; + return; + } + const snapshot = await api('/api/session?id=' + encodeURIComponent(state.sessionId)); + if (!snapshot || snapshot.error) return; + if (snapshot.shareState === 'publishing') { + // 发布要等用户在页面上允许 website_deploy:确认卡片在快照的 permissions 里,必须画出来。 + render(snapshot); + return; + } + clearInterval(state.linkTimer); + state.linkTimer = null; + render(snapshot); + if (snapshot.shareState === 'done' && snapshot.shareUrl) { + if (snapshot.shareClip === 'ok') { + // 服务端 pbcopy 已经写好了。页面不用再试一次——试了也要文档聚焦,多半白试。 + toastLinkCopied(snapshot.shareUrl, true); + } else { + const ok = await copyLink(snapshot.shareUrl); + if (ok) toastLinkCopied(snapshot.shareUrl, false); + else toastCopyFailed(snapshot.shareUrl); + } + } + }, 4000); +} + +// 只有最后一轮会跑,计时器只管它;更早的轮次用时是定值,渲染时写一次就行。 +function tickClock() { + const item = lastTurnNode(); + const view = state.turns[state.turns.length - 1]; + if (!item || !view) return; + const elapsed = view.running ? Date.now() - state.clockOffset - view.at : view.elapsedMs; + item.head.clock.textContent = fmtDuration(elapsed); + item.head.clockLabel.textContent = view.running ? '已运行' : '总用时'; + const status = turnStatus(view); + item.head.status.textContent = status.label; + item.head.status.className = 'pill ' + status.kind; +} + +function turnStatus(view) { + if (view.running || view.status === 'running') return { label: '运行中', kind: 'running' }; + if (view.delivery || view.status === 'delivered') return { label: '已交付', kind: 'pass' }; + if (view.status === 'cancelled') return { label: '已取消', kind: 'warn' }; + if (view.status === 'failed') return { label: '失败', kind: 'fail' }; + return { label: '已结束', kind: '' }; +} + +// ---------------------------------------------------------------- 对话骨架 + +function lastTurnNode() { + return state.turnNodes.length ? state.turnNodes[state.turnNodes.length - 1] : null; +} + +// 老服务端或者异常快照没有 turns,退化成单轮,页面不至于空白。 +function turnsOf(snapshot) { + if (Array.isArray(snapshot.turns) && snapshot.turns.length) return snapshot.turns; + return [ + { + index: 0, + instruction: snapshot.instruction || '', + at: snapshot.createdAt || Date.now(), + running: Boolean(snapshot.busy), + status: snapshot.busy ? 'running' : snapshot.status, + elapsedMs: (snapshot.timing || {}).elapsedMs || 0, + cost: snapshot.cost || {}, + delivery: snapshot.delivery || null, + events: snapshot.events || [], + stageStats: snapshot.stageStats || [], + }, + ]; +} + +// 骨架按"会话 + 轮数"重建一次,之后只更新各轮的内容,展开状态和滚动位置才不会丢。 +function buildThread(turns) { + const thread = el('thread'); + thread.textContent = ''; + state.turnNodes = turns.map((view, index) => buildTurn(view, index, index === turns.length - 1)); + state.turnNodes.forEach((item) => thread.appendChild(item.section)); + state.anchor = 'auto'; + state.deliverSeen = false; + // DOM 刚被清空,签名必须一起作废。 + clearSigs(true); + +} + +// 分享页在当前页面里全屏预览。这个页面的每个请求都要带 Host 授权: +// window.open 新标签拿不到授权上下文,只会被 401 挡成一片空白; +// 在面板里落点不可控,点了也不知道文件去哪了。 +// iframe 用 blob URL 渲染,既不走网络也不依赖下载,是唯一稳定能看的路径; +// 想发给别人就用预览条上的「另存为文件」。 +function openSharePreview(url, sessionId) { + const app = document.querySelector('.app'); + if (!app) return; + const old = app.querySelector('.share-overlay'); + if (old) old.remove(); + if (state.shareUrl) URL.revokeObjectURL(state.shareUrl); + state.shareUrl = url; + + const overlay = node('div', 'share-overlay'); + const bar = node('div', 'share-bar'); + bar.appendChild(node('span', 'share-title', '分享页预览 · ' + sessionId)); + const save = node('a', 'btn tiny share-save', '另存为文件'); + save.href = url; + save.download = 'h3-' + sessionId + '.html'; + const close = node('button', 'btn tiny', '关闭预览'); + close.type = 'button'; + bar.appendChild(save); + bar.appendChild(close); + + const frame = node('iframe', 'share-frame'); + frame.src = url; + frame.title = '分享页预览'; + overlay.appendChild(bar); + overlay.appendChild(frame); + app.appendChild(overlay); + + const destroy = () => { + overlay.remove(); + document.removeEventListener('keydown', onKey); + }; + const onKey = (event) => { + if (event.key === 'Escape') destroy(); + }; + close.addEventListener('click', destroy); + document.addEventListener('keydown', onKey); + close.focus(); +} + +function buildTurn(view, index, isLast) { + const item = { + index, + isLast, + section: node('section', 'turn' + (isLast ? ' current' : '')), + touched: false, + desired: null, + }; + item.section.dataset.turn = String(index); + + const user = node('div', 'msg user'); + const userBubble = node('div', 'bubble'); + item.userText = node('div', 'msg-text', ''); + item.userRefs = node('div', 'msg-refs'); + item.userRefs.hidden = true; + // 需求在传输里被改坏时挂在这里:错字会一路画到交付,必须紧挨着原文说清楚。 + item.userWarn = node('div', 'msg-warn'); + item.userWarn.hidden = true; + userBubble.append(item.userText, item.userRefs, item.userWarn); + user.appendChild(userBubble); + + const process = node('div', 'msg assistant'); + const processBubble = node('div', 'bubble'); + const head = node('div', 'msg-head'); + const status = node('span', 'pill', '运行中'); + const clockLabel = node('span', 'clock-label', '已运行'); + const clock = node('span', 'clock', '00:00'); + const summary = node('span', 'msg-summary', ''); + head.append(status, clockLabel, clock, summary); + item.head = { status, clockLabel, clock, summary }; + + const processBox = node('details', 'process'); + const processSummary = node('summary'); + item.summaryLabel = node('span', null, '过程详情'); + processSummary.appendChild(item.summaryLabel); + const body = node('div', 'process-body'); + const stages = node('ol', 'pipeline'); + stages.setAttribute('aria-label', '流程阶段'); + const tools = node('div', 'timeline-tools'); + const expandAll = node('button', 'btn tiny', '全部展开'); + expandAll.type = 'button'; + const collapseAll = node('button', 'btn tiny', '全部收起'); + collapseAll.type = 'button'; + tools.append(expandAll, collapseAll, node('span', 'hint', '点任意一行展开,能看到这一步实际发给模型的提示词、工具调用和判定依据。')); + const timeline = node('ul', 'timeline'); + body.append(stages, tools, timeline); + // 候选与校验只反映"当前这一轮"的图,所以只建在最后一轮上。 + if (isLast) { + item.imagesWrap = node('div', 'process-images'); + item.imagesWrap.hidden = true; + item.imagesWrap.appendChild(node('h3', null, '候选与校验')); + item.imagesBox = node('div'); + item.imagesWrap.appendChild(item.imagesBox); + body.appendChild(item.imagesWrap); + } + processBox.append(processSummary, body); + processBubble.append(head, processBox); + process.appendChild(processBubble); + item.process = processBox; + item.headBox = head; + item.stages = stages; + item.timeline = timeline; + + const interact = node('div', 'msg assistant'); + interact.hidden = true; + const interactBubble = node('div', 'bubble'); + item.interactions = node('div'); + interactBubble.appendChild(item.interactions); + interact.appendChild(interactBubble); + item.interact = interact; + + const deliver = node('div', 'msg assistant deliver'); + deliver.hidden = true; + const deliverBubble = node('div', 'bubble'); + item.delivery = node('div'); + deliverBubble.appendChild(item.delivery); + deliver.appendChild(deliverBubble); + item.deliver = deliver; + + // 顺序:需求 → 交互 → 过程 → 交付。面板保持「过程在中间」的原顺序:有交付图之后 + // 靠折叠 + 降权把过程压成一行,而不是把交付提到过程前面——那是分享页才做的事。 + // 交付和交互平时都是 hidden,所以运行中看起来仍然是「需求 → 过程」,和以前一样。 + item.section.append(user, interact, process, deliver); + + expandAll.onclick = () => timeline.querySelectorAll('details.event-detail').forEach((one) => (one.open = true)); + collapseAll.onclick = () => timeline.querySelectorAll('details.event-detail').forEach((one) => (one.open = false)); + processBox.addEventListener('toggle', () => { + // 程序自己设的 open 不算用户操作,否则运行中展开一次就永远收不起来。 + if (processBox.open !== item.desired) item.touched = true; + applyProcessWeight(item); + }); + return item; +} + +// 交付完成之后,过程就该退到背景里。 +// 状态、用时、生图数在交付头上都写着,过程头再摆一遍是重复,所以收起时整条头都藏掉, +// 只留一条低调的「过程详情」入口:想看的人点一下就行,但它不该和交付图抢视觉权重。 +function applyProcessWeight(item) { + const light = Boolean(item.hasDelivery) && !item.process.open; + item.process.classList.toggle('compact', light); + if (item.headBox) item.headBox.hidden = light; +} + +// ---------------------------------------------------------------- 渲染 + +function render(snapshot) { + const turns = turnsOf(snapshot); + state.turns = turns; + const key = snapshot.id + ':' + turns.length; + if (state.threadFor !== key) { + state.threadFor = key; + buildThread(turns); + } + // 打开会话后输入区收窄:对话本身才是主体。 + el('brief-form').classList.add('compact'); + // 顶栏标题就是这一轮的需求原文:侧边栏收起后也能知道自己在看哪个会话。 + el('topbar-title').textContent = snapshot.instruction || 'H3-Image-Executor'; + const thread = el('thread'); + const nearBottom = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120; + turns.forEach((view, index) => { + const item = state.turnNodes[index]; + if (!item) return; + renderUserMessage(view, item, snapshot); + renderProcessHead(view, item, snapshot); + renderStages(view, item); + renderTimeline(view, item); + renderDelivery(view, item, snapshot); + }); + tickClock(); + renderInteractions(snapshot); + renderImages(snapshot); + followThread(thread, nearBottom, snapshot); +} + +// 交付图是异步加载的,而且"滚到底"会把最该看的这张图切掉一截, +// 所以交付一出现就钉在图顶;运行中才跟随底部,且只在你本来就在看最新进展时跟。 +function followThread(thread, nearBottom, snapshot) { + const item = lastTurnNode(); + const delivered = Boolean(item) && !item.deliver.hidden && !snapshot.busy; + if (delivered && !state.deliverSeen) { + state.deliverSeen = true; + state.anchor = 'deliver'; + alignDeliver(thread); + return; + } + if (!delivered) state.anchor = 'bottom'; + if (state.anchor === 'bottom' && nearBottom) thread.scrollTop = thread.scrollHeight; +} + +// 对齐到交付图本身:卡片头部只有一句"交付 N 张",为它牺牲图的完整性不划算。 +function alignDeliver(thread) { + const item = lastTurnNode(); + if (!item || item.deliver.hidden) return; + const target = item.deliver.querySelector('.deliver-item img') || item.deliver; + const top = target.getBoundingClientRect().top - thread.getBoundingClientRect().top + thread.scrollTop; + thread.scrollTop = Math.max(0, top - 8); +} + +function renderUserMessage(view, item, snapshot) { + // 这条气泡只放用户自己上传的图。系统检索到的参考图属于执行过程,出现在轨迹里, + // 混进这里会让用户以为"我给过这张图"。追问不带参考图,所以只有第一轮有。 + const mine = view.index === 0 ? (snapshot.refs || []).filter((ref) => ref.origin === 'user') : []; + // 传输损坏警告只在「气泡里的原文正好是那条被改坏的 prompt」时出现。 + const warn = state.launchLenWarn && state.launchLenWarn.prompt === (view.instruction || '') + ? state.launchLenWarn + : null; + const signature = (view.instruction || '') + ':' + mine.length + ':' + + (warn ? warn.expected + '/' + warn.actual : '-'); + const key = 'u' + view.index; + if (state.sigs[key] === signature) return; + state.sigs[key] = signature; + item.userText.textContent = view.instruction || '(空需求)'; + const box = item.userRefs; + box.textContent = ''; + mine.forEach((ref) => { + const img = node('img'); + img.src = fileUrl('refs', String(ref.file || '').split('/').pop()); + img.alt = ref.note || ref.id; + img.onclick = () => openLightbox(img.src); + box.appendChild(img); + }); + box.hidden = !mine.length; + if (item.userWarn) { + item.userWarn.textContent = ''; + item.userWarn.hidden = !warn; + if (warn) { + item.userWarn.append( + node('strong', null, '需求可能在传输中损坏'), + node('span', null, 'Agent 说是 ' + warn.expected + ' 字,实际收到 ' + warn.actual + + ' 字。已经开始画了,但请先核对上面这句话有没有错字。'), + ); + } + } +} + +// 折叠起来时也要一眼看到"干了什么",不然收起来就等于什么都没说。 +function turnSummary(view, snapshot) { + const parts = []; + if (view.index === 0 && (snapshot.refs || []).length) parts.push('参考图 ' + snapshot.refs.length + ' 张'); + const cost = view.cost || {}; + if (cost.ditImages) parts.push('生图 ' + cost.ditImages + ' 张'); + if (cost.agentCalls) parts.push('Agent ' + cost.agentCalls + ' 次'); + return parts.join(' · '); +} + +function renderProcessHead(view, item, snapshot) { + state.shareLink = snapshot.shareUrl || ''; + // 交付完成后分享页已经自动落盘,公开链接随时可能被 MCode 回填。 + // 交付态的 poll 已经停了,这里主动盯着:链接一到,「分享」就直接变成可复制的状态, + // 用户不用先点一次分享才知道有链接。 + // 刷新页面 / 切回会话时,如果服务端那一轮发布还在跑,观察器要重新挂上,不然按钮永远停在「发布中」。 + if (snapshot.shareState === 'publishing' && view.delivery && !view.running) watchSharePublish(); + const summary = turnSummary(view, snapshot); + const signature = summary + ':' + (view.status || '') + ':' + view.running; + const key = 'h' + view.index; + if (state.sigs[key] !== signature) { + state.sigs[key] = signature; + item.head.summary.textContent = summary; + } + // 最后一轮的头部由 tickClock 每秒刷新,这里只写已经定格的更早轮次。 + if (!item.isLast) { + item.head.clock.textContent = fmtDuration(view.elapsedMs); + item.head.clockLabel.textContent = '用时'; + const status = turnStatus(view); + item.head.status.textContent = status.label; + item.head.status.className = 'pill ' + status.kind; + } + // 运行时展开看进度,交付后自动收起只看结果;用户手动开合过就不再自动接管。 + // 已经过去的轮次一律默认收起,没人想看三轮之前的流水账。 + const hasError = (view.events || []).some((event) => event.level === 'error'); + // 「有交付图」才算拿到结果——这时过程是背景材料,收起。 + // 注意不能只看 hasError:一次跑里出过 error 但图照样交付出来了(选优交付), + // 这种会话结果已经在手,失败原因在交付卡的「选优交付」标签和 reason 里都写着, + // 没必要为了它把整个过程摊开。 + const deliveredCount = view.delivery + ? (view.delivery.items || []).filter((entry) => entry.file).length + : 0; + const hasResult = deliveredCount > 0; + // 唯一必须展开的例外:交付 0 张的失败会话。错误事件只存在于过程详情里, + // 折叠着等于把唯一的失败线索藏起来——用户只会看到「交付 0 张」,不知道失败在哪。 + const desired = (hasError && !hasResult) || (item.isLast ? Boolean(snapshot.busy) : false); + if (item.desired !== desired) { + item.desired = desired; + if (!item.touched) item.process.open = desired; + } + item.hasDelivery = hasResult; + applyProcessWeight(item); +} + +function renderStages(view, item) { + const stats = new Map((view.stageStats || []).map((entry) => [entry.stage, entry])); + const events = view.events || []; + const seen = new Set(events.map((event) => event.stage)); + const signature = JSON.stringify(view.stageStats || []) + view.running; + const key = 's' + view.index; + if (state.sigs[key] === signature) return; + state.sigs[key] = signature; + const list = item.stages; + list.textContent = ''; + STAGES.forEach(([stage, label]) => { + const entry = document.createElement('li'); + entry.append(node('span', 'step-dot'), node('span', 'step-name', label)); + const hit = stats.get(stage); + if (hit && hit.spanMs > 0) entry.appendChild(node('span', 'step-dur', fmtSeconds(hit.spanMs))); + if (seen.has(stage)) entry.className = view.running ? 'active' : 'done'; + list.appendChild(entry); + }); +} + +function renderTimeline(view, item) { + const events = view.events || []; + const last = events.length ? events[events.length - 1] : null; + const signature = events.length + ':' + (last ? last.seq + ':' + last.t : '') + ':' + view.running; + const key = 't' + view.index; + if (state.sigs[key] === signature) return; + state.sigs[key] = signature; + + const list = item.timeline; + const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 80; + const stats = window.H3Blocks.renderTimeline(list, events, { + openSet: state.openEvents, + image: thumbUrl, + onImage: openLightbox, + }); + item.summaryLabel.textContent = '过程详情(' + stats.events + ' 条 · ' + stats.groups + ' 步)'; + if (nearBottom) list.scrollTop = list.scrollHeight; +} + +// 这一栏有按钮和选中态,1.5s 一次的轮询不能把它重建掉,否则用户点好的选项会被抹掉。 +// 闸门和权限确认只可能出现在正在跑的那一轮,所以只渲染最后一轮。 +function renderInteractions(snapshot) { + const item = lastTurnNode(); + if (!item) return; + const gate = snapshot.gate; + const signature = JSON.stringify([ + (snapshot.permissions || []).map((record) => record.requestId), + gate ? gate.id : '', + snapshot.pendingQuestion ? snapshot.pendingQuestion.requestId : '', + ]); + if (state.sigs.interactions === signature) return; + state.sigs.interactions = signature; + + const box = item.interactions; + box.textContent = ''; + let count = 0; + + if (gate && (gate.questions || []).length) { + count += 1; + box.appendChild(buildGateCard(gate)); + } + (snapshot.permissions || []).forEach((record) => { + count += 1; + const card = node('div', 'prompt-card'); + card.append(node('h3', null, '需要确认工具调用:' + record.toolName), node('p', 'meta', record.reason || '')); + const allow = node('button', 'btn primary', '允许一次'); + allow.onclick = () => decide(record.requestId, 'allowOnce'); + const deny = node('button', 'btn danger', '拒绝'); + deny.onclick = () => decide(record.requestId, 'deny'); + card.append(allow, deny); + box.appendChild(card); + }); + + const pending = snapshot.pendingQuestion; + if (pending && pending.request) { + count += 1; + box.appendChild(buildQuestionCard(pending)); + } + item.interact.hidden = count === 0; +} + +// Agent 的问卷:每一步都要选(单选或多选),全部选完才提交。没有选项的步骤按跳过处理。 +// 回答的形状见 docs/agent.md:每步一条 { stepId, selectedOptionIds, selectedOther }。 +function buildQuestionCard(pending) { + const request = pending.request || {}; + const steps = Array.isArray(request.steps) ? request.steps : []; + const card = node('div', 'prompt-card gate-card'); + card.appendChild(node('h3', null, request.title || 'Agent 需要你补充信息')); + const picks = new Map(); + const submit = node('button', 'btn primary', '提交回答'); + submit.type = 'button'; + const needsPick = (step) => Array.isArray(step.options) && step.options.length > 0; + const refresh = () => { + submit.disabled = steps.some((step) => needsPick(step) && !(picks.get(step.id) || new Set()).size); + }; + steps.forEach((step) => { + const block = node('div', 'gate-item'); + block.appendChild(node('p', 'gate-q', step.question || step.header || '')); + if (step.description) block.appendChild(node('p', 'meta', step.description)); + const row = node('div', 'gate-options'); + const multiple = step.selectionMode === 'multiple'; + (step.options || []).forEach((option) => { + const button = node('button', 'btn tiny', option.label); + button.type = 'button'; + button.dataset.optionId = option.id; + if (option.description) button.title = option.description; + button.onclick = () => { + const chosen = picks.get(step.id) || new Set(); + if (!multiple) chosen.clear(); + if (multiple && chosen.has(option.id)) chosen.delete(option.id); + else chosen.add(option.id); + picks.set(step.id, chosen); + Array.from(row.children).forEach((one) => one.classList.toggle('picked', chosen.has(one.dataset.optionId))); + refresh(); + }; + row.appendChild(button); + }); + block.appendChild(row); + card.appendChild(block); + }); + submit.onclick = () => { + submit.disabled = true; + const answers = steps.map((step) => { + const selected = Array.from(picks.get(step.id) || []); + return needsPick(step) + ? { stepId: step.id, selectedOptionIds: selected, selectedOther: false } + : { stepId: step.id, selectedOptionIds: [], selectedOther: false, skipped: true }; + }); + submitAnswers(pending.requestId, answers); + }; + refresh(); + const actions = node('div', 'gate-actions'); + actions.appendChild(submit); + card.appendChild(actions); + return card; +} + +// 出图前的关键确认:不定也能继续,会按规划时选的默认方案出图。 +function buildGateCard(gate) { + const card = node('div', 'prompt-card gate-card'); + card.append( + node('h3', null, '出图前需要你定 ' + gate.questions.length + ' 件事'), + node('p', 'meta', '选完点「按这个出图」;不想选就按默认方案继续。'), + ); + const picks = new Map(); + const submit = node('button', 'btn primary', '按这个出图'); + submit.disabled = true; + gate.questions.forEach((item, index) => { + const block = node('div', 'gate-item'); + block.appendChild(node('p', 'gate-q', item.text)); + const row = node('div', 'gate-options'); + const options = item.options && item.options.length ? item.options : [item.default || '按默认方案']; + options.forEach((option) => { + const button = node('button', 'btn tiny', option); + if (option === item.default) button.classList.add('suggested'); + button.onclick = () => { + picks.set(index, option); + Array.from(row.children).forEach((one) => one.classList.toggle('picked', one === button)); + submit.disabled = picks.size < gate.questions.length; + }; + row.appendChild(button); + }); + block.appendChild(row); + card.appendChild(block); + }); + const actions = node('div', 'gate-actions'); + submit.onclick = () => answerGate(gate.questions.map((item, index) => ({ question: item.text, answer: picks.get(index) }))); + const skip = node('button', 'btn', '按默认方案继续'); + skip.onclick = () => answerGate([]); + actions.append(submit, skip); + card.appendChild(actions); + return card; +} + +async function answerGate(answers) { + await api('/api/session/answer', { method: 'POST', body: JSON.stringify({ id: state.sessionId, answers }) }); + state.sigs.interactions = ''; + poll(); +} + +async function decide(requestId, decision) { + const result = await api('/api/session/permission', { + method: 'POST', + body: JSON.stringify({ id: state.sessionId, requestId, decision }), + }); + // 回复没送到也要说话:用户关掉了 Agent 权限时,Host 会拒绝这次回复。 + if (result && result.ok === false) { + flash(result.code === 'NOT_PERMITTED' + ? 'Agent 权限已关闭:在插件详情里重新打开 session.run 后再试' + : '确认没有送达(' + (result.code || '未知原因') + '),这次请求可能已经结束'); + } + poll(); +} + +async function submitAnswers(requestId, answers) { + const result = await api('/api/session/answer', { + method: 'POST', + body: JSON.stringify({ id: state.sessionId, requestId, answers }), + }); + if (result && result.error === 'no_question') flash('这份问卷已经结束了,回答没有提交'); + state.sigs.interactions = ''; + poll(); +} + +function renderImages(snapshot) { + const item = lastTurnNode(); + if (!item || !item.imagesBox) return; + const wrap = item.imagesWrap; + const box = item.imagesBox; + const signature = snapshot.id + ':' + JSON.stringify(snapshot.images).length + ':' + snapshot.busy; + if (state.sigs.images === signature) return; + state.sigs.images = signature; + box.textContent = ''; + if (!snapshot.images.length) { + wrap.hidden = true; + return; + } + wrap.hidden = false; + snapshot.images.forEach((image) => { + const block = node('div', 'image-block'); + const head = node('div', 'image-head'); + head.append( + node('h3', null, image.id + ' · ' + image.title), + node('span', 'pill ' + image.status, statusLabel(image)), + node( + 'span', + 'meta', + image.spec.area + ' · ' + image.spec.aspectRatio + ' · ' + (image.spec.entry === 'edit' ? '带参考图' : '文生图'), + ), + ); + block.appendChild(head); + + if (image.blocked) block.appendChild(node('p', 'meta', '缺用户信息,未生成:' + (image.blockReason || ''))); + + image.rounds.forEach((round) => { + const wrapRound = node('div', 'round'); + const actionText = round.action && round.action.type === 'edit' ? '局部编辑' : '生成'; + wrapRound.appendChild( + node( + 'div', + 'round-title', + '第 ' + (round.index + 1) + ' 轮 · ' + actionText + + (round.verdict ? ' · ' + round.verdict.note : '') + + (round.error ? ' · ' + round.error : ''), + ), + ); + const grid = node('div', 'candidates'); + (round.candidates || []).forEach((candidate) => { + grid.appendChild(renderCandidate(candidate, image.chosenId === candidate.id)); + }); + wrapRound.appendChild(grid); + renderPromptCompare( + wrapRound, + (round.candidates || []).map((candidate) => ({ + id: candidate.id, + prompt: candidate.prompt, + revisedPrompt: candidate.revisedPrompt, + })), + ); + block.appendChild(wrapRound); + }); + + box.appendChild(block); + }); +} + +function statusLabel(image) { + if (image.blocked) return '阻塞'; + const map = { pending: '待生成', running: '进行中', passed: '通过', unpassed: '未通过(已选优交付)', failed: '失败' }; + return map[image.status] || image.status; +} + +function renderCandidate(candidate, chosen) { + const card = node('div', 'candidate' + (chosen ? ' chosen' : '')); + const img = node('img'); + img.src = fileUrl('preview', candidate.previewFile || candidate.file); + img.alt = candidate.id; + img.onclick = () => openLightbox(fileUrl('image', candidate.file)); + card.appendChild(img); + + const body = node('div', 'candidate-body'); + body.appendChild(node('div', null, candidate.id + ' · ' + (candidate.size || ''))); + + const chips = node('div', 'chips'); + if (candidate.verdict) { + chips.appendChild(chip('hard ' + candidate.verdict.hardPassed + '/' + candidate.verdict.hardTotal, candidate.verdict.hardFailed.length ? 'fail' : 'pass')); + chips.appendChild(chip('质量 ' + candidate.verdict.quality, '')); + } + const hasCastCheck = (candidate.checks || []).some((check) => check.id === 'P-cast'); + if (candidate.program && candidate.program.redCast) { + chips.appendChild(chip('偏红 · 已打回重画', 'fail')); + } else if (!hasCastCheck && candidate.program && candidate.program.drift) { + chips.appendChild(chip(candidate.program.drift.label, candidate.program.drift.cast === 'none' ? 'pass' : 'warn')); + } + if (candidate.program && candidate.program.sharpness && candidate.program.sharpness.overSharpened) { + chips.appendChild(chip('锐度 ' + candidate.program.sharpness.ratio + '×', 'warn')); + } + (candidate.checks || []).forEach((check) => { + if (check.result === 'pass' && check.id.indexOf('P-text') !== 0) return; + chips.appendChild(chip(check.text, check.result)); + }); + body.appendChild(chips); + + if (candidate.verdict && candidate.verdict.checks) { + const detailBox = node('details', 'candidate-detail'); + detailBox.appendChild(node('summary', null, '评审逐项判定')); + const inner = node('div', 'detail-body'); + renderBlocks( + [ + { + kind: 'list', + label: '验收清单', + items: Object.keys(candidate.verdict.checks).map((key) => { + const entry = candidate.verdict.checks[key]; + const result = entry.result || '未评'; + return { + tag: result === 'pass' ? 'pass' : result === 'fail' ? 'fail' : 'warn', + text: key, + note: (entry.note || '') + (result === 'uncertain' ? '(拿不准,放行)' : ''), + }; + }), + }, + candidate.verdict.summary ? { kind: 'text', label: '总评', text: candidate.verdict.summary } : null, + ].filter(Boolean), + inner, + ); + detailBox.appendChild(inner); + body.appendChild(detailBox); + } + + card.appendChild(body); + return card; +} + +function chip(text, kind) { + return node('span', 'chip' + (kind ? ' ' + kind : ''), text); +} + +// 复制公开链接:clipboard API 在没有授权时会抛,降级到 execCommand,保证按钮永远有反馈。 +async function copyLink(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + try { + const box = node('textarea'); + box.value = text; + box.setAttribute('readonly', ''); + box.style.position = 'fixed'; + box.style.top = '-1000px'; + box.style.opacity = '0'; + document.body.appendChild(box); + box.select(); + const ok = document.execCommand('copy'); + box.remove(); + return ok; + } catch (fallbackError) { + return false; + } + } +} + +// 复制成功的明确反馈:按钮文案会变,但用户未必盯着按钮,再给一条浮层把网址摆出来。 +// viaServer 决定措辞:服务端 pbcopy 写进去的不依赖文档聚焦,页面自己写的依赖—— +// 两者可靠性不一样,说法也不该一样。 +function toastLinkCopied(text, viaServer) { + const app = document.querySelector('.app') || document.body; + const old = app.querySelector('.share-toast'); + if (old) old.remove(); + const box = node('div', 'share-toast'); + box.append( + node('strong', null, viaServer ? '链接已放进剪贴板' : '链接已复制到剪贴板'), + node('span', 'share-toast-url', text), + ); + app.appendChild(box); + setTimeout(() => { box.remove(); }, 2600); +} + +// 复制失败也必须说话:静默失败最糟——用户以为剪贴板里有东西,粘出去是别的内容。 +// 停留时间比成功提示长得多,用户要来得及手动选中那串地址。 +function toastCopyFailed(text) { + const app = document.querySelector('.app') || document.body; + const old = app.querySelector('.share-toast'); + if (old) old.remove(); + const box = node('div', 'share-toast fail'); + box.append(node('strong', null, '没能自动复制,请手动复制'), node('span', 'share-toast-url', text)); + app.appendChild(box); + setTimeout(() => { box.remove(); }, 9000); +} + +// 分享控件。三件事分开,别挤在一个按钮里: +// - 预览:在本页全屏看自包含分享页。不发布、不联网、别人看不到。 +// - 分享:没链接就发布成公开链接(服务端起一个 Agent turn 去部署),有链接就只是复制。 +// - 公开链接:新标签打开已经发出去的页面。 +// +// 发布要用户明确知道「会公开」,所以没链接时按钮文案写全称,并在下方挂一行常驻提示—— +// 只放 title 里不够,title 要 hover 才看得到。 +function buildShareTools(snapshot) { + const tools = node('div', 'share-tools'); + const link = snapshot.shareUrl || ''; + const shareState = snapshot.shareState || 'idle'; + const shareError = snapshot.shareError || ''; + const publishing = shareState === 'publishing'; + + // 预览:走 fetch 拿回 HTML 再开 object URL。不能用 window.open——这个页面的每个请求都要带 + // Host 授权,新标签拿不到授权上下文,只会被 401 挡成一片空白。 + const preview = node('button', 'btn tiny', '预览'); + preview.type = 'button'; + preview.title = '在本页全屏预览分享页。不发布、不联网,只有你自己能看到'; + preview.addEventListener('click', async () => { + if (!state.sessionId) return; + const label = preview.textContent; + preview.disabled = true; + preview.textContent = '生成中…'; + try { + const query = '?id=' + encodeURIComponent(state.sessionId); + const saved = await fetch('/api/session/share' + query + '&save=1'); + if (!saved.ok) throw new Error('HTTP ' + saved.status); + state.shareSaved = await saved.json(); + const page = await fetch('/api/session/share' + query); + if (!page.ok) throw new Error('HTTP ' + page.status); + openSharePreview(URL.createObjectURL(await page.blob()), state.sessionId); + preview.textContent = label; + } catch (error) { + preview.textContent = '预览失败'; + setTimeout(() => { preview.textContent = label; }, 2000); + } + preview.disabled = false; + }); + + const share = node('button', 'btn tiny', publishing ? '发布中…' : (link ? '分享' : '发布公开链接')); + share.type = 'button'; + share.disabled = publishing; + share.title = link + ? '复制公开链接,任何人拿到都能打开' + : '发布成公开链接:任何拿到链接的人都能打开这个页面。' + + '发布会起一个 Agent 会话去部署,部署前需要你在页面上允许 website_deploy;' + + '大约 1–3 分钟,好了自动把链接放进剪贴板'; + if (!link && shareState === 'failed') { + share.title = '上次发布失败:' + (shareError || '未知原因') + '(点一下重试)'; + } + + const shareLink = node('a', 'share-link', '公开链接'); + shareLink.target = '_blank'; + shareLink.rel = 'noreferrer'; + shareLink.hidden = !link; + if (link) { + shareLink.href = link; + shareLink.title = link; + } + + share.addEventListener('click', async () => { + if (!state.sessionId) return; + // 链接已经在手:这一步就是「复制 + 告诉用户复制好了」,不再重新部署。 + if (link) { + let ok = await copyLink(link); + let viaServer = false; + if (!ok) { + // 页面侧写不进去(面板没焦点 / 没有用户激活):退回服务端 pbcopy,它不看焦点。 + try { + const res = await fetch('/api/session/share/copy', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: state.sessionId }), + }); + ok = res.ok; + viaServer = res.ok; + } catch (error) { + ok = false; + } + } + share.textContent = ok ? '链接已复制' : '复制失败'; + if (ok) toastLinkCopied(link, viaServer); + else toastCopyFailed(link); + setTimeout(() => { share.textContent = '分享'; }, 2000); + return; + } + // 点火:202 立刻返回,真正的部署在服务端后台跑;观察器负责把结果和剪贴板收尾。 + share.disabled = true; + share.textContent = '发布中…'; + try { + const res = await fetch('/api/session/share/publish', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: state.sessionId }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status)); + watchSharePublish(); + } catch (error) { + const reason = String(error && error.message ? error.message : error); + share.disabled = false; + share.textContent = '发布失败'; + share.title = reason + '(点一下重试)'; + setTimeout(() => { share.textContent = '发布公开链接'; }, 2600); + } + }); + + tools.append(preview, share, shareLink); + // 没链接时把「会公开」和「会公开什么」摆在明面上:这是用户点发布前唯一能看到的告知。 + if (!link) { + tools.appendChild(node( + 'div', + 'share-note' + (shareState === 'failed' ? ' fail' : ''), + publishing + ? '正在部署:出现 website_deploy 确认时点「允许一次」才会发布,好了会自动把链接放进剪贴板' + : (shareState === 'failed' + ? '上次发布失败:' + (shareError || '未知原因') + : '发布后任何拿到链接的人都能打开。页面包含你的需求、你上传和检索到的参考图、' + + '生成图与评审图、发给模型的完整提示词和执行记录'), + )); + } + return tools; +} +// 交付是这一整轮的结论:图要大、信息要少,细节全部收进折叠里。 +function renderDelivery(view, item, snapshot) { + const card = item.deliver; + const box = item.delivery; + const delivery = view.delivery; + const key = 'd' + view.index; + // shareUrl 也要进签名:发布完把链接回填之后,这块必须重画才能亮出「公开链接」。 + // 跨图结论同样要进签名,否则它变了这一块不会重画。 + const crossSig = delivery && delivery.crossCheck + ? (delivery.crossCheck.consistent ? 'ok' : 'x' + (delivery.crossCheck.issues || []).length) + : '-'; + // 失败原因也要进签名:追问改完 prompt 后 reason 会变,这块不重画就还显示旧报错。 + const failSig = delivery + ? (delivery.items || []).filter((entry) => !entry.file).map((entry) => entry.id + '=' + (entry.reason || '')).join('|') + : ''; + const signature = + (delivery ? delivery.version : 0) + ':' + + (delivery ? (delivery.items || []).length : 0) + ':' + + crossSig + ':' + + failSig + ':' + + (snapshot.shareUrl || '') + ':' + + // 发布状态也要进签名:publishing → done / failed 时按钮文案和提示行都得跟着重画, + // 否则用户会盯着一个永远不变的「发布中」。 + (snapshot.shareState || 'idle') + ':' + + (snapshot.shareError || '') + ':' + + (snapshot.shareClip || ''); + if (state.sigs[key] === signature) return; + state.sigs[key] = signature; + box.textContent = ''; + if (!delivery) { + card.hidden = true; + return; + } + card.hidden = false; + + const items = delivery.items || []; + const done = items.filter((entry) => entry.file); + const head = node('div', 'deliver-head'); + head.append( + node('strong', null, '交付 ' + done.length + ' 张'), + node( + 'span', + 'meta', + 'v' + delivery.version + + ' · 生图 ' + ((view.cost || {}).ditImages || 0) + ' 张 · 用时 ' + fmtDuration(view.elapsedMs), + ), + ); + // 分享按钮放交付头,不放过程头:交付后视图会自动对齐到交付图,过程头早被滚出屏幕, + // 用户想分享时根本找不到按钮。分享的是整个会话,所以只有最后一轮有。 + if (item.isLast && done.length) head.appendChild(buildShareTools(snapshot)); + // 跨图一致性:过了只给一个低调标记,没过才展开讲清楚哪里对不上。 + // 逐图评审看不到别的图,这一条是唯一能发现「同一个主体被画成两个样子」的地方。 + const cross = delivery.crossCheck || null; + if (cross && cross.consistent) head.appendChild(node('span', 'pill pass', '主体一致')); + const failed = items.filter((entry) => !entry.file); + if (failed.length) head.appendChild(node('span', 'pill fail', failed.length + ' 张失败')); + box.appendChild(head); + + // 有图没画出来时,失败原因必须顶到交付区最前面,而且要带接口原文。 + // 以前这里只有一句灰色「未生成」——用户看到「交付 0 张」,完全不知道发生了什么, + // 报错全藏在折叠的「过程详情」里,等于没有。 + if (failed.length) { + const failBox = node('div', 'deliver-fail'); + failBox.appendChild( + node('strong', null, done.length ? failed.length + ' 张图没有生成出来' : '这一轮一张图都没生成出来'), + ); + const failList = node('ul', 'fail-list'); + failed.forEach((entry) => { + const row = node('li', 'fail-item'); + row.appendChild(node('div', 'fail-title', entry.id + ' · ' + entry.title)); + row.appendChild(node('div', 'fail-reason', entry.reason || '未生成')); + if (entry.contentPolicy) { + row.appendChild( + node( + 'div', + 'fail-hint', + '内容安全审核拒绝,重画不会过。请修改需求本身(例如换成原创角色或别的题材)后再重新发起。', + ), + ); + } + failList.appendChild(row); + }); + failBox.appendChild(failList); + box.appendChild(failBox); + } + + if (cross && !cross.consistent) { + const issues = cross.issues || []; + const warn = node('div', 'cross-warn'); + warn.appendChild(node('strong', null, '跨图主体不一致(' + issues.length + ' 处)')); + if (cross.summary) warn.appendChild(node('p', 'cross-summary', cross.summary)); + if (issues.length) { + const list = node('ul', 'cross-issues'); + for (const issue of issues.slice(0, 4)) { + list.appendChild( + node( + 'li', + null, + (issue.image ? issue.image + ' · ' : '') + (issue.subject ? issue.subject + ':' : '') + issue.problem, + ), + ); + } + warn.appendChild(list); + } + box.appendChild(warn); + } + + // 待确认项默认收起,而且放在图下面:一长串"需要你确认"排在图前面会把交付图整个挤出屏幕, + // 而这一轮真正的结论是图,不是这些没定的细节。 + let ask = null; + if (delivery.questions && delivery.questions.length) { + ask = node('details', 'deliver-questions'); + ask.appendChild(node('summary', null, '需要你确认(' + delivery.questions.length + ' 项)')); + ask.appendChild(node('p', null, delivery.questions.join(';'))); + } + + const shots = []; + const grid = node('div', 'deliver-grid'); + items.forEach((entry) => { + const cell = node('div', 'deliver-item'); + if (!entry.file) { + cell.appendChild(node('p', 'meta', entry.id + ' · ' + entry.title + ':' + (entry.reason || '未生成'))); + grid.appendChild(cell); + return; + } + // 用后端给的真实文件名,不要自己拼 .png:接口回 JPEG 时交付文件就是 .jpg。 + const src = fileUrl('deliver', String(entry.file).split('/').pop(), 'v' + delivery.version); + const img = node('img'); + img.src = src; + img.alt = entry.title; + img.onclick = () => openLightbox(src); + cell.appendChild(img); + shots.push(img); + + const cap = node('div', 'deliver-cap'); + const title = node('div', 'deliver-title'); + title.append( + node('strong', null, entry.id + ' · ' + entry.title), + node('span', 'pill ' + (entry.status === 'passed' ? 'pass' : 'unpassed'), entry.status === 'passed' ? '通过' : '选优交付'), + ); + cap.appendChild(title); + const meta = node('div', 'meta', [entry.size || '-', entry.rounds + ' 轮', entry.verdict ? 'hard ' + entry.verdict.hardPassed + '/' + entry.verdict.hardTotal : ''].filter(Boolean).join(' · ')); + cap.appendChild(meta); + // 交付后「过程详情」会自动收起,请求号不能跟着一起消失: + // 反馈问题时要报的就是它,必须在交付图旁边常驻。 + if (entry.requestId) cap.appendChild(window.H3Blocks.reqChip(entry.requestId)); + + const actions = node('div', 'deliver-actions'); + const open = node('button', 'btn tiny', '看大图'); + open.onclick = () => openLightbox(src); + const download = node('a', 'btn tiny', '下载 4K'); + download.href = src; + download.download = String(entry.file).split('/').pop(); + actions.append(open, download); + cap.appendChild(actions); + cell.appendChild(cap); + grid.appendChild(cell); + }); + // 一张都没出来时网格里全是同一批失败项的灰色重复文案, + // 失败块已经讲清楚了,这里再铺一遍只是噪音。 + if (done.length) box.appendChild(grid); + if (ask) box.appendChild(ask); + + const extra = node('details', 'deliver-extra'); + extra.appendChild(node('summary', null, '交付详情与 Prompt 对照')); + const extraBody = node('div', 'detail-body'); + // 尺寸和轮次图下面已经写了,这里只留图上看不到的东西。 + items.forEach((entry) => { + const rows = [ + entry.program && entry.program.redCast ? { kind: 'kv', label: entry.id + ' 色彩判定', value: '偏红,已打回重画' } : null, + { kind: 'kv', label: entry.id + ' request_id', value: (entry.requestId || '').slice(0, 16) }, + entry.reason ? { kind: 'text', label: entry.id + ' 说明', text: entry.reason } : null, + ].filter(Boolean); + if (rows.length) renderBlocks(rows, extraBody); + }); + extra.appendChild(extraBody); + renderPromptCompare( + extra, + items.map((entry) => ({ id: entry.id + ' · ' + entry.title, prompt: entry.prompt, revisedPrompt: entry.revisedPrompt })), + false, + '张交付图', + ); + box.appendChild(extra); + + // 交付图是异步加载的:加载完才占住高度,而对齐那一刻 scrollTop 会被当时的内容高度截断, + // 结果就是图出来了、按钮和详情还在屏幕外。每张加载完再对齐一次,全加载完就停止接管。 + let pending = shots.length; + const settled = () => { + pending -= 1; + if (state.anchor === 'deliver') alignDeliver(el('thread')); + if (pending <= 0 && state.anchor === 'deliver') state.anchor = 'idle'; + }; + shots.forEach((img) => { + if (img.complete && img.naturalWidth) settled(); + else img.addEventListener('load', settled, { once: true }); + }); +} + +// ---------------------------------------------------------------- 大图 + +function openLightbox(src) { + el('lightbox-img').src = src; + el('lightbox').hidden = false; +} +el('lightbox-close').addEventListener('click', () => { + el('lightbox').hidden = true; + el('lightbox-img').src = ''; +}); +el('lightbox').addEventListener('click', (event) => { + if (event.target === el('lightbox')) { + el('lightbox').hidden = true; + el('lightbox-img').src = ''; + } +}); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') el('lightbox').hidden = true; +}); + +// ---------------------------------------------------------------- 从 MCode 带着需求进来 +// Agent 在聊天里收到画图需求时,不该自己在外面调生图工具,而应该把需求原样递进这里: +// miniapp({ action: 'open', pluginId: 'h3-image-executor', +// path: '/dashboard?prompt=' + encodeURIComponent(需求) + '&auto=1' }) +// 页面读到 query 就自动填入并提交,用户看到的是「面板打开 → 已经在画」。 +// +// prompt / q 需求原文,交给输入框,一个字都不改写 +// auto 默认 1(填完直接提交);auto=0 只填不提交,留给用户自己按发送 +// session 会话 ID(20261109-877482 这种)。带上它就是接着调那张图,不带就是开新的一张 +function launchParams() { + const search = location.search || ''; + if (!search || search === '?') return null; + let params; + try { + params = new URLSearchParams(search); + } catch (error) { + return null; + } + const prompt = String(params.get('prompt') || params.get('q') || '').trim(); + const rawSession = String(params.get('session') || '').trim(); + // 只认 20261109-877482 这种形状。乱传的 id 会让页面停在一个根本不存在的会话上, + // 轮询一直 404,而地址栏上还看不出问题在哪。 + const session = /^\d{8}-[a-z0-9]{4,}$/i.test(rawSession) ? rawSession : ''; + if (!prompt && !session) return null; + const raw = params.get('auto'); + const auto = raw === null ? true : !/^(0|false|no|off)$/i.test(String(raw).trim()); + // len:Agent 声明的字符数,只用来做软校验。传了坏值就当没传,不能因此把整条需求挡掉。 + const rawLen = params.get('len'); + const numLen = rawLen === null ? NaN : Number(String(rawLen).trim()); + const len = Number.isInteger(numLen) && numLen > 0 ? numLen : null; + return { prompt: prompt, session: session, auto: auto, len: len }; +} + +// 处理完立刻把 query 从地址栏抹掉,否则用户刷新一次就会把同一条需求再提交一遍。 +// 只删自己认识的四个键,地址栏上别的参数原样留着。 +function stripLaunchParams() { + try { + const keep = new URLSearchParams(location.search || ''); + ['prompt', 'q', 'auto', 'session', 'len'].forEach((key) => keep.delete(key)); + const rest = keep.toString(); + history.replaceState(null, '', location.pathname + (rest ? '?' + rest : '') + (location.hash || '')); + } catch (error) { + // 宿主不让改 history 就算了:留着 query 只是刷新会重复提交,本次不受影响。 + } +} + +async function applyLaunchParams() { + const launch = launchParams(); + if (!launch) return false; + // 先抹地址栏再干活:中途任何一步失败都不会让刷新重复提交。 + stripLaunchParams(); + + // 没有 session 就是全新一张。必须先把页面切到空态:loadSessions 结尾会自动打开上一次的 + // 会话,那会把 mode 变成 followup,这条需求就被当成「追问上一张图」提交了。 + if (launch.session) { + openSession(launch.session); + } else { + // skipLoad:启动代码紧接着就会自己调一次 loadSessions。 + startNewPainting({ skipLoad: true }); + } + + if (!launch.prompt) return true; + const input = el('instruction'); + input.value = launch.prompt; + autoGrow(); + input.focus(); + + // 字符数软校验:Agent 传了 len 就比一下,对不上说明需求在传输里被改坏了。 + // 最常见的原因是模型自己做了 percent 编码又算错了字节:实测出现过需求里的字被换成别的字, + // 整张图白画,页面上还看不出任何异常。 + // 只提示不拦截:用户可能自己改过字。但必须紧挨着原文说清楚,错字会一路画到交付。 + // 码点数和 UTF-16 长度都认,emoji 这类代理对不该误报。 + const actual = Array.from(launch.prompt).length; + state.launchLenWarn = + launch.len !== null && launch.len !== actual && launch.len !== launch.prompt.length + ? { expected: launch.len, actual: actual, prompt: launch.prompt } + : null; + + if (!launch.auto) { + el('brief-hint').textContent = '已收到需求,确认后按发送'; + return true; + } + + // 没配 Key 就别自动开跑:跑起来也只会立刻失败,用户还以为是自己需求写得不好。 + const config = await api('/api/config'); + // 读不到就当它就绪:接口抖动不该把用户的需求挡在门外,真没配 Key 服务端会当场报错,看得见。 + if (config && !config.failed && config.ready === false) { + el('brief-hint').textContent = '先在左下角配好生图接口,再按发送'; + flash('需求已填好,先配置生图接口'); + return true; + } + flash('已从 MCode 收到需求,开始作画'); + await sendInstruction(); + return true; +} + +// ---------------------------------------------------------------- 视图状态 +// 页面隐藏时 Host 可能直接销毁它,用户回来时再重新加载,中间不保证有任何回调 +// (docs/runtime.md「Lifecycle」)。所以 visibilitychange 变成 hidden 时把视图状态记到本地: +// 打开的是哪个会话、是不是「新建绘画」空白页、输入框里没发出去的字。 +// 只存视图状态,不存业务数据:会话本身都在服务端的 dataDir 里。没发出去的参考图太大存不下, +// 只记张数,回来时提示重新添加。 +const VIEW_KEY = 'h3.view'; +const VIEW_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +function saveViewState() { + try { + localStorage.setItem(VIEW_KEY, JSON.stringify({ + at: Date.now(), + sessionId: state.sessionId || '', + fresh: Boolean(state.fresh) && !state.sessionId, + draft: el('instruction').value.slice(0, 4000), + refCount: state.refs.length, + })); + } catch (error) { + // 存储写不了(配额、隐私模式)就算了:回来时只是少恢复一点。 + } +} + +function readViewState() { + try { + const saved = JSON.parse(localStorage.getItem(VIEW_KEY) || 'null'); + if (!saved || typeof saved !== 'object') return null; + if (Date.now() - Number(saved.at || 0) > VIEW_TTL_MS) return null; + return saved; + } catch (error) { + return null; + } +} + +function restoreViewState() { + const saved = readViewState(); + if (!saved) return; + if (saved.fresh) { + // skipLoad:启动代码紧接着就会调一次 loadSessions。 + startNewPainting({ skipLoad: true }); + if (Number(saved.refCount) > 0) { + el('brief-hint').textContent = '页面重新加载过,之前选的 ' + saved.refCount + ' 张参考图需要重新添加'; + } + } else if (/^\d{8}-[a-z0-9]{4,}$/i.test(String(saved.sessionId || ''))) { + // 不直接 openSession:会话可能已经被删了。交给 loadSessions,列表里还有它才打开。 + state.restoreSessionId = saved.sessionId; + } + if (saved.draft && !el('instruction').value) { + el('instruction').value = String(saved.draft); + autoGrow(); + } +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') saveViewState(); +}); + +applyComposerMode(); +loadConfig(); +// 从 MCode 带着需求进来时,以这次的需求为准,不恢复上一次的视图状态。 +if (!launchParams()) restoreViewState(); +// 顺序其实不敏感:applyLaunchParams 里的 startNewPainting / openSession 会把 mode 和 +// sessionId 整个重置掉,不管 loadSessions 是不是已经自动打开了上一次的会话。 +// 真正不能省的是那一步——不切空态就直接提交,这条需求会被当成「追问上一张图」 +// 发到 /api/session/followup,用户会以为上一张白画了。 +applyLaunchParams(); +loadSessions(); diff --git a/plugins/liddalidd/h3-image-executor/miniapp/client/blocks.js b/plugins/liddalidd/h3-image-executor/miniapp/client/blocks.js new file mode 100644 index 0000000..8e87ed4 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/client/blocks.js @@ -0,0 +1,613 @@ +'use strict'; + +// 共享渲染原语:主页面和导出的分享页共用这一份,保证两边看到的内容完全一致。 +// 纯浏览器 API,不依赖任何框架,也不在 Node 侧执行。 +window.H3Blocks = (function () { + function pad2(value) { + return String(value).padStart(2, '0'); + } + + // 已运行时间:不到 1 小时用 mm:ss,超过用 h:mm:ss。 + function fmtDuration(ms) { + const total = Math.max(0, Math.round((Number(ms) || 0) / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + if (hours) return hours + ':' + pad2(minutes) + ':' + pad2(seconds); + return pad2(minutes) + ':' + pad2(seconds); + } + + function fmtSeconds(ms) { + return (Math.max(0, Number(ms) || 0) / 1000).toFixed(1) + 's'; + } + + function fmtClock(t) { + const date = new Date(Number(t) || 0); + return pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds()); + } + + function fmtDateTime(t) { + const date = new Date(Number(t) || 0); + return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate()) + ' ' + fmtClock(t); + } + + const TAG_LABELS = { + pass: '通过', + fail: '不通过', + warn: '注意', + uncertain: '拿不准', + hard: '必须', + soft: '偏好', + }; + + const STAGES = [ + ['input', '00 输入'], + ['think', '02 检索'], + ['plan', '03 规划'], + ['paint', '04 生成'], + ['verify', '05 校验'], + ['refine', '06 修正'], + ['select', '选优'], + ['deliver', '07 交付'], + ]; + + // 长内容默认收起。展开一个节点后如果还是几百行,重点就没了; + // 折叠后每段只剩一行标题,需要哪段点哪段。 + const LIST_COLLAPSE = 5; + const TEXT_COLLAPSE = 320; + + function appendBlock(parent, wrap, summaryText) { + if (!summaryText) { + parent.appendChild(wrap); + return; + } + const box = document.createElement('details'); + box.className = 'd-collapse'; + const summary = document.createElement('summary'); + summary.textContent = summaryText; + box.append(summary, wrap); + parent.appendChild(box); + } + + // ctx.image(key) 把后端给的寻址键(cache/xxx.jpg、refs/R1.png)解析成可显示的地址: + // 主页面解析成 /api/file 请求,分享页解析成内嵌的 data URL。两边共用同一份渲染代码。 + function renderBlocks(blocks, parent, ctx) { + (blocks || []).forEach((block) => { + if (!block) return; + if (block.kind === 'kv') { + const row = document.createElement('div'); + row.className = 'd-kv'; + const label = document.createElement('span'); + label.className = 'd-label'; + label.textContent = block.label; + const value = document.createElement('span'); + value.className = 'd-value'; + value.textContent = block.value; + row.append(label, value); + parent.appendChild(row); + return; + } + if (block.kind === 'text' || block.kind === 'json') { + const content = block.kind === 'json' ? JSON.stringify(block.value, null, 2) : String(block.text || ''); + const collapse = Boolean(block.label) && content.length > TEXT_COLLAPSE; + const wrap = document.createElement('div'); + wrap.className = 'd-text'; + if (block.label && !collapse) { + const label = document.createElement('div'); + label.className = 'd-label'; + label.textContent = block.label; + wrap.appendChild(label); + } + const pre = document.createElement('pre'); + pre.textContent = content; + wrap.appendChild(pre); + appendBlock(parent, wrap, collapse ? block.label : ''); + return; + } + if (block.kind === 'list') { + const items = block.items || []; + // 条数多或者总字数多都算「太长」:分诊思考只有 5 条,但每条都是一整段, + // 只按条数判会漏掉它,展开后还是一堵墙。 + const chars = items.reduce((sum, entry) => sum + String((entry && entry.text) || '').length, 0); + const collapse = Boolean(block.label) && (items.length > LIST_COLLAPSE || chars > TEXT_COLLAPSE); + const wrap = document.createElement('div'); + wrap.className = 'd-list'; + if (block.label && !collapse) { + const label = document.createElement('div'); + label.className = 'd-label'; + label.textContent = block.label; + wrap.appendChild(label); + } + const ul = document.createElement('ul'); + items.forEach((entry) => { + const li = document.createElement('li'); + if (entry.tag) { + const tag = document.createElement('span'); + tag.className = 'd-tag ' + entry.tag; + tag.textContent = TAG_LABELS[entry.tag] || entry.tag; + li.appendChild(tag); + } + const text = document.createElement('span'); + text.textContent = entry.text; + li.appendChild(text); + if (entry.note) { + const note = document.createElement('em'); + note.className = 'd-note'; + note.textContent = entry.note; + li.appendChild(note); + } + ul.appendChild(li); + }); + wrap.appendChild(ul); + appendBlock(parent, wrap, collapse ? block.label + '(' + items.length + ' 项)' : ''); + return; + } + if (block.kind === 'thumbs') { + const items = block.items || []; + const collapse = Boolean(block.label) && items.length > 6; + const wrap = document.createElement('div'); + wrap.className = 'd-thumbs'; + if (block.label && !collapse) { + const label = document.createElement('div'); + label.className = 'd-label'; + label.textContent = block.label; + wrap.appendChild(label); + } + const grid = document.createElement('div'); + grid.className = 'd-thumb-grid'; + items.forEach((item) => { + const cell = document.createElement('figure'); + cell.className = 'd-thumb' + (item.tag ? ' is-' + item.tag : ''); + const src = item.key && ctx && ctx.image ? ctx.image(item.key) : ''; + if (src) { + const image = document.createElement('img'); + image.src = src; + image.alt = item.title || ''; + image.loading = 'lazy'; + if (ctx && ctx.onImage) image.onclick = () => ctx.onImage(src); + cell.appendChild(image); + } else { + const empty = document.createElement('div'); + empty.className = 'd-thumb-empty'; + empty.textContent = item.text || '未下载'; + cell.appendChild(empty); + } + const cap = document.createElement('figcaption'); + const title = document.createElement('span'); + title.className = 'd-thumb-title'; + title.textContent = item.title || '(无标题)'; + cap.appendChild(title); + if (item.tag) { + const tag = document.createElement('span'); + tag.className = 'd-tag ' + item.tag; + tag.textContent = TAG_LABELS[item.tag] || item.tag; + cap.appendChild(tag); + } + if (src && item.text) { + const note = document.createElement('em'); + note.className = 'd-note'; + note.textContent = item.text; + cap.appendChild(note); + } + if (item.note) { + const from = document.createElement('em'); + from.className = 'd-thumb-src'; + from.textContent = item.note; + cap.appendChild(from); + } + cell.appendChild(cap); + grid.appendChild(cell); + }); + wrap.appendChild(grid); + appendBlock(parent, wrap, collapse ? block.label + '(' + items.length + ' 张)' : ''); + return; + } + if (block.kind === 'pair') { + const wrap = document.createElement('div'); + wrap.className = 'd-pair'; + if (block.label) { + const label = document.createElement('div'); + label.className = 'd-label'; + label.textContent = block.label; + wrap.appendChild(label); + } + const grid = document.createElement('div'); + grid.className = 'd-pair-grid'; + [block.left, block.right].forEach((side) => { + const col = document.createElement('div'); + col.className = 'd-pair-col'; + const head = document.createElement('div'); + head.className = 'd-pair-head'; + head.textContent = side.label; + const pre = document.createElement('pre'); + pre.textContent = side.text || '(无)'; + col.append(head, pre); + grid.appendChild(col); + }); + wrap.appendChild(grid); + parent.appendChild(wrap); + return; + } + if (block.kind === 'group') { + const wrap = document.createElement('div'); + wrap.className = 'd-group'; + const title = document.createElement('div'); + title.className = 'd-group-title'; + title.textContent = block.label; + wrap.appendChild(title); + const body = document.createElement('div'); + body.className = 'd-group-body'; + renderBlocks(block.blocks, body, ctx); + wrap.appendChild(body); + parent.appendChild(wrap); + } + }); + } + + // Prompt 对照:左边是我们写并送进模型的,右边是服务端改写后真正生效的。 + function renderPromptCompare(host, entries, open, noun) { + const rows = (entries || []).filter((entry) => entry.prompt || entry.revisedPrompt); + if (!rows.length) return; + const box = document.createElement('details'); + box.className = 'prompt-compare'; + if (open) box.open = true; + const summary = document.createElement('summary'); + summary.textContent = 'Prompt 对照(' + rows.length + ' ' + (noun || '个候选') + ')'; + box.appendChild(summary); + const body = document.createElement('div'); + body.className = 'detail-body'; + renderBlocks( + rows.map((entry) => ({ + kind: 'pair', + label: entry.id, + left: { label: '我们送进模型的 prompt', text: entry.prompt || '' }, + right: { label: '服务端改写后真正生效的 prompt', text: entry.revisedPrompt || '' }, + })), + body, + ); + box.appendChild(body); + host.appendChild(box); + } + + // ---------------------------------------------------------------- 时间线 + // + // 时间线从上往下走(新的在底部),并且把「同一节点的连续事件」合并成一条: + // 主行只给结论,副行给过程梗概,展开后才是逐条明细。 + // 原始事件流是"我正在干啥"的流水账,直接铺出来没人看得下去。 + + const TOOL_RE = /^调用工具\s+(.+)$/; + // 预算计数是全角括号写的:调用工具 read(1/6)。工具名要把尾巴去掉。 + const TOOL_COUNT_RE = /[((]\s*\d+\s*\/\s*\d+\s*[))]\s*$/; + const LIFECYCLE_RE = /^(开始|完成([^)]*)|模型\s+\S+\s+已启动)$/; + + function toolNameOf(text) { + const hit = TOOL_RE.exec(String(text || '').trim()); + return hit ? hit[1].replace(TOOL_COUNT_RE, '').trim() : ''; + } + + function isLifecycle(text) { + return LIFECYCLE_RE.test(String(text || '').trim()); + } + + function hasBlocks(event) { + return Boolean(event && event.detail && event.detail.blocks && event.detail.blocks.length); + } + + function shorten(text, max) { + const value = String(text || ''); + return value.length > max ? value.slice(0, max - 1) + '…' : value; + } + + // 工具名去掉 connector__matrix__ 这种命名空间前缀,副行才读得下去。 + function shortToolName(name) { + const parts = String(name || '').split('__'); + return shorten(parts[parts.length - 1] || name, 26); + } + + // 组内工具调用汇总成 "read ×2、search_images ×1"。 + function summarizeTools(counts) { + const names = Object.keys(counts); + if (!names.length) return ''; + const parts = names.map((name) => { + const short = shortToolName(name); + return counts[name] > 1 ? short + ' ×' + counts[name] : short; + }); + if (parts.length <= 3) return parts.join('、'); + return parts.slice(0, 3).join('、') + ' 等 ' + parts.length + ' 种'; + } + + function buildGroup(stage, events) { + const tools = {}; + let toolTotal = 0; + const content = []; + events.forEach((event) => { + const tool = toolNameOf(event.text); + if (tool) { + tools[tool] = (tools[tool] || 0) + 1; + toolTotal += 1; + return; + } + if (isLifecycle(event.text)) return; + content.push(event); + }); + + const first = events[0]; + const last = events[events.length - 1]; + const summed = events.reduce((sum, event) => sum + (Number(event.durMs) || 0), 0); + const durMs = summed || Math.max(0, (Number(last.t) || 0) - (Number(first.t) || 0)); + const toolText = summarizeTools(tools); + + // 主行优先级:带详情的结论 > 普通结论 > 工具汇总 > 生命周期文本。 + let main; + const detailed = content.filter(hasBlocks); + if (detailed.length) main = detailed[detailed.length - 1].text; + else if (content.length) main = content[content.length - 1].text; + else if (toolTotal) main = toolTotal + ' 次工具调用:' + toolText; + else main = last.text; + + // 副行:其余结论 + 工具汇总(主行已经写了工具调用就不重复)。 + const rest = []; + content.forEach((event) => { + if (event.text !== main) rest.push(event.text); + }); + if (toolTotal && String(main).indexOf('工具调用') < 0) { + rest.push(toolTotal + ' 次工具调用:' + toolText); + } + const shown = rest.slice(0, 2); + if (rest.length > shown.length) shown.push('+' + (rest.length - shown.length) + ' 项'); + + let level = 'info'; + events.forEach((event) => { + if (event.level === 'error') level = 'error'; + else if (event.level === 'warn' && level !== 'error') level = 'warn'; + }); + + return { + key: first.seq, + stage, + events, + main, + sub: shown.join(' · '), + level, + durMs, + startT: first.t, + startOffset: first.elapsedMs, + expandable: events.length > 1 || events.some(hasBlocks), + count: events.length, + }; + } + + // 按 stage 的连续段分组:同一个节点连着做的几件事算一类,跨节点才断开。 + // agent 只是在报「哪个模型在跑」,是元信息,并进当前节点,不另起一行。 + function groupEvents(events) { + const ordered = (events || []).slice().sort((a, b) => (a.seq || 0) - (b.seq || 0)); + const raw = []; + ordered.forEach((event) => { + const current = raw[raw.length - 1]; + if (current && (current.stage === event.stage || event.stage === 'agent')) { + current.events.push(event); + return; + } + raw.push({ stage: event.stage, events: [event] }); + }); + return raw.map((group) => buildGroup(group.stage, group.events)); + } + + function renderEventDetail(event, parent, ctx) { + if (!hasBlocks(event)) return; + if (event.detail.title) { + const title = document.createElement('div'); + title.className = 'detail-title'; + title.textContent = event.detail.title; + parent.appendChild(title); + } + renderBlocks(event.detail.blocks, parent, ctx); + } + + // 过程一句话带过:生命周期(开始 / 模型已启动 / 完成(39s))和逐次工具调用 + // 都是过程噪音,合并成一行就够了,不该和结论抢位置。 + function groupMetaLine(group) { + const parts = []; + const done = group.events + .map((event) => String(event.text || '').trim()) + .filter((text) => text.indexOf('完成(') === 0) + .pop(); + if (done) parts.push(done.replace(/^完成(/, '').replace(/)$/, '')); + const tools = {}; + let total = 0; + group.events.forEach((event) => { + const name = toolNameOf(event.text); + if (!name) return; + tools[name] = (tools[name] || 0) + 1; + total += 1; + }); + if (total) parts.push(total + ' 次工具调用:' + summarizeTools(tools)); + return parts.join(' · '); + } + + // 展开后的明细:只列有结论的事件,每条一行;要看它的完整详情再点一层。 + // 结论那条默认展开,其余收起——这样一进来就能看到重点。 + function renderGroupItems(group, parent, ctx) { + const wrap = document.createElement('div'); + wrap.className = 'group-items'; + + const meta = groupMetaLine(group); + if (meta) { + const line = document.createElement('div'); + line.className = 'group-meta'; + line.textContent = meta; + wrap.appendChild(line); + } + + const items = group.events.filter((event) => !isLifecycle(event.text) && !toolNameOf(event.text)); + items.forEach((event) => { + const detailed = hasBlocks(event); + const row = document.createElement('div'); + row.className = 'group-item' + (detailed ? ' has-detail' : ''); + + // 整行就是折叠开关:详情入口跟事件排在同一行、靠右对齐。 + // 之前「展开详情」单独占一行,一组十几个事件堆下来全是重复的入口文字。 + const box = document.createElement(detailed ? 'details' : 'div'); + box.className = 'group-item-detail'; + if (detailed && event.text === group.main) box.open = true; + + const head = document.createElement(detailed ? 'summary' : 'div'); + head.className = 'group-item-head'; + const time = document.createElement('span'); + time.className = 'event-time'; + time.textContent = fmtClock(event.t); + const offset = document.createElement('span'); + offset.className = 'event-offset'; + offset.textContent = '+' + fmtDuration(event.elapsedMs); + const text = document.createElement('span'); + text.className = 'group-item-text'; + text.textContent = event.text; + head.append(time, offset, text); + if (event.durMs) { + const dur = document.createElement('span'); + dur.className = 'event-dur'; + dur.textContent = fmtSeconds(event.durMs); + head.appendChild(dur); + } + // 展开后每一条生图各自带自己的请求号:一组里可能有好几轮, + // 只在组头给一个会分不清是哪一轮的。 + if (event.requestId) head.appendChild(reqChip(event.requestId)); + box.appendChild(head); + + if (detailed) { + const body = document.createElement('div'); + body.className = 'detail-body'; + renderEventDetail(event, body, ctx); + box.appendChild(body); + } + row.appendChild(box); + wrap.appendChild(row); + }); + parent.appendChild(wrap); + } + + function subLine(text) { + const node = document.createElement('div'); + node.className = 'event-sub'; + node.textContent = text; + return node; + } + + // request_id 整段太长会挤掉结论,显示前 12 位,title 和复制都带完整值。 + function reqChip(requestId) { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'req-id'; + chip.textContent = 'req ' + String(requestId).slice(0, 12); + chip.title = 'request_id ' + requestId + '(点击复制)'; + chip.onclick = (event) => { + // 这一行可能包在 summary 里,不拦住冒泡就会顺手把详情展开/收起。 + event.preventDefault(); + event.stopPropagation(); + const done = () => { + chip.classList.add('copied'); + setTimeout(() => chip.classList.remove('copied'), 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(requestId).then(done, () => {}); + } else { + done(); + } + }; + return chip; + } + + // opts.openSet: Set,记住哪些组是展开的(主页面用;分享页不传就都不展开)。 + function renderTimeline(host, events, opts) { + const options = opts || {}; + const openSet = options.openSet || null; + const ctx = { image: options.image || null, onImage: options.onImage || null }; + const groups = groupEvents(events); + host.textContent = ''; + + groups.forEach((group) => { + const item = document.createElement('li'); + item.className = + 'event' + + (group.expandable ? ' has-detail' : '') + + (group.level !== 'info' ? ' ' + group.level : ''); + + const head = document.createElement(group.expandable ? 'summary' : 'div'); + head.className = 'event-head'; + + const time = document.createElement('span'); + time.className = 'event-time'; + time.textContent = fmtClock(group.startT); + const offset = document.createElement('span'); + offset.className = 'event-offset'; + offset.textContent = '+' + fmtDuration(group.startOffset); + const tag = document.createElement('span'); + tag.className = 'stage-tag'; + tag.textContent = group.stage; + const text = document.createElement('span'); + text.className = 'event-text'; + text.textContent = group.main; + head.append(time, offset, tag, text); + + // 生图请求号:出图那一刻就要能看见、能复制。 + // 以前它只躺在三层折叠的「DiT 输出」详情里,排查一次要展开三次。 + const reqId = group.events.map((event) => event.requestId).filter(Boolean).pop(); + if (reqId) head.appendChild(reqChip(reqId)); + + // 步数与耗时合成一组右对齐:窄屏里结论一长,两个徽标会各自掉到第二行, + // 左对齐在时间列下面,像 bug 不像排版。 + if (group.count > 1 || group.durMs) { + const badges = document.createElement('span'); + badges.className = 'event-badges'; + if (group.count > 1) { + const badge = document.createElement('span'); + badge.className = 'event-count'; + badge.textContent = group.count + ' 步'; + badges.appendChild(badge); + } + if (group.durMs) { + const dur = document.createElement('span'); + dur.className = 'event-dur'; + dur.textContent = fmtSeconds(group.durMs); + badges.appendChild(dur); + } + head.appendChild(badges); + } + + // 副行必须放进 summary 里:details 没展开时,summary 之外的子元素会被浏览器藏掉。 + if (group.sub) head.appendChild(subLine(group.sub)); + + if (!group.expandable) { + item.appendChild(head); + host.appendChild(item); + return; + } + + const box = document.createElement('details'); + box.className = 'event-detail'; + box.open = openSet ? openSet.has(group.key) : false; + if (openSet) { + box.ontoggle = () => { + if (box.open) openSet.add(group.key); + else openSet.delete(group.key); + }; + } + box.appendChild(head); + + const body = document.createElement('div'); + body.className = 'detail-body'; + if (group.count > 1) renderGroupItems(group, body, ctx); + else renderEventDetail(group.events[0], body, ctx); + box.appendChild(body); + item.appendChild(box); + host.appendChild(item); + }); + + return { groups: groups.length, events: (events || []).length }; + } + + return { + pad2, fmtDuration, fmtSeconds, fmtClock, fmtDateTime, TAG_LABELS, STAGES, + renderBlocks, renderPromptCompare, groupEvents, renderTimeline, reqChip, + }; +})(); diff --git a/plugins/liddalidd/h3-image-executor/miniapp/client/index.html b/plugins/liddalidd/h3-image-executor/miniapp/client/index.html new file mode 100644 index 0000000..34e5552 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/client/index.html @@ -0,0 +1,116 @@ + + + + + + H3-Image-Executor + + + + + + +
+
+ +

H3-Image-Executor

+
+ 默认输出 4K + +
+
+ +
+
+
+

开始一次新的绘画

+

写下你要的图。检索参考图、规划、生成、校验、修正都会自动做完。

+
+
+ +
+ +
+ +
+ + + + +
+
+
+ + + +
+
+
+
+ + + + + + + + + + diff --git a/plugins/liddalidd/h3-image-executor/miniapp/client/share.js b/plugins/liddalidd/h3-image-executor/miniapp/client/share.js new file mode 100644 index 0000000..32c33e5 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/client/share.js @@ -0,0 +1,368 @@ +'use strict'; + +// 分享页渲染脚本:只读一次内嵌的数据,没有任何网络请求。 +// 渲染原语来自同页内嵌的 blocks.js,与主页面完全一致。 +(function () { + const { fmtDuration, fmtSeconds, fmtDateTime, fmtClock, STAGES, renderBlocks, renderPromptCompare } = window.H3Blocks; + const data = JSON.parse(document.getElementById('share-data').textContent); + const snapshot = data.snapshot; + const images = data.images || {}; + + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + // largeKey:交付图另带一份 2048px 的大图,点开看大图;没有大图(超出预算或生成失败)就看这张预览。 + const img = (key, alt, className, largeKey) => { + if (!images[key]) return null; + const node = document.createElement('img'); + node.src = images[key]; + node.alt = alt || ''; + if (className) node.className = className; + const full = (largeKey && images[largeKey]) || images[key]; + if (full !== images[key]) node.title = '点击查看大图'; + node.onclick = () => openLightbox(full); + return node; + }; + + function openLightbox(src) { + document.getElementById('lightbox-img').src = src; + document.getElementById('lightbox').hidden = false; + } + document.getElementById('lightbox-close').addEventListener('click', () => { + document.getElementById('lightbox').hidden = true; + document.getElementById('lightbox-img').src = ''; + }); + document.getElementById('lightbox').addEventListener('click', (event) => { + if (event.target.id === 'lightbox') document.getElementById('lightbox').hidden = true; + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') document.getElementById('lightbox').hidden = true; + }); + + const root = document.getElementById('root'); + const cost = snapshot.cost || {}; + const timing = snapshot.timing || {}; + + document.getElementById('share-meta').textContent = + snapshot.id + ' · ' + fmtDateTime(snapshot.createdAt) + ' · 总用时 ' + fmtDuration(timing.elapsedMs); + + // 装配顺序集中在这里定,不在各段构建处:分享页是给人看结果的,交付必须排在 + // 执行过程和候选前面。各段先构建,最后统一按阅读顺序挂上去。 + let refsCard = null; + let candidatesCard = null; + let deliverCard = null; + + // ---------------------------------------------------------------- 概要 + const brief = el('section', 'card'); + brief.appendChild(el('h2', null, '需求')); + brief.appendChild(el('p', 'share-instruction', snapshot.instruction || '(空)')); + + const metrics = el('div', 'run-metrics'); + const parts = [ + ['会话', snapshot.id], + ['状态', statusLabel(snapshot.status)], + ['开始于', fmtDateTime(snapshot.createdAt)], + ['总用时', fmtDuration(timing.elapsedMs)], + ['路线', snapshot.route], + ['生图', (cost.ditImages || 0) + ' 张 · ' + fmtSeconds(cost.ditMs)], + ['Agent', (cost.agentCalls || 0) + ' 次 · ' + fmtSeconds(cost.agentMs) + ' · ' + (cost.agentTools || 0) + ' 次工具'], + ]; + if (cost.usage && cost.usage.total_tokens) parts.push(['token', cost.usage.total_tokens]); + Object.keys(cost.byStep || {}).forEach((step) => { + const bucket = cost.byStep[step]; + parts.push([step + ' 节点', fmtSeconds(bucket.ms) + ' · ' + bucket.tools + ' 工具']); + }); + parts.forEach(([label, value]) => { + const cell = el('div', 'metric'); + cell.append(el('span', 'metric-label', label), el('strong', null, String(value))); + metrics.appendChild(cell); + }); + brief.appendChild(metrics); + + if ((snapshot.questions || []).length) { + const q = el('div', 'questions', '需要确认:' + snapshot.questions.join(';')); + brief.appendChild(q); + } + root.appendChild(brief); + + // ---------------------------------------------------------------- 参考图 + if ((snapshot.refs || []).length) { + const card = el('section', 'card'); + card.appendChild(el('h2', null, '参考图')); + const grid = el('div', 'ref-grid'); + snapshot.refs.forEach((ref) => { + const file = String(ref.file || '').split('/').pop(); + const node = el('div', 'ref-card'); + const picture = img('refs/' + file, ref.id); + if (picture) node.appendChild(picture); + node.appendChild(el('strong', null, ref.id)); + node.appendChild(el('span', 'meta', ref.note || '')); + grid.appendChild(node); + }); + card.appendChild(grid); + refsCard = card; + } + + // ---------------------------------------------------------------- 执行过程 + // 默认折叠:分享页是给人看结果的,执行过程是「想深究再看」的材料。 + // 折叠时也要能看出规模,所以提示挂在标题旁边,不藏进展开后的正文里。 + const runCard = el('details', 'card share-fold'); + const runHead = el('summary', 'share-fold-head'); + runHead.appendChild(el('h2', null, '执行过程')); + const runHint = el('span', 'share-fold-hint', ''); + runHead.appendChild(runHint); + runCard.appendChild(runHead); + const runBody = el('div', 'share-fold-body'); + const tools = el('div', 'timeline-tools'); + const expandAll = el('button', 'btn tiny', '全部展开'); + const collapseAll = el('button', 'btn tiny', '全部收起'); + tools.append(expandAll, collapseAll, el('span', 'hint', '点任意一行展开,能看到这一步实际发给模型的提示词、工具调用和判定依据。')); + runBody.appendChild(tools); + + const stats = new Map((snapshot.stageStats || []).map((item) => [item.stage, item])); + const seen = new Set((snapshot.events || []).map((event) => event.stage)); + const stages = el('ol', 'pipeline'); + STAGES.forEach(([key, label]) => { + const item = el('li'); + item.append(el('span', 'step-dot'), el('span', 'step-name', label)); + const hit = stats.get(key); + if (hit && hit.spanMs > 0) item.appendChild(el('span', 'step-dur', fmtSeconds(hit.spanMs))); + if (seen.has(key)) item.className = 'done'; + stages.appendChild(item); + }); + runBody.appendChild(stages); + + const box = el('details', 'timeline-box'); + box.open = true; + const summary = el('summary'); + const summaryText = el('span', null, '思考过程时间线'); + summary.appendChild(summaryText); + box.appendChild(summary); + const list = el('ul', 'timeline'); + const timelineStats = window.H3Blocks.renderTimeline(list, snapshot.events || [], { + image: (key) => images[key] || '', + onImage: openLightbox, + }); + summaryText.textContent = + '思考过程时间线(' + timelineStats.events + ' 条 · 合并为 ' + timelineStats.groups + ' 步)'; + runHint.textContent = + timelineStats.events + ' 条事件 · 合并为 ' + timelineStats.groups + ' 步 · Agent ' + (cost.agentCalls || 0) + ' 次'; + box.appendChild(list); + runBody.appendChild(box); + runCard.appendChild(runBody); + + expandAll.addEventListener('click', () => { + runCard.querySelectorAll('details.event-detail').forEach((node) => { node.open = true; }); + }); + collapseAll.addEventListener('click', () => { + runCard.querySelectorAll('details.event-detail').forEach((node) => { node.open = false; }); + }); + + // ---------------------------------------------------------------- 候选与校验 + if ((snapshot.images || []).length) { + // 默认折叠:候选与校验是复核材料,不是分享出去的第一眼内容。 + const card = el('details', 'card share-fold'); + const candHead = el('summary', 'share-fold-head'); + candHead.appendChild(el('h2', null, '候选与校验')); + const totalRounds = snapshot.images.reduce((sum, image) => sum + ((image.rounds || []).length), 0); + const totalCands = snapshot.images.reduce( + (sum, image) => sum + (image.rounds || []).reduce((n, round) => n + ((round.candidates || []).length), 0), + 0, + ); + candHead.appendChild(el('span', 'share-fold-hint', + snapshot.images.length + ' 张图 · ' + totalRounds + ' 轮 · ' + totalCands + ' 个候选')); + card.appendChild(candHead); + const candBody = el('div', 'share-fold-body'); + snapshot.images.forEach((image) => { + const block = el('div', 'image-block'); + const imageHead = el('div', 'image-head'); + imageHead.append( + el('h3', null, image.id + ' · ' + image.title), + el('span', 'pill ' + image.status, imageStatusLabel(image)), + el('span', 'meta', image.spec.area + ' · ' + image.spec.aspectRatio + ' · ' + (image.spec.entry === 'edit' ? '带参考图' : '文生图')), + ); + block.appendChild(imageHead); + + (image.rounds || []).forEach((round) => { + const wrap = el('div', 'round'); + const actionText = round.action && round.action.type === 'edit' ? '局部编辑' : '生成'; + wrap.appendChild(el('div', 'round-title', + '第 ' + (round.index + 1) + ' 轮 · ' + actionText + + (round.verdict ? ' · ' + round.verdict.note : '') + + (round.error ? ' · ' + round.error : ''))); + + const grid = el('div', 'candidates'); + (round.candidates || []).forEach((candidate) => { + grid.appendChild(renderCandidate(candidate, image.chosenId === candidate.id)); + }); + wrap.appendChild(grid); + + renderPromptCompare(wrap, (round.candidates || []).map((candidate) => ({ + id: candidate.id, + prompt: candidate.prompt, + revisedPrompt: candidate.revisedPrompt, + }))); + + // 评审材料图:评审当时看到的就是这一张,分享出去才能复核判定依据。 + const sheets = (round.candidates || []).filter((candidate) => candidate.sheetFile && images['sheets/' + candidate.sheetFile]); + if (sheets.length) { + const sheetBox = el('details', 'prompt-compare'); + sheetBox.appendChild(el('summary', null, '评审看图材料(' + sheets.length + ' 张)')); + const sheetBody = el('div', 'detail-body'); + const sheetGrid = el('div', 'sheet-grid'); + sheets.forEach((candidate) => { + const col = el('div', 'sheet-col'); + col.appendChild(el('div', 'd-pair-head', candidate.id)); + const picture = img('sheets/' + candidate.sheetFile, candidate.id + ' 看图材料'); + if (picture) col.appendChild(picture); + sheetGrid.appendChild(col); + }); + sheetBody.appendChild(sheetGrid); + sheetBox.appendChild(sheetBody); + wrap.appendChild(sheetBox); + } + + block.appendChild(wrap); + }); + candBody.appendChild(block); + }); + card.appendChild(candBody); + candidatesCard = card; + } + + function renderCandidate(candidate, chosen) { + const card = el('div', 'candidate' + (chosen ? ' chosen' : '')); + const picture = img('previews/' + candidate.previewFile, candidate.id); + if (picture) card.appendChild(picture); + const body = el('div', 'candidate-body'); + body.appendChild(el('div', null, candidate.id + ' · ' + (candidate.size || ''))); + const chips = el('div', 'chips'); + if (candidate.verdict) { + chips.appendChild(chip('hard ' + candidate.verdict.hardPassed + '/' + candidate.verdict.hardTotal, + candidate.verdict.hardFailed.length ? 'fail' : 'pass')); + chips.appendChild(chip('质量 ' + candidate.verdict.quality, '')); + } + if (candidate.program && candidate.program.redCast) { + chips.appendChild(chip('偏红 · 已打回重画', 'fail')); + } else if (candidate.program && candidate.program.drift) { + chips.appendChild(chip(candidate.program.drift.label, candidate.program.drift.cast === 'none' ? 'pass' : 'warn')); + } + (candidate.checks || []).forEach((check) => { + if (check.result === 'pass' && check.id.indexOf('P-text') !== 0) return; + chips.appendChild(chip(check.text, check.result)); + }); + body.appendChild(chips); + + if (candidate.verdict && candidate.verdict.checks) { + const detailBox = el('details', 'candidate-detail'); + detailBox.appendChild(el('summary', null, '评审逐项判定')); + const inner = el('div', 'detail-body'); + renderBlocks([ + { + kind: 'list', + label: '验收清单', + items: Object.keys(candidate.verdict.checks).map((key) => { + const entry = candidate.verdict.checks[key]; + const result = entry.result || '未评'; + return { + tag: result === 'pass' ? 'pass' : result === 'fail' ? 'fail' : 'warn', + text: key, + note: (entry.note || '') + (result === 'uncertain' ? '(拿不准,放行)' : ''), + }; + }), + }, + candidate.verdict.summary ? { kind: 'text', label: '总评', text: candidate.verdict.summary } : null, + ].filter(Boolean), inner); + detailBox.appendChild(inner); + body.appendChild(detailBox); + } + + card.appendChild(body); + return card; + } + + // ---------------------------------------------------------------- 交付 + if (snapshot.delivery) { + const card = el('section', 'card'); + card.appendChild(el('h2', null, '交付')); + card.appendChild(el('p', 'meta', + 'v' + snapshot.delivery.version + + ' · 生图 ' + (cost.ditImages || 0) + ' 张' + + ' · Agent ' + (cost.agentCalls || 0) + ' 次' + + (cost.usage ? ' · tokens ' + cost.usage.total_tokens : ''))); + const grid = el('div', 'deliver-grid'); + snapshot.delivery.items.forEach((item) => { + const node = el('div', 'deliver-item'); + if (!item.file) { + node.textContent = item.id + ' · ' + item.title + ':' + (item.reason || '未生成'); + grid.appendChild(node); + return; + } + const picture = img('deliver/' + item.previewFile, item.title, null, 'large/' + item.file); + if (picture) node.appendChild(picture); + node.appendChild(el('h3', null, item.id + ' · ' + item.title)); + node.appendChild(el('span', 'pill ' + (item.status === 'passed' ? 'pass' : 'unpassed'), + item.status === 'passed' ? '通过' : '未通过(选优交付)')); + node.appendChild(kv('尺寸', item.size || '-')); + node.appendChild(kv('轮次', String(item.rounds))); + if (item.verdict) node.appendChild(kv('hard', item.verdict.hardPassed + '/' + item.verdict.hardTotal)); + node.appendChild(kv('request_id', (item.requestId || '').slice(0, 12))); + node.appendChild(el('p', 'meta', item.reason || '')); + grid.appendChild(node); + }); + card.appendChild(grid); + + renderPromptCompare(card, snapshot.delivery.items.map((item) => ({ + id: item.id + ' · ' + item.title, + prompt: item.prompt, + revisedPrompt: item.revisedPrompt, + })), false, '张交付图'); + + if ((snapshot.delivery.questions || []).length) { + card.appendChild(el('div', 'questions', '需要确认:' + snapshot.delivery.questions.join(';'))); + } + deliverCard = card; + } + + // 阅读顺序:需求 → 参考图 → 交付 → 执行过程 → 候选与校验。 + // 结果在前,过程在后:拿到链接的人十有八九只想看图,执行过程和候选是「想深究再看」的材料, + // 所以后两段默认折叠。参考图属于输入语境,跟着需求走,不插在结果和过程中间。 + if (refsCard) root.appendChild(refsCard); + if (deliverCard) root.appendChild(deliverCard); + root.appendChild(runCard); + if (candidatesCard) root.appendChild(candidatesCard); + + card_footer(); + + function card_footer() { + const foot = el('section', 'card'); + foot.appendChild(el('p', 'meta', + '本页由 H3-Image-Executor 于 ' + fmtDateTime(data.generatedAt) + ' 导出。' + + '所有数据与图片均已内嵌,可离线保存或直接转发。')); + root.appendChild(foot); + } + + function chip(text, kind) { + return el('span', 'chip' + (kind ? ' ' + kind : ''), text); + } + + function kv(label, value) { + const row = el('div', 'kv'); + row.append(el('span', null, label), el('strong', null, value)); + return row; + } + + function statusLabel(status) { + const map = { delivered: '已交付', failed: '失败', cancelled: '已取消', running: '进行中' }; + return map[status] || status; + } + + function imageStatusLabel(image) { + if (image.blocked) return '阻塞'; + const map = { pending: '待生成', running: '进行中', passed: '通过', unpassed: '未通过(已选优交付)', failed: '失败' }; + return map[image.status] || image.status; + } +})(); diff --git a/plugins/liddalidd/h3-image-executor/miniapp/client/styles.css b/plugins/liddalidd/h3-image-executor/miniapp/client/styles.css new file mode 100644 index 0000000..25afbde --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/client/styles.css @@ -0,0 +1,2222 @@ +/* H3-Image-Executor —— 遵循 MiniMax Code 视觉基线: + 中性灰阶 + 扁平容器 + 1px 边框,强调色只留给状态与主操作。 + 所有颜色走 --mcode-* 语义变量,浅色为默认,跟随 prefers-color-scheme。 */ + +:root, +[data-theme='light'] { + --mcode-bg: #ffffff; + --mcode-bg-secondary: #f5f5f5; + --mcode-surface: #ffffff; + --mcode-surface-muted: #fafafa; + --mcode-text: #171717; + --mcode-text-muted: #666666; + --mcode-text-subtle: #adadad; + --mcode-border: #0a0a0a14; + --mcode-border-strong: #0a0a0af2; + --mcode-accent: #0094fc; + --mcode-success: #04b54b; + --mcode-warning: #f56811; + --mcode-danger: #f73646; + --mcode-primary-action-bg: #171717; + --mcode-primary-action-text: #ffffff; + + /* 派生层:只在语义变量上做透明度叠加,不引入新的色相 */ + --mcode-hairline: 0 0 0 1px var(--mcode-border); + --mcode-elevated: 0 0 20px rgb(10 10 10 / 8%); + --mcode-radius-card: 12px; + --mcode-radius-control: 8px; + --mcode-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + --mcode-elevated: 0 0 20px rgb(0 0 0 / 32%); + } +} + +[data-theme='dark'] { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + --mcode-elevated: 0 0 20px rgb(0 0 0 / 32%); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 32px 24px 64px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text); + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +main { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1120px; + margin: 0 auto; +} + +/* ---------------------------------------------------------------- 页头 */ + +.page-header { + max-width: 1120px; + margin: 0 auto 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--mcode-border); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px 24px; + flex-wrap: wrap; +} +/* 品牌标记:纯 CSS,无远程图标。给页头一个视觉锚点。 */ +.brand { display: flex; align-items: flex-start; gap: 10px; min-width: 0; } +.brand-mark { + flex: 0 0 auto; + width: 26px; + height: 26px; + margin-top: 1px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--mcode-primary-action-text); + background: var(--mcode-primary-action-bg); + border-radius: 7px; +} +.page-header h1 { + margin: 0; + font-size: 19px; + line-height: 26px; + font-weight: 600; + letter-spacing: -0.015em; +} +.page-desc { + margin: 2px 0 0; + max-width: 62ch; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); +} + +h2 { + margin: 0 0 14px; + font-size: 14px; + line-height: 20px; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--mcode-text); +} +h3 { margin: 0; font-size: 14px; font-weight: 500; } + +/* 引擎状态:状态点 + 文字,不只靠颜色传达 */ +.engine { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + padding: 7px 12px; + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: 999px; + white-space: nowrap; +} +.engine::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mcode-text-subtle); + flex: 0 0 auto; +} +.engine.ok { color: var(--mcode-text); } +.engine.ok::before { background: var(--mcode-success); } +.engine.bad { color: var(--mcode-danger); } +.engine.bad::before { background: var(--mcode-danger); } + +/* ---------------------------------------------------------------- 卡片 */ + +.card { + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-card); + padding: 18px; +} +.card + .card { margin-top: 0; } + +/* 分享页的折叠段:执行过程和候选与校验默认收起。 + 分享页是给人看结果的,这两段是「想深究再看」的材料。 + 折叠时标题旁边挂一行规模提示——不点开也能知道里面有多少东西。 */ +.share-fold > summary { + display: flex; + align-items: baseline; + gap: 10px; + cursor: pointer; + list-style: none; + user-select: none; +} +.share-fold > summary::-webkit-details-marker { display: none; } +.share-fold > summary::before { + content: '▸'; + flex: none; + font-size: 11px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.share-fold[open] > summary::before { transform: rotate(90deg); } +.share-fold > summary h2 { margin: 0; } +.share-fold-hint { + min-width: 0; + font-size: 12px; + color: var(--mcode-text-muted); + transition: color 150ms ease-out; +} +.share-fold > summary:hover .share-fold-hint { color: var(--mcode-text); } +.share-fold-body { + padding-top: 14px; + margin-top: 12px; + border-top: 1px solid var(--mcode-border); +} + +/* ---------------------------------------------------------------- 表单 */ + +.field { display: block; margin-bottom: 14px; } +.field:last-of-type { margin-bottom: 16px; } +.field-label { + display: block; + margin-bottom: 6px; + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); +} +textarea, input[type='text'] { + width: 100%; + min-height: 40px; + padding: 10px 12px; + font: inherit; + color: var(--mcode-text); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + resize: vertical; + transition: border-color 150ms ease-out; +} +textarea { line-height: 1.6; } +textarea::placeholder, input::placeholder { color: var(--mcode-text-subtle); } +textarea:hover, input[type='text']:hover { border-color: var(--mcode-text-subtle); } +textarea:focus, input[type='text']:focus { + outline: none; + border-color: var(--mcode-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mcode-accent) 16%, transparent); +} +button:focus-visible, summary:focus-visible, .session-row:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.ref-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.ref-row input[type='file'] { font-size: 12px; color: var(--mcode-text-muted); max-width: 100%; } +.ref-row input[type='file']::file-selector-button { + margin-right: 10px; + padding: 6px 12px; + font: inherit; + font-size: 12px; + color: var(--mcode-text); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + cursor: pointer; + transition: border-color 150ms ease-out, background 150ms ease-out; +} +.ref-row input[type='file']::file-selector-button:hover { + border-color: var(--mcode-text-subtle); + background: var(--mcode-bg-secondary); +} +.ref-thumbs { display: flex; gap: 8px; flex-wrap: wrap; } +.ref-thumbs img { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: var(--mcode-radius-control); + border: 1px solid var(--mcode-border); +} + +/* 示例需求:降低"不知道写什么"的门槛。 + 横向滚动一行,窄面板里不会堆成三行把表单撑高。 */ +.examples { + display: flex; + gap: 8px; + flex-wrap: nowrap; + margin: 10px -2px 0; + padding: 0 2px 4px; + overflow-x: auto; + scrollbar-width: none; +} +.examples::-webkit-scrollbar { display: none; } +.example { + flex: 0 0 auto; + white-space: nowrap; + padding: 5px 10px; + font-size: 12px; + line-height: 16px; + color: var(--mcode-text-muted); + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; + cursor: pointer; + transition: color 150ms ease-out, border-color 150ms ease-out; +} +.example:hover { color: var(--mcode-text); border-color: var(--mcode-text-subtle); } + +.actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + +/* ---------------------------------------------------------------- 按钮 */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 16px; + font: inherit; + font-weight: 500; + color: var(--mcode-text); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + cursor: pointer; + white-space: nowrap; + transition: background 150ms ease-out, border-color 150ms ease-out, opacity 150ms ease-out; +} +.btn:hover:not(:disabled) { background: var(--mcode-surface-muted); border-color: var(--mcode-text-subtle); } +.btn:active:not(:disabled) { opacity: 0.82; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.btn.primary:hover:not(:disabled) { background: var(--mcode-primary-action-bg); opacity: 0.88; } +.btn.danger { color: var(--mcode-danger); } +.btn.tiny { height: 28px; padding: 0 10px; font-size: 12px; font-weight: 400; } +/* display:inline-flex 会盖过 [hidden] 的 display:none,隐藏按钮必须显式关掉。 */ +.btn[hidden] { display: none; } + +.hint { font-size: 12px; line-height: 16px; color: var(--mcode-text-subtle); } +.meta { font-size: 12px; line-height: 16px; color: var(--mcode-text-subtle); } +.empty { color: var(--mcode-text-subtle); font-size: 12px; } + +/* ---------------------------------------------------------------- 流水线阶段 */ + +.pipeline { + display: flex; + align-items: flex-start; + list-style: none; + margin: 0 0 20px; + padding: 0; +} +.pipeline li { + position: relative; + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding-top: 4px; + text-align: center; +} +/* 节点之间的连接线:已完成段用 success,未开始用 border */ +.pipeline li::before { + content: ''; + position: absolute; + top: 8px; + right: 50%; + left: -50%; + height: 1px; + background: var(--mcode-border); +} +.pipeline li:first-child::before { display: none; } +.pipeline li.done::before, .pipeline li.active::before { background: color-mix(in srgb, var(--mcode-success) 45%, transparent); } + +.step-dot { + position: relative; + z-index: 1; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--mcode-surface); + box-shadow: inset 0 0 0 1.5px var(--mcode-text-subtle); +} +.pipeline li.done .step-dot { background: var(--mcode-success); box-shadow: none; } +.pipeline li.active .step-dot { + background: var(--mcode-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mcode-accent) 20%, transparent); +} +.step-name { + font-size: 11px; + line-height: 15px; + color: var(--mcode-text-subtle); + white-space: nowrap; +} +.pipeline li.done .step-name, .pipeline li.active .step-name { color: var(--mcode-text); } +.step-dur { + font-family: var(--mcode-mono); + font-size: 10px; + line-height: 14px; + color: var(--mcode-text-subtle); + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------------- KPI */ + +.run-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 1px; + margin-bottom: 20px; + /* 底色用 surface 而不是 border 色:最后一行没填满时,空位才不会露出一块灰。 */ + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + overflow: hidden; +} +.metric { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + background: var(--mcode-surface); + /* 分隔线画在单元上,相邻单元在 1px 间隙里叠成一条细线。 */ + box-shadow: 0 0 0 1px var(--mcode-border); +} +.metric-label { font-size: 11px; line-height: 15px; color: var(--mcode-text-subtle); } +.metric strong { + font-size: 13px; + font-weight: 500; + line-height: 18px; + color: var(--mcode-text); + font-variant-numeric: tabular-nums; +} + +/* 计时器:关键值层级 */ +.run-clock { display: flex; align-items: center; gap: 10px; } +.clock-label { font-size: 12px; color: var(--mcode-text-muted); } +.clock { + font-family: var(--mcode-mono); + font-size: 26px; + line-height: 32px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--mcode-text); + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------------- 状态标签 */ + +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; + white-space: nowrap; +} +.pill::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + flex: 0 0 auto; +} +.pill.pass, .pill.passed { color: var(--mcode-success); border-color: color-mix(in srgb, var(--mcode-success) 32%, transparent); } +.pill.unpassed, .pill.warn, .pill.cancelled { color: var(--mcode-warning); border-color: color-mix(in srgb, var(--mcode-warning) 32%, transparent); } +.pill.failed, .pill.blocked, .pill.fail { color: var(--mcode-danger); border-color: color-mix(in srgb, var(--mcode-danger) 32%, transparent); } +.pill.running, .pill.active { color: var(--mcode-accent); border-color: color-mix(in srgb, var(--mcode-accent) 32%, transparent); } + +/* ---------------------------------------------------------------- 时间线 */ + +.timeline-box { + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + padding: 12px 14px; +} +.timeline-box > summary { + cursor: pointer; + font-size: 12px; + color: var(--mcode-text-muted); + list-style: none; +} +.timeline-box > summary::-webkit-details-marker { display: none; } +.timeline-box > summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.timeline-box[open] > summary::before { transform: rotate(90deg); } + +.timeline-tools { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin: 12px 0 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--mcode-border); +} + +.timeline { + position: relative; + list-style: none; + margin: 12px 0 0; + padding: 0 0 0 4px; + max-height: 560px; + overflow: auto; +} +/* 左侧时间轴竖线 */ +.timeline::before { + content: ''; + position: absolute; + top: 10px; + bottom: 10px; + left: 8px; + width: 1px; + background: var(--mcode-border); +} +.timeline > li { position: relative; padding-left: 22px; } +.timeline > li::before { + content: ''; + position: absolute; + top: 12px; + left: 5px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--mcode-surface); + box-shadow: inset 0 0 0 1.5px var(--mcode-text-subtle); +} +.timeline > li.has-detail::before { background: var(--mcode-text-subtle); box-shadow: none; } +.timeline > li.warn::before { background: var(--mcode-warning); box-shadow: none; } +.timeline > li.error::before { background: var(--mcode-danger); box-shadow: none; } + +.event-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + padding: 6px 0; + font-size: 12px; + cursor: default; +} +details.event-detail > .event-head { cursor: pointer; list-style: none; } +details.event-detail > .event-head::-webkit-details-marker { display: none; } +/* 入口常显:藏在 hover 里的话,不移动鼠标根本不知道这一行能展开。 */ +details.event-detail > .event-head::after { + content: '展开'; + flex: 0 0 auto; + margin-left: 8px; + font-size: 11px; + color: var(--mcode-text-subtle); + transition: color 150ms ease-out; +} +details.event-detail > .event-head:hover::after { color: var(--mcode-text-muted); } +details.event-detail[open] > .event-head::after { content: '收起'; } + +.event-time { + flex: 0 0 auto; + font-family: var(--mcode-mono); + font-size: 11px; + color: var(--mcode-text-subtle); + font-variant-numeric: tabular-nums; +} +.event-offset { + flex: 0 0 52px; + font-family: var(--mcode-mono); + font-size: 11px; + color: var(--mcode-text-subtle); + font-variant-numeric: tabular-nums; +} +.stage-tag { + flex: 0 0 52px; + font-family: var(--mcode-mono); + font-size: 11px; + color: var(--mcode-text-muted); +} +.event-text { flex: 1 1 auto; min-width: 0; color: var(--mcode-text); word-break: break-word; } +/* 生图请求号:出图那一刻就要能看见、能复制,不能只藏在折叠详情里。 */ +.req-id { + flex: 0 0 auto; + padding: 1px 6px; + font-family: var(--mcode-mono); + font-size: 10px; + line-height: 1.6; + color: var(--mcode-text-muted); + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; + cursor: pointer; + transition: color 120ms ease-out, border-color 120ms ease-out; +} +.req-id:hover { color: var(--mcode-text); } +.req-id.copied { color: var(--mcode-success); border-color: color-mix(in srgb, var(--mcode-success) 32%, transparent); } +.group-item-head .req-id { margin-left: auto; } +/* 交付图说明行上的请求号:左对齐,跟着图走,不跟过程详情的收起状态绑在一起。 */ +.deliver-cap .req-id { align-self: flex-start; margin-top: 6px; } +.event-badges { display: flex; align-items: baseline; gap: 6px; flex: 0 0 auto; margin-left: auto; } +/* 合并后的步数徽标 */ +.event-count { + flex: 0 0 auto; + padding: 0 6px; + font-size: 10px; + line-height: 15px; + color: var(--mcode-text-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; + font-variant-numeric: tabular-nums; +} +/* 副行:主结论之外的过程梗概,常显不用展开。 + order 让它排在 summary 的「展开」提示之后,换行到第二行。 */ +.event-sub { + order: 1; + flex: 1 0 100%; + margin: 2px 0 0; + padding-left: 186px; + font-size: 11px; + line-height: 16px; + color: var(--mcode-text-subtle); + word-break: break-word; +} +/* 展开后的逐条明细 */ +.group-items { display: flex; flex-direction: column; gap: 3px; } +.group-item { + padding-left: 9px; + border-left: 2px solid var(--mcode-border); +} +.group-item.has-detail { border-left-color: var(--mcode-text-subtle); } +.group-item-head { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 12px; +} +.group-item-text { flex: 1 1 auto; color: var(--mcode-text-muted); word-break: break-word; } +.group-item .detail-title { margin-top: 6px; } +/* 过程噪音合并成的一行:开始 / 模型已启动 / 完成(39s)/ N 次工具调用 */ +.group-meta { + font-size: 11px; + color: var(--mcode-text-subtle); + word-break: break-word; +} +/* 详情入口跟事件同一行、推到最右:单独占一行时,一组十几个事件全是重复的「展开详情」。 */ +details.group-item-detail > .group-item-head { + cursor: pointer; + list-style: none; +} +details.group-item-detail > .group-item-head::-webkit-details-marker { display: none; } +details.group-item-detail > .group-item-head::after { + content: '详情'; + flex: 0 0 auto; + margin-left: 8px; + font-size: 11px; + color: var(--mcode-text-subtle); + transition: color 150ms ease-out; +} +details.group-item-detail > .group-item-head:hover::after { color: var(--mcode-text-muted); } +details.group-item-detail[open] > .group-item-head::after { content: '收起'; } +details.group-item-detail[open] > .group-item-head .group-item-text { color: var(--mcode-text); } +.timeline > li.warn .event-text { color: var(--mcode-warning); } +.timeline > li.error .event-text { color: var(--mcode-danger); } +.event-dur { + flex: 0 0 auto; + font-family: var(--mcode-mono); + font-size: 10px; + padding: 1px 6px; + color: var(--mcode-text-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------------- 详情块 */ + +.detail-body { + display: flex; + flex-direction: column; + gap: 10px; + margin: 2px 0 12px; + padding: 12px 14px; + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); +} +.detail-title { + font-size: 12px; + font-weight: 600; + color: var(--mcode-text); + letter-spacing: -0.005em; +} +.d-kv { display: flex; gap: 12px; align-items: baseline; font-size: 12px; } +.d-kv .d-label { flex: 0 0 92px; color: var(--mcode-text-subtle); } +.d-kv .d-value { + flex: 1 1 auto; + color: var(--mcode-text); + word-break: break-word; + font-variant-numeric: tabular-nums; +} +.d-text, .d-list, .d-pair { display: flex; flex-direction: column; gap: 5px; } +.d-text .d-label, .d-list .d-label, .d-pair > .d-label { + font-size: 11px; + color: var(--mcode-text-subtle); +} +.d-text pre, .d-pair-col pre { + margin: 0; + padding: 10px 12px; + font-family: var(--mcode-mono); + font-size: 11px; + line-height: 1.65; + color: var(--mcode-text); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + white-space: pre-wrap; + word-break: break-word; + max-height: 280px; + overflow: auto; +} +.d-list ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; } +.d-list li { display: flex; gap: 7px; align-items: baseline; font-size: 12px; color: var(--mcode-text); } +/* 详情里过长的段落默认收起,展开一层后还要再翻一层才看得到全文 */ +.d-collapse > summary { + font-size: 11px; + color: var(--mcode-text-muted); + cursor: pointer; + list-style: none; +} +.d-collapse > summary::-webkit-details-marker { display: none; } +.d-collapse > summary::before { + content: '▸'; + display: inline-block; + margin-right: 5px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.d-collapse[open] > summary::before { transform: rotate(90deg); } +.d-collapse > summary:hover { color: var(--mcode-text); } +.d-collapse[open] > summary { margin-bottom: 5px; } +.d-tag { + flex: 0 0 auto; + padding: 0 6px; + font-size: 10px; + line-height: 16px; + color: var(--mcode-text-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; +} +/* 过程详情里的展开区:外面已经是浅灰底加分隔线,再套一张带底色带边框的卡片会非常重, + 而这一层经常连着出现十几次。去掉底色与边框,只留一条左细线表达层级,padding 全部收掉。 */ +.event-detail > .detail-body, +.group-item-detail > .detail-body { + margin: 2px 0 10px; + padding: 0 0 0 12px; + gap: 7px; + background: transparent; + border: 0; + border-left: 1px solid var(--mcode-border); + border-radius: 0; +} +/* 内层不再画左细线:group-item 自己已经有一条竖线了,再套一条纯属重复。 */ +.group-item-detail > .detail-body { + margin: 3px 0 6px; + padding: 0 0 0 10px; + gap: 6px; + border-left: 0; +} +.event-detail > .detail-body .detail-title, +.group-item-detail > .detail-body .detail-title { + margin-top: 0; + font-size: 11px; + font-weight: 500; + color: var(--mcode-text-muted); +} +.event-detail > .detail-body .d-kv, +.group-item-detail > .detail-body .d-kv { + gap: 8px; + font-size: 11px; + line-height: 17px; +} +.event-detail > .detail-body .d-kv .d-label, +.group-item-detail > .detail-body .d-kv .d-label { + flex-basis: 66px; +} +.event-detail > .detail-body .d-text pre, +.group-item-detail > .detail-body .d-text pre { + padding: 8px 10px; + line-height: 1.6; +} + +.d-tag.pass { color: var(--mcode-success); border-color: color-mix(in srgb, var(--mcode-success) 32%, transparent); } +.d-tag.fail { color: var(--mcode-danger); border-color: color-mix(in srgb, var(--mcode-danger) 32%, transparent); } +.d-tag.warn { color: var(--mcode-warning); border-color: color-mix(in srgb, var(--mcode-warning) 32%, transparent); } +.d-tag.hard { color: var(--mcode-text); border-color: var(--mcode-text-subtle); } +.d-note { font-style: normal; font-size: 11px; color: var(--mcode-text-subtle); } +/* ---------------------------------------------------------------- 检索候选缩略图 */ +/* 链路里直接看到"搜到了什么",不用点开原图再回来猜哪张被采用 */ + +.d-thumbs { display: flex; flex-direction: column; gap: 6px; } +.d-thumb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); + gap: 8px; +} +.d-thumb { + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + background: var(--mcode-surface-muted); + overflow: hidden; +} +.d-thumb.is-pass { border-color: color-mix(in srgb, var(--mcode-success) 45%, transparent); } +.d-thumb.is-fail { opacity: 0.62; } +.d-thumb img { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 4px; + background: var(--mcode-bg-secondary); + cursor: zoom-in; +} +.d-thumb-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1 / 1; + padding: 4px; + border-radius: 4px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-subtle); + font-size: 10px; + line-height: 13px; + text-align: center; + overflow: hidden; +} +.d-thumb figcaption { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.d-thumb-title { + font-size: 11px; + line-height: 14px; + color: var(--mcode-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.d-thumb .d-tag { align-self: flex-start; } +.d-thumb-src { + font-style: normal; + font-size: 10px; + line-height: 13px; + color: var(--mcode-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.d-group { + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + overflow: hidden; +} +.d-group-title { + padding: 7px 12px; + font-size: 12px; + font-weight: 500; + color: var(--mcode-text); + background: var(--mcode-surface-muted); + border-bottom: 1px solid var(--mcode-border); +} +.d-group-body { display: flex; flex-direction: column; gap: 10px; padding: 12px; } + +.d-pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.d-pair-col { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.d-pair-head { font-size: 11px; color: var(--mcode-text-muted); } + +.prompt-compare { + margin-top: 14px; + padding: 10px 14px; + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); +} +.prompt-compare > summary { + font-size: 12px; + color: var(--mcode-text-muted); + cursor: pointer; + list-style: none; +} +.prompt-compare > summary::-webkit-details-marker { display: none; } +.prompt-compare > summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.prompt-compare[open] > summary::before { transform: rotate(90deg); } +.prompt-compare .detail-body { margin-bottom: 0; } + +/* ---------------------------------------------------------------- 候选与交付 */ + +.image-block { padding-top: 20px; margin-top: 20px; border-top: 1px solid var(--mcode-border); } +.image-block:first-child { padding-top: 0; margin-top: 0; border-top: none; } +.image-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } + +.round { margin-bottom: 20px; } +.round:last-child { margin-bottom: 0; } +.round-title { margin-bottom: 10px; font-size: 12px; color: var(--mcode-text-muted); } + +.candidates { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-start; } +.candidate { + flex: 1 1 216px; + min-width: 200px; + max-width: 340px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + overflow: hidden; + transition: border-color 150ms ease-out; +} +.candidate:hover { border-color: var(--mcode-text-subtle); } +.candidate.chosen { border-color: color-mix(in srgb, var(--mcode-success) 55%, transparent); } +.candidate img { + width: 100%; + height: auto; + display: block; + cursor: zoom-in; + background: var(--mcode-bg-secondary); + border-bottom: 1px solid var(--mcode-border); +} +.candidate-body { display: flex; flex-direction: column; gap: 6px; padding: 10px; font-size: 12px; } +.candidate-body > div:first-child { + font-family: var(--mcode-mono); + font-size: 11px; + color: var(--mcode-text-muted); +} +.chips { display: flex; flex-wrap: wrap; gap: 4px; } +.chip { + padding: 1px 7px; + font-size: 11px; + line-height: 16px; + color: var(--mcode-text-muted); + border: 1px solid var(--mcode-border); + border-radius: 999px; +} +.chip.pass { color: var(--mcode-success); border-color: color-mix(in srgb, var(--mcode-success) 32%, transparent); } +.chip.fail { color: var(--mcode-danger); border-color: color-mix(in srgb, var(--mcode-danger) 32%, transparent); } +.chip.warn { color: var(--mcode-warning); border-color: color-mix(in srgb, var(--mcode-warning) 32%, transparent); } + +.candidate-detail { margin-top: 2px; } +.candidate-detail > summary { + font-size: 11px; + color: var(--mcode-text-muted); + cursor: pointer; + list-style: none; +} +.candidate-detail > summary::-webkit-details-marker { display: none; } +.candidate-detail > summary::before { + content: '▸'; + display: inline-block; + margin-right: 4px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.candidate-detail[open] > summary::before { transform: rotate(90deg); } +.candidate-detail .detail-body { margin-bottom: 0; padding: 10px; } + +.deliver-grid { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start; } +.deliver-item { + flex: 1 1 340px; + min-width: 300px; + max-width: 640px; + padding: 14px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-card); +} +.deliver-item img { + width: 100%; + border-radius: var(--mcode-radius-control); + cursor: zoom-in; + display: block; + margin-bottom: 12px; + background: var(--mcode-bg-secondary); +} +.deliver-item h3 { margin-bottom: 8px; } +.deliver-item .pill { margin-bottom: 10px; } +.kv { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 3px 0; + font-size: 12px; + color: var(--mcode-text-muted); + border-bottom: 1px solid var(--mcode-border); +} +.kv:last-of-type { border-bottom: none; } +.kv strong { color: var(--mcode-text); font-weight: 500; font-variant-numeric: tabular-nums; } + +.questions { + margin-top: 16px; + padding: 12px 14px; + font-size: 12px; + color: var(--mcode-text); + background: color-mix(in srgb, var(--mcode-warning) 8%, var(--mcode-surface)); + border: 1px solid color-mix(in srgb, var(--mcode-warning) 30%, transparent); + border-radius: var(--mcode-radius-control); +} + +.followup { margin-top: 20px; display: flex; gap: 12px; align-items: flex-end; } +.followup .field { flex: 1; margin-bottom: 0; } + +.prompt-card { + margin-bottom: 14px; + padding: 14px; + background: color-mix(in srgb, var(--mcode-warning) 7%, var(--mcode-surface)); + border: 1px solid color-mix(in srgb, var(--mcode-warning) 30%, transparent); + border-radius: var(--mcode-radius-control); +} +.prompt-card h3 { margin-bottom: 8px; } +.prompt-card .btn { margin-right: 8px; } + +/* ---------------------------------------------------------------- 历史会话 */ + +.session-list { display: flex; flex-direction: column; gap: 6px; } +/* 侧边栏里的会话项:无边框,靠底色块区分悬停与选中,比卡片更像原生列表。 */ +.session-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px 10px; + text-align: left; + font: inherit; + color: var(--mcode-text); + background: transparent; + border: 0; + border-radius: var(--mcode-radius-control); + cursor: pointer; + transition: background 120ms ease-out; +} +.session-row:hover { background: color-mix(in srgb, var(--mcode-text) 6%, transparent); } +.session-row.current { + background: color-mix(in srgb, var(--mcode-text) 9%, transparent); + font-weight: 500; +} +.session-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.session-main strong { + font-size: 13px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-row.current .session-main strong { font-weight: 500; } + +/* 悬停操作区:行是 button,操作按钮不能嵌进去,所以浮在 wrapper 上。 */ +.app .session-item { position: relative; } +.app .session-actions { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 1px; + padding-left: 10px; + opacity: 0; + pointer-events: none; + /* 左侧渐隐,避免图标直接压在标题上像贴纸 */ + background: linear-gradient(to right, transparent, var(--mcode-bg-secondary) 10px); + transition: opacity 120ms ease-out; +} +.app .session-item:hover .session-actions, +.app .session-item:focus-within .session-actions { opacity: 1; pointer-events: auto; } +/* 图标出现时让状态标签让位,不然两者会叠在一起 */ +.app .session-item:hover .session-row .pill, +.app .session-item:focus-within .session-row .pill { opacity: 0; } +.app .session-row .pill { transition: opacity 120ms ease-out; } + +.app .row-icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--mcode-text-muted); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; + transition: color 120ms ease-out, background 120ms ease-out; +} +.app .row-icon:hover { color: var(--mcode-text); background: color-mix(in srgb, var(--mcode-text) 9%, transparent); } +.app .row-icon:disabled { opacity: 0.4; cursor: progress; } +.app .row-icon:focus-visible { outline: 2px solid var(--mcode-accent); outline-offset: 1px; } + +/* 重跑:顶部开口的圆环 + 右上角一个箭头,合起来才是「再转一圈」的意思。 + 只留缺口会读成一条弧线,看不出是重跑。 */ +.app .icon-rerun { + position: relative; + width: 11px; + height: 11px; + border: 1.5px solid currentColor; + border-radius: 50%; + border-top-color: transparent; +} +.app .icon-rerun::after { + content: ''; + position: absolute; + top: -1px; + right: -2px; + width: 0; + height: 0; + border-left: 4px solid currentColor; + border-top: 2.5px solid transparent; + border-bottom: 2.5px solid transparent; +} + +/* 删除:桶身 + 盖线 + 提手,三个盒子拼出来 */ +.app .icon-trash { + position: relative; + width: 10px; + height: 10px; + margin-top: 3px; + border: 1.5px solid currentColor; + border-top: 0; + border-radius: 0 0 2px 2px; +} +.app .icon-trash::before { + content: ''; + position: absolute; + left: -2px; + right: -2px; + top: -2.5px; + height: 1.5px; + background: currentColor; + border-radius: 1px; +} +.app .icon-trash::after { + content: ''; + position: absolute; + left: 2px; + top: -5.5px; + width: 3px; + height: 3px; + border: 1.5px solid currentColor; + border-bottom: 0; + border-radius: 1px 1px 0 0; +} + +/* 删除二次确认:就地把这一行换成确认条,不弹窗打断视线 */ +.app .session-confirm { + position: absolute; + inset: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + font-size: 12px; + color: var(--mcode-text); + background: var(--mcode-surface); + border-radius: var(--mcode-radius-control); + box-shadow: var(--mcode-hairline); +} +.app .session-confirm .link-btn { margin-left: 0; font-size: 12px; } +.app .session-confirm .link-btn:first-of-type { margin-left: auto; } +.app .link-btn.danger { color: var(--mcode-danger); } +.app .link-btn.danger:hover { color: var(--mcode-danger); opacity: 0.8; } +/* meta 必须单行:一旦折行,每条会话就胖一圈,一屏放不下几条。 */ +.session-main .meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-row .pill { flex: 0 0 auto; padding: 1px 7px; font-size: 10px; } +/* 已交付是常态,不值得占掉标题宽度;只有异常状态才在列表里显形。 */ +.session-row .pill.quiet { display: none; } +.session-more { + align-self: flex-start; + padding: 4px 2px; + font: inherit; + font-size: 12px; + color: var(--mcode-text-muted); + background: none; + border: 0; + cursor: pointer; +} +.session-more:hover { color: var(--mcode-text); } + +/* 跨图一致性提示。逐图评审看不到别的图,这一条是唯一能发现「同一个主体被画成两个样子」的地方: + 过了只在交付头给一个低调 pill,没过才展开讲清楚哪里对不上。 */ +.app .cross-warn { + display: flex; + flex-direction: column; + gap: 6px; + margin: 2px 0 12px; + padding: 10px 12px; + background: var(--mcode-surface-muted); + border: 1px solid color-mix(in srgb, var(--mcode-warning) 32%, transparent); + border-radius: var(--mcode-radius-control); +} +.app .cross-warn strong { + font-size: 12px; + font-weight: 500; + color: var(--mcode-warning); +} +.app .cross-summary { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: var(--mcode-text-muted); +} +.app .cross-issues { + margin: 0; + padding-left: 18px; +} +/* ---------------------------------------------------------------- 接口设置 */ +/* 引擎状态本身是按钮:没配好时它是侧边栏唯一显眼的红字,点它最直接。 */ +.app .engine { + width: 100%; + padding: 6px 8px; + font: inherit; + font-size: 11px; + text-align: left; + background: none; + border: 1px solid transparent; + border-radius: var(--mcode-radius-control); + cursor: pointer; +} +.app .engine:hover { border-color: var(--mcode-border); } +.app .settings { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgb(10 10 10 / 32%); +} +.app .settings[hidden] { display: none; } +.app .settings-card { + display: flex; + flex-direction: column; + gap: 12px; + width: min(420px, 100%); + max-height: 100%; + overflow: auto; + padding: 16px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-card); +} +.app .settings-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.app .settings-head h2 { + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--mcode-text); +} +.app .settings-note, +.app .settings-hint { + margin: 0; + font-size: 11px; + line-height: 1.7; + color: var(--mcode-text-muted); +} +.app .settings-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.app .settings-field > span { + font-size: 11px; + color: var(--mcode-text-muted); +} +.app .settings-field input { + width: 100%; + padding: 7px 9px; + font: inherit; + font-family: var(--mcode-mono); + font-size: 12px; + color: var(--mcode-text); + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); +} +.app .settings-field input:focus { + outline: none; + border-color: var(--mcode-accent); + box-shadow: none; +} +.app .settings-row { + display: flex; + gap: 6px; + align-items: stretch; +} +.app .settings-row input { + flex: 1 1 auto; + min-width: 0; +} +.app .settings-row .btn { + flex: 0 0 auto; + white-space: nowrap; +} +.app .settings-error { + margin: 0; + font-size: 11px; + line-height: 1.6; + color: var(--mcode-danger); +} +.app .settings-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.app .cross-issues li { + font-size: 12px; + line-height: 1.7; + color: var(--mcode-text); +} + +/* 生成失败提示。报错以前只躺在折叠的过程详情里,交付区只有一句灰色「未生成」, + 用户看到「交付 0 张」却完全不知道出了什么事——这块必须自带接口原文。 */ +.app .deliver-fail { + display: flex; + flex-direction: column; + gap: 8px; + margin: 2px 0 12px; + padding: 10px 12px; + background: var(--mcode-surface-muted); + border: 1px solid color-mix(in srgb, var(--mcode-danger) 32%, transparent); + border-radius: var(--mcode-radius-control); +} +.app .deliver-fail > strong { + font-size: 12px; + font-weight: 500; + color: var(--mcode-danger); +} +.app .fail-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} +.app .fail-item { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 8px; + border-left: 2px solid color-mix(in srgb, var(--mcode-danger) 40%, transparent); +} +.app .fail-title { + font-size: 12px; + font-weight: 500; + color: var(--mcode-text); +} +/* 报错原文要能整段复制走,窄面板里宁可换行也不能被截断。 */ +.app .fail-reason { + font-family: var(--mcode-mono); + font-size: 11px; + line-height: 1.6; + color: var(--mcode-danger); + overflow-wrap: anywhere; + white-space: pre-wrap; + user-select: text; +} +.app .fail-hint { + font-size: 11px; + line-height: 1.6; + color: var(--mcode-text-muted); +} + +/* ---------------------------------------------------------------- 分享页 */ + +.share-instruction { margin: 0 0 18px; font-size: 15px; line-height: 24px; color: var(--mcode-text); } +.ref-grid { display: flex; gap: 12px; flex-wrap: wrap; } +.ref-card { + width: 160px; + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); +} +.ref-card img { width: 100%; border-radius: 6px; cursor: zoom-in; display: block; } +.sheet-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; } +.sheet-col { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.sheet-col img { + width: 100%; + border-radius: 6px; + cursor: zoom-in; + display: block; + border: 1px solid var(--mcode-border); +} + +/* ---------------------------------------------------------------- 大图 */ + +.lightbox[hidden] { display: none; } +.lightbox { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: rgb(10 10 10 / 84%); +} +.lightbox img { + max-width: 92vw; + max-height: 88vh; + border-radius: var(--mcode-radius-control); + box-shadow: var(--mcode-elevated); +} +.lightbox-close { + position: absolute; + top: 20px; + right: 20px; + height: 32px; + padding: 0 14px; + font: inherit; + font-size: 13px; + color: #fff; + background: rgb(255 255 255 / 12%); + border: 1px solid rgb(255 255 255 / 28%); + border-radius: var(--mcode-radius-control); + cursor: pointer; + transition: background 150ms ease-out; +} +.lightbox-close:hover { background: rgb(255 255 255 / 20%); } + +/* ---------------------------------------------------------------- 响应式与动效 */ + +@media (max-width: 900px) { + .d-pair-grid { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + body { padding: 20px 16px 48px; } + .page-header { flex-direction: column; gap: 12px; } + .engine { white-space: normal; } + .card { padding: 16px; } + /* 窄屏列窄,数值降一档,避免「4 次工具」被拆成两行 */ + .run-metrics { grid-template-columns: repeat(auto-fit, minmax(124px, 1fr)); } + .metric { padding: 9px 10px; } + .metric strong { font-size: 12px; } + .actions { gap: 10px; } + .actions .btn.primary { flex: 1 1 auto; height: 40px; } + .actions .hint { flex: 1 1 100%; } + .pipeline { flex-wrap: wrap; gap: 14px 0; } + .pipeline li { flex: 0 0 25%; } + .pipeline li::before { display: none; } + .candidate, .deliver-item { width: 100%; } + .run-clock { flex-wrap: wrap; } + .timeline { max-height: none; } + /* 窄屏列宽不再固定,副行取消缩进直接顶格 */ + .event-sub { padding-left: 0; } + .event-head { gap: 8px; } +} +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} + +/* ================================================================ 对话布局 */ +/* 一句需求 → 中间过程(可折叠)→ 最终交付的图。页面本身不滚动,只有对话区滚。 + 全部限定在 .app 下:导出的分享页共用这份样式表,但它是一份长文档,不能被锁死滚动。 */ + +body.app { + padding: 0; + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: row; + overflow: hidden; + background: var(--mcode-bg); +} + +/* ---------------------------------------------------------------- 左侧历史栏 */ + +/* 收起只改宽度与透明度,内容不重排;宽度过渡完再隐藏,避免文字被压扁。 */ +.app .sidebar { + flex: 0 0 auto; + width: 264px; + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--mcode-bg-secondary); + border-right: 1px solid var(--mcode-border); + transition: width 220ms cubic-bezier(0.4, 0, 0.2, 1); +} +.app.collapsed .sidebar { width: 0; border-right-color: transparent; } +.app.collapsed .sidebar > * { opacity: 0; pointer-events: none; } +.app .sidebar > * { transition: opacity 160ms ease-out; } + +.app .sidebar-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 12px 10px 16px; +} +.app .brand { display: flex; align-items: center; gap: 9px; min-width: 0; } +.app .brand-mark { + flex: 0 0 auto; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--mcode-primary-action-text); + background: var(--mcode-primary-action-bg); + border-radius: 7px; +} +.app .brand-name { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; +} + +/* 新建会话:整行块,和会话项同一套圆角与留白,视觉上是列表的一部分。 */ +.app .new-chat { + flex: 0 0 auto; + margin: 0 10px 12px; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--mcode-text); + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + cursor: pointer; + white-space: nowrap; + transition: background 120ms ease-out, border-color 120ms ease-out; +} +.app .new-chat:hover { border-color: var(--mcode-text-subtle); } +.app .new-chat:active { background: var(--mcode-bg-secondary); } + +.app .sidebar-section { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px 6px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--mcode-text-subtle); + white-space: nowrap; +} +.app .sessions-count { color: var(--mcode-text-subtle); } +.app .link-btn { + margin-left: auto; + padding: 0; + font: inherit; + font-size: 11px; + color: var(--mcode-text-subtle); + background: none; + border: 0; + cursor: pointer; +} +.app .link-btn:hover { color: var(--mcode-text); } + +/* 列表自己滚,侧边栏头部与底部固定——会话多了也不会把「新建」顶走。 */ +.app .session-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 10px 12px; + display: flex; + flex-direction: column; + gap: 1px; +} +.app .sidebar-foot { + flex: 0 0 auto; + padding: 10px 16px 14px; + border-top: 1px solid var(--mcode-border); +} +/* 引擎文案比侧边栏宽。text-overflow 只在块级盒上生效,所以这里不能用 flex, + 状态点改成绝对定位,文字自己截断。 */ +.app .sidebar-foot .engine { + position: relative; + display: block; + max-width: 100%; + padding: 0 0 0 14px; + background: none; + border: 0; + font-size: 11px; + line-height: 16px; + /* 模型名比侧边栏宽,交给它换行:宁可多占一行,也不要把型号截成半截。 */ + overflow-wrap: anywhere; +} +.app .sidebar-foot .engine::before { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); +} + +/* 图标全部用 currentColor 画,不引远程图标,主题切换时自动跟随。 */ +.app .icon-btn { + flex: 0 0 auto; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--mcode-text-muted); + background: transparent; + border: 0; + border-radius: var(--mcode-radius-control); + cursor: pointer; + transition: background 120ms ease-out, color 120ms ease-out; +} +.app .icon-btn:hover { color: var(--mcode-text); background: color-mix(in srgb, var(--mcode-text) 7%, transparent); } +.app .icon-btn[hidden] { display: none; } + +.app .icon-plus { position: relative; width: 11px; height: 11px; flex: 0 0 auto; } +.app .icon-plus::before, +.app .icon-plus::after { + content: ''; + position: absolute; + background: currentColor; + border-radius: 1px; +} +.app .icon-plus::before { left: 50%; top: 0; width: 1.5px; height: 100%; transform: translateX(-50%); } +.app .icon-plus::after { top: 50%; left: 0; width: 100%; height: 1.5px; transform: translateY(-50%); } + +/* 展开态图标:三条横线 */ +.app .icon-expand { position: relative; width: 14px; height: 10px; } +.app .icon-expand::before { + content: ''; + position: absolute; + inset: 0; + border-top: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; +} +.app .icon-expand::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1.5px; + background: currentColor; + transform: translateY(-50%); +} + +/* 收起态图标:一个小窗加左侧竖条,示意「把左栏收进去」 */ +.app .icon-collapse { + position: relative; + width: 14px; + height: 12px; + border: 1.5px solid currentColor; + border-radius: 3px; +} +.app .icon-collapse::before { + content: ''; + position: absolute; + left: 3px; + top: 0; + bottom: 0; + width: 1.5px; + background: currentColor; +} + +/* ---------------------------------------------------------------- 主区 */ + +.app .main { + flex: 1 1 auto; + min-width: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.app .topbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + height: 52px; + padding: 0 20px; + background: var(--mcode-bg); + border-bottom: 1px solid var(--mcode-border); +} +.app .topbar-title { + margin: 0; + min-width: 0; + flex: 1 1 auto; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.app .topbar-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; +} +.app .chat { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + width: 100%; + max-width: 820px; + margin: 0 auto; + padding: 0 20px; + gap: 0; +} + +/* ---------------------------------------------------------------- 对话主体 */ + +/* 分享页预览:全屏盖住当前页面,iframe 渲染 blob URL。 */ +.app .share-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + flex-direction: column; + background: var(--mcode-bg); +} + +.app .share-bar { + display: flex; + align-items: center; + gap: 8px; + flex: none; + padding: 10px 16px; + border-bottom: 1px solid var(--mcode-border); + background: var(--mcode-surface); +} + +.app .share-title { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--mcode-text-muted); + font-size: 13px; +} + +.app .share-save { + text-decoration: none; +} + +.app .share-frame { + flex: 1; + width: 100%; + border: 0; + background: var(--mcode-bg); +} + +.app .thread { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 2px 8px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.app .empty-state { + margin: auto 0; + padding: 32px 16px; + text-align: center; +} +.app .empty-title { margin: 0 0 6px; font-size: 15px; font-weight: 600; } +.app .empty-desc { margin: 0; font-size: 13px; color: var(--mcode-text-muted); } + +/* 一轮对话 = 用户消息 + 这一轮的过程 + 这一轮的交付。追问会新开一轮, + 轮与轮之间用一条虚线分开,让人一眼看出"这句话之后是新一轮"。 */ +.app .turn { display: flex; flex-direction: column; gap: 14px; } +.app .turn + .turn { + margin-top: 4px; + padding-top: 20px; + border-top: 1px dashed var(--mcode-border); +} +.app .msg { display: flex; } +.app .msg.user { justify-content: flex-end; } +.app .msg .bubble { + max-width: 100%; + min-width: 0; + border-radius: var(--mcode-radius-card); + padding: 12px 14px; +} +.app .msg.user .bubble { + max-width: 86%; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); +} +.app .msg.user .msg-text { font-size: 14px; line-height: 22px; white-space: pre-wrap; word-break: break-word; } +.app .msg.user .msg-refs { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +/* 需求在传输中损坏的警告。放在用户气泡里紧挨着原文——用户要核对的就是这句话。 + 边框用 currentColor:用户气泡在浅色/深色主题下背景相反,写死颜色会有一边看不见。 */ +.app .msg.user .msg-warn { + margin-top: 8px; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, currentColor 42%, transparent); + border-radius: 8px; + font-size: 12px; + line-height: 18px; +} +.app .msg.user .msg-warn strong { display: block; font-weight: 600; margin-bottom: 2px; } +/* 作者样式里没写 display,但 [hidden] 遇到任何 display 声明都会失效,钉死更省心。 */ +.app .msg.user .msg-warn[hidden] { display: none; } +.app .msg.user .msg-refs img { + width: 44px; + height: 44px; + object-fit: cover; + border-radius: 6px; + cursor: zoom-in; +} +.app .msg.assistant .bubble { + width: 100%; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); +} + +/* 过程头部:状态 + 用时 + 一行摘要 + 导出 */ +.app .msg-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.app .msg-summary { + flex: 1 1 140px; + min-width: 0; + font-size: 12px; + color: var(--mcode-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* 分享控件:贴在交付头右侧——交付后视图对齐的就是这一行。 */ +.app .share-tools { + margin-left: auto; + align-self: center; + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} +/* 「会公开」这句告知必须常驻可见:它是用户点发布前唯一的提示, + 放进 title 要 hover 才看得到,等于没有。整行独占,窄面板(798px)里不会挤到按钮。 */ +.app .share-note { + flex-basis: 100%; + text-align: right; + font-size: 11px; + line-height: 1.4; + color: var(--mcode-text-muted); +} +.app .share-note.fail { color: var(--mcode-danger, #c0392b); } +/* 公开链接:发布成功后取代「分享」按钮,跟按钮同高、可点。 */ +.app .share-link { + flex: none; + max-width: 200px; + height: 28px; + display: inline-flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + color: var(--mcode-accent); + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, var(--mcode-accent) 40%, transparent); +} +.app .share-link:hover { border-bottom-color: var(--mcode-accent); } +.app .share-link[hidden] { display: none; } + +/* 复制成功的浮层提示:把网址直接摆出来,用户不用回头找按钮看结果。 */ +.app .share-toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 40; + max-width: min(520px, calc(100vw - 32px)); + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--mcode-surface); + border-radius: var(--mcode-radius-control); + box-shadow: var(--mcode-hairline), var(--mcode-elevated); + font-size: 12px; + color: var(--mcode-text); +} +.app .share-toast strong { font-weight: 600; white-space: nowrap; } +/* 复制失败:颜色要跟成功区分开,而且停留更久——用户要来得及手动选中那串地址。 */ +.app .share-toast.fail { border-color: color-mix(in srgb, var(--mcode-danger, #c0392b) 45%, transparent); } +.app .share-toast.fail strong { color: var(--mcode-danger, #c0392b); } +.app .share-toast-url { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: var(--mcode-mono); + color: var(--mcode-text-muted); +} + +/* 过程详情:默认收起,展开才是时间线和候选 */ +.app .process { margin-top: 10px; } +.app .process > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 12px; + color: var(--mcode-text-muted); + background: var(--mcode-surface-muted); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-control); + cursor: pointer; + list-style: none; + user-select: none; +} +.app .process > summary::-webkit-details-marker { display: none; } +.app .process > summary::before { + content: '▸'; + font-size: 10px; + color: var(--mcode-text-subtle); + transition: transform 150ms ease-out; +} +.app .process[open] > summary::before { transform: rotate(90deg); } +.app .process > summary:hover { color: var(--mcode-text); } +.app .process-body { padding-top: 12px; } +.app .process-images { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--mcode-border); } +.app .process-images > h3 { margin: 0 0 12px; font-size: 13px; color: var(--mcode-text-muted); } +.app .process .timeline { max-height: 420px; } + +/* 交付完成 + 收起:过程退成一条低调入口,不再和交付图抢视觉权重。 + 状态、用时、生图数在交付头上都有,那条头整个被 applyProcessWeight 藏掉了, + 所以这里只留一个几乎不占地方的开关。 */ +.app .process.compact { margin-top: 0; } +.app .process.compact > summary { + background: transparent; + border-color: transparent; + padding: 5px 8px; + color: var(--mcode-text-subtle); +} +.app .process.compact > summary:hover { + background: var(--mcode-surface-muted); + border-color: var(--mcode-border); + color: var(--mcode-text-muted); +} + +/* ---------------------------------------------------------------- 交付 */ + +.app .msg.deliver .bubble { padding: 14px; } +.app .deliver-head { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.app .deliver-head strong { font-size: 15px; } +.app .deliver-grid { display: flex; flex-direction: column; gap: 18px; } +.app .deliver-item { + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + border: 0; + background: transparent; +} +.app .deliver-item img { + width: 100%; + margin-bottom: 0; + border-radius: var(--mcode-radius-control); + cursor: zoom-in; + display: block; + background: var(--mcode-bg-secondary); + border: 1px solid var(--mcode-border); +} +.app .deliver-cap { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; } +.app .deliver-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.app .deliver-title strong { font-size: 13px; font-weight: 600; } +.app .deliver-actions { display: flex; gap: 8px; margin-top: 6px; } +.app .deliver-actions .btn { text-decoration: none; } +.app .deliver-extra { margin-top: 16px; } +.app .deliver-extra > summary { + font-size: 12px; + color: var(--mcode-text-muted); + cursor: pointer; + list-style: none; + padding: 4px 0; +} +.app .deliver-extra > summary::-webkit-details-marker { display: none; } +.app .deliver-extra > summary::before { + content: '▸ '; + font-size: 10px; + color: var(--mcode-text-subtle); +} +.app .deliver-extra[open] > summary::before { content: '▾ '; } +/* ---------------------------------------------------------------- 输入区 */ + +.app .composer { + flex: 0 0 auto; + margin: 8px 0 14px; + padding: 10px; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-card); +} +/* 输入区收成一个盒子:边框只画一层,聚焦时整体变深,不再是「文本框 + 一排按钮」。 */ +.app .composer-box { + display: flex; + flex-direction: column; + background: var(--mcode-surface); + border: 1px solid var(--mcode-border); + border-radius: var(--mcode-radius-card); + transition: border-color 140ms ease-out, box-shadow 140ms ease-out; +} +.app .composer-box:focus-within { + border-color: var(--mcode-text-subtle); + box-shadow: var(--mcode-elevated); +} +.app .composer textarea { + display: block; + width: 100%; + min-height: 24px; + max-height: 180px; + padding: 12px 14px 4px; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--mcode-text); + background: transparent; + border: 0; + resize: none; + overflow-y: auto; +} +/* 全局 textarea:focus 会加一圈蓝色 box-shadow;这里聚焦反馈交给外层 .composer-box, + 输入框本身必须彻底交出去,否则会出现「盒内又套一个蓝框」。 */ +.app .composer textarea:focus, +.app .composer textarea:focus-visible { outline: none; box-shadow: none; } +.app .composer textarea::placeholder { color: var(--mcode-text-subtle); } +.app .composer-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 8px 10px; +} +.app .composer-spacer { flex: 1 1 auto; } +.app .chip-btn { + flex: 0 0 auto; + height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + font-size: 12px; + color: var(--mcode-text-muted); + background: transparent; + border: 1px solid var(--mcode-border); + border-radius: 999px; + cursor: pointer; + white-space: nowrap; + transition: color 120ms ease-out, border-color 120ms ease-out; +} +.app .chip-btn:hover { color: var(--mcode-text); border-color: var(--mcode-text-subtle); } +.app .send-btn { + flex: 0 0 auto; + height: 28px; + padding: 0 14px; + font: inherit; + font-size: 12px; + font-weight: 500; + color: var(--mcode-primary-action-text); + background: var(--mcode-primary-action-bg); + border: 0; + border-radius: 999px; + cursor: pointer; + transition: opacity 120ms ease-out; +} +.app .send-btn:hover:not(:disabled) { opacity: 0.88; } +.app .send-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.app .composer-refs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.app .composer-refs img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; } +.app .composer .hint { font-size: 11px; color: var(--mcode-text-subtle); } +.app .file-btn { cursor: pointer; } +.app .composer .examples { margin-top: 10px; margin-bottom: 0; } + +/* ---------------------------------------------------------------- 窄屏 */ + +/* 面板变窄先收侧边栏宽度,再窄就让它浮起来盖住主区,不把对话挤成一条缝。 */ +@media (max-width: 900px) { + .app .sidebar { width: 220px; } +} + +@media (max-width: 640px) { + .app .sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 30; + box-shadow: var(--mcode-elevated); + } +} + +@media (max-width: 768px) { + .app .topbar { padding: 0 12px; } + .app .chat { padding: 0 12px; } + .app .msg.user .bubble { max-width: 92%; } + .app .msg-head { gap: 6px; } + .app .msg-summary { flex-basis: 100%; order: 9; } + .app .process .timeline { max-height: none; } + .app .deliver-actions .btn { flex: 1 1 auto; text-align: center; } +} + +/* display:flex 会盖过 [hidden] 的 display:none,空气泡必须显式关掉 */ +.app .msg[hidden] { display: none; } +/* 同理,过程头也是 flex 的:只设 hidden 属性不会消失。 + 交付完成后 applyProcessWeight 要靠它把重复的状态 / 用时 / 生图数整条藏掉, + 漏掉这条规则的话 DOM 上 hidden=true、视觉上却还在——只有截图才看得出来,断言会被骗过去。 */ +.app .msg-head[hidden] { display: none; } + +/* 待确认项:默认收起,否则一长串文字会把交付图挤出屏幕 */ +.app .deliver-questions { margin-top: 12px; } +.app .deliver-questions > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + font-size: 12px; + color: var(--mcode-warning); + background: color-mix(in srgb, var(--mcode-warning) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--mcode-warning) 28%, transparent); + border-radius: var(--mcode-radius-control); + cursor: pointer; + list-style: none; +} +.app .deliver-questions > summary::-webkit-details-marker { display: none; } +.app .deliver-questions > summary::before { + content: '▸'; + font-size: 10px; + transition: transform 150ms ease-out; +} +.app .deliver-questions[open] > summary::before { transform: rotate(90deg); } + +/* 出图前的关键确认:能给选项就给选项,用户点一下就能继续,不逼他打字 */ +.app .gate-item { margin-top: 14px; } +.app .gate-q { margin: 0 0 8px; font-size: 13px; font-weight: 600; line-height: 19px; } +.app .gate-options { display: flex; flex-wrap: wrap; gap: 8px; } +.app .gate-options .btn { margin-right: 0; height: auto; min-height: 28px; padding: 4px 10px; text-align: left; white-space: normal; } +.app .gate-options .btn.suggested::after { content: ' · 默认'; color: var(--mcode-text-subtle); font-size: 11px; } +.app .gate-options .btn.picked { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.app .gate-options .btn.picked::after { color: color-mix(in srgb, var(--mcode-primary-action-text) 60%, transparent); } +.app .gate-actions { display: flex; gap: 8px; margin-top: 16px; } +.app .gate-actions .btn { flex: 1 1 auto; margin-right: 0; } +.app .deliver-questions p { + margin: 8px 0 0; + padding: 0 2px; + font-size: 12px; + line-height: 20px; + color: var(--mcode-text-muted); +} + +/* 打开会话后输入区让位:示例需求只在空态出现 */ +.app .composer.compact .examples { display: none; } + +/* 侧边栏一条会话都没有时的提示,占位不留白 */ +.app .session-empty { + margin: 4px 10px; + font-size: 12px; + color: var(--mcode-text-subtle); +} + diff --git a/plugins/liddalidd/h3-image-executor/miniapp/miniapp.json b/plugins/liddalidd/h3-image-executor/miniapp/miniapp.json new file mode 100644 index 0000000..aae48a3 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/miniapp.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard", + "window": { + "width": 1200, + "height": 860, + "minWidth": 760, + "minHeight": 520, + "resizable": true, + "frame": true + } + }, + "mcpEndpoints": [], + "toolAccess": [ + "call" + ], + "agentAccess": [ + "session.run" + ], + "agentConcurrency": 8 +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/agent.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/agent.mjs new file mode 100644 index 0000000..5bd26ca --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/agent.mjs @@ -0,0 +1,319 @@ +// Agent 节点调度:每个 LLM / VLM 节点都是「一次独立会话 + 一次 JSON 回复」。 +// 回复不合 schema 就地在同一会话里要求重写;权限与问卷请求上抛给页面由用户决定。 +import { randomUUID } from 'node:crypto'; +import { detail, kv, list, seconds, text as textBlock } from './detail.mjs'; + +// 用户可以在插件详情里随时关掉清单声明的权限(docs/package-contract.md「Permission switches」)。 +// 关掉之后 context.agent 还在,但调用会以 NOT_PERMITTED 失败、进行中的回合以 aborted 结束。 +// 只把错误码丢给用户等于什么都没说,所以错误文本里必须写清楚去哪里重新打开。 +export const AGENT_OFF_HINT = '请在 MiniMax Code 的插件详情里把 H3-Image-Executor 的 Agent 权限(session.run)重新打开,然后重试'; + +export function isNotPermitted(error) { + return Boolean(error) && (error.code === 'NOT_PERMITTED' || error.message === 'not_permitted'); +} + +function notPermittedError() { + const error = new Error('Agent 权限已关闭(NOT_PERMITTED):' + AGENT_OFF_HINT); + error.code = 'NOT_PERMITTED'; + return error; +} + +export function createAgentRunner(context, host) { + const pendingPermissions = new Map(); + const running = new Set(); + // 等用户回答的问卷,按问卷的 requestId 记:{ session, request, resolve }。 + const questionWaiters = new Map(); + + // 页面一次只画一份问卷:取这个会话最早还在等回答的那份。 + function syncPendingQuestion(session) { + for (const [requestId, waiter] of questionWaiters) { + if (waiter.session === session) { + session.pendingQuestion = { requestId, request: waiter.request }; + return; + } + } + session.pendingQuestion = null; + } + + // 回合停在问卷上时,run 会带着 pendingQuestion 结束(docs/agent.md「Interaction」)。 + // answer 只能在 run 结束之后调,所以问卷到这时才交给页面;回合被中止(停止、工具预算、关闭进程)时放弃等待。 + function waitForAnswer(session, pending, signal) { + return new Promise((resolve, reject) => { + const requestId = String(pending.requestId); + const finish = () => { + signal.removeEventListener('abort', onAbort); + questionWaiters.delete(requestId); + syncPendingQuestion(session); + }; + const onAbort = () => { + finish(); + const error = new Error('question_cancelled'); + error.code = 'REQUEST_CANCELLED'; + reject(error); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + questionWaiters.set(requestId, { + session, + request: pending.request, + resolve: (answers) => { + finish(); + resolve(answers); + }, + }); + syncPendingQuestion(session); + host.onQuestion(session, session.pendingQuestion); + }); + } + + function handleEvent(session, event, record) { + if (event.type === 'permission.requested') { + const record = { requestId: event.requestId, sessionId: event.sessionId, toolName: event.toolName, reason: event.reason || '' }; + session.permissions = session.permissions || []; + session.permissions.push(record); + host.onPermission(session, record); + return; + } + // question.requested 不在这里处理:这时 run 还占着会话,提前把问卷挂到页面上, + // 用户的回答只会以 busy 失败。问卷在 run 返回之后由 runTurn 交给页面。 + if (!record) return; + if (event.type === 'tool.started') { + // 按节点计数:规划和评审的工具用量要能分开看,否则没法判断哪一段在拖时间。 + record.toolCalls += 1; + const toolName = event.toolName || event.name || '未知工具'; + record.tools.push({ name: toolName, at: Date.now() - record.startedAt }); + // 提示词里写"最多 N 次工具"约束不住 agent,必须在运行时硬性执行。 + if (record.maxTools && record.toolCalls > record.maxTools && !record.overBudget) { + record.overBudget = true; + host.onProgress(session, record.step, '工具调用已达上限 ' + record.maxTools + ' 次,中断并强制收尾', { + detail: detail('工具预算耗尽', [ + kv('上限', record.maxTools + ' 次'), + list('调用序列', record.tools.map((item) => ({ text: item.name, note: '+' + seconds(item.at) }))), + ]), + }); + record.controller.abort(); + return; + } + host.onProgress( + session, + record.step, + '调用工具 ' + toolName + '(' + record.toolCalls + '/' + (record.maxTools || '∞') + ')', + { + // 完整调用序列只在节点收尾时给一次:每调一次工具就重复一遍整串,越到后面越长。 + detail: detail('工具调用', [ + kv('工具', toolName), + kv('本节点累计', record.toolCalls + ' / ' + (record.maxTools || '∞')), + kv('节点已运行', seconds(Date.now() - record.startedAt)), + ]), + }, + ); + } + if (event.type === 'turn.started' && event.model) { + host.onProgress(session, 'agent', '模型 ' + modelLabel(event.model) + ' 已启动', { + detail: detail('模型', [kv('模型', modelLabel(event.model)), kv('节点', record.step)]), + }); + } + } + + async function runTurn(session, text, sessionId, step, maxTools) { + const controller = new AbortController(); + const started = Date.now(); + const record = { + controller, + session, + sessionId, + step, + startedAt: started, + tools: [], + toolCalls: 0, + maxTools: maxTools || 0, + overBudget: false, + // 等用户回答问卷的时间,不算进节点耗时和成本。 + waitMs: 0, + }; + running.add(record); + const onEvent = (event) => handleEvent(session, event, record); + try { + let result = await context.agent.run( + sessionId ? { text, sessionId } : { text }, + { signal: controller.signal, onEvent }, + ); + // 回合停在问卷上:等页面上的回答,再用 answer 接着跑同一回合,直到它不再提问。 + while (result && result.pendingQuestion && result.pendingQuestion.requestId && !controller.signal.aborted) { + const pending = result.pendingQuestion; + const waitStarted = Date.now(); + const answers = await waitForAnswer(session, pending, controller.signal); + record.waitMs += Date.now() - waitStarted; + result = await context.agent.answer( + { sessionId: result.sessionId, requestId: pending.requestId, answers }, + { signal: controller.signal, onEvent }, + ); + } + return { + result, + ms: Date.now() - started - record.waitMs, + toolCalls: record.toolCalls, + tools: record.tools, + overBudget: record.overBudget, + }; + } catch (error) { + if (isNotPermitted(error)) throw notPermittedError(); + throw error; + } finally { + running.delete(record); + } + } + + // 跑一个需要 JSON 回复的节点。 + // - 工具调用超过 maxTools 就在运行时中断,然后在同一会话里强制收尾(保留已经查到的信息)。 + // - 回复不是合法 JSON 时在同一会话里要求重写。 + async function runJson(session, options) { + const step = options.step; + const maxTools = options.maxTools || 0; + let sessionId = null; + let ms = 0; + let toolCalls = 0; + let text = options.instruction; + const allTools = []; + + for (let attempt = 0; attempt < 3; attempt += 1) { + host.onProgress(session, step, attempt === 0 ? '开始' : '收尾中', { + detail: detail('节点输入 · ' + step, [ + kv('轮次', attempt === 0 ? '首次调用' : '第 ' + (attempt + 1) + ' 次(收尾)'), + kv('工具预算', (attempt === 0 ? maxTools : 0) + ' 次'), + textBlock('发给模型的完整提示词', text), + ]), + }); + let turn; + try { + turn = await runTurn(session, text, sessionId, step, attempt === 0 ? maxTools : 0); + } catch (error) { + // 中断后同一会话可能不可用,退回到全新会话强制收尾。 + // 权限被关掉时换会话也没用,直接把带说明的错误交上去。 + if (!sessionId || isNotPermitted(error)) throw error; + sessionId = null; + turn = await runTurn(session, options.instruction + '\n\n注意:不要再调用任何工具,直接用现有信息输出最终 JSON。', null, step, 0); + } + sessionId = turn.result.sessionId || sessionId; + ms += turn.ms; + toolCalls += turn.toolCalls; + allTools.push(...(turn.tools || [])); + session.cost.agentCalls += 1; + session.cost.agentMs += turn.ms; + session.cost.agentTools = (session.cost.agentTools || 0) + turn.toolCalls; + session.cost.byStep = session.cost.byStep || {}; + const bucket = session.cost.byStep[step] || { calls: 0, ms: 0, tools: 0 }; + bucket.calls += 1; + bucket.ms += turn.ms; + bucket.tools += turn.toolCalls; + session.cost.byStep[step] = bucket; + + if (turn.overBudget) { + text = '工具调用预算已经用完,不要再调用任何工具。现在就用你已经掌握的信息,直接输出最终 JSON 对象。'; + continue; + } + if (turn.result.status !== 'finished') { + // result.error 是 { code, message } 对象,直接拼字符串会变成 [object Object], + // 排查时什么都看不到。展开成「code message」。 + const info = turn.result.error; + const suffix = info ? ':' + [info.code, info.message].filter(Boolean).join(' ') : ''; + const failure = 'agent_' + turn.result.status + suffix; + // Host 把这一轮判成 error(实测过一次:第 2 次 LLM 返回后卡 191 秒,整轮 205 秒后 error), + // 是上游的问题不是我们的问题。换个干净会话再试一次,别让一次抽风把整个节点报废。 + // aborted 是我们自己中断的(工具预算耗尽 / 用户点停止),重试没有意义。 + if (turn.result.status === 'error' && attempt < 2 && session.status !== 'cancelled') { + host.onProgress(session, step, '本轮被判失败,换新会话重试:' + failure, { level: 'warn' }); + sessionId = null; + text = options.instruction; + continue; + } + // 不是工具预算(上面已经 continue)也不是用户点了停止,那多半是用户在插件详情里 + // 关掉了 Agent 权限:Host 会把进行中的回合判成 aborted。 + if (turn.result.status === 'aborted' && session.status !== 'cancelled') { + throw new Error(failure + ':Agent 回合被中止。如果你刚关闭了 Agent 权限,' + AGENT_OFF_HINT); + } + throw new Error(failure); + } + const lastText = turn.result.text || ''; + const data = extractJson(lastText); + if (data) { + host.onProgress(session, step, '完成(' + Math.round(ms / 1000) + 's,' + toolCalls + ' 次工具调用)', { + durMs: ms, + detail: detail('节点完成 · ' + step, [ + kv('总耗时', seconds(ms)), + kv('模型调用', attempt + 1 + ' 次'), + kv('工具调用', toolCalls + ' 次'), + list('调用序列', allTools.map((item) => ({ text: item.name, note: '+' + seconds(item.at) }))), + textBlock('模型最终回复', lastText), + ]), + }); + return { data, text: lastText, sessionId }; + } + text = '上一次回复无法解析成 JSON。请只输出一个合法 JSON 对象,不要 markdown 代码块、不要任何解释文字。'; + } + throw new Error('agent_json_parse_failed'); + } + + async function decidePermission(requestId, decision) { + const record = pendingPermissions.get(requestId); + const target = record || {}; + await context.agent.reply({ sessionId: target.sessionId, requestId, decision }); + pendingPermissions.delete(requestId); + } + + function trackPermission(record) { + pendingPermissions.set(record.requestId, record); + } + + // 页面提交的问卷回答。返回 false 表示这份问卷不在等回答(已经答过、回合已中止、或者不属于这个会话)。 + function answerQuestion(session, requestId, answers) { + const waiter = questionWaiters.get(String(requestId || '')); + if (!waiter || waiter.session !== session) return false; + waiter.resolve(Array.isArray(answers) ? answers : []); + return true; + } + + // 只中止这个会话自己的回合(连同它在等的问卷)。以前「停止」调的是 cancelAll, + // 会把同时在跑的其他会话的回合一起中止,那些会话随后就莫名其妙地失败了。 + function cancelSession(session) { + for (const record of running) { + if (record.session === session) record.controller.abort(); + } + } + + // 只在进程停止(dispose)时用。 + function cancelAll() { + for (const record of running) record.controller.abort(); + running.clear(); + } + + return { runJson, decidePermission, trackPermission, answerQuestion, cancelSession, cancelAll }; +} + +function modelLabel(model) { + if (!model) return '未知'; + if (typeof model === 'string') return model; + return model.modelId || model.id || model.name || JSON.stringify(model); +} + +export function extractJson(text) { + if (!text) return null; + const candidates = []; + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) candidates.push(fenced[1]); + candidates.push(text); + for (const candidate of candidates) { + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start < 0 || end <= start) continue; + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch { + /* 继续尝试下一个候选 */ + } + } + return null; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/clipboard.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/clipboard.mjs new file mode 100644 index 0000000..dca05a0 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/clipboard.mjs @@ -0,0 +1,202 @@ +// 系统剪贴板读取。 +// +// 为什么在服务端:Mini App 页面被产品层禁止读剪贴板——manifest 的 deviceAccess 只开放 +// 麦克风 / 摄像头 / 通知,"clipboard read … denied to Mini App pages"。所以 +// navigator.clipboard.readText() 在面板里永远失败,不是 Electron 的偶发问题。 +// 剩下的唯一通路是 Node 侧调本机剪贴板命令(package-contract 的 data-access 允许 +// spawn/execFile 调本机 CLI,前提是参数数组、超时、输出上限、dispose 清理)。 +// +// 三条硬约束: +// 1. 只在用户点「剪贴板」按钮时读一次,绝不在加载 / 轮询 / 保存时顺手读。 +// 2. 读到的内容不写日志、不落盘、不进 session.json。 +// 3. 只有「像 Key」或「像接口地址」的内容才回给页面,其余一律不回传原文—— +// 免得这个接口变成一个通用的「读我剪贴板」后门。 +import { spawn } from 'node:child_process'; + +const TIMEOUT_MS = 2000; +const MAX_BYTES = 8 * 1024; + +// 还在跑的子进程。dispose() 要负责收尸:Host 停 Mini App 时只会 SIGTERM 我们这个 +// Node 入口,不会自动清理孙进程。 +const live = new Set(); + +// 每个平台一条候选链:前一条命令不存在(ENOENT / 非零退出)就试下一条。 +function readers(platform) { + if (platform === 'darwin') return [{ command: 'pbpaste', args: [] }]; + if (platform === 'win32') { + return [{ command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard'] }]; + } + return [ + { command: 'wl-paste', args: ['--no-newline'] }, + { command: 'xclip', args: ['-selection', 'clipboard', '-o'] }, + ]; +} + +function run(command, args) { + return new Promise((resolve) => { + let child = null; + try { + // 固定命令 + 固定参数数组,不拼 shell、不接受任何页面输入。 + child = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); + } catch { + resolve({ ok: false, reason: 'unavailable' }); + return; + } + live.add(child); + let settled = false; + let size = 0; + const chunks = []; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + live.delete(child); + resolve(result); + }; + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* 已经退出 */ } + finish({ ok: false, reason: 'timeout' }); + }, TIMEOUT_MS); + child.stdout.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BYTES) { + try { child.kill('SIGKILL'); } catch { /* 已经退出 */ } + finish({ ok: false, reason: 'too_large' }); + return; + } + chunks.push(chunk); + }); + child.on('error', () => finish({ ok: false, reason: 'unavailable' })); + child.on('close', (code) => { + if (code !== 0) return finish({ ok: false, reason: 'unavailable' }); + finish({ ok: true, text: Buffer.concat(chunks).toString('utf8') }); + }); + }); +} + +// —— 写剪贴板 —— +// +// 为什么必须在服务端写:页面侧两条路都被浏览器安全模型堵死。 +// navigator.clipboard.writeText 要求文档处于聚焦状态——实测面板里 document.hasFocus() 是 false, +// 直接抛 NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Document is not focused; +// document.execCommand('copy') 同样要求聚焦。而发布完成是在 setInterval 回调里发生的, +// 既没有用户激活,用户在这 1–3 分钟里也早就切走了。服务端 spawn pbcopy 完全绕过这两条限制。 +// +// 三条硬约束(和读那边对称): +// 1. 只写「我们自己刚部署出来的公开地址」,不接受页面传来的任意文本——否则这个接口 +// 会变成一个通用的「往用户剪贴板里写东西」的后门。 +// 2. 单行、长度上限、必须锚定部署域名。 +// 3. 固定命令 + 固定参数数组,不拼 shell、不接受任何页面输入当命令。 +const WRITE_MAX_BYTES = 2048; + +// 只认 Host 部署出来的地址形状,和 publish.mjs 的 DEPLOY_URL_RE 保持一致。 +const DEPLOY_URL_RE = /^https:\/\/[a-z0-9][a-z0-9-]*\.space\.mcode\.cn(?:\/[^\s"'<>`)】,。;、]*)?$/i; + +export function validateClipText(text) { + const value = String(text == null ? '' : text); + if (!value) return { error: 'clipboard_empty' }; + if (value.length > WRITE_MAX_BYTES) return { error: 'clipboard_too_long' }; + if (/[\r\n]/.test(value)) return { error: 'clipboard_multiline' }; + if (!DEPLOY_URL_RE.test(value)) return { error: 'clipboard_not_deploy_url' }; + return { ok: true, value }; +} + +// 每个平台一条候选链:前一条命令不存在(ENOENT / 非零退出)就试下一条。 +function writers(platform) { + if (platform === 'darwin') return [{ command: 'pbcopy', args: [] }]; + if (platform === 'win32') { + return [{ command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value ([Console]::In.ReadToEnd())'] }]; + } + return [ + { command: 'wl-copy', args: [] }, + { command: 'xclip', args: ['-selection', 'clipboard'] }, + ]; +} + +// 写入走 stdin,所以单独一份 spawn:读那边是 ['ignore','pipe','ignore'],不能复用。 +function runWrite(command, args, value) { + return new Promise((resolve) => { + let child = null; + try { + child = spawn(command, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'] }); + } catch { + resolve({ ok: false, reason: 'unavailable' }); + return; + } + live.add(child); + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + live.delete(child); + resolve(result); + }; + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* 已经退出 */ } + finish({ ok: false, reason: 'timeout' }); + }, TIMEOUT_MS); + child.on('error', () => finish({ ok: false, reason: 'unavailable' })); + child.on('close', (code) => { + if (code !== 0) return finish({ ok: false, reason: 'unavailable' }); + finish({ ok: true }); + }); + try { + // pbcopy 可能提前退出,不接 error 会变成 unhandled 'error' 事件把进程带崩。 + child.stdin.on('error', () => { /* 写端已关 */ }); + child.stdin.end(value); + } catch { + finish({ ok: false, reason: 'unavailable' }); + } + }); +} + +export async function writeClipboard(text, platform) { + const picked = validateClipText(text); + if (picked.error) return picked; + const list = writers(platform || process.platform); + for (let i = 0; i < list.length; i += 1) { + const result = await runWrite(list[i].command, list[i].args, picked.value); + if (result.ok) return { ok: true }; + // 命令在但没写成(超时)就不再试下一条:换命令也不会变好。 + if (result.reason !== 'unavailable') return result; + } + return { ok: false, reason: 'unavailable' }; +} + +export { DEPLOY_URL_RE as CLIP_DEPLOY_URL_RE, WRITE_MAX_BYTES }; + +export async function readClipboard(platform) { + const list = readers(platform || process.platform); + for (let i = 0; i < list.length; i += 1) { + const result = await run(list[i].command, list[i].args); + if (result.ok) return result; + // 命令在但没读成(超时 / 超限)就不再试下一条:换命令也不会变好。 + if (result.reason !== 'unavailable') return result; + } + return { ok: false, reason: 'unavailable' }; +} + +// 剪贴板内容分类。只回传能直接填进输入框的东西,其余只回一个错误码。 +export function classifyClipboard(text) { + const value = String(text == null ? '' : text).replace(/\r\n/g, '\n').trim(); + if (!value) return { error: 'clipboard_empty' }; + if (value.length > 512) return { error: 'clipboard_too_long' }; + if (/\s/.test(value)) return { error: 'clipboard_multiline' }; + if (/^https:\/\//i.test(value)) return { ok: true, field: 'baseUrl', value }; + // 用户复制了 http 地址:直接告诉他为什么不能用,比丢一句「不像 Key」有用。 + if (/^http:\/\//i.test(value)) return { error: 'base_url_must_be_https' }; + if (value.length < 8) return { error: 'clipboard_not_key' }; + return { ok: true, field: 'apiKey', value }; +} + +export function disposeClipboard() { + for (const child of live) { + try { child.kill('SIGKILL'); } catch { /* 已经退出 */ } + } + live.clear(); +} + +export function liveClipboardChildren() { + return live.size; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/detail.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/detail.mjs new file mode 100644 index 0000000..404257b --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/detail.mjs @@ -0,0 +1,64 @@ +// 事件详情块:执行过程里每条事件展开后看到的结构化内容。 +// 后端只负责按 kind 组装,渲染逻辑全部留在前端,避免展示规则散到各个节点里。 + +export function kv(label, value) { + if (value === undefined || value === null || value === '') return null; + return { kind: 'kv', label, value: String(value) }; +} + +export function text(label, value) { + if (value === undefined || value === null || value === '') return null; + return { kind: 'text', label, text: String(value) }; +} + +export function json(label, value) { + if (value === undefined || value === null) return null; + return { kind: 'json', label, value }; +} + +// items 可以是字符串,也可以是 { text, tag, note };tag 用于前端上色(pass/fail/warn/...)。 +export function list(label, items) { + const rows = (items || []).filter((item) => item !== undefined && item !== null && item !== ''); + if (!rows.length) return null; + return { + kind: 'list', + label, + items: rows.map((item) => (typeof item === 'string' ? { text: item } : item)), + }; +} + +// 缩略图网格:检索候选、最终选用的参考图都走这个块。 +// key 是前端寻址键(cache/xxx.jpg、refs/R1.png),后端只给键不给路径,分享页才能把同一份数据内嵌成 data URL。 +export function thumbs(label, items) { + const rows = (items || []).filter((item) => item && (item.key || item.text || item.title)); + if (!rows.length) return null; + return { kind: 'thumbs', label: label || '', items: rows }; +} + +// 子分组,用于「一张图一个块」这类二级结构。 +export function group(label, blocks) { + const inner = (blocks || []).filter(Boolean); + if (!inner.length) return null; + return { kind: 'group', label, blocks: inner }; +} + +// 左右对照:用于「我们送进去的 prompt」vs「服务端改写后的 prompt」。 +export function pair(label, leftLabel, leftText, rightLabel, rightText) { + if (!leftText && !rightText) return null; + return { + kind: 'pair', + label: label || '', + left: { label: leftLabel, text: String(leftText || '') }, + right: { label: rightLabel, text: String(rightText || '') }, + }; +} + +export function detail(title, blocks) { + const inner = (blocks || []).filter(Boolean); + if (!inner.length) return null; + return { title: title || '', blocks: inner }; +} + +export function seconds(ms) { + return (Math.max(0, Number(ms) || 0) / 1000).toFixed(1) + 's'; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/h3.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/h3.mjs new file mode 100644 index 0000000..02b35ef --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/h3.mjs @@ -0,0 +1,214 @@ +// H3-Image 接口客户端:文生图 / 图像编辑 / 参考图上传。 +// Key 与接口地址解析顺序:页面里保存的配置 -> 进程环境变量 -> 用户 shell 配置。 +// +// 这里不内置任何默认接口地址。H3-Image 的接口地址随账号开通渠道而定, +// 写死一个地址既会把这个包绑死在某个环境上,也会让拿到包的人以为自己开箱可用。 +// 没有地址就直接判未就绪,让用户在页面里填。 +import { readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, basename, extname } from 'node:path'; +import { fetchImage } from './urlguard.mjs'; + +export const DEFAULT_BASE_URL = ''; +// 早期的预览版模型名已经下线,平台现在只认正式名(区分大小写)。 +// 用旧名字会稳定返回 HTTP 400:unknown model "..." must be one of MiniMax-H3-Image。 +export const DEFAULT_MODEL = 'MiniMax-H3-Image'; +export const CANDIDATE_MODELS = ['MiniMax-H3-Image']; +const SHELL_FILES = ['.zshrc', '.zprofile', '.bash_profile', '.profile']; +const REF_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']); +const TIMEOUT_MS = 600000; +// 单张生成图的字节上限。4K PNG 一般在几十 MB 以内,留足余量。 +const MAX_IMAGE_BYTES = 100 * 1024 * 1024; + +let cached = null; +// 页面里保存的配置。放在模块作用域,由 server 启动时从 dataDir 读进来、 +// 保存时更新。Key 只在 Node 进程里流转,绝不回传给页面。 +let stored = null; + +export function setStoredSettings(value) { + const key = value && value.apiKey ? String(value.apiKey).trim() : ''; + const url = value && value.baseUrl ? String(value.baseUrl).trim() : ''; + stored = key ? { apiKey: key, baseUrl: url } : null; + cached = null; + return stored; +} + +export function getStoredSettings() { + return stored; +} + +export async function resolveConfig(force) { + if (cached && !force) return cached; + // 页面优先:用户在这里明确表达过意图,环境变量不该盖掉它。 + let apiKey = stored ? stored.apiKey : ''; + let baseUrl = stored ? stored.baseUrl : ''; + let source = apiKey ? 'app' : ''; + if (!apiKey) { + apiKey = process.env.MINIMAX_API_KEY || process.env.MCODE_API_KEY || ''; + baseUrl = process.env.MINIMAX_BASE_URL || ''; + source = apiKey ? 'env' : ''; + } + if (!apiKey || !baseUrl) { + for (const name of SHELL_FILES) { + let text = ''; + try { + text = await readFile(join(homedir(), name), 'utf8'); + } catch { + continue; + } + if (!apiKey) { + const m = text.match(/^\s*export\s+(?:MINIMAX_API_KEY|MCODE_API_KEY)=["']?([^"'\s#]+)["']?/m); + if (m) { + apiKey = m[1]; + source = 'shell:' + name; + } + } + if (!baseUrl) { + const m = text.match(/^\s*export\s+MINIMAX_BASE_URL=["']?([^"'\s#]+)["']?/m); + if (m) baseUrl = m[1]; + } + if (apiKey && baseUrl) break; + } + } + cached = { + apiKey: apiKey || '', + baseUrl: (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''), + source: source || 'none', + model: process.env.H3_IMAGE_MODEL || DEFAULT_MODEL, + }; + return cached; +} + +export function configSummary(config) { + const key = config.apiKey; + return { + ready: Boolean(key && config.baseUrl), + hasKey: Boolean(key), + hasBaseUrl: Boolean(config.baseUrl), + // 只给前缀和长度:够用户确认「是哪个 Key、长度对不对」,但不能反推出尾部字符。 + keyPreview: key ? key.slice(0, 6) + '…' : '', + keyLength: key.length, + source: config.source, + baseUrl: config.baseUrl, + model: config.model, + models: CANDIDATE_MODELS, + }; +} + +function authHeaders(config) { + return { Authorization: 'Bearer ' + config.apiKey }; +} + +export async function generate(config, options) { + const payload = { model: options.model || config.model, prompt: options.prompt }; + applySize(payload, options); + if (options.n && options.n > 1) payload.n = options.n; + if (options.outputFormat) payload.output_format = options.outputFormat; + if (options.disablePromptEnhanced) payload.disable_prompt_enhanced = true; + return post(config, '/v1/images/generations', payload, '文生图'); +} + +export async function edit(config, options) { + const payload = { + model: options.model || config.model, + prompt: options.prompt, + images: options.images.map((imageUrl) => ({ image_url: imageUrl })), + }; + applySize(payload, options); + if (options.n && options.n > 1) payload.n = options.n; + if (options.outputFormat) payload.output_format = options.outputFormat; + return post(config, '/v1/images/edits', payload, '图像编辑'); +} + +function applySize(payload, options) { + if (options.size) { + payload.size = options.size; + return; + } + if (options.aspectRatio) payload.aspect_ratio = options.aspectRatio; + if (options.area) payload.area = options.area; +} + +async function post(config, path, payload, label) { + if (!config.apiKey) throw new Error('missing_api_key'); + if (!config.baseUrl) throw new Error('missing_base_url'); + const response = await fetch(config.baseUrl + path, { + method: 'POST', + headers: { ...authHeaders(config), 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await response.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = null; + } + if (response.status !== 200) { + const message = (body && body.error && body.error.message) || text.slice(0, 300); + const error = new Error(label + '失败 HTTP ' + response.status + ':' + message); + error.status = response.status; + error.requestId = body && body.request_id; + throw error; + } + return normalize(body); +} + +function normalize(body) { + const data = Array.isArray(body.data) ? body.data : []; + return { + requestId: body.request_id || '', + outputFormat: body.output_format || 'png', + usage: body.usage || null, + items: data.map((item) => + item && item.error + ? { error: (item.error && item.error.message) || '生成失败' } + : { url: item.url, size: item.size || '', revisedPrompt: item.revised_prompt || '' }, + ), + }; +} + +// 本地参考图 -> 上传 -> mm_file:// 引用;URL / data URI / mm_file:// 原样透传。 +export async function resolveReference(config, ref) { + if (/^(https?:\/\/|mm_file:\/\/|data:)/.test(ref)) return ref; + const ext = extname(ref).toLowerCase(); + if (!REF_EXT.has(ext)) throw new Error('参考图格式不支持:' + ext); + return uploadReference(config, ref); +} + +export async function uploadReference(config, filePath) { + if (!config.apiKey) throw new Error('missing_api_key'); + if (!config.baseUrl) throw new Error('missing_base_url'); + const buffer = await readFile(filePath); + const form = new FormData(); + form.append('purpose', 'image_generation_input'); + form.append('file', new Blob([buffer]), basename(filePath)); + const response = await fetch(config.baseUrl + '/v1/files/upload', { + method: 'POST', + headers: authHeaders(config), + body: form, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const body = await response.json().catch(() => null); + const baseResp = (body && body.base_resp) || {}; + if (response.status !== 200 || baseResp.status_code !== 0) { + throw new Error('参考图上传失败:' + (baseResp.status_msg || 'HTTP ' + response.status)); + } + return 'mm_file://' + body.file.file_id; +} + +export async function download(url, targetPath) { + // 成图地址来自接口返回。接口本身可能是内网地址(放行),但云元数据段必须拦, + // 而且每一跳重定向都要重新拦一遍——fetchImage 负责这两件事,并给响应体封顶。 + let buffer; + try { + buffer = await fetchImage(url, { maxBytes: MAX_IMAGE_BYTES, timeoutMs: 180000 }); + } catch (error) { + const message = String(error && error.message ? error.message : error); + throw new Error(error && error.blocked ? '图片' + message : '图片下载失败 ' + message); + } + // targetPath 可省略:调用方要先看字节才知道该用什么扩展名(接口回的不一定是 PNG)。 + if (targetPath) await writeFile(targetPath, buffer); + return buffer; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/image.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/image.mjs new file mode 100644 index 0000000..f326c9d --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/image.mjs @@ -0,0 +1,78 @@ +// 图片格式分发:harness 只认"给我一个 buffer,还我 {width,height,channels,data}"。 +// PNG 走 png.mjs,JPEG 走 jpeg.mjs;WebP/HEIC 明确不支持,调用方按需跳过。 +import { decodePng, readPngHeader } from './png.mjs'; +import { decodeJpeg, isJpeg } from './jpeg.mjs'; + +export function sniffFormat(buffer) { + if (buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG') return 'png'; + if (isJpeg(buffer)) return 'jpeg'; + if (buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'webp'; + if (buffer.length > 12 && buffer.subarray(4, 8).toString('ascii') === 'ftyp') return 'heif'; + return 'unknown'; +} + +// 按实际字节给扩展名。接口回的不一定是 PNG,而落盘一律叫 .png 会让文件名说谎: +// 下载下来用错扩展名打开会出问题,分享页和 curl -OJ 也会拿到错的类型。 +// 认不出来的格式仍回 .png——调用方已经决定要存它,扩展名只影响下游怎么打开, +// 真正的把关交给 undecodableReason 和程序校验的 P-decode。 +export function extensionFor(buffer) { + const format = sniffFormat(buffer); + if (format === 'jpeg') return '.jpg'; + if (format === 'webp') return '.webp'; + if (format === 'heif') return '.heic'; + return '.png'; +} + +export function decodeImage(buffer) { + const format = sniffFormat(buffer); + if (format === 'png') return decodePng(buffer); + if (format === 'jpeg') return decodeJpeg(buffer); + throw new Error('unsupported_image_format:' + format); +} + +// 只看头部判断能不能解码,不做实际解码:4K 图纯 JS 解码要一两秒, +// 而下载环节只想知道"这张图后面能不能用",不值得为此付解码的代价。 +// 返回空字符串表示可用,否则返回不能用的原因。 +export function undecodableReason(buffer) { + const format = sniffFormat(buffer); + if (format === 'png') { + let header; + try { + header = readPngHeader(buffer); + } catch { + return 'PNG 头损坏'; + } + if (header.interlace !== 0) return '隔行 PNG 不支持'; + if (header.colorType === 3) return header.bitDepth <= 8 ? '' : '调色板位深 ' + header.bitDepth + ' 不支持'; + if (header.bitDepth !== 8) return '位深 ' + header.bitDepth + ' 不支持'; + if (![0, 2, 4, 6].includes(header.colorType)) return '颜色类型 ' + header.colorType + ' 不支持'; + return ''; + } + if (format === 'jpeg') return imageSize(buffer) ? '' : 'JPEG 头损坏'; + return '格式不支持(' + format + ')'; +} + +// 只要尺寸不解码全图,用于快速判断参考图能不能用。 +export function imageSize(buffer) { + const format = sniffFormat(buffer); + if (format === 'png') return readPngHeader(buffer); + if (format === 'jpeg') { + let offset = 2; + while (offset < buffer.length - 8) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buffer[offset + 1]; + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) }; + } + offset += 2 + buffer.readUInt16BE(offset + 2); + } + } + return null; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpeg.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpeg.mjs new file mode 100644 index 0000000..8321371 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpeg.mjs @@ -0,0 +1,355 @@ +// 纯 Node 实现的 baseline JPEG 解码器(不支持 progressive / arithmetic coding)。 +// 存在的理由:参考图(真人照片、车型图、用户上传图)绝大多数是 JPEG, +// 没有它评审就看不到参考图,一致性判断只能靠猜。 +// 支持 8bit、1 或 3 分量、4:4:4 / 4:2:2 / 4:2:0 / 4:1:1 抽样、restart interval。 + +const ZIGZAG = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +const COS = (() => { + const table = new Float32Array(64); + for (let u = 0; u < 8; u += 1) { + const cu = (u === 0 ? Math.SQRT1_2 : 1) * 0.5; + for (let x = 0; x < 8; x += 1) { + table[u * 8 + x] = cu * Math.cos(((2 * x + 1) * u * Math.PI) / 16); + } + } + return table; +})(); + +export function isJpeg(buffer) { + return buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; +} + +export function decodeJpeg(buffer) { + const quantTables = []; + const huffDC = []; + const huffAC = []; + let frame = null; + let restartInterval = 0; + let offset = 2; + + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = buffer[offset + 1]; + offset += 2; + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (marker === 0xd9) break; + const length = buffer.readUInt16BE(offset); + const segment = buffer.subarray(offset + 2, offset + length); + + if (marker === 0xdb) { + let p = 0; + while (p < segment.length) { + const precision = segment[p] >> 4; + const id = segment[p] & 15; + p += 1; + const table = new Int32Array(64); + for (let i = 0; i < 64; i += 1) { + const z = ZIGZAG[i]; + table[z] = precision ? segment.readUInt16BE(p + i * 2) : segment[p + i]; + } + p += precision ? 128 : 64; + quantTables[id] = table; + } + } else if (marker === 0xc4) { + let p = 0; + while (p < segment.length) { + const tableClass = segment[p] >> 4; + const id = segment[p] & 15; + p += 1; + const counts = new Uint8Array(16); + let total = 0; + for (let i = 0; i < 16; i += 1) { + counts[i] = segment[p + i]; + total += counts[i]; + } + p += 16; + const values = new Uint8Array(total); + for (let i = 0; i < total; i += 1) values[i] = segment[p + i]; + p += total; + const table = buildHuffman(counts, values); + if (tableClass === 0) huffDC[id] = table; + else huffAC[id] = table; + } + } else if (marker === 0xdd) { + restartInterval = segment.readUInt16BE(0); + } else if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if (marker !== 0xc0 && marker !== 0xc1) throw new Error('jpeg_progressive_unsupported'); + const height = segment.readUInt16BE(1); + const width = segment.readUInt16BE(3); + const count = segment[5]; + const components = []; + for (let i = 0; i < count; i += 1) { + const base = 6 + i * 3; + components.push({ + id: segment[base], + h: segment[base + 1] >> 4 || 1, + v: segment[base + 1] & 15 || 1, + quantId: segment[base + 2], + }); + } + frame = { width, height, components }; + } else if (marker === 0xda) { + if (!frame) throw new Error('jpeg_no_frame'); + const count = segment[0]; + for (let i = 0; i < count; i += 1) { + const id = segment[1 + i * 2]; + const tables = segment[2 + i * 2]; + const component = frame.components.find((item) => item.id === id); + if (component) { + component.dcId = tables >> 4; + component.acId = tables & 15; + } + } + return decodeScan(buffer, offset + length, frame, quantTables, huffDC, huffAC, restartInterval); + } + offset += length; + } + throw new Error('jpeg_no_scan'); +} + +function buildHuffman(counts, values) { + const mincode = new Int32Array(17); + const maxcode = new Int32Array(18).fill(-1); + const valptr = new Int32Array(17); + let code = 0; + let k = 0; + for (let length = 1; length <= 16; length += 1) { + valptr[length] = k; + mincode[length] = code; + code += counts[length - 1]; + k += counts[length - 1]; + maxcode[length] = counts[length - 1] ? code - 1 : -1; + code <<= 1; + } + maxcode[17] = 0x7fffffff; + return { mincode, maxcode, valptr, values }; +} + +function decodeScan(buffer, start, frame, quantTables, huffDC, huffAC, restartInterval) { + const { width, height, components } = frame; + const hmax = Math.max(...components.map((c) => c.h)); + const vmax = Math.max(...components.map((c) => c.v)); + const mcusPerLine = Math.ceil(width / (8 * hmax)); + const mcusPerColumn = Math.ceil(height / (8 * vmax)); + + for (const component of components) { + component.blocksPerLine = mcusPerLine * component.h; + component.blocksPerColumn = mcusPerColumn * component.v; + component.pixels = new Uint8Array(component.blocksPerLine * 8 * component.blocksPerColumn * 8); + component.pred = 0; + component.quant = quantTables[component.quantId] || new Int32Array(64).fill(1); + component.dc = huffDC[component.dcId]; + component.ac = huffAC[component.acId]; + if (!component.dc || !component.ac) throw new Error('jpeg_missing_huffman'); + } + + const reader = new BitReader(buffer, start); + const block = new Int32Array(64); + const pixels = new Uint8Array(64); + let mcu = 0; + + for (let mcuRow = 0; mcuRow < mcusPerColumn; mcuRow += 1) { + for (let mcuCol = 0; mcuCol < mcusPerLine; mcuCol += 1) { + if (restartInterval && mcu && mcu % restartInterval === 0) { + reader.restart(); + for (const component of components) component.pred = 0; + } + mcu += 1; + for (const component of components) { + for (let v = 0; v < component.v; v += 1) { + for (let h = 0; h < component.h; h += 1) { + const row = mcuRow * component.v + v; + const col = mcuCol * component.h + h; + decodeBlock(reader, component, block); + idct(block, pixels); + const stride = component.blocksPerLine * 8; + for (let y = 0; y < 8; y += 1) { + const target = (row * 8 + y) * stride + col * 8; + for (let x = 0; x < 8; x += 1) component.pixels[target + x] = pixels[y * 8 + x]; + } + } + } + } + } + } + + const planes = components.map((component) => upsample(component, width, height, hmax, vmax)); + const out = Buffer.alloc(width * height * 3); + if (components.length === 1) { + for (let p = 0; p < width * height; p += 1) { + const value = planes[0][p]; + out[p * 3] = value; + out[p * 3 + 1] = value; + out[p * 3 + 2] = value; + } + return { width, height, channels: 3, data: out }; + } + const [Y, Cb, Cr] = planes; + for (let p = 0; p < width * height; p += 1) { + const y = Y[p]; + const cb = Cb[p] - 128; + const cr = Cr[p] - 128; + const di = p * 3; + out[di] = clamp(y + 1.402 * cr); + out[di + 1] = clamp(y - 0.344136 * cb - 0.714136 * cr); + out[di + 2] = clamp(y + 1.772 * cb); + } + return { width, height, channels: 3, data: out }; +} + +// 把某个分量的 MCU 块平面上采样到整图分辨率。 +// 采样中心映射 src = (dst + 0.5) * (h / hmax) - 0.5,与 libjpeg 的 fancy(三角 3:1)上采样等价。 +function upsample(component, width, height, hmax, vmax) { + const stride = component.blocksPerLine * 8; + const rows = component.blocksPerColumn * 8; + const src = component.pixels; + const out = new Uint8Array(width * height); + const rx = component.h / hmax; + const ry = component.v / vmax; + const identity = rx === 1 && ry === 1; + if (identity) { + for (let y = 0; y < height; y += 1) { + const sy = Math.min(rows - 1, y); + for (let x = 0; x < width; x += 1) out[y * width + x] = src[sy * stride + Math.min(stride - 1, x)]; + } + return out; + } + for (let y = 0; y < height; y += 1) { + const fy = (y + 0.5) * ry - 0.5; + const y0 = Math.floor(fy); + const wy = fy - y0; + const ya = Math.min(rows - 1, Math.max(0, y0)) * stride; + const yb = Math.min(rows - 1, Math.max(0, y0 + 1)) * stride; + for (let x = 0; x < width; x += 1) { + const fx = (x + 0.5) * rx - 0.5; + const x0 = Math.floor(fx); + const wx = fx - x0; + const xa = Math.min(stride - 1, Math.max(0, x0)); + const xb = Math.min(stride - 1, Math.max(0, x0 + 1)); + const top = src[ya + xa] + (src[ya + xb] - src[ya + xa]) * wx; + const bottom = src[yb + xa] + (src[yb + xb] - src[yb + xa]) * wx; + out[y * width + x] = Math.round(top + (bottom - top) * wy); + } + } + return out; +} + +class BitReader { + constructor(data, pos) { + this.data = data; + this.pos = pos; + this.buf = 0; + this.count = 0; + } + + bit() { + if (this.count === 0) { + if (this.pos >= this.data.length) return 0; + let byte = this.data[this.pos]; + this.pos += 1; + if (byte === 0xff) { + const next = this.data[this.pos]; + if (next === 0x00) this.pos += 1; + else { + this.pos -= 1; + return 0; + } + } + this.buf = byte; + this.count = 8; + } + this.count -= 1; + return (this.buf >> this.count) & 1; + } + + receive(length) { + let value = 0; + for (let i = 0; i < length; i += 1) value = (value << 1) | this.bit(); + return value; + } + + restart() { + this.count = 0; + while (this.pos < this.data.length - 1) { + if (this.data[this.pos] === 0xff && this.data[this.pos + 1] >= 0xd0 && this.data[this.pos + 1] <= 0xd7) { + this.pos += 2; + return; + } + this.pos += 1; + } + } +} + +function decodeHuffman(reader, table) { + let code = reader.bit(); + let length = 1; + while (length <= 16) { + if (table.maxcode[length] >= 0 && code <= table.maxcode[length]) { + return table.values[table.valptr[length] + code - table.mincode[length]]; + } + code = (code << 1) | reader.bit(); + length += 1; + } + return 0; +} + +function decodeBlock(reader, component, block) { + block.fill(0); + const t = decodeHuffman(reader, component.dc); + const diff = t ? extend(reader.receive(t), t) : 0; + component.pred += diff; + block[0] = component.pred * component.quant[0]; + let k = 1; + while (k < 64) { + const rs = decodeHuffman(reader, component.ac); + const size = rs & 15; + const run = rs >> 4; + if (size === 0) { + if (run < 15) break; + k += 16; + continue; + } + k += run; + if (k > 63) break; + const z = ZIGZAG[k]; + block[z] = extend(reader.receive(size), size) * component.quant[z]; + k += 1; + } +} + +function extend(value, length) { + return value < 1 << (length - 1) ? value - (1 << length) + 1 : value; +} + +function idct(block, out) { + const tmp = new Float32Array(64); + for (let u = 0; u < 8; u += 1) { + for (let x = 0; x < 8; x += 1) { + let sum = 0; + for (let v = 0; v < 8; v += 1) sum += block[u * 8 + v] * COS[v * 8 + x]; + tmp[u * 8 + x] = sum; + } + } + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + let sum = 0; + for (let u = 0; u < 8; u += 1) sum += tmp[u * 8 + x] * COS[u * 8 + y]; + const value = Math.round(sum) + 128; + out[y * 8 + x] = value < 0 ? 0 : value > 255 ? 255 : value; + } + } +} + +function clamp(value) { + const v = Math.round(value); + return v < 0 ? 0 : v > 255 ? 255 : v; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpegenc.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpegenc.mjs new file mode 100644 index 0000000..9081ed2 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/jpegenc.mjs @@ -0,0 +1,430 @@ +// 纯 Node 实现的 baseline JPEG 编码器(4:2:0 抽样,标准 Annex K 量化表与霍夫曼表)。 +// +// 存在的理由:分享页把图内嵌成 data URL,而 PNG 存照片极不划算——实测 640px 的交付预览图 +// 平均 780KB/张,占整页七成体积(20261110-c46dde 16.9MB、20261109-11b90b 19.4MB), +// 大到 website_deploy 部署不动。同一张图存成 q82 的 JPEG 只要几十 KB。 +// +// 只做 baseline:8bit、3 分量、4:2:0、无 restart interval。与 jpeg.mjs 的解码器成对, +// 编码出来的图能被它解回来(测试里做往返比对)。 +// 不支持 progressive / arithmetic coding / 12bit——分享页用不上,徒增风险。 + +const ZIGZAG = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +// Annex K 表 K.1 / K.2,自然顺序(不是 zigzag)。写进 DQT 时才按 zigzag 重排。 +const QUANT_LUMA = [ + 16, 11, 10, 16, 24, 40, 51, 61, + 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, + 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68, 109, 103, 77, + 24, 35, 55, 64, 81, 104, 113, 92, + 49, 64, 78, 87, 103, 121, 120, 101, + 72, 92, 95, 98, 112, 100, 103, 99, +]; + +const QUANT_CHROMA = [ + 17, 18, 24, 47, 99, 99, 99, 99, + 18, 21, 26, 66, 99, 99, 99, 99, + 24, 26, 56, 99, 99, 99, 99, 99, + 47, 66, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, +]; + +// Annex K 表 K.3:标准霍夫曼码长与符号。 +const DC_LUMA_BITS = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]; +const DC_LUMA_VALS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const DC_CHROMA_BITS = [0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0]; +const DC_CHROMA_VALS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +const AC_LUMA_BITS = [0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7d]; +const AC_LUMA_VALS = [ + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, + 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, + 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, + 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, + 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, + 0xf9, 0xfa, +]; + +const AC_CHROMA_BITS = [0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77]; +const AC_CHROMA_VALS = [ + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, + 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, + 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, + 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, + 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, + 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, + 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, + 0xf9, 0xfa, +]; + +// 与 jpeg.mjs 解码器同一套余弦表,保证正变换与逆变换严格配对。 +const COS = (() => { + const table = new Float32Array(64); + for (let u = 0; u < 8; u += 1) { + const cu = (u === 0 ? Math.SQRT1_2 : 1) * 0.5; + for (let x = 0; x < 8; x += 1) { + table[u * 8 + x] = cu * Math.cos(((2 * x + 1) * u * Math.PI) / 16); + } + } + return table; +})(); + +export const DEFAULT_QUALITY = 82; + +// IJG 的质量到量化缩放:q=50 时原表不变,q 越大量化步长越小。 +export function scaleQuantTable(base, quality) { + const q = Math.max(1, Math.min(100, Math.round(quality))); + const scale = q < 50 ? 5000 / q : 200 - q * 2; + const out = new Int32Array(64); + for (let i = 0; i < 64; i += 1) { + // 钳到 1..255:0 会让解码端除零,>255 在 8bit 精度下写不进 DQT。 + out[i] = Math.max(1, Math.min(255, Math.floor((base[i] * scale + 50) / 100))); + } + return out; +} + +// 由码长表生成「符号 -> {code,length}」。JPEG 的规范霍夫曼码是按码长递增、 +// 同码长内按符号表顺序连续分配的,不需要真的建霍夫曼树。 +function buildCodeTable(bits, values) { + const table = new Array(256); + let code = 0; + let k = 0; + for (let length = 1; length <= 16; length += 1) { + for (let i = 0; i < bits[length - 1]; i += 1) { + table[values[k]] = { code, length }; + code += 1; + k += 1; + } + code <<= 1; + } + return table; +} + +class BitWriter { + constructor() { + this.bytes = []; + this.acc = 0; + this.nbits = 0; + } + + write(code, length) { + if (!length) return; + for (let i = length - 1; i >= 0; i -= 1) { + this.acc = (this.acc << 1) | ((code >> i) & 1); + this.nbits += 1; + if (this.nbits === 8) this.emitByte(); + } + } + + emitByte() { + const byte = this.acc & 0xff; + this.bytes.push(byte); + // 熵编码段里 0xFF 后面必须补 0x00,否则会被当成标记。这是 JPEG 唯一的转义规则。 + if (byte === 0xff) this.bytes.push(0x00); + this.acc = 0; + this.nbits = 0; + } + + // 末尾不足一字节时用 1 补齐(JPEG 规范要求填 1,不是填 0)。 + flush() { + if (this.nbits === 0) return; + const pad = 8 - this.nbits; + this.acc = ((this.acc << pad) | ((1 << pad) - 1)) & 0xff; + this.nbits = 8; + this.emitByte(); + } +} + +function categoryOf(value) { + let v = Math.abs(value); + let n = 0; + while (v) { + n += 1; + v >>= 1; + } + return n; +} + +// 负数用补码表示:-3 在 3bit 下写成 100(= 4)。这是 JPEG 的定长幅度编码。 +function magnitudeBits(value, category) { + return value >= 0 ? value : value + (1 << category) - 1; +} + +function forwardDct(input, output, tmp) { + for (let y = 0; y < 8; y += 1) { + for (let u = 0; u < 8; u += 1) { + let sum = 0; + for (let x = 0; x < 8; x += 1) sum += input[y * 8 + x] * COS[u * 8 + x]; + tmp[y * 8 + u] = sum; + } + } + for (let x = 0; x < 8; x += 1) { + for (let v = 0; v < 8; v += 1) { + let sum = 0; + for (let y = 0; y < 8; y += 1) sum += tmp[y * 8 + x] * COS[v * 8 + y]; + output[v * 8 + x] = sum; + } + } +} + +function encodeBlock(writer, coefficients, quant, dcTable, acTable, previousDc) { + const quantized = new Int32Array(64); + for (let i = 0; i < 64; i += 1) { + quantized[i] = Math.round(coefficients[i] / quant[i]); + } + + const dc = quantized[0]; + const diff = dc - previousDc; + const dcCategory = categoryOf(diff); + const dcCode = dcTable[dcCategory]; + if (!dcCode) throw new Error('jpeg_bad_dc_category'); + writer.write(dcCode.code, dcCode.length); + if (dcCategory) writer.write(magnitudeBits(diff, dcCategory), dcCategory); + + let run = 0; + for (let i = 1; i < 64; i += 1) { + const value = quantized[ZIGZAG[i]]; + if (value === 0) { + run += 1; + continue; + } + // 游程最多 15,超过就用 ZRL(0xF0)分段。 + while (run > 15) { + const zrl = acTable[0xf0]; + writer.write(zrl.code, zrl.length); + run -= 16; + } + const category = categoryOf(value); + const symbol = (run << 4) | category; + const acCode = acTable[symbol]; + if (!acCode) throw new Error('jpeg_bad_ac_symbol'); + writer.write(acCode.code, acCode.length); + writer.write(magnitudeBits(value, category), category); + run = 0; + } + if (run > 0) { + const eob = acTable[0x00]; + writer.write(eob.code, eob.length); + } + return dc; +} + +function marker(type, payload) { + const head = Buffer.alloc(4); + head[0] = 0xff; + head[1] = type; + head.writeUInt16BE(payload.length + 2, 2); + return Buffer.concat([head, payload]); +} + +function buildDqt(tables) { + const parts = []; + for (const [id, table] of tables) { + const body = Buffer.alloc(65); + body[0] = id; // 精度 0(8bit)+ 表号 + for (let i = 0; i < 64; i += 1) body[1 + i] = table[ZIGZAG[i]]; + parts.push(body); + } + return marker(0xdb, Buffer.concat(parts)); +} + +function buildDht(tables) { + const parts = []; + for (const [tableClass, id, bits, values] of tables) { + const body = Buffer.alloc(1 + 16 + values.length); + body[0] = (tableClass << 4) | id; + for (let i = 0; i < 16; i += 1) body[1 + i] = bits[i]; + for (let i = 0; i < values.length; i += 1) body[17 + i] = values[i]; + parts.push(body); + } + return marker(0xc4, Buffer.concat(parts)); +} + +function buildJfif() { + const body = Buffer.alloc(14); + body.write('JFIF', 0, 'ascii'); + body[4] = 0; + body[5] = 1; // version 1.1 + body[6] = 1; + body[7] = 0; // 单位:0 = 无单位 + body.writeUInt16BE(1, 8); + body.writeUInt16BE(1, 10); + body[12] = 0; // 无缩略图 + body[13] = 0; + return marker(0xe0, body); +} + +function buildSof0(width, height) { + const body = Buffer.alloc(15); + body[0] = 8; // 8bit 精度 + body.writeUInt16BE(height, 1); + body.writeUInt16BE(width, 3); + body[5] = 3; + // Y 取 2x2、Cb/Cr 各 1x1 —— 这就是 4:2:0。 + body[6] = 1; body[7] = 0x22; body[8] = 0; + body[9] = 2; body[10] = 0x11; body[11] = 1; + body[12] = 3; body[13] = 0x11; body[14] = 1; + return marker(0xc0, body); +} + +function buildSos() { + const body = Buffer.alloc(10); + body[0] = 3; + body[1] = 1; body[2] = 0x00; // Y: DC0 / AC0 + body[3] = 2; body[4] = 0x11; // Cb: DC1 / AC1 + body[5] = 3; body[6] = 0x11; // Cr: DC1 / AC1 + body[7] = 0; // Ss + body[8] = 63; // Se + body[9] = 0; // Ah / Al + return marker(0xda, body); +} + +// 从原图取一个 8x8 块,边缘用夹取(复制最后一行/列)补齐。 +function extractBlock(plane, planeWidth, planeHeight, blockX, blockY, out) { + for (let y = 0; y < 8; y += 1) { + const sy = Math.min(planeHeight - 1, blockY * 8 + y); + const row = sy * planeWidth; + for (let x = 0; x < 8; x += 1) { + const sx = Math.min(planeWidth - 1, blockX * 8 + x); + // DCT 输入要减 128 做中心化:解码端 IDCT 之后会加回来。 + out[y * 8 + x] = plane[row + sx] - 128; + } + } +} + +/** + * 把一张图编码成 baseline JPEG。 + * @param {{width:number,height:number,channels:number,data:Buffer|Uint8Array}} image + * @param {{quality?:number}} [options] quality 1..100,默认 82 + * @returns {Buffer} + */ +export function encodeJpeg(image, options) { + const width = image.width | 0; + const height = image.height | 0; + const channels = image.channels || 3; + const data = image.data; + if (width <= 0 || height <= 0) throw new Error('jpeg_bad_size'); + if (channels !== 1 && channels !== 3 && channels !== 4) throw new Error('jpeg_bad_channels'); + + const quality = options && options.quality !== undefined ? options.quality : DEFAULT_QUALITY; + const quantLuma = scaleQuantTable(QUANT_LUMA, quality); + const quantChroma = scaleQuantTable(QUANT_CHROMA, quality); + + const dcLuma = buildCodeTable(DC_LUMA_BITS, DC_LUMA_VALS); + const acLuma = buildCodeTable(AC_LUMA_BITS, AC_LUMA_VALS); + const dcChroma = buildCodeTable(DC_CHROMA_BITS, DC_CHROMA_VALS); + const acChroma = buildCodeTable(AC_CHROMA_BITS, AC_CHROMA_VALS); + + const chromaWidth = Math.ceil(width / 2); + const chromaHeight = Math.ceil(height / 2); + const luma = new Float32Array(width * height); + const cb = new Float32Array(chromaWidth * chromaHeight); + const cr = new Float32Array(chromaWidth * chromaHeight); + + const readRgb = (x, y, out) => { + const index = (y * width + x) * channels; + if (channels === 1) { + out[0] = data[index]; + out[1] = data[index]; + out[2] = data[index]; + return; + } + out[0] = data[index]; + out[1] = data[index + 1]; + out[2] = data[index + 2]; + }; + + const rgb = new Float32Array(3); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + readRgb(x, y, rgb); + luma[y * width + x] = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + } + } + // 色度按 2x2 平均降采样。直接取左上角像素会丢一半信息,细密彩色纹理会出现色块。 + for (let cy = 0; cy < chromaHeight; cy += 1) { + for (let cx = 0; cx < chromaWidth; cx += 1) { + let sumB = 0; + let sumR = 0; + let count = 0; + for (let dy = 0; dy < 2; dy += 1) { + const y = cy * 2 + dy; + if (y >= height) continue; + for (let dx = 0; dx < 2; dx += 1) { + const x = cx * 2 + dx; + if (x >= width) continue; + readRgb(x, y, rgb); + sumB += 128 - 0.168736 * rgb[0] - 0.331264 * rgb[1] + 0.5 * rgb[2]; + sumR += 128 + 0.5 * rgb[0] - 0.418688 * rgb[1] - 0.081312 * rgb[2]; + count += 1; + } + } + cb[cy * chromaWidth + cx] = sumB / count; + cr[cy * chromaWidth + cx] = sumR / count; + } + } + + const writer = new BitWriter(); + const block = new Float32Array(64); + const coefficients = new Float32Array(64); + const tmp = new Float32Array(64); + + const mcusPerLine = Math.ceil(width / 16); + const mcusPerColumn = Math.ceil(height / 16); + let dcY = 0; + let dcCb = 0; + let dcCr = 0; + + for (let mcuY = 0; mcuY < mcusPerColumn; mcuY += 1) { + for (let mcuX = 0; mcuX < mcusPerLine; mcuX += 1) { + // 一个 4:2:0 MCU = 4 个亮度块 + 1 个 Cb + 1 个 Cr,顺序固定。 + for (let by = 0; by < 2; by += 1) { + for (let bx = 0; bx < 2; bx += 1) { + extractBlock(luma, width, height, mcuX * 2 + bx, mcuY * 2 + by, block); + forwardDct(block, coefficients, tmp); + dcY = encodeBlock(writer, coefficients, quantLuma, dcLuma, acLuma, dcY); + } + } + extractBlock(cb, chromaWidth, chromaHeight, mcuX, mcuY, block); + forwardDct(block, coefficients, tmp); + dcCb = encodeBlock(writer, coefficients, quantChroma, dcChroma, acChroma, dcCb); + extractBlock(cr, chromaWidth, chromaHeight, mcuX, mcuY, block); + forwardDct(block, coefficients, tmp); + dcCr = encodeBlock(writer, coefficients, quantChroma, dcChroma, acChroma, dcCr); + } + } + writer.flush(); + + return Buffer.concat([ + Buffer.from([0xff, 0xd8]), + buildJfif(), + buildDqt([[0, quantLuma], [1, quantChroma]]), + buildSof0(width, height), + buildDht([ + [0, 0, DC_LUMA_BITS, DC_LUMA_VALS], + [1, 0, AC_LUMA_BITS, AC_LUMA_VALS], + [0, 1, DC_CHROMA_BITS, DC_CHROMA_VALS], + [1, 1, AC_CHROMA_BITS, AC_CHROMA_VALS], + ]), + buildSos(), + Buffer.from(writer.bytes), + Buffer.from([0xff, 0xd9]), + ]); +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/png.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/png.mjs new file mode 100644 index 0000000..9099d62 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/png.mjs @@ -0,0 +1,370 @@ +// PNG 解码 / 编码(8bit、非隔行)、色彩统计、灰世界白平衡校正、预览缩图。 +// 纯 Node 内置实现,不引入任何第三方依赖。 +import zlib from 'node:zlib'; + +const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 }; + +export function readPngHeader(buffer) { + if (buffer.length < 24 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error('not_png'); + if (buffer.toString('ascii', 12, 16) !== 'IHDR') throw new Error('png_no_ihdr'); + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + bitDepth: buffer[24], + colorType: buffer[25], + interlace: buffer[28], + }; +} + +// 调色板 PNG(colorType 3)在网上非常常见——新闻站、百科的插图大量是这种。 +// 之前直接报 png_color_type_unsupported,结果是参考图下载成功、却拼不进对比图, +// 判定节点只能"取第一张可用图",等于 silently 放弃了这张图。 +const PALETTE_DEPTHS = new Set([1, 2, 4, 8]); + +export function decodePng(buffer) { + const header = readPngHeader(buffer); + if (header.interlace !== 0) throw new Error('png_interlaced_unsupported'); + const palette = header.colorType === 3; + if (palette) { + if (!PALETTE_DEPTHS.has(header.bitDepth)) throw new Error('png_bit_depth_unsupported'); + } else if (header.bitDepth !== 8) { + throw new Error('png_bit_depth_unsupported'); + } + const channels = palette ? 1 : CHANNELS[header.colorType]; + if (!channels) throw new Error('png_color_type_unsupported'); + + let plte = null; + let trns = null; + const chunks = []; + let offset = 8; + while (offset + 8 <= buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString('ascii', offset + 4, offset + 8); + const start = offset + 8; + if (type === 'IDAT') chunks.push(buffer.subarray(start, start + length)); + else if (type === 'PLTE') plte = buffer.subarray(start, start + length); + else if (type === 'tRNS') trns = buffer.subarray(start, start + length); + if (type === 'IEND') break; + offset = start + length + 4; + } + const raw = zlib.inflateSync(Buffer.concat(chunks)); + const { width, height } = header; + // 过滤永远按字节做:调色板图每个像素不足 1 字节,一行是 ceil(width*bitDepth/8) 字节。 + const unit = palette ? 1 : channels; + const stride = palette ? Math.ceil((width * header.bitDepth) / 8) : width * channels; + const out = Buffer.alloc(stride * height); + + let pos = 0; + for (let y = 0; y < height; y += 1) { + const filter = raw[pos]; + pos += 1; + const line = raw.subarray(pos, pos + stride); + pos += stride; + const cur = out.subarray(y * stride, (y + 1) * stride); + const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null; + for (let x = 0; x < stride; x += 1) { + const rawByte = line[x]; + const a = x >= unit ? cur[x - unit] : 0; + const b = prev ? prev[x] : 0; + const c = prev && x >= unit ? prev[x - unit] : 0; + let value; + if (filter === 0) value = rawByte; + else if (filter === 1) value = rawByte + a; + else if (filter === 2) value = rawByte + b; + else if (filter === 3) value = rawByte + ((a + b) >> 1); + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + value = rawByte + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + } else throw new Error('png_filter_unsupported'); + cur[x] = value & 0xff; + } + } + + if (!palette) return { width, height, channels, data: out }; + if (!plte) throw new Error('png_palette_missing'); + + const withAlpha = Boolean(trns && trns.length); + const outChannels = withAlpha ? 4 : 3; + const rgb = Buffer.alloc(width * height * outChannels); + const mask = (1 << header.bitDepth) - 1; + const perByte = 8 / header.bitDepth; + for (let y = 0; y < height; y += 1) { + const row = out.subarray(y * stride, (y + 1) * stride); + for (let x = 0; x < width; x += 1) { + let index; + if (header.bitDepth === 8) index = row[x]; + else { + const byte = row[Math.floor(x / perByte)] || 0; + const shift = 8 - header.bitDepth * ((x % perByte) + 1); + index = (byte >> shift) & mask; + } + const p = index * 3; + const o = (y * width + x) * outChannels; + rgb[o] = plte[p] || 0; + rgb[o + 1] = plte[p + 1] || 0; + rgb[o + 2] = plte[p + 2] || 0; + if (withAlpha) rgb[o + 3] = index < trns.length ? trns[index] : 255; + } + } + return { width, height, channels: outChannels, data: rgb }; +} + +// 画布合成:用于把多张看图材料拼成一张 contact sheet,评审一次 read 就能看全。 +export function createCanvas(width, height, channels) { + const ch = channels || 3; + return { width, height, channels: ch, data: Buffer.alloc(width * height * ch) }; +} + +export function drawImage(canvas, src, dx, dy, dw, dh) { + const targetW = Math.max(1, Math.round(dw)); + const targetH = Math.max(1, Math.round(dh)); + for (let y = 0; y < targetH; y += 1) { + const cy = dy + y; + if (cy < 0 || cy >= canvas.height) continue; + const sy = Math.min(src.height - 1, Math.floor((y / targetH) * src.height)); + for (let x = 0; x < targetW; x += 1) { + const cx = dx + x; + if (cx < 0 || cx >= canvas.width) continue; + const sx = Math.min(src.width - 1, Math.floor((x / targetW) * src.width)); + const si = (sy * src.width + sx) * src.channels; + const di = (cy * canvas.width + cx) * canvas.channels; + for (let c = 0; c < canvas.channels; c += 1) { + canvas.data[di + c] = src.channels === 1 ? src.data[si] : src.data[si + Math.min(c, src.channels - 1)]; + } + } + } + return canvas; +} + +// gamma 提亮:把暗部拉起来,用于核对暗区里有没有文字/水印/多余物体。 +export function adjustGamma(image, gamma) { + const lut = new Uint8Array(256); + for (let i = 0; i < 256; i += 1) lut[i] = Math.round(255 * Math.pow(i / 255, gamma)); + const out = Buffer.from(image.data); + const channels = image.channels; + const limit = channels >= 3 ? 3 : 1; + for (let i = 0; i < out.length; i += channels) { + for (let c = 0; c < limit; c += 1) out[i + c] = lut[out[i + c]]; + } + return { width: image.width, height: image.height, channels, data: out }; +} + +export function encodePng(image, options) { + const { width, height, channels, data } = image; + const stride = width * channels; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y += 1) { + raw[y * (stride + 1)] = 0; + data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = channels === 1 ? 0 : channels === 2 ? 4 : channels === 3 ? 2 : 6; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + return Buffer.concat([ + SIGNATURE, + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: (options && options.level) || 6 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +function chunk(type, data) { + const out = Buffer.alloc(data.length + 12); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} + +let crcTable = null; +function crc32(buffer) { + if (!crcTable) { + crcTable = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crcTable[n] = c; + } + } + let crc = -1; + for (let i = 0; i < buffer.length; i += 1) crc = crcTable[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ -1) >>> 0; +} + +// 色彩统计:通道均值、红/蓝相对增益、锐度代理(平均梯度能量)。 +export function analyze(image) { + const { width, height, channels, data } = image; + const step = Math.max(1, Math.floor((width * height) / 400000)); + let sumR = 0; + let sumG = 0; + let sumB = 0; + let count = 0; + let clipped = 0; + for (let p = 0; p < width * height; p += step) { + const i = p * channels; + if (channels === 1) { + sumR += data[i]; + sumG += data[i]; + sumB += data[i]; + } else if (channels === 2) { + sumR += data[i]; + sumG += data[i]; + sumB += data[i]; + } else { + sumR += data[i]; + sumG += data[i + 1]; + sumB += data[i + 2]; + } + if (data[i] > 250 || data[i + Math.min(1, channels - 1)] > 250) clipped += 1; + count += 1; + } + const meanR = sumR / count; + const meanG = sumG / count; + const meanB = sumB / count; + const avg = (meanR + meanG + meanB) / 3; + const luma = 0.2126 * meanR + 0.7152 * meanG + 0.0722 * meanB; + const redGain = meanR / Math.max(1, (meanG + meanB) / 2); + const blueGain = meanB / Math.max(1, (meanR + meanG) / 2); + + let gradient = 0; + let gcount = 0; + const gstep = Math.max(1, Math.floor(Math.min(width, height) / 600)); + for (let y = gstep; y < height - gstep; y += gstep) { + for (let x = gstep; x < width - gstep; x += gstep) { + const i = (y * width + x) * channels; + const right = (y * width + x + 1) * channels; + const down = ((y + 1) * width + x) * channels; + gradient += Math.abs(data[i] - data[right]) + Math.abs(data[i] - data[down]); + gcount += 1; + } + } + return { + meanR: round(meanR), + meanG: round(meanG), + meanB: round(meanB), + luma: round(luma), + redGain: round(redGain, 4), + blueGain: round(blueGain, 4), + clippedRatio: round(clipped / count, 4), + sharpness: round(gcount ? gradient / gcount : 0, 3), + balance: round(avg, 2), + }; +} + +// 偏色判定:红增益显著高于 1 视为发红;蓝增益显著高于 1 视为发冷。 +export function detectCast(stats, options) { + const redLimit = (options && options.redLimit) || 1.06; + const blueLimit = (options && options.blueLimit) || 1.08; + if (stats.redGain >= redLimit) { + return { cast: 'red', strength: round(stats.redGain - 1, 4), label: '整体偏红' }; + } + if (stats.blueGain >= blueLimit) { + return { cast: 'blue', strength: round(stats.blueGain - 1, 4), label: '整体偏冷' }; + } + return { cast: 'none', strength: 0, label: '无明显偏色' }; +} + +// 灰世界白平衡:把三通道均值拉回同一水平,强度可调,单次确定性校正。 +export function whiteBalance(image, strength) { + const stats = analyze(image); + const target = (stats.meanR + stats.meanG + stats.meanB) / 3; + const factor = strength === undefined ? 0.85 : strength; + const gains = [target / Math.max(1, stats.meanR), target / Math.max(1, stats.meanG), target / Math.max(1, stats.meanB)].map( + (g) => clamp(1 + (g - 1) * factor, 0.78, 1.28), + ); + const { channels, data } = image; + const out = Buffer.from(data); + if (channels >= 3) { + for (let i = 0; i < out.length; i += channels) { + out[i] = clamp(out[i] * gains[0], 0, 255); + out[i + 1] = clamp(out[i + 1] * gains[1], 0, 255); + out[i + 2] = clamp(out[i + 2] * gains[2], 0, 255); + } + } + return { image: { width: image.width, height: image.height, channels, data: out }, gains: gains.map((g) => round(g, 4)), before: stats }; +} + +// 按目标统计配平:用于把编辑后漂移的色彩拉回父图/参考图的平衡,而不是一律拉回灰色。 +// 这样刻意暖调的原图不会被误校正。 +export function matchBalance(image, target, strength) { + const stats = analyze(image); + const factor = strength === undefined ? 0.9 : strength; + const gains = [ + target.meanR / Math.max(1, stats.meanR), + target.meanG / Math.max(1, stats.meanG), + target.meanB / Math.max(1, stats.meanB), + ].map((g) => clamp(1 + (g - 1) * factor, 0.75, 1.35)); + const { channels, data } = image; + const out = Buffer.from(data); + if (channels >= 3) { + for (let i = 0; i < out.length; i += channels) { + out[i] = clamp(out[i] * gains[0], 0, 255); + out[i + 1] = clamp(out[i + 1] * gains[1], 0, 255); + out[i + 2] = clamp(out[i + 2] * gains[2], 0, 255); + } + } + return { image: { width: image.width, height: image.height, channels, data: out }, gains: gains.map((g) => round(g, 4)), before: stats }; +} + +// 相对父图的偏色漂移:只有编辑把画面推红了才判定为“发红”。 +export function detectDrift(stats, parentStats, options) { + const limit = (options && options.limit) || 0.05; + if (!parentStats) { + const abs = detectCast(stats, { redLimit: (options && options.absoluteRed) || 1.15 }); + return abs.cast === 'none' ? { cast: 'none', strength: 0, label: '色彩正常', mode: 'absolute' } : { ...abs, mode: 'absolute' }; + } + const redDrift = stats.redGain - parentStats.redGain; + const blueDrift = stats.blueGain - parentStats.blueGain; + if (redDrift >= limit) { + return { cast: 'red', strength: round(redDrift, 4), label: '相对父图发红', mode: 'relative' }; + } + if (blueDrift >= limit) { + return { cast: 'blue', strength: round(blueDrift, 4), label: '相对父图偏冷', mode: 'relative' }; + } + return { cast: 'none', strength: 0, label: '色彩正常', mode: 'relative' }; +} + +// 锐度漂移:全图 edit 累积的过度锐化。 +export function detectOverSharpen(stats, parentStats, options) { + const limit = (options && options.limit) || 0.25; + if (!parentStats || !parentStats.sharpness) return { overSharpened: false, ratio: 1 }; + const ratio = stats.sharpness / parentStats.sharpness; + return { overSharpened: ratio >= 1 + limit, ratio: round(ratio, 3) }; +} + +export function downsample(image, maxEdge) { + const scale = Math.min(1, maxEdge / Math.max(image.width, image.height)); + if (scale >= 1) return image; + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const { channels } = image; + const out = Buffer.alloc(width * height * channels); + for (let y = 0; y < height; y += 1) { + const sy = Math.min(image.height - 1, Math.floor((y / height) * image.height)); + for (let x = 0; x < width; x += 1) { + const sx = Math.min(image.width - 1, Math.floor((x / width) * image.width)); + const si = (sy * image.width + sx) * channels; + const di = (y * width + x) * channels; + for (let c = 0; c < channels; c += 1) out[di + c] = image.data[si + c]; + } + } + return { width, height, channels, data: out }; +} + +function clamp(value, min, max) { + return value < min ? min : value > max ? max : value; +} +function round(value, digits) { + const p = Math.pow(10, digits === undefined ? 2 : digits); + return Math.round(value * p) / p; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/publish.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/publish.mjs new file mode 100644 index 0000000..45e4409 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/publish.mjs @@ -0,0 +1,175 @@ +// 分享页发布:把自包含 HTML 部署成公开链接。 +// +// 为什么必须绕道 Agent turn:Mini App 的 context 只有 agent / capabilities / hostConnector / +// listen / logger / tool 六个成员,**没有任何 drive / upload / storage 能力**,页面和服务端 +// 自己绝对做不到部署。而 website_deploy 是 Host 的「顶层控制工具」,只下发给 Agent turn—— +// context.tool(Connector / MCP)够不到它。所以唯一通路是 context.agent.run 起一个 turn, +// 让那个 turn 去调 website_deploy,我们从它的最终回复里把公开地址捞出来。 +// +// 权限:website_deploy 会触发 permission.requested。这一条**不能自己放行**:点「发布」只说明 +// 用户想发布,不等于批准这一次工具调用(docs/security.md:Never answer allowOnce automatically)。 +// 所以把它交给 onPermission,由页面上的确认卡片让用户点「允许一次」或「拒绝」。 +// 其余工具一律自动 deny(拒绝可以自动),免得这个节点变成通用执行面。 +// 问卷请求(question.requested)不能自动答:没人回答会一直挂着,所以直接中断整轮。 +import { AGENT_OFF_HINT, isNotPermitted } from './agent.mjs'; + +// 部署地址的形状:Host 返回 *.space.mcode.cn。从自由文本里捞地址必须锚定域名, +// 否则 agent 回复里任何一条 http 链接(文档、报错、它自己举的例子)都会被误当成部署结果。 +const DEPLOY_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*\.space\.mcode\.cn(?:\/[^\s"'<>`)】,。;、]*)?/i; + +// 一次发布的上限。3MB 的页面上传本身不慢,但这个 turn 要先起会话、跑模型、再调工具, +// 实测量级在分钟级。给 6 分钟,超过了就当失败——用户可以再点一次。 +const DEFAULT_TIMEOUT_MS = 6 * 60 * 1000; + +export function buildPublishPrompt({ htmlPath, projectName, note }) { + return [ + '把一个已经生成好的自包含 HTML 页面发布成公开链接。这是一次纯执行任务,不要规划、不要提问、不要做发布以外的任何事。', + '', + '待发布的文件(绝对路径,已经在磁盘上):', + htmlPath, + '', + '严格按下面四步做:', + '', + '1. 运行 pwd,确认你当前的工作目录。', + '2. 在工作目录下建一个子目录 h3-share-publish,把文件拷进去:', + ' mkdir -p h3-share-publish && cp ' + JSON.stringify(htmlPath) + ' h3-share-publish/index.html', + ' 注意:website_deploy 只接受工作目录以内的路径,直接把上面那个绝对路径传给它会被拒绝', + ' (报 Cannot deploy: ... is outside the workspace or invalid)。所以必须先拷进工作目录。', + ' 另外不要用 mv 或 rm,权限门会把它们当成删除操作拦下来;只用 cp。', + '3. 调用 website_deploy,三个参数这样填:', + ' path = h3-share-publish', + ' source_path = h3-share-publish', + ' project_name = ' + JSON.stringify(projectName), + '4. 拿到返回的公开地址后,**最后一行只输出那个 https 地址本身**,不要加任何解释、', + ' 不要加 markdown、不要加句号。前面几行你爱写什么都行,但最后一行必须是纯地址。', + '', + '不要做的事:不要修改这个 HTML 的内容;不要部署任何别的文件;不要创建 PR 或提交代码;', + '不要调用 ask_user 或任何问卷工具(这个任务没有需要用户决定的事)。', + note ? '' : null, + note ? '补充说明:' + note : null, + ].filter((line) => line !== null).join('\n'); +} + +// 从 turn 的最终回复里捞部署地址。找不到就是失败——宁可报错也不能把一个不相干的 +// 链接当成发布成功写回会话,用户会拿着它去分享。 +export function extractDeployUrl(text) { + const value = String(text == null ? '' : text); + const hit = DEPLOY_URL_RE.exec(value); + if (!hit) return null; + // 结尾的标点是句法的一部分,不是地址的一部分:agent 常写成「地址。」或「地址)。」 + return hit[0].replace(/[).,;:'"\]】)]+$/, ''); +} + +// 给页面确认卡片看的说明:发出去的是什么、谁能看到,以及 Agent 实际要传给工具的参数。 +function describeDeployRequest(event) { + const parts = ['允许后,分享页会被发布成公开网址,任何拿到链接的人都能打开。']; + if (event.reason) parts.push(String(event.reason)); + if (event.toolInput && typeof event.toolInput === 'object') { + try { + parts.push('参数:' + JSON.stringify(event.toolInput).slice(0, 300)); + } catch { + /* 参数展示不出来就不展示,不影响确认 */ + } + } + return parts.join(' '); +} + +export async function publishHtml({ context, htmlPath, projectName, note, timeoutMs, onProgress, onPermission }) { + if (!context || !context.agent || typeof context.agent.run !== 'function') { + return { ok: false, error: 'agent_unavailable' }; + } + const limit = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + // 问卷中断和超时都会 abort 同一个 signal,光看 signal.aborted 分不出是哪种。 + // 失败原因要能直接告诉用户「是它问了问题」还是「是它太慢」,所以单独记一份。 + let abortReason = null; + const timer = setTimeout(() => { + abortReason = 'publish_timeout'; + controller.abort(); + }, limit); + const started = Date.now(); + const tools = []; + let denied = 0; + + const report = (text, extra) => { + if (typeof onProgress === 'function') onProgress(text, extra || {}); + }; + + try { + report('开始发布,正在起一个 Agent 会话'); + const result = await context.agent.run( + { text: buildPublishPrompt({ htmlPath, projectName, note }) }, + { + signal: controller.signal, + onEvent: (event) => { + if (event.type === 'permission.requested') { + const tool = String(event.toolName || ''); + // 部署本身交给用户在页面上确认;没有页面可问(onPermission 缺失)时按拒绝处理。 + if (tool === 'website_deploy' && typeof onPermission === 'function') { + report('等待你在页面上确认 website_deploy:点「允许一次」后才会真正发布', { level: 'warn' }); + onPermission({ + requestId: event.requestId, + sessionId: event.sessionId, + toolName: tool, + reason: describeDeployRequest(event), + }); + return; + } + // 其余工具(shell、文件写入、别的控制工具)一律拒绝: + // 这个节点的职责就是发一个页面,放行别的等于给了一个不受控的执行面。 + denied += 1; + report('拒绝非部署工具调用:' + (tool || '未知'), { level: 'warn' }); + void Promise.resolve( + context.agent.reply({ + sessionId: event.sessionId, + requestId: event.requestId, + decision: 'deny', + }), + ).catch(() => { /* 请求可能已经自己结束了 */ }); + return; + } + if (event.type === 'question.requested') { + // 没人能回答问卷,这一轮会一直挂着。直接中断,让用户看到明确失败而不是转圈。 + report('发布会话发起了提问,已中断(发布任务不应该需要提问)', { level: 'warn' }); + abortReason = 'publish_asked_question'; + controller.abort(); + return; + } + if (event.type === 'tool.started') { + const name = event.toolName || event.name || '未知工具'; + tools.push({ name, at: Date.now() - started }); + report('调用工具 ' + name); + } + }, + }, + ); + + const ms = Date.now() - started; + if (result && result.status === 'aborted') { + // 不是我们自己中断的(超时 / 提问),多半是用户在插件详情里关掉了 Agent 权限,或在别处停掉了它。 + const reason = abortReason || 'publish_aborted:Agent 回合被中止。如果你关闭了 Agent 权限,' + AGENT_OFF_HINT; + return { ok: false, error: reason, ms, tools }; + } + if (!result || result.status !== 'finished') { + const info = result && result.error; + const detail = info ? [info.code, info.message].filter(Boolean).join(' ') : ''; + return { ok: false, error: 'publish_turn_failed' + (detail ? ':' + detail : ''), ms, tools }; + } + const url = extractDeployUrl(result.text); + if (!url) { + return { ok: false, error: 'publish_no_url', ms, tools, text: String(result.text || '').slice(0, 400) }; + } + return { ok: true, url, ms, tools, denied, text: String(result.text || '').slice(0, 400) }; + } catch (error) { + const ms = Date.now() - started; + if (abortReason) return { ok: false, error: abortReason, ms, tools }; + if (isNotPermitted(error)) { + return { ok: false, error: 'publish_not_permitted:Agent 权限已关闭,' + AGENT_OFF_HINT, ms, tools }; + } + return { ok: false, error: 'publish_error:' + String(error && error.message ? error.message : error), ms, tools }; + } finally { + clearTimeout(timer); + } +} + +export { DEPLOY_URL_RE, DEFAULT_TIMEOUT_MS }; diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refcache.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refcache.mjs new file mode 100644 index 0000000..a6f5e78 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refcache.mjs @@ -0,0 +1,174 @@ +// 参考图下载缓存:按图片地址寻址,跨会话复用。 +// +// 为什么要有缓存:同一个实体(某个地标、某款产品)在不同会话里会被反复搜到同一批地址, +// 每次重新下载既慢又占带宽;而且判定不合格的地址应该记住,别再试第二遍。 +// 缓存只存"下载成功的原图"和"失败原因",不存任何会话信息,删掉不影响正确性。 +import { createHash } from 'node:crypto'; +import { readFile, writeFile, mkdir, readdir, stat, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { sniffFormat, imageSize, undecodableReason } from './image.mjs'; +import { fetchImage, privateReason } from './urlguard.mjs'; + +// 失败记录只记一天:防盗链、临时 404 这类问题隔天可能就好了。 +const FAIL_TTL_MS = 24 * 60 * 60 * 1000; +// 缓存条目上限。超了按修改时间淘汰最旧的,避免长期运行把磁盘吃满。 +const MAX_ENTRIES = 400; +const FETCH_TIMEOUT_MS = 45000; +// 单张参考图的字节上限:参考图会被缩小使用,超过这个大小的多半不是正常图片。 +const MAX_BYTES = 25 * 1024 * 1024; +// 机械门槛:太小的图放大就是马赛克,拿去当参考图反而会污染生成。 +// 字节门槛压得比较松:纯色 logo 的 PNG 可能只有几 KB,主要靠 MIN_EDGE 把关。 +const MIN_BYTES = 3 * 1024; +const MIN_EDGE = 320; + +export function createRefCache(dir) { + const ready = mkdir(dir, { recursive: true }).catch(() => {}); + + // 文件名只由地址决定,不含任何可读信息,也不接受外部传入的名字。 + function keyOf(url) { + return createHash('sha1').update(String(url)).digest('hex').slice(0, 24); + } + + function extOf(format) { + return format === 'png' ? 'png' : 'jpg'; + } + + async function lookup(url) { + await ready; + const key = keyOf(url); + for (const ext of ['png', 'jpg']) { + const file = key + '.' + ext; + try { + const info = await stat(join(dir, file)); + if (info.size > 0) return { key, file, path: join(dir, file), bytes: info.size, url, cached: true }; + } catch { + /* 换下一个后缀 */ + } + } + return null; + } + + async function store(url, buffer, format) { + await ready; + const key = keyOf(url); + const file = key + '.' + extOf(format); + await writeFile(join(dir, file), buffer); + return { key, file, path: join(dir, file), bytes: buffer.length, url, cached: false }; + } + + async function failedReason(url) { + await ready; + try { + const parsed = JSON.parse(await readFile(join(dir, keyOf(url) + '.fail'), 'utf8')); + if (Date.now() - Number(parsed.at || 0) > FAIL_TTL_MS) return ''; + return String(parsed.reason || '下载失败'); + } catch { + return ''; + } + } + + async function markFailed(url, reason) { + await ready; + try { + await writeFile( + join(dir, keyOf(url) + '.fail'), + JSON.stringify({ at: Date.now(), reason: String(reason || '') }), + ); + } catch { + /* 记不住失败不影响主流程 */ + } + } + + // 下载一张候选图。命中缓存直接返回;失败原因也会缓存,避免反复试同一个坏地址。 + // publicOnly:地址来自检索结果(第三方),不许指向本机或内网,命中缓存也不例外。 + // + // 方法名不能叫 fetch:函数声明会把全局 fetch 遮蔽掉,里面再调 fetch 就变成自我递归, + // 表现为整个参考图环节静默挂死(实测卡在规划完成之后一条事件都不出)。 + async function download(url, options) { + const publicOnly = Boolean(options && options.publicOnly); + if (publicOnly) { + const reason = await privateReason(url); + if (reason) return { ok: false, url, error: '地址被安全策略拒绝:' + reason }; + } + const hit = await lookup(url); + if (hit) return { ok: true, ...hit }; + const known = await failedReason(url); + if (known) return { ok: false, url, error: known, cached: true }; + try { + // 云元数据这类地址、以及重定向到它们的地址,由 fetchImage 逐跳拦下。 + const buffer = await fetchImage(url, { publicOnly, maxBytes: MAX_BYTES, timeoutMs: FETCH_TIMEOUT_MS }); + const format = sniffFormat(buffer); + if (format !== 'png' && format !== 'jpeg') throw new Error('格式不支持(' + format + ')'); + // 解不开的图必须在这里就拒掉:否则它会一路当上参考图,却拼不进对比图, + // 评审端看不到它,判定节点也只能"取第一张可用图",问题被藏得看不见。 + const undecodable = undecodableReason(buffer); + if (undecodable) throw new Error(undecodable); + if (buffer.length < MIN_BYTES) throw new Error('文件过小(' + buffer.length + ' 字节)'); + const size = imageSize(buffer); + if (size && Math.max(size.width, size.height) < MIN_EDGE) { + throw new Error('尺寸过小(' + size.width + 'x' + size.height + ')'); + } + const stored = await store(url, buffer, format); + return { ok: true, ...stored, width: size ? size.width : 0, height: size ? size.height : 0 }; + } catch (error) { + const reason = String(error && error.message ? error.message : error); + // 被安全策略拦下的不记失败:同一个地址换个来源(检索结果 / 用户自己给的)结论可能不一样, + // 而且每次重新判一遍很便宜。 + if (!(error && error.blocked)) await markFailed(url, reason); + return { ok: false, url, error: reason }; + } + } + + // 淘汰最旧的条目。放在每次会话开始时跑一次就够了。 + async function prune() { + await ready; + try { + const names = (await readdir(dir)).filter((name) => /\.(png|jpg)$/.test(name)); + if (names.length <= MAX_ENTRIES) return 0; + const stats = await Promise.all( + names.map(async (name) => { + try { + const info = await stat(join(dir, name)); + return { name, mtime: info.mtimeMs }; + } catch { + return null; + } + }), + ); + const alive = stats.filter(Boolean).sort((a, b) => b.mtime - a.mtime); + const drop = alive.slice(MAX_ENTRIES); + for (const item of drop) { + try { + await unlink(join(dir, item.name)); + } catch { + /* 删不掉就下次再说 */ + } + } + return drop.length; + } catch { + return 0; + } + } + + async function stats() { + await ready; + try { + const names = await readdir(dir); + const files = names.filter((name) => /\.(png|jpg)$/.test(name)); + const fails = names.filter((name) => name.endsWith('.fail')); + let bytes = 0; + for (const name of files) { + try { + bytes += (await stat(join(dir, name))).size; + } catch { + /* 统计失败无所谓 */ + } + } + return { files: files.length, fails: fails.length, bytes, dir }; + } catch { + return { files: 0, fails: 0, bytes: 0, dir }; + } + } + + return { dir, keyOf, lookup, download, failedReason, markFailed, prune, stats }; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refsearch.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refsearch.mjs new file mode 100644 index 0000000..15f92f3 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/refsearch.mjs @@ -0,0 +1,326 @@ +// 参考图检索:运行时直连 Connector 搜图工具。 +// +// 为什么不让 planner 自己调:planner 跑在 Mini App 派生的子会话里,工具列表里 +// 看不到 Connector 工具,它只会转而用 tool_search / run_code 摸索并烧光工具预算, +// refs 永远为空(实测多次,全部 refs: [])。 +// 改成:planner 只产出「要搜什么」(ref_queries),检索由运行时确定性执行。 + +const TOOL_MATCH = 'search_images'; +const MAX_QUERIES = 6; +// 每个实体保留的候选池大小。池子只是"待选名单",不会一次全下: +// 运行时按 3 张一批下载并判定,不合格才取下一批(见 workflow.mjs 的 refBatch)。 +// 池子留大一点,是为了让"这一批不行"时有下一批可取,而不是立刻放弃整个实体。 +const MAX_CANDIDATES = 9; + +// 工具名必须从 list() 取,不能自己拼(运行时文档明确要求)。 +const nameCache = new WeakMap(); + +function asText(value) { + return typeof value === 'string' ? value : ''; +} + +function message(error) { + return String(error && error.message ? error.message : error); +} + +// 用户可以在插件详情里关掉工具调用(tool:call),之后 list() 把所有工具报成 deny、call() 报 +// TOOL_NOT_PERMITTED。失败原因会原样进时间线,所以这里写清楚去哪里打开,而不只是一个错误码。 +const TOOL_OFF_HINT = + '如果你在 MiniMax Code 的插件详情里关闭了 H3-Image-Executor 的工具调用(tool:call),请重新打开;' + + '也可能是你的权限规则拒绝了这个工具'; + +// 失败原因要带错误码,否则排查时只能看到一句 provider down。 +function describeError(error) { + const code = error && error.code ? String(error.code) : ''; + const base = (code ? '(' + code + ')' : '') + message(error); + if (code === 'TOOL_NOT_PERMITTED') return base + '。' + TOOL_OFF_HINT; + if (code === 'TOOL_APPROVAL_REQUIRED') return base + '。搜图工具需要你确认,但现在没有能弹出确认框的 MiniMax Code 窗口'; + return base; +} + +// provider 侧的临时故障值得再试一次;权限和参数错误重试也没用。 +const RETRYABLE_CODES = new Set([ + 'CONNECTOR_PROVIDER_ERROR', + 'CONNECTOR_TIMEOUT', + 'CONNECTOR_UNAVAILABLE', + 'TOOL_TIMEOUT', + 'SERVICE_RESTARTED', +]); + +function isRetryable(error) { + if (!error) return false; + if (error.retryable === true) return true; + return RETRYABLE_CODES.has(String(error.code || '')); +} + +// 权限类错误和查询内容无关:换写法、换实体再调只会被同样拒绝,等于绕着 deny 反复试 +// (docs/security.md:Do not try to work around a deny)。碰到就整次检索停下。 +const PERMISSION_CODES = new Set(['TOOL_NOT_PERMITTED', 'TOOL_APPROVAL_REQUIRED']); + +function isPermissionError(error) { + return Boolean(error) && PERMISSION_CODES.has(String(error.code || '')); +} + +function normalizeQuery(value) { + return asText(value).replace(/\s+/g, ' ').trim(); +} + +async function resolveTool(context) { + if (!context || !context.tool || typeof context.tool.call !== 'function') { + return { error: '运行时没有 tool 能力:manifest 需要声明 "toolAccess": ["call"]' }; + } + const cached = nameCache.get(context); + // 只缓存已放行的结果:deny / ask 每次都重新列一遍,用户把开关打开后下一轮就能用,不用重启。 + if (cached && cached.permission === 'allow') return cached; + let listed; + try { + listed = await context.tool.list(); + } catch (error) { + return { error: '读取工具列表失败:' + message(error) }; + } + const tools = (listed && listed.tools) || []; + const found = tools.find((item) => String(item.name || '').indexOf(TOOL_MATCH) >= 0); + if (!found) return { error: '工具列表里没有搜图工具(已列出 ' + tools.length + ' 个)' }; + const resolved = { name: found.name, permission: found.permission }; + nameCache.set(context, resolved); + return resolved; +} + +// ask 状态先弹一次授权,把工具加进用户的长期允许列表;没人可问就放弃,不要盲调。 +async function ensureAllowed(context, resolved) { + if (resolved.permission === 'allow') return ''; + if (resolved.permission === 'deny') return '搜图工具 ' + resolved.name + ' 当前被拒绝(deny)。' + TOOL_OFF_HINT; + const caps = context.capabilities; + const canRequest = caps && typeof caps.has === 'function' && caps.has('tool.request'); + if (!canRequest) return '搜图工具需要授权,但当前运行时不支持 tool.request'; + try { + const answer = await context.tool.request([resolved.name]); + const state = answer && answer[resolved.name]; + if (state) resolved.permission = state; + } catch (error) { + return '搜图工具授权未完成:' + describeError(error); + } + if (resolved.permission === 'deny') return '搜图工具 ' + resolved.name + ' 被拒绝(deny)。' + TOOL_OFF_HINT; + if (resolved.permission !== 'allow') return '搜图工具仍需授权(' + resolved.permission + '):你没有在确认框里允许它'; + return ''; +} + +// provider 可能返回 JSON 字符串,也可能直接返回对象,两种都接。 +function parseValue(value) { + if (value && typeof value === 'object') return value; + const raw = asText(value); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// provider 用 results[].query 回显查询文本;优先按文本匹配,匹配不上再按顺序兜底。 +// +// 每个实体保留多张候选(https 优先),因为第一张经常下载失败或是不支持的格式(比如 webp)。 +// 只取一张会让整个实体白丢。 +// 批量返回是一份扁平 results,要按 query 文本分回各个实体。 +// 只有条数一一对应时才允许按顺序兜底:拆单条之后 rows 会比 queries 短, +// 此时 rows[index] 指向的是别人的结果——参考图张冠李戴比没有参考图危险得多。 +function splitBatchRows(rows, queries) { + const aligned = rows.length === queries.length; + const used = new Set(); + return queries.map((item, index) => { + const byText = rows.find( + (candidate) => + !used.has(candidate) && normalizeQuery(candidate && candidate.query) === normalizeQuery(item.query), + ); + let row = byText || null; + if (!row && aligned) { + const fallback = rows[index]; + if (fallback && !used.has(fallback)) row = fallback; + } + if (row) used.add(row); + return row ? [row] : []; + }); +} + +// rowsPerQuery 与 queries 一一对应,每项是该实体自己的结果行。 +// 每个实体保留一串候选(https 优先),因为第一张经常下载失败或是不支持的格式。 +function pickImages(rowsPerQuery, queries) { + return queries.map((item, index) => { + const images = []; + for (const row of rowsPerQuery[index] || []) { + for (const image of row && Array.isArray(row.images) ? row.images : []) images.push(image); + } + const seen = new Set(); + const candidates = []; + for (const pattern of [/^https:\/\//i, /^http:\/\//i]) { + for (const image of images) { + const url = asText(image && image.image_url); + if (!pattern.test(url) || seen.has(url)) continue; + seen.add(url); + candidates.push({ + url, + title: asText(image.title), + source: asText(image.source) || asText(image.link), + }); + if (candidates.length >= MAX_CANDIDATES) break; + } + if (candidates.length >= MAX_CANDIDATES) break; + } + const head = candidates[0] || null; + return { + task_name: item.task_name, + query: item.query, + url: head ? head.url : '', + title: head ? head.title : '', + source: head ? head.source : '', + candidates, + }; + }); +} + +// 同一个实体换一批说法再搜。只在搜索成功、但候选全部下载失败或判定不合格时用, +// 不用于绕过搜索服务的拒绝。原查询通常写得很"描述性"("东京塔 黄昏 侧面 远景"), +// 搜索引擎更吃"实体名 + 官方图 / 高清正面"这种写法。 +export function broadenQuery(query, round) { + const base = normalizeQuery(query); + const head = base.split(' ').slice(0, 2).join(' ') || base; + const variants = [base + ' 官方图', head + ' 高清 正面', head]; + const picked = variants[Math.max(0, Math.min(Number(round) || 0, variants.length - 1))]; + return normalizeQuery(picked) || base; +} + +export function normalizeRefQueries(raw) { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const out = []; + for (const item of raw) { + if (!item) continue; + const query = asText(typeof item === 'string' ? item : item.query).trim(); + if (!query) continue; + let taskName = asText(item.task_name).trim().replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); + if (!taskName) taskName = 'ref' + (out.length + 1); + while (seen.has(taskName)) taskName += '1'; + seen.add(taskName); + out.push({ task_name: taskName, query, prompt: asText(item.prompt).trim() }); + if (out.length >= MAX_QUERIES) break; + } + return out; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// provider 侧限流明显:单条调用之间几乎不间隔时,后面几条会直接报 CONNECTOR_PROVIDER_ERROR。 +// 拆单条之后必须留间隔,失败再退避重试。 +const SINGLE_GAP_MS = 900; +const SINGLE_ATTEMPTS = 2; +const BACKOFF_MS = 1200; +// 连续这么多个实体都失败,就认定搜图服务整体不可用,后面的实体不再逐个去试, +// 否则服务真挂掉时会把整轮时间耗光。 +const BREAKER_LIMIT = 3; + +function isOk(payload) { + return Boolean(payload) && (payload.code === undefined || Number(payload.code) === 0); +} + +function invoke(context, name, list) { + return context.tool.call(name, { + queries: list.map((item) => ({ + query: item.query, + prompt: item.prompt || item.query, + task_name: item.task_name, + })), + }); +} + +// 单条查询:抛错和「返回了但 code != 0」都算失败,临时故障(限流等)值得退避后用同一个查询再试一次。 +// 不换说法去绕过:搜索服务拒绝某个查询时,这个实体就按没搜到处理,用户可以自己上传参考图。 +// 权限错误直接停下整次检索。 +async function searchOne(context, name, item) { + let lastError = ''; + for (let attempt = 0; attempt < SINGLE_ATTEMPTS; attempt += 1) { + if (attempt) await sleep(BACKOFF_MS * attempt); + try { + const payload = parseValue((await invoke(context, name, [item])).value); + if (isOk(payload) && Array.isArray(payload.results)) return { rows: payload.results, error: '' }; + lastError = payload ? asText(payload.message) || 'code ' + payload.code : '返回无法解析'; + } catch (error) { + lastError = describeError(error); + if (isPermissionError(error)) return { rows: [], error: lastError, blocked: true }; + if (!isRetryable(error)) break; + } + } + return { rows: [], error: lastError || '未知失败' }; +} + +export async function searchRefImages(context, queries) { + const list = (queries || []).filter((item) => item && asText(item.query)); + if (!list.length) return { ok: false, reason: '没有检索意图', hits: [], notes: [] }; + const resolved = await resolveTool(context); + if (resolved.error) return { ok: false, reason: resolved.error, hits: [], notes: [] }; + const blocked = await ensureAllowed(context, resolved); + if (blocked) return { ok: false, reason: blocked, hits: [], notes: [] }; + + const failures = []; + const notes = []; + let rowsPerQuery = null; + // 权限被拒:整次检索停下,并丢掉「已放行」的缓存,下一次检索重新列工具、读到最新的权限。 + const stopForPermission = (reason) => { + nameCache.delete(context); + return { ok: false, reason: '搜图工具不可用:' + reason, hits: [], notes }; + }; + + // 批量只试一次:这个 provider 对多条 queries 经常直接报错,单条才稳定。 + if (list.length > 1) { + try { + const payload = parseValue((await invoke(context, resolved.name, list)).value); + if (isOk(payload) && Array.isArray(payload.results)) rowsPerQuery = splitBatchRows(payload.results, list); + else failures.push('批量检索:' + (payload ? asText(payload.message) || 'code ' + payload.code : '返回无法解析')); + } catch (error) { + if (isPermissionError(error)) return stopForPermission(describeError(error)); + failures.push('批量检索:' + describeError(error)); + } + } + + if (!rowsPerQuery) { + rowsPerQuery = []; + let deadStreak = 0; + for (let index = 0; index < list.length; index += 1) { + const item = list[index]; + if (deadStreak >= BREAKER_LIMIT) { + rowsPerQuery.push([]); + failures.push(item.task_name + ':搜图服务连续失败,已跳过'); + continue; + } + if (index) await sleep(SINGLE_GAP_MS); + const one = await searchOne(context, resolved.name, item); + if (one.blocked) return stopForPermission(one.error); + rowsPerQuery.push(one.rows); + if (one.rows.length) { + deadStreak = 0; + } else { + deadStreak += 1; + failures.push(item.task_name + ':' + (one.error || '没有返回结果')); + } + } + } + + const hits = pickImages(rowsPerQuery, list); + const got = hits.filter((item) => item.url); + if (!got.length) { + return { + ok: false, + reason: '搜图失败:' + (failures[0] || '没有返回结果'), + hits: [], + notes, + }; + } + return { + ok: true, + reason: failures.length ? '部分实体没搜到:' + failures.join(';') : '', + notes, + tool: resolved.name, + hits, + }; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/review.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/review.mjs new file mode 100644 index 0000000..b8e7eba --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/review.mjs @@ -0,0 +1,292 @@ +// 评审材料准备:把"agent 自己写脚本裁图量像素"这件事彻底收进 harness。 +// +// 设计目标:评审节点只需要 read 一张图就能看全整体、亮部、暗部和 1:1 局部, +// 不再需要裁图、拼图、量像素、跑统计。材料自带数字标签,评审可以直接引用"第 5 块"。 +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { encodePng, downsample, drawImage, createCanvas, adjustGamma, analyze, detectDrift, detectOverSharpen } from './png.mjs'; +import { decodeImage } from './image.mjs'; + +export const SHEET_WIDTH = 1024; +const TILE_COLS = 2; +const TILE_ROWS = 2; +const SEPARATOR = 3; +const LABEL_SCALE = 4; + +export const CANDIDATE_SHEET_LAYOUT = + '自上而下三段:第 1 段「1」= 整体预览(看构图、主体、氛围);' + + '第 2 段是两联,左「2」= 暗部提亮(看暗处有没有藏东西、多余物体、水印),右「3」= 亮部压暗(看高光有没有溢出死白);' + + '第 3 段「4」「5」「6」「7」= 局部放大,依次取自左上、右上、左下、右下象限的中心(看畸形、糊字、接缝、材质)。每块左上角有数字标签。'; + +// ---------------------------------------------------------------- 候选看图材料 + +// 从已解码图像直接合成,避免重复解码(候选图常有 1600 万像素,解码很贵)。 +export function renderCandidateSheet(image) { + const overview = fitWidth(image, SHEET_WIDTH); + // 曝光两联只需要半宽,把像素预算让给局部放大。 + const halfWidth = Math.floor(SHEET_WIDTH / 2); + const small = fitWidth(image, halfWidth); + const lifted = adjustGamma(small, 0.45); + const pulled = adjustGamma(small, 1.9); + const tileBoxW = Math.floor(SHEET_WIDTH / TILE_COLS); + const quadW = Math.floor(image.width / TILE_COLS); + const quadH = Math.floor(image.height / TILE_ROWS); + // 局部放大倍率控制在 2.5x 以内:再大就会糊掉文字和手部细节,再小则看不出畸形。 + const cropSide = Math.max(64, Math.min(quadW, quadH, Math.round(tileBoxW * 2.5))); + + const bandHeights = [overview.height, lifted.height, tileBoxW * TILE_ROWS]; + const total = bandHeights.reduce((a, b) => a + b, 0) + SEPARATOR * 2; + const canvas = createCanvas(SHEET_WIDTH, total, 3); + + let y = 0; + drawImage(canvas, overview, 0, y, SHEET_WIDTH, overview.height); + drawLabel(canvas, '1', 0, y); + y += overview.height; + fillBand(canvas, y, SEPARATOR); + y += SEPARATOR; + + drawImage(canvas, lifted, 0, y, halfWidth, lifted.height); + drawLabel(canvas, '2', 0, y); + drawImage(canvas, pulled, halfWidth, y, halfWidth, pulled.height); + drawLabel(canvas, '3', halfWidth, y); + y += lifted.height; + fillBand(canvas, y, SEPARATOR); + y += SEPARATOR; + + for (let row = 0; row < TILE_ROWS; row += 1) { + for (let col = 0; col < TILE_COLS; col += 1) { + const cx = col * quadW + Math.floor((quadW - cropSide) / 2); + const cy = row * quadH + Math.floor((quadH - cropSide) / 2); + const tile = crop(image, cx, cy, cropSide, cropSide); + const dx = col * tileBoxW; + const dy = y + row * tileBoxW; + drawImage(canvas, tile, dx, dy, tileBoxW, tileBoxW); + drawLabel(canvas, String(4 + row * TILE_COLS + col), dx, dy); + } + } + return canvas; +} + +export async function buildCandidateSheet(imagePath, outPath) { + const image = decodeImage(await readFile(imagePath)); + const canvas = renderCandidateSheet(image); + await writeImage(outPath, canvas); + return { sheet: outPath, width: image.width, height: image.height, layout: CANDIDATE_SHEET_LAYOUT }; +} + +// 批量生成,entries: [{ id, path }],返回 Map(id -> sheetPath)。并发解码,失败的单张跳过。 +export async function buildCandidateSheets(entries, outDir) { + await mkdir(outDir, { recursive: true }); + const results = await Promise.all( + entries.map(async (entry) => { + try { + const out = join2(outDir, entry.id + '-sheet.png'); + const info = await buildCandidateSheet(entry.path, out); + return { id: entry.id, sheet: info.sheet }; + } catch (error) { + return { id: entry.id, error: String(error && error.message ? error.message : error) }; + } + }), + ); + const map = new Map(); + for (const item of results) if (item.sheet) map.set(item.id, item.sheet); + return map; +} + +// ---------------------------------------------------------------- 参考图 / 并排对比 + +// 把多张图拼成一张带编号的对比图。labels 决定左上角标签文字。 +export async function buildGridSheet(items, outPath, options) { + const width = (options && options.width) || SHEET_WIDTH; + const perRow = Math.min(items.length, (options && options.perRow) || 4); + const rows = Math.ceil(items.length / perRow); + const cellW = Math.floor(width / perRow); + const cellH = cellW; + const canvas = createCanvas(width, cellH * rows, 3); + const used = []; + for (let index = 0; index < items.length; index += 1) { + let image; + try { + image = decodeImage(await readFile(items[index].path)); + } catch { + continue; + } + const box = fitBox(image.width, image.height, cellW, cellH); + const col = index % perRow; + const row = Math.floor(index / perRow); + const dx = col * cellW + Math.floor((cellW - box.w) / 2); + const dy = row * cellH + Math.floor((cellH - box.h) / 2); + drawImage(canvas, image, dx, dy, box.w, box.h); + const label = items[index].label || String(index + 1); + drawLabel(canvas, label, col * cellW, row * cellH); + used.push({ label, path: items[index].path, width: image.width, height: image.height }); + } + await writeImage(outPath, canvas); + return { sheet: outPath, items: used }; +} + +export async function buildReferenceSheet(refs, outPath) { + const items = refs.map((ref, index) => ({ path: ref.path || ref, label: ref.id || String(index + 1) })); + const result = await buildGridSheet(items, outPath, { perRow: 4 }); + if (!result.items.length) return null; + return result; +} + +// ---------------------------------------------------------------- 程序测量 + +export async function imageStats(imagePath) { + const image = decodeImage(await readFile(imagePath)); + const stats = analyze(image); + return { + file: imagePath, + width: image.width, + height: image.height, + aspectRatio: round(image.width / image.height, 4), + longEdge: Math.max(image.width, image.height), + stats, + drift: detectDrift(stats, null), + histogram: histogram(image), + }; +} + +export async function compareImages(imagePath, parentPath) { + const current = decodeImage(await readFile(imagePath)); + const parent = decodeImage(await readFile(parentPath)); + const currentStats = analyze(current); + const parentStats = analyze(parent); + return { + current: currentStats, + parent: parentStats, + drift: detectDrift(currentStats, parentStats), + sharpness: detectOverSharpen(currentStats, parentStats), + meanAbsDiff: round(meanAbsDiff(current, parent), 3), + }; +} + +// ---------------------------------------------------------------- 内部工具 + +async function writeImage(outPath, canvas) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, encodePng(canvas, { level: 3 })); +} + +function fitWidth(image, width) { + if (image.width <= width) return image; + return downsample(image, width); +} + +export function fitBox(srcW, srcH, boxW, boxH) { + const scale = Math.min(boxW / srcW, boxH / srcH); + return { w: Math.max(1, Math.round(srcW * scale)), h: Math.max(1, Math.round(srcH * scale)) }; +} + +function fillBand(canvas, y, height) { + for (let row = y; row < Math.min(canvas.height, y + height); row += 1) { + canvas.data.fill(96, row * canvas.width * canvas.channels, (row + 1) * canvas.width * canvas.channels); + } +} + +const DIGITS = { + 0: ['111', '101', '101', '101', '111'], + 1: ['010', '110', '010', '010', '111'], + 2: ['111', '001', '111', '100', '111'], + 3: ['111', '001', '111', '001', '111'], + 4: ['101', '101', '111', '001', '001'], + 5: ['111', '100', '111', '001', '111'], + 6: ['111', '100', '111', '101', '111'], + 7: ['111', '001', '001', '001', '001'], + 8: ['111', '101', '111', '101', '111'], + 9: ['111', '101', '111', '001', '111'], + R: ['111', '101', '111', '110', '101'], +}; + +// 在 (x, y) 处画一个黑底白字的编号标签,让材料图自解释,评审可以直接说"第 5 块"。 +function drawLabel(canvas, text, x, y) { + const scale = LABEL_SCALE; + const glyphW = 3 * scale; + const glyphH = 5 * scale; + const pad = scale; + const chars = String(text).split(''); + const boxW = chars.length * (glyphW + scale) - scale + pad * 2; + const boxH = glyphH + pad * 2; + for (let row = y; row < Math.min(canvas.height, y + boxH); row += 1) { + for (let col = x; col < Math.min(canvas.width, x + boxW); col += 1) { + const di = (row * canvas.width + col) * canvas.channels; + canvas.data[di] = 17; + canvas.data[di + 1] = 17; + canvas.data[di + 2] = 17; + } + } + chars.forEach((char, index) => { + const glyph = DIGITS[char] || DIGITS[Number(char)]; + if (!glyph) return; + const ox = x + pad + index * (glyphW + scale); + const oy = y + pad; + for (let gy = 0; gy < 5; gy += 1) { + for (let gx = 0; gx < 3; gx += 1) { + if (glyph[gy][gx] !== '1') continue; + for (let py = 0; py < scale; py += 1) { + for (let px = 0; px < scale; px += 1) { + const cx = ox + gx * scale + px; + const cy = oy + gy * scale + py; + if (cx >= canvas.width || cy >= canvas.height) continue; + const di = (cy * canvas.width + cx) * canvas.channels; + canvas.data[di] = 255; + canvas.data[di + 1] = 255; + canvas.data[di + 2] = 255; + } + } + } + } + }); +} + +function crop(image, x, y, width, height) { + const out = Buffer.alloc(width * height * image.channels); + for (let row = 0; row < height; row += 1) { + const sy = Math.min(image.height - 1, y + row); + const src = (sy * image.width + x) * image.channels; + image.data.copy(out, row * width * image.channels, src, src + width * image.channels); + } + return { width, height, channels: image.channels, data: out }; +} + +function histogram(image) { + const buckets = new Array(8).fill(0); + const { channels, data } = image; + const step = Math.max(1, Math.floor((image.width * image.height) / 200000)); + let count = 0; + for (let p = 0; p < image.width * image.height; p += step) { + const i = p * channels; + const luma = 0.2126 * data[i] + 0.7152 * data[i + Math.min(1, channels - 1)] + 0.0722 * data[i + Math.min(2, channels - 1)]; + buckets[Math.min(7, Math.floor(luma / 32))] += 1; + count += 1; + } + return buckets.map((value) => round(value / count, 4)); +} + +function meanAbsDiff(a, b) { + const width = Math.min(a.width, b.width); + const height = Math.min(a.height, b.height); + const step = Math.max(1, Math.floor((width * height) / 100000)); + let sum = 0; + let count = 0; + for (let p = 0; p < width * height; p += step) { + const y = Math.floor(p / width); + const x = p % width; + const ai = (Math.floor((y / height) * a.height) * a.width + Math.floor((x / width) * a.width)) * a.channels; + const bi = (Math.floor((y / height) * b.height) * b.width + Math.floor((x / width) * b.width)) * b.channels; + sum += Math.abs(a.data[ai] - b.data[bi]); + count += 1; + } + return count ? sum / count : 0; +} + +function round(value, digits) { + const p = Math.pow(10, digits); + return Math.round(value * p) / p; +} + +function join2(dir, name) { + return dir.endsWith('/') ? dir + name : dir + '/' + name; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/share.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/share.mjs new file mode 100644 index 0000000..2ee9e4d --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/share.mjs @@ -0,0 +1,322 @@ +// 会话分享:把一整条链路(事件流 + 详情块 + prompt 对照 + 图)打包成一个自包含 HTML。 +// 图片全部降采样后内嵌成 data URL,不依赖 Mini App 在线、不暴露任何本地路径或凭据。 +import { readFile, writeFile, mkdir, rename, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as png from './png.mjs'; +import { decodeImage } from './image.mjs'; +import { encodeJpeg } from './jpegenc.mjs'; + +const DEFAULTS = { + // 预览图长边。再大对"看链路"没帮助,只会把这个 HTML 撑到几十 MB。 + edge: 640, + // 评审材料图长边。分享页是给人翻的,不是给 VLM 看的——720px 的看图材料内嵌成 + // PNG 后平均 630KB/张,占整页七成体积(实测 20261110-c46dde 16.9MB、 + // 20261109-11b90b 19.4MB),大到部署不动。降到 560px 面积比 0.6,体积省四成, + // 局部放大块仍然看得清。 + sheetEdge: 560, + // 检索候选缩略图长边。这些图在页面上只有 92px 宽(2x 屏约 184px),内嵌大了纯属浪费体积。 + thumbEdge: 240, + // 交付图点开后显示的大图长边。640px 的预览放到全屏会糊,原图又太大(实测单张 3–7 MB、 + // 5472×3072,整页部署不动)。2048px、q86 的 JPEG 实测每张 0.6–0.9 MB,全屏看足够清楚。 + // 设为 0 就不内嵌大图,点开仍是预览图。 + largeEdge: 2048, + // 大图的总预算(编码后的字节数,base64 之前;base64 之后约再大三分之一)。 + // 按交付顺序加,超出预算的几张只带普通预览,免得交付图多的会话整页大到部署不动。 + largeBudget: 4 * 1024 * 1024, + includeSheets: true, + includeRefs: true, + // 内嵌 JPEG 质量。86 是给「看图材料上有编号文字」留的余量:q82 在照片上已经看不出差别, + // 但评审材料块号是小字,压太狠会糊。 + embedQuality: 86, +}; + +const EMBED_QUALITY = DEFAULTS.embedQuality; + +// 读一张图,降采样到 maxEdge,返回 data URL;失败返回 null(分享页不能因为一张图挂掉)。 +// 用 decodeImage 而不是 decodePng:缓存里的候选图常常是 JPEG,只认 PNG 会让缩略图整片消失。 +// 内嵌统一走 JPEG:分享页里的图全是照片类内容(生成图、参考图、评审看图材料), +// PNG 存照片极不划算——实测 640px 交付预览图 PNG level6 要 559KB,同一张 q86 JPEG 只要 ~90KB。 +// 整页因此从 13–19MB 降到 1–2MB,否则大到 website_deploy 部署不动。 +async function embedImage(path, maxEdge) { + try { + const buffer = await readFile(path); + const decoded = decodeImage(buffer); + const small = Math.max(decoded.width, decoded.height) > maxEdge ? png.downsample(decoded, maxEdge) : decoded; + return 'data:image/jpeg;base64,' + encodeJpeg(small, { quality: EMBED_QUALITY }).toString('base64'); + } catch { + return null; + } +} + +// 交付图的大图。纯 JS 解码一张 5472×3072 的原图要 1.8 秒左右,而分享页在交付后、点预览、 +// 点发布时都会重建,所以结果缓存在会话目录的 share/large/ 下:交付文件名带时间戳、内容不会变, +// 同一张图只算一次。同一进程里同时发起的构建共用一次计算。失败返回 null,点开就退回预览图。 +const largeJobs = new Map(); + +async function largeImage(sourcePath, cachePath, maxEdge) { + let source; + try { + source = await stat(sourcePath); + } catch { + return null; + } + try { + const cached = await stat(cachePath); + if (cached.size > 0 && cached.mtimeMs >= source.mtimeMs) return await readFile(cachePath); + } catch { + /* 还没缓存过 */ + } + if (largeJobs.has(cachePath)) return largeJobs.get(cachePath); + const job = (async () => { + try { + // 解码是同步的:先让出一次事件循环,页面轮询之类的请求不至于一直排在几张大图后面。 + await new Promise((resolve) => setImmediate(resolve)); + const decoded = decodeImage(await readFile(sourcePath)); + const small = Math.max(decoded.width, decoded.height) > maxEdge ? png.downsample(decoded, maxEdge) : decoded; + const bytes = encodeJpeg(small, { quality: EMBED_QUALITY }); + await mkdir(dirname(cachePath), { recursive: true }); + const tmp = cachePath + '.tmp'; + await writeFile(tmp, bytes); + await rename(tmp, cachePath); + return bytes; + } catch { + return null; + } finally { + largeJobs.delete(cachePath); + } + })(); + largeJobs.set(cachePath, job); + return job; +} + +// 按交付顺序给每张交付图配一份大图,键是 large/<交付文件>,总量受 largeBudget 限制。 +async function collectLargeImages(snapshot, dirs, options, images) { + if (!(options.largeEdge > 0) || !dirs.root || !dirs.deliver) return 0; + let used = 0; + let count = 0; + for (const item of (snapshot.delivery && snapshot.delivery.items) || []) { + if (!item || !item.file) continue; + const parts = String(item.file).split('/'); + const file = parts[parts.length - 1]; + const sub = parts.length > 1 ? parts[0] : ''; + if (!sub || !file) continue; + const cachePath = join(dirs.root, 'share', 'large', sub + '-' + file.replace(/\.[^.]+$/, '') + '.' + options.largeEdge + '.jpg'); + const bytes = await largeImage(join(dirs.deliver, sub, file), cachePath, options.largeEdge); + if (!bytes) continue; + if (used + bytes.length > options.largeBudget) break; + used += bytes.length; + count += 1; + images['large/' + item.file] = 'data:image/jpeg;base64,' + bytes.toString('base64'); + } + return count; +} + +// 事件详情里的缩略图键:检索候选存在共享缓存目录里,分享页要离线可看就必须一并内嵌。 +function collectThumbKeys(snapshot) { + const keys = []; + const walk = (blocks) => { + (blocks || []).forEach((block) => { + if (!block) return; + if (block.kind === 'thumbs') { + for (const item of block.items || []) if (item && item.key) keys.push(item.key); + } + if (block.kind === 'group') walk(block.blocks); + }); + }; + for (const event of snapshot.events || []) { + if (event.detail) walk(event.detail.blocks); + } + return keys; +} + +// 收集这次会话需要内嵌的所有图片,返回 { key: dataUrl }。 +async function collectImages(snapshot, dirs, options) { + const wanted = new Map(); + const add = (key, path, edge) => { + if (!key || !path) return; + if (!wanted.has(key)) wanted.set(key, { path, edge }); + }; + + if (options.includeRefs) { + for (const ref of snapshot.refs || []) { + const file = String(ref.file || '').split('/').pop(); + if (file) add('refs/' + file, join(dirs.refs, file), options.edge); + } + } + + // 检索候选:按事件里出现过的键去取,避免把整个缓存目录扫一遍。 + if (dirs.cache) { + for (const key of new Set(collectThumbKeys(snapshot))) { + if (key.indexOf('cache/') !== 0) continue; + const file = key.slice('cache/'.length); + if (file) add(key, join(dirs.cache, file), options.thumbEdge); + } + } + + for (const image of snapshot.images || []) { + for (const round of image.rounds || []) { + for (const candidate of round.candidates || []) { + if (candidate.previewFile) add('previews/' + candidate.previewFile, join(dirs.previews, candidate.previewFile), options.edge); + if (options.includeSheets && candidate.sheetFile) { + add('sheets/' + candidate.sheetFile, join(dirs.sheets, candidate.sheetFile), options.sheetEdge); + } + } + } + } + + for (const item of (snapshot.delivery && snapshot.delivery.items) || []) { + if (!item.previewFile) continue; + const parts = String(item.previewFile).split('/'); + const file = parts[parts.length - 1]; + const sub = parts.length > 1 ? parts[0] : ''; + if (sub && file) add('deliver/' + item.previewFile, join(dirs.deliver, sub, file), options.edge); + } + + const entries = await Promise.all( + Array.from(wanted.entries()).map(async ([key, target]) => [key, await embedImage(target.path, target.edge)]), + ); + const images = {}; + for (const [key, dataUrl] of entries) { + if (dataUrl) images[key] = dataUrl; + } + return images; +} + +// 内嵌在 与行分隔符。 +function escapeForScript(json) { + return json.split('<').join('\\u003c').split('\u2028').join('\\u2028').split('\u2029').join('\\u2029'); +} + +// 分享页会离开本机、可能被部署成公开链接,而提示词里直接写着看图材料的绝对路径 +// (评审节点的输入就是「看图材料:/Users/…/sheets/xxx.png」)。 +// 1. 会话目录、插件数据目录:精确替换成占位符,保留后面的相对结构,读者还认得出是哪个文件。 +// 原样写法和 JSON 转义后的写法都要换(Windows 路径进了 JSON 反斜杠会翻倍)。 +// 2. 其余本机路径整段抹掉:用户主目录下的任何路径,以及 macOS /Users/… 与 Windows X:\Users\… 兜底, +// 覆盖 Agent 回复里提到的工作目录之类的路径。以前只认 /Users/,Windows 上会原样公开出去。 +function redactLocalPaths(html, input) { + let out = html; + for (const [path, label] of [ + [input.sessionDir, '<会话目录>'], + [input.dataDir, '<插件数据目录>'], + ]) { + for (const form of pathForms(path)) out = out.split(form).join(label); + } + for (const form of pathForms(input.homeDir)) { + out = out.replace(new RegExp(escapeRegExp(form) + '[^\\s"\'<>]*', 'g'), '<本机路径>'); + } + return out + .replace(/\/Users\/[^\s"'<>]+/g, '<本机路径>') + .replace(/[A-Za-z]:(?:\\+|\/)Users(?:\\+|\/)[^\s"'<>]+/gi, '<本机路径>'); +} + +// 一个目录在页面里可能出现的几种写法,长的在前:先换长的,短的才不会把长的切成两半。 +function pathForms(path) { + const raw = String(path || '').replace(/[\\/]+$/, ''); + // 太短的不替换:根目录或盘符这种前缀会把整页正文都换掉。 + if (raw.length < 4) return []; + const forms = new Set([raw, JSON.stringify(raw).slice(1, -1)]); + if (raw.includes('\\')) { + const slashed = raw.split('\\').join('/'); + forms.add(slashed); + } + return Array.from(forms).sort((a, b) => b.length - a.length); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char]); +} + +// 老会话落盘时还没有 candidate.prompt / item.prompt,用当轮动作或规格里的 prompt 兜底, +// 否则分享出去的页面只剩服务端改写那一栏,看不出我们原本送了什么。 +function backfillPrompts(snapshot) { + for (const image of snapshot.images || []) { + for (const round of image.rounds || []) { + const actionPrompt = (round.action && round.action.prompt) || image.spec.prompt || ''; + for (const candidate of round.candidates || []) { + if (!candidate.prompt) candidate.prompt = actionPrompt; + } + } + } + for (const item of (snapshot.delivery && snapshot.delivery.items) || []) { + if (item.prompt) continue; + const image = (snapshot.images || []).find((entry) => entry.id === item.id); + item.prompt = (image && image.spec && image.spec.prompt) || ''; + } + return snapshot; +} + +export async function buildShareHtml(input) { + // 逐键合并:直接把 undefined 展开进去会把默认值覆盖掉, + // 结果是降采样被判成"不需要",原图被整张内嵌,分享页直接膨胀到十几 MB。 + const options = { ...DEFAULTS }; + for (const [key, value] of Object.entries(input.options || {})) { + if (value !== undefined) options[key] = value; + } + const snapshot = backfillPrompts(input.snapshot); + const dirs = input.dirs; + const clientDir = input.clientDir; + + const [css, blocks, pageScript] = await Promise.all([ + readFile(join(clientDir, 'styles.css'), 'utf8'), + readFile(join(clientDir, 'blocks.js'), 'utf8'), + readFile(join(clientDir, 'share.js'), 'utf8'), + ]); + const images = await collectImages(snapshot, dirs, options); + const large = await collectLargeImages(snapshot, dirs, options, images); + const payload = escapeForScript(JSON.stringify({ snapshot, images, generatedAt: Date.now() })); + const title = 'H3-Image-Executor · ' + (snapshot.instruction || snapshot.id); + + const html = [ + '', + '', + ' ', + ' ', + ' ', + ' ' + escapeHtml(title) + '', + ' ', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + '', + '', + ].join('\n'); + + const safe = redactLocalPaths(html, { + sessionDir: dirs && dirs.root, + dataDir: input.dataDir, + homeDir: homedir(), + }); + return { html: safe, images: Object.keys(images).length, large, bytes: Buffer.byteLength(safe) }; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/urlguard.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/urlguard.mjs new file mode 100644 index 0000000..26f478a --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/urlguard.mjs @@ -0,0 +1,285 @@ +// 图片下载地址安全校验。 +// +// 为什么要管:规划节点(LLM)可以在 image.spec.refs 里直接写公网地址,运行时只校验了 +// 协议是不是 http/https 就去 fetch,地址本身没人看。于是 http://169.254.169.254/ 这种 +// 云元数据地址会被当成「参考图」下载并落盘到 refcache/。 +// +// 只拦两类,都是「不可能是合法图片源、但恰恰最危险」的地址: +// 169.254.0.0/16 链路本地段,AWS/GCP/Azure/阿里云的元数据服务都在这里 +// 0.0.0.0 / :: 通配地址 +// 另外顺带拦 fe80::/10(IPv6 的链路本地段,和 169.254/16 是同一语义,同样零误伤)。 +// +// 私有段(10./172.16./192.168.)和 127.0.0.1 / ::1 在这一层放行:H3 接口本身可能就在企业 +// 内网,它返回的成图地址也是内网地址;用户也可能合法地在需求里给出家里 NAS 上的照片当参考图。 +// 检索工具给出的第三方地址另有一层更严的检查,见下面的 privateReason。 +// +// 绕过形式必须一起堵,否则等于没拦: +// - 2852039166 / 0251.0376.0251.0376 / 0xA9.0xFE.0xA9.0xFE 这类 IPv4 写法,WHATWG URL +// 会先归一化成点分十进制,所以只要 new URL() 之后再判断就够。 +// - [::ffff:169.254.169.254] 会被归一化成 [::ffff:a9fe:a9fe],指向同一个地址却不是 +// IPv4,isIP() 判成 6,必须单独展开判断。 + +import { isIP } from 'node:net'; +import { lookup } from 'node:dns/promises'; + +// IPv4 点分十进制 -> 4 字节。调用前 isIP() 已确认是合法 IPv4,这里只做防御。 +function v4Bytes(host) { + const parts = host.split('.'); + if (parts.length !== 4) return null; + const bytes = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const value = Number(part); + if (value > 255) return null; + bytes.push(value); + } + return bytes; +} + +// IPv6 规范形式 -> 16 字节。WHATWG URL 归一化后是最长零段压缩一次的写法, +// 没有 IPv4 尾部(尾部已被折成十六进制组)。 +function v6Bytes(host) { + const at = host.indexOf('::'); + let head = []; + let tail = []; + if (at >= 0) { + if (at > 0) head = host.slice(0, at).split(':'); + if (at + 2 < host.length) tail = host.slice(at + 2).split(':'); + } else { + head = host.split(':'); + } + const fill = 8 - head.length - tail.length; + if (fill < 0) return null; + const groups = head.concat(new Array(fill).fill('0'), tail); + if (groups.length !== 8) return null; + const bytes = []; + for (const group of groups) { + if (!/^[0-9a-f]{1,4}$/.test(group)) return null; + const value = parseInt(group, 16); + bytes.push((value >> 8) & 0xff, value & 0xff); + } + return bytes; +} + +function v4Reason(b) { + if (b[0] === 169 && b[1] === 254) { + return '169.254.0.0/16 是链路本地段(云主机元数据服务在这个段里),不能作为图片来源'; + } + if (b[0] === 0 && b[1] === 0 && b[2] === 0 && b[3] === 0) { + return '0.0.0.0 是通配地址,不能作为图片来源'; + } + return ''; +} + +function v6Reason(b) { + // IPv4-mapped:前 10 字节全 0,接着两个 0xff,后 4 字节就是 IPv4。 + let mapped = true; + for (let i = 0; i < 10; i += 1) { + if (b[i] !== 0) { mapped = false; break; } + } + if (mapped && b[10] === 0xff && b[11] === 0xff) { + const reason = v4Reason([b[12], b[13], b[14], b[15]]); + return reason ? '(IPv6 映射形式)' + reason : ''; + } + let allZero = true; + for (let i = 0; i < 16; i += 1) { + if (b[i] !== 0) { allZero = false; break; } + } + if (allZero) return ':: 是通配地址,不能作为图片来源'; + // fe80::/10 + if (b[0] === 0xfe && (b[1] & 0xc0) === 0x80) { + return 'fe80::/10 是链路本地段,不能作为图片来源'; + } + return ''; +} + +// 放行返回空串,拦截返回一句可以直接进时间线给人看的原因。 +export function blockedReason(rawUrl) { + const text = String(rawUrl || '').trim(); + if (!text) return ''; + let parsed; + try { + parsed = new URL(text); + } catch { + // 解析不了的地址不归这里管,交给下游按「下载失败」报错。 + return ''; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + let host = parsed.hostname; + if (!host) return ''; + if (host.charCodeAt(0) === 91 && host.charCodeAt(host.length - 1) === 93) { + host = host.slice(1, -1); + } + const kind = isIP(host); + if (kind === 4) { + const bytes = v4Bytes(host); + return bytes ? v4Reason(bytes) : ''; + } + if (kind === 6) { + const bytes = v6Bytes(host); + return bytes ? v6Reason(bytes) : ''; + } + return ''; +} + +export function isBlocked(rawUrl) { + return Boolean(blockedReason(rawUrl)); +} + +// ---------------------------------------------------------------- 检索结果:本机与内网 +// +// 检索结果是第三方网页给的地址,不能让它指向本机或内网:否则一条搜索结果就能让应用去读用户 +// 局域网里的设备,读到的图还会被送进生成接口,甚至进公开分享页。 +// 接口返回的成图地址、按用户需求写进规划的地址不走这一层(理由见文件头)。 +// 主机名要解析一遍再判,挡住解析到内网的域名。解析和随后的请求之间仍有时间差(DNS rebinding), +// 这一层是尽力而为的拦截,不是边界。 + +function v4PrivateReason(b) { + if (b[0] === 127) return '127.0.0.0/8 是本机回环地址'; + if (b[0] === 10) return '10.0.0.0/8 是内网地址'; + if (b[0] === 172 && b[1] >= 16 && b[1] <= 31) return '172.16.0.0/12 是内网地址'; + if (b[0] === 192 && b[1] === 168) return '192.168.0.0/16 是内网地址'; + if (b[0] === 100 && b[1] >= 64 && b[1] <= 127) return '100.64.0.0/10 是运营商内网地址'; + if (b[0] === 0) return '0.0.0.0/8 不是合法的图片来源'; + return ''; +} + +function v6PrivateReason(b) { + let mapped = true; + for (let i = 0; i < 10; i += 1) { + if (b[i] !== 0) { mapped = false; break; } + } + if (mapped && b[10] === 0xff && b[11] === 0xff) return v4PrivateReason([b[12], b[13], b[14], b[15]]); + let loopback = b[15] === 1; + for (let i = 0; i < 15 && loopback; i += 1) { + if (b[i] !== 0) loopback = false; + } + if (loopback) return '::1 是本机回环地址'; + if ((b[0] & 0xfe) === 0xfc) return 'fc00::/7 是内网地址'; + return ''; +} + +// DNS 给的 IPv6 可能带点分十进制尾巴(::ffff:192.168.1.1)或区域标识(fe80::1%en0), +// 先整理成 v6Bytes 认得的形式。 +function normalizeV6(address) { + const lower = String(address).toLowerCase().split('%')[0]; + const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(lower); + if (!tail) return lower; + const bytes = v4Bytes(tail[2]); + if (!bytes) return lower; + return tail[1] + ((bytes[0] << 8) | bytes[1]).toString(16) + ':' + ((bytes[2] << 8) | bytes[3]).toString(16); +} + +function addressReason(address) { + const kind = isIP(String(address).split('%')[0]); + if (kind === 4) { + const bytes = v4Bytes(address); + return bytes ? v4Reason(bytes) || v4PrivateReason(bytes) : ''; + } + if (kind === 6) { + const bytes = v6Bytes(normalizeV6(address)); + return bytes ? v6Reason(bytes) || v6PrivateReason(bytes) : ''; + } + return ''; +} + +export async function privateReason(rawUrl) { + let parsed; + try { + parsed = new URL(String(rawUrl || '').trim()); + } catch { + return ''; + } + let host = parsed.hostname.toLowerCase(); + if (host.charCodeAt(0) === 91 && host.charCodeAt(host.length - 1) === 93) host = host.slice(1, -1); + if (!host) return ''; + if (host === 'localhost' || host.endsWith('.localhost')) return 'localhost 是本机地址'; + if (isIP(host)) return addressReason(host); + let addresses = []; + try { + addresses = await lookup(host, { all: true, verbatim: true }); + } catch { + // 解析失败交给随后的请求按「下载失败」报错。 + return ''; + } + for (const entry of addresses) { + const reason = addressReason(entry.address); + if (reason) return host + ' 解析到 ' + entry.address + ',' + reason; + } + return ''; +} + +// ---------------------------------------------------------------- 下载 +// +// fetch 默认自动跟随重定向,只查第一跳等于没查:一个公网地址 302 到 169.254.169.254 就绕过去了。 +// 所以重定向改成手动跟随,每一跳都重新过一遍检查;响应体按字节数封顶,读到上限就断开, +// 免得一个超大的响应把进程内存吃满。 + +const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECTS = 5; + +function megabytes(bytes) { + return (bytes / 1024 / 1024).toFixed(1) + ' MB'; +} + +async function discard(response) { + try { + if (response.body) await response.body.cancel(); + } catch { + /* 已经关了 */ + } +} + +async function readCapped(response, maxBytes) { + const declared = Number(response.headers.get('content-length') || 0); + if (maxBytes && declared > maxBytes) { + await discard(response); + throw new Error('图片太大(' + megabytes(declared) + ',上限 ' + megabytes(maxBytes) + ')'); + } + if (!response.body) return Buffer.alloc(0); + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (maxBytes && size > maxBytes) throw new Error('图片太大(超过 ' + megabytes(maxBytes) + ')'); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +// publicOnly:检索结果专用,额外拒绝本机与内网地址。被策略拒绝的错误带 blocked = true。 +export async function fetchImage(rawUrl, { publicOnly = false, maxBytes = 0, timeoutMs = 60000 } = {}) { + const signal = AbortSignal.timeout(timeoutMs); + let url = String(rawUrl || '').trim(); + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { + let parsed; + try { + parsed = new URL(url); + } catch { + throw new Error('图片地址无效'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('只下载 http / https 地址的图片'); + } + const reason = blockedReason(url) || (publicOnly ? await privateReason(url) : ''); + if (reason) { + const error = new Error('地址被安全策略拒绝:' + reason); + error.blocked = true; + throw error; + } + const response = await fetch(url, { redirect: 'manual', signal }); + if (REDIRECT_CODES.has(response.status)) { + const location = response.headers.get('location'); + await discard(response); + if (!location) throw new Error('HTTP ' + response.status + ':重定向没有给出目标地址'); + url = new URL(location, url).href; + continue; + } + if (!response.ok) { + await discard(response); + throw new Error('HTTP ' + response.status); + } + return readCapped(response, maxBytes); + } + throw new Error('重定向超过 ' + MAX_REDIRECTS + ' 次'); +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/lib/workflow.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/workflow.mjs new file mode 100644 index 0000000..2f60f3a --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/lib/workflow.mjs @@ -0,0 +1,2678 @@ +// H3-Image-Executor 状态机:00 输入 -> 01 分诊 -> 02 THINK/检索 -> 03 PLAN -> 04 PAINT +// -> 05 VERIFY(程序检查 + 色偏校正 + VLM 评审)-> 06 REFINE -> 07 DELIVER -> 08 增量编辑。 +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import * as h3 from './h3.mjs'; +import * as png from './png.mjs'; +import * as review from './review.mjs'; +import { searchRefImages, normalizeRefQueries, broadenQuery } from './refsearch.mjs'; +import { createRefCache } from './refcache.mjs'; +// 接口返回的不一定是 PNG:实测同一个端点有时回 JPEG。只用 png.decodePng 会让整条 +// 程序校验链路(尺寸 / 色偏 / 锐度 / 预览图 / 评审看图材料)在 JPEG 响应下静默失效, +// 而图照样交付——用户看到的是「已交付」,看不到「其实一项都没验」。 +import { decodeImage, extensionFor } from './image.mjs'; +import { detail, group, kv, list, seconds, text, thumbs } from './detail.mjs'; + +export const DEFAULTS = { + area: '4K', + candidates: 2, + maxRefine: 5, + maxRefineFast: 1, + maxFullEdits: 2, + previewEdge: 1024, + staleActionRounds: 2, + staleStopRounds: 3, + // 反过度评审:hard 项越多,评审越容易挑刺、越容易打回。 + // 8 = 最多 4 条文字层项 + planner 写的 4 条语义项。原来只有 5,文字层一多 + // (真机 20261110-5e10ae 手语字母表 26 个字母标签)就把 planner 写的「手型对不对」 + // 全部挤成 soft,而 soft 不参与放行——图就这样放行了。 + maxHardItems: 8, + // 各节点的工具调用硬上限(运行时强制,超了就中断并强制收尾)。 + toolBudget: { plan: 10, judge: 6, refine: 4, select: 2, edit: 4 }, + // 看图材料长边上限:再大 VLM 也会缩,反而拖慢上传与推理。 + sheetMaxEdge: 1568, + // 多图并行上限。每张图同一时刻最多占 1 个 agent 运行槽(runJson 每次都开新会话), + // 所以这里必须和 miniapp/miniapp.json 的 agentConcurrency 对齐:取它的一半, + // 另一半留给同时跑的第二个会话、角色基准图和跨图评审。改任一边都要改另一边, + // h3-concurrency-test.mjs 会校验这两个数的关系。 + // 注意 agentConcurrency 属于 Mini App 清单(miniapp/miniapp.json),不是 + // .minimax-plugin/plugin.json——写进后者会被社区校验器判成未知字段,而且完全不生效。 + // 实测瓶颈 95% 在 judge(VLM 评审,均 367s/次),paint 只要 16s,所以放开并行直接砍耗时。 + imageConcurrency: 4, + // 评审节点的工具调用预算,写进提示词用于约束 agent。 + judgeToolBudget: 5, + refDownloadLimit: 8, + // 参考图:一批先下 3 张判定,不合格再取下一批;池子空了就换个说法再搜。 + refBatch: 3, + refGateRounds: 3, + refSearchRounds: 2, +}; + +// ---------------------------------------------------------------- prompt 槽位 +// 第一层扩写不再让 planner 自由发挥,而是填固定槽位、由 harness 拼装。 +// 好处:长度与覆盖面可控,必填项缺了能立刻发现并兜底,不同图之间的描述粒度一致。 +// 顺序刻意模仿摄影描述的习惯:风格 → 景别 → 构图 → 场景 → 主体 → 材质 → 光线 → 镜头。 +const SLOT_SPEC = [ + { key: 'style', label: '风格', required: true, fallback: '高质量写实摄影,色调自然' }, + { key: 'shot', label: '景别', required: true, fallback: '中景' }, + { key: 'composition', label: '构图', required: true, fallback: '主体突出,构图均衡,视线有落点' }, + { key: 'scene', label: '场景', required: true, fallback: '与主体相符的真实环境,背景干净不抢主体' }, + { key: 'subject', label: '主体', required: true, fallback: '' }, + { key: 'material', label: '环境材质', required: false, fallback: '' }, + { key: 'light', label: '光线', required: true, fallback: '自然光,方向明确,主体面部清晰、曝光准确' }, + { key: 'lens', label: '镜头', required: true, fallback: '35mm 等效焦段、f/2.8,主体在焦内,背景轻微虚化' }, +]; + +// 色彩是 DiT 最容易翻车、评审又看不出来的一维,而程序级配平救不回来: +// 子图比父图亮时三通道增益会一起撞到 clamp 下限(实测 [0.75,0.75,0.75]), +// 退化成整体压暗。所以改成在扩写阶段就把白平衡写成硬要求,出图后只检测、不动像素。 +const COLOR_CLAUSE = + '色彩要求:整体白平衡中性准确,红绿蓝三通道均衡,不要整体偏红、偏暖或偏黄;' + + '肤色与中性物体(白车、灰石、柏油、白墙)必须还原真实中性,红通道不得明显压过绿蓝通道。'; + +// 重画时追加的一句:告诉模型上一轮哪里不对。只加一次,避免多轮叠加成复读机。 +const COLOR_RETRY_CLAUSE = + '(上一轮输出整体偏红,这一轮必须修正白平衡:红通道降到与绿蓝相当,不要暖黄、橘红、粉红倾向,' + + '画面里的中性物体必须还原成真实中性色。)'; + +function withColorRetry(prompt) { + const base = String(prompt || '').trim(); + return base.includes('上一轮输出整体偏红') ? base : base + COLOR_RETRY_CLAUSE; +} + +function asText(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.filter(Boolean).map((item) => String(item).trim()).filter(Boolean).join('、'); + return String(value).trim(); +} + +function normalizeSlots(raw, textLayers) { + const source = raw && typeof raw === 'object' ? raw : {}; + const slots = {}; + for (const spec of SLOT_SPEC) slots[spec.key] = asText(source[spec.key]); + // 图内文字以 text_layers 为准,planner 漏填时用 text_layers 兜底。 + const fromLayers = (Array.isArray(textLayers) ? textLayers : []) + .map((layer) => (typeof layer === 'string' ? layer : layer && layer.text)) + .filter(Boolean); + slots.text = asText(source.text) || asText(fromLayers); + slots.negative = asText(source.negative); + return slots; +} + +// 把槽位拼成一句完整的中文长 prompt。返回缺失的必填槽位,供上层告警。 +// 完全没有 slots 的老格式(planner 直接给 prompt)原样沿用,不塞兜底槽位。 +function composePrompt(slots, freeText, instruction, hasSlots) { + if (!hasSlots) { + const legacy = String(freeText || instruction || '').trim(); + return { prompt: legacy ? legacy + '。' + COLOR_CLAUSE : COLOR_CLAUSE, missing: [], slots: null }; + } + const missing = []; + const filled = { ...slots }; + for (const spec of SLOT_SPEC) { + if (filled[spec.key]) continue; + if (!spec.required) continue; + missing.push(spec.label); + filled[spec.key] = spec.key === 'subject' ? String(instruction || '').trim() : spec.fallback; + } + + const parts = []; + const head = [filled.style, filled.shot].filter(Boolean).join(','); + if (head) parts.push(head + '。'); + for (const spec of SLOT_SPEC) { + if (spec.key === 'style' || spec.key === 'shot') continue; + if (!filled[spec.key]) continue; + parts.push(spec.label + ':' + filled[spec.key] + '。'); + } + if (filled.text) parts.push('图内文字:' + filled.text + ',字形正确、清晰可读。'); + if (filled.negative) parts.push('不要出现:' + filled.negative + '。'); + parts.push(COLOR_CLAUSE); + + const extra = String(freeText || '').trim(); + // planner 同时给了 slots 和自由 prompt 时,自由部分只作为补充追加,避免和槽位重复。 + if (extra && !parts.join('').includes(extra)) parts.push(extra); + + return { prompt: parts.join(''), missing, slots: filled }; +} + +// ---------------------------------------------------------------- 入口 + +// 用户点了「停止」。每个阶段之间都要看一眼:以前停止只中断了 Agent 回合,工作流还会按默认方案 +// 往下走,接着检索、出角色图、生图,照样花生图请求,最后还会被 deliver 改回「已交付」。 +function isCancelled(session) { + return session.status === 'cancelled'; +} + +export async function runSession(session, deps) { + await planNode(session, deps); + if (isCancelled(session)) return; + await confirmBlockers(session, deps); + if (isCancelled(session)) return; + await materializeRefs(session, deps); + if (isCancelled(session)) return; + // 角色基准图必须赶在故事图之前:它就是后面每张图的外观基准。 + await buildCharacterRefs(session, deps); + if (isCancelled(session)) return; + const queue = session.images.filter((image) => !image.blocked); + const limit = Math.max(1, Math.min(DEFAULTS.imageConcurrency, deps.concurrency || DEFAULTS.imageConcurrency)); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => { + while (cursor < queue.length && !isCancelled(session)) { + const image = queue[cursor]; + cursor += 1; + try { + await runImage(session, image, deps); + } catch (error) { + // 停止带出来的中断不算异常,也不该再为它起一个选优回合。 + if (isCancelled(session)) return; + // 走到这里说明 runImage 自己没兜住。仍然把已有候选交给选优, + // 而不是直接判死——手上明明有图却交付 0 张,是最糟的失败方式。 + const message = describeError(error); + deps.emit(session, 'paint', image.id + ' 流程异常:' + message, { level: 'error' }); + try { + await selectBestEffort(session, image, deps); + } catch { + image.status = 'failed'; + } + } + } + }); + await Promise.all(workers); + if (isCancelled(session)) return; + // 所有图都出完了才比得了:逐图评审看不到别的图,跨图漂移只能在这里抓。 + await crossImageReview(session, deps); + if (isCancelled(session)) return; + await deliver(session, deps); +} + +// 关键待确认必须在出图之前问。事后在交付里堆一串"需要你确认"没有意义: +// 图已经生成完了,用户看到也只能重跑一遍。 +const MAX_BLOCKERS = 2; + +async function confirmBlockers(session, deps) { + const ask = (session.blockers || []).slice(0, MAX_BLOCKERS); + if (!ask.length) return; + if (typeof deps.askUser !== 'function') { + session.questions.push(...ask.map((item) => item.text)); + return; + } + deps.emit(session, 'question', '出图前需要你定 ' + ask.length + ' 件事', { + level: 'warn', + detail: detail('出图前确认', [ + list( + '问题', + ask.map((item) => ({ tag: 'warn', text: item.text, note: (item.options || []).join(' / ') })), + ), + ]), + }); + const answers = await deps.askUser(session, ask); + session.blockers = []; + if (!answers.length) { + deps.emit(session, 'question', '没等到回答,按规划时选的默认方案继续出图'); + session.assumptions.push( + ...ask.map((item) => item.text + ':未回答,按默认「' + (item.default || '自行判断') + '」执行'), + ); + return; + } + session.answered.push(...answers); + deps.emit(session, 'question', '已确认,按你的回答重新规划'); + await planNode(session, deps); +} + +function normalizeBlockers(raw) { + const rows = Array.isArray(raw) ? raw : []; + const out = []; + for (const item of rows) { + if (typeof item === 'string') { + if (item.trim()) out.push({ text: item.trim(), options: [], default: '' }); + continue; + } + if (!item || typeof item !== 'object') continue; + const question = String(item.text || item.question || '').trim(); + if (!question) continue; + const options = (Array.isArray(item.options) ? item.options : []) + .map((one) => String(one || '').trim()) + .filter(Boolean) + .slice(0, 4); + out.push({ text: question, options, default: String(item.default || options[0] || '').trim() }); + } + return out.slice(0, MAX_BLOCKERS); +} + +// 检索节点:planner 只产出「要搜什么」(ref_queries),真正的搜图由运行时调 Connector 工具。 +// 失败不阻断主流程——降级成无参考图文生图,并把原因写进交付的待确认项。 +async function runRefSearch(session, queries, deps) { + const startedAt = Date.now(); + deps.emit(session, 'think', '检索参考图:' + queries.map((item) => item.query).join('、'), { + detail: detail('检索节点 · 输入', [ + text( + '检索意图', + queries.map((item) => item.task_name + ':' + item.query).join('\n'), + ), + list( + '相关性要求', + queries.map((item) => ({ text: item.task_name + ':' + (item.prompt || '(无)') })), + ), + ]), + }); + + const result = await searchRefImages(deps.context, queries); + if (!result.ok) { + deps.emit(session, 'think', '参考图检索未成功:' + result.reason, { + level: 'warn', + durMs: Date.now() - startedAt, + detail: detail('检索结果', [ + kv('状态', '未取得参考图'), + text('原因', result.reason), + text('影响', '本轮按无参考图走文生图;需要真人 / 车型一致性时请手动上传参考图。'), + ]), + }); + session.questions.push('参考图检索未成功(' + result.reason + '),如果一致性要求高请手动上传参考图。'); + return []; + } + + const hits = result.hits.filter((item) => item.url); + // 有图但有实体没搜到时也要说一句,否则用户会以为某个人/车没要求参考图。 + if (result.reason) { + deps.emit(session, 'think', '部分参考图没检索到:' + result.reason, { level: 'warn' }); + } + deps.emit(session, 'think', '检索到 ' + hits.length + ' 张参考图:' + hits.map((item) => item.task_name).join('、'), { + durMs: Date.now() - startedAt, + detail: detail('检索结果', [ + kv('工具', result.tool || 'search_images'), + kv( + '候选池', + result.hits.reduce((sum, item) => sum + (item.candidates || []).length, 0) + + ' 张,每次取 ' + DEFAULTS.refBatch + ' 张下载判定', + ), + list( + '逐条', + result.hits.map((item) => ({ + tag: item.url ? 'pass' : 'fail', + text: + item.task_name + + ':' + + ((item.candidates || []).length ? '返回 ' + item.candidates.length + ' 张候选' : '没有返回图片'), + note: item.url ? item.title || '' : '', + })), + ), + list( + '候选来源', + result.hits.flatMap((item) => + (item.candidates || []).slice(0, DEFAULTS.refBatch).map((candidate, index) => ({ + text: item.task_name + ' #' + (index + 1) + ':' + (candidate.title || '(无标题)'), + note: candidate.source || candidate.url, + })), + ), + ), + ]), + }); + return hits; +} + +// 标识符归一:大小写、下划线、连字符、空格、中文一律抹平后再比。 +// 规划在 ref_queries 和 refs 两处写的名字经常不完全一致("lixiang mega_home" / "lixiangmega_home"), +// 严格相等会让参考图白白丢掉。 +function canonicalRef(value) { + return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +// refs 允许三种写法:R 编号、ref_queries 的 task_name、公网地址,统一归一成 R 编号。 +// 一张都没拿到时也要跑:否则规划写的 task_name 原样留着、entry 还是 edit, +// 执行端会直接抛「edit 缺少可用参考图」,整张图白跑。 +function settleImageRefs(session, deps, plannedRefs, byUrl, byTask, knownTasks) { + const byTaskLoose = new Map(); + for (const [name, id] of byTask) { + const key = canonicalRef(name); + if (key && !byTaskLoose.has(key)) byTaskLoose.set(key, id); + } + const known = knownTasks || new Set(); + for (const image of session.images) { + const next = []; + for (const ref of image.spec.refs || []) { + if (typeof ref !== 'string' || !ref) continue; + if (/^https?:\/\//i.test(ref)) { + const id = byUrl.get(ref); + if (id) next.push(id); + continue; + } + if (/^R\d+$/i.test(ref)) { + next.push(ref.toUpperCase()); + continue; + } + const id = byTask.get(ref) || byTaskLoose.get(canonicalRef(ref)); + if (id) { + next.push(id); + continue; + } + if (known.has(canonicalRef(ref))) { + deps.emit(session, 'think', image.id + ' 想要的参考图「' + ref + '」没搜到可用图片,已忽略', { + level: 'warn', + }); + continue; + } + // 检索整体失败时不必逐条报「已忽略」,后面那条「已降级成文生图」已经说清了。 + if (byUrl.size || byTask.size) { + deps.emit(session, 'think', image.id + ' 的参考图标识「' + ref + '」既不是 R 编号也不是检索任务名,已忽略', { + level: 'warn', + }); + } + } + image.spec.refs = dedupe(next); + if (image.spec.refs.length) { + image.spec.entry = 'edit'; + } else if (image.spec.entry === 'edit') { + // 检索或下载全挂时必须降级成文生图:规划说 entry=edit 但一张参考图都没有, + // 执行端会直接抛「edit 缺少可用参考图」,整张图就白跑了。 + image.spec.entry = 't2i'; + deps.emit(session, 'think', image.id + ' 没有可用参考图,已降级成文生图', { + level: 'warn', + detail: detail('降级为文生图', [ + kv('图', image.id + ' · ' + image.title), + text('原本要的参考图', (plannedRefs.get(image.id) || []).join('、') || '(无)'), + text('影响', '一致性只能靠文字描述,成图与真人 / 真车可能有差距。'), + ]), + }); + } + } +} + +// ---------------------------------------------------------------- 参考图 + +// 检索结果收成统一的"实体"结构。 +// 检索来的候选和规划直接给的公网地址走同一条下载 + 判定链路,只是后者不做相关性判定。 +function refEntities(session, hits, queries) { + const requirementOf = new Map(); + for (const item of queries) requirementOf.set(item.task_name, item.prompt || ''); + const entities = []; + const byHead = new Map(); + for (const hit of hits) { + const pool = (hit.candidates || []).filter((item) => item && item.url); + if (!pool.length) continue; + const head = pool[0].url; + const existing = byHead.get(head); + // 两个实体搜到同一批图时合并成一个,登记阶段再把 task_name 记成别名。 + if (existing) { + if (hit.task_name && existing.taskNames.indexOf(hit.task_name) < 0) existing.taskNames.push(hit.task_name); + continue; + } + const entity = { + key: 'q' + entities.length, + taskNames: hit.task_name ? [hit.task_name] : [], + query: hit.query, + note: hit.query, + requirement: requirementOf.get(hit.task_name) || '', + pool, + cursor: 0, + searches: 0, + queried: new Set([hit.query]), + seen: new Set(pool.map((item) => item.url)), + direct: false, + }; + byHead.set(head, entity); + entities.push(entity); + } + const directSeen = new Set(); + for (const image of session.images) { + for (const ref of image.spec.refs || []) { + if (typeof ref !== 'string' || !/^https?:\/\//i.test(ref) || directSeen.has(ref)) continue; + directSeen.add(ref); + entities.push({ + key: 'u' + entities.length, + taskNames: [], + query: ref, + // 规划可能给好几张地址,都叫"规划给出的地址"在时间线上分不清谁是谁,带上文件名。 + note: '规划给出的 ' + shortUrlLabel(ref), + requirement: '', + pool: [{ url: ref, title: '', source: ref }], + cursor: 0, + searches: 0, + queried: new Set(), + seen: new Set([ref]), + direct: true, + }); + } + } + return entities.slice(0, DEFAULTS.refDownloadLimit); +} + +// 从地址里取一个能认出来的短标签:优先文件名,没有就用主机名。 +function shortUrlLabel(url) { + const text = String(url || ''); + try { + const parsed = new URL(text); + const name = parsed.pathname.split('/').filter(Boolean).pop(); + return (name || parsed.hostname).slice(0, 28); + } catch { + return text.slice(-28); + } +} + +function cacheOf(deps) { + if (deps.cache) return deps.cache; + // 没注入缓存时退回到会话内目录,保证链路本身不依赖调用方。 + if (!deps.localCache) deps.localCache = createRefCache(join(deps.dirs.root, 'cache')); + return deps.localCache; +} + +// 相关性判定:把这一批候选拼成一张带编号的材料图,让 VLM 挑一张。 +// 挑不出来比硬塞一张强得多——错的参考图会让成图看着完全正常却根本不像, +// 评审端也发现不了(它只对照参考图,看不出参考图本身是错的)。 +async function gateRefCandidates(session, entity, usable, deps, attempt) { + if (entity.direct) return { entry: usable[0].entry, reason: '规划直接指定,不再判定' }; + if (usable.length === 1 && !entity.requirement) { + return { entry: usable[0].entry, reason: '只有一张可用' }; + } + await mkdir(deps.dirs.sheets, { recursive: true }); + const sheetPath = join(deps.dirs.sheets, 'cand-' + entity.key + '-' + attempt + '.png'); + let built = null; + try { + built = await review.buildGridSheet( + usable.map((item, index) => ({ path: item.entry.path, label: String(index + 1) })), + sheetPath, + { perRow: Math.min(3, usable.length) }, + ); + } catch { + built = null; + } + // 拼图失败就别浪费一次模型调用,直接用第一张能解码的。 + if (!built || !built.items.length) return { entry: usable[0].entry, reason: '候选拼图失败,取第一张可用图' }; + // buildGridSheet 会跳过解不开的图,标签顺序和 usable 下标要重新对齐。 + const order = built.items.map((item) => usable.findIndex((row) => row.entry.path === item.path)); + + const instruction = [ + '你是参考图筛选节点。候选图已经由程序拼成一张材料图,你只要打开它、看、给结论。', + '', + '要找的东西:' + entity.note, + '相关性要求:' + (entity.requirement || '(无额外要求,能清楚看到目标即可)'), + '', + '候选材料图:' + sheetPath, + ' 共 ' + order.length + ' 张,从左到右编号 ' + order.map((_, index) => index + 1).join('、') + ',每张左上角有数字标签。', + '', + '判定规则(严格照做):', + '1. 人物必须是本人、面部清晰可辨识;车型 / logo 必须是同一款。', + '2. 这些情况一律不要选:不是本人或不是同一款、糊、严重遮挡、纯文字截图、大面积水印、明显是插画或表情包。', + '3. 拿不准就不选。宁可这一批全否,也不要塞一张错的参考图进生成。', + '4. 不要写脚本、不要裁图量像素、不要联网搜索、不要打开其它文件。只 read 上面这一张材料图。', + '5. 工具调用上限 2 次,看完立刻给结论。', + '', + '只输出一个 JSON 对象,不要解释、不要 markdown 代码块:', + '{"pick":1,"reason":"不超过 30 字"}', + ' pick 填 1..' + order.length + ' 里最合适那张的编号;全都不符合要求就填 0。', + ].join('\n'); + + let data; + try { + ({ data } = await deps.agent.runJson(session, { step: 'think', instruction, maxTools: 2 })); + } catch { + return { entry: usable[0].entry, reason: '判定节点异常,取第一张可用图' }; + } + const reason = asText(data.reason).slice(0, 60); + const pick = Number(data.pick); + if (!pick || pick < 1 || pick > order.length) return { entry: null, reason: reason || '都不符合要求' }; + const index = order[pick - 1]; + if (index < 0) return { entry: null, reason: reason || '都不符合要求' }; + return { entry: usable[index].entry, reason: reason || '符合要求' }; +} + +// 一批候选:下载到缓存 -> 机械过滤(缓存模块做)-> 相关性判定。 +// 每次只取 refBatch 张:一批 3 张足够挑出一张能用的,全下既慢又把时间线塞满。 +async function reviewRefBatch(session, entity, batch, deps, attempt) { + const startedAt = Date.now(); + const cache = cacheOf(deps); + // 检索来的候选是第三方地址,不许指向本机或内网;规划直接给的地址来自用户需求,按接口地址的规则放行。 + const fetched = await Promise.all(batch.map((item) => cache.download(item.url, { publicOnly: !entity.direct }))); + const usable = []; + fetched.forEach((entry, index) => { + if (entry.ok) usable.push({ entry, meta: batch[index] }); + }); + + const verdict = usable.length ? await gateRefCandidates(session, entity, usable, deps, attempt) : null; + const pickedPath = verdict && verdict.entry ? verdict.entry.path : ''; + + const rows = fetched.map((entry, index) => { + const meta = batch[index]; + if (!entry.ok) { + return { + key: '', + title: meta.title || '(无标题)', + note: meta.source || meta.url, + tag: 'warn', + text: '下载失败:' + entry.error, + }; + } + const chosen = entry.path === pickedPath; + return { + key: 'cache/' + entry.file, + title: meta.title || '(无标题)', + note: meta.source || meta.url, + tag: chosen ? 'pass' : 'fail', + text: chosen ? '已选用' : '不符合要求', + }; + }); + + let headline; + if (verdict && verdict.entry) headline = entity.note + ':从 ' + batch.length + ' 张候选里选定 1 张'; + else if (usable.length) headline = entity.note + ':' + usable.length + ' 张候选都不符合要求,继续找'; + else headline = entity.note + ':这一批 ' + batch.length + ' 张候选都没下载成功,继续找'; + + deps.emit(session, 'think', headline, { + level: verdict && verdict.entry ? 'info' : 'warn', + durMs: Date.now() - startedAt, + detail: detail('候选判定 · 第 ' + (attempt + 1) + ' 批', [ + kv('要找的', entity.note), + text('相关性要求', entity.requirement || '(规划没写要求,按能下载、够清晰采用)'), + thumbs('检索结果(' + batch.length + ' 张)', rows), + verdict ? text('判定', verdict.reason) : null, + fetched.some((entry) => entry.cached) ? kv('缓存命中', fetched.filter((entry) => entry.cached).length + ' 张') : null, + ]), + }); + + if (!verdict || !verdict.entry) return null; + return { path: verdict.entry.path, file: verdict.entry.file, url: verdict.entry.url, reason: verdict.reason }; +} + +// 候选池用完了:换个说法再搜一轮。原查询通常太"描述性",搜索引擎更吃"实体名 + 官方图"。 +async function searchMoreCandidates(session, entity, deps) { + if (entity.direct || entity.searches >= DEFAULTS.refSearchRounds) return []; + const query = broadenQuery(entity.query, entity.searches); + if (!query || entity.queried.has(query)) return []; + entity.queried.add(query); + entity.searches += 1; + deps.emit(session, 'think', entity.note + ':候选用完了,换个说法再搜(' + query + ')', { level: 'warn' }); + let result; + try { + result = await searchRefImages(deps.context, [ + { task_name: entity.taskNames[0] || entity.key, query, prompt: entity.requirement }, + ]); + } catch { + return []; + } + const hit = result && result.ok ? result.hits[0] : null; + const fresh = ((hit && hit.candidates) || []).filter((item) => item && item.url && !entity.seen.has(item.url)); + for (const item of fresh) entity.seen.add(item.url); + entity.pool.push(...fresh); + return fresh; +} + +async function resolveEntityRef(session, entity, deps) { + for (let attempt = 0; attempt < DEFAULTS.refGateRounds; attempt += 1) { + let batch = entity.pool.slice(entity.cursor, entity.cursor + DEFAULTS.refBatch); + entity.cursor += batch.length; + if (!batch.length) { + const more = await searchMoreCandidates(session, entity, deps); + if (!more.length) return null; + batch = entity.pool.slice(entity.cursor, entity.cursor + DEFAULTS.refBatch); + entity.cursor += batch.length; + if (!batch.length) return null; + } + const picked = await reviewRefBatch(session, entity, batch, deps, attempt); + if (picked) return picked; + } + return null; +} + +// 参考图:检索 -> 分批下载到缓存 -> 判定相关性 -> 合格才复制进会话并登记成 R1、R2… +// 存进会话而不是直接用缓存,是为了让生成端和评审端拿到同一份稳定素材。 +async function materializeRefs(session, deps) { + const queries = normalizeRefQueries(session.plan && session.plan.ref_queries); + const hits = queries.length ? await runRefSearch(session, queries, deps) : []; + const knownTasks = new Set(queries.map((item) => canonicalRef(item.task_name)).filter(Boolean)); + // 降级判定要用规划时写的原始 refs,映射之后就看不出来了。 + const plannedRefs = new Map(); + for (const image of session.images) plannedRefs.set(image.id, (image.spec.refs || []).slice()); + + const entities = refEntities(session, hits, queries); + if (!entities.length) { + settleImageRefs(session, deps, plannedRefs, new Map(), new Map(), knownTasks); + return; + } + + const cache = cacheOf(deps); + // 每轮会话最多清一次缓存,失败不影响主流程。 + if (cache.prune) await cache.prune(); + + // 并发 2:下载是网络等待、判定是一次模型调用,再高会顶到运行时的 agentConcurrency。 + const resolved = new Map(); + let cursor = 0; + const workers = Array.from({ length: Math.min(2, entities.length) }, async () => { + while (cursor < entities.length) { + const entity = entities[cursor]; + cursor += 1; + try { + const picked = await resolveEntityRef(session, entity, deps); + if (picked) resolved.set(entity.key, picked); + } catch (error) { + deps.emit(session, 'think', entity.note + ' 参考图处理异常:' + String(error && error.message ? error.message : error), { + level: 'warn', + }); + } + } + }); + await Promise.all(workers); + + const byUrl = new Map(); + const byTask = new Map(); + const byPath = new Map(); + const reasons = new Map(); + const missed = []; + for (const entity of entities) { + const picked = resolved.get(entity.key); + if (!picked) { + missed.push({ tag: 'fail', text: entity.note }); + deps.emit(session, 'think', entity.note + ':没找到符合要求的参考图', { level: 'warn' }); + session.questions.push('「' + entity.note + '」没找到符合要求的参考图,如果一致性要求高请手动上传。'); + continue; + } + let id = byPath.get(picked.path); + if (!id) { + id = 'R' + (session.refs.length + 1); + const file = id + (picked.file.endsWith('.png') ? '.png' : '.jpg'); + await copyFile(picked.path, join(deps.dirs.refs, file)); + session.refs.push({ id, file, path: join(deps.dirs.refs, file), note: entity.note, source: picked.url, origin: 'search' }); + byPath.set(picked.path, id); + } + reasons.set(id, picked.reason || ''); + byUrl.set(picked.url, id); + for (const name of entity.taskNames) if (name) byTask.set(name, id); + } + + settleImageRefs(session, deps, plannedRefs, byUrl, byTask, knownTasks); + session.refSheet = null; + if (!session.refs.length) { + deps.emit(session, 'think', '没有可用的参考图,本轮全部按文生图走', { level: 'warn' }); + return; + } + deps.emit(session, 'think', '参考图就绪:' + session.refs.map((ref) => ref.id).join('、'), { + detail: detail('参考图', [ + thumbs( + '最终选用(' + session.refs.length + ' 张)', + session.refs.map((ref) => ({ + key: 'refs/' + ref.file, + title: ref.id + ' · ' + (ref.note || ''), + note: ref.source || '', + tag: 'pass', + text: reasons.get(ref.id) || '', + })), + ), + missed.length ? list('没找到的', missed) : null, + ]), + }); +} + +// ---------------------------------------------------------------- 角色基准图 +// +// 多图各画各的必然漂。实测一组四格漫画:同一只狐狸从"巴掌大蹲纸箱"一路画到"大型犬四足着地", +// 同一个少女从圆脸齐肩内扣画到微卷长发,而四张图的 hard 全部 5/5 通过——逐图评审看不到别的图, +// 每张单看都没毛病。唯一能真正锁住主体的办法,是先出一张角色设定图,让后面每张都以它为外观基准。 + +// 上限 2 个:每多一个主体就多跑一次生图,再多会把整组图的出图时间拖垮。 +const MAX_CHARACTER_REFS = 2; + +function normalizeCharacterRefs(data) { + const raw = data && Array.isArray(data.character_refs) ? data.character_refs : []; + const out = []; + const used = new Set(); + for (const item of raw) { + if (out.length >= MAX_CHARACTER_REFS) break; + if (!item || typeof item !== 'object') continue; + const prompt = String(item.prompt || '').trim(); + const key = String(item.key || '').trim().toLowerCase(); + if (!prompt || !key || used.has(key)) continue; + used.add(key); + out.push({ + key, + note: String(item.note || key).trim(), + prompt, + images: Array.isArray(item.images) + ? item.images.map((id) => String(id || '').trim().toUpperCase()).filter(Boolean) + : [], + }); + } + return out; +} + +// 生成一张角色设定图并登记成 R 编号参考图。 +// 串行执行:并发时两个 worker 会同时算出同一个 R 编号,把彼此的图覆盖掉。 +async function makeCharacterSheet(session, item, deps) { + const started = Date.now(); + const result = await h3.generate(deps.config, { + prompt: item.prompt, + // 设定图只需要当参考,2K 足够;4K 纯属白等。 + aspectRatio: '3:4', + area: '2K', + n: 1, + }); + const first = (result.items || []).find((entry) => entry && !entry.error && entry.url); + if (!first) return null; + await deps.ensureDir(deps.dirs.refs); + const id = 'R' + (session.refs.length + 1); + // 角色基准图同样按实际字节定扩展名,理由同候选图。 + const refBuffer = await h3.download(first.url, null); + const file = id + extensionFor(refBuffer); + const path = join(deps.dirs.refs, file); + await writeFile(path, refBuffer); + const ditMs = Date.now() - started; + session.cost.ditImages += 1; + session.cost.ditMs += ditMs; + if (result.usage) session.cost.usage = mergeUsage(session.cost.usage, result.usage); + const record = { + id, + file, + path, + note: item.note, + source: '角色设定图', + origin: 'generated', + images: item.images, + }; + session.refs.push(record); + deps.emit(session, 'paint', '角色设定图 ' + id + '(' + item.note + ')· ' + seconds(ditMs), { + durMs: ditMs, + detail: detail('角色设定图', [ + kv('编号', id), + kv('主体', item.note), + kv('用于', item.images.length ? item.images.join('、') : '全部图'), + kv('尺寸', first.size || '2K'), + kv('耗时', seconds(ditMs)), + text('设定 prompt', item.prompt), + ]), + }); + return record; +} + +// 在正式出故事图之前先把角色基准图准备好,并绑到相关图上。 +async function buildCharacterRefs(session, deps) { + session.characterRefs = []; + const list = normalizeCharacterRefs(session.plan); + const live = session.images.filter((image) => !image.blocked); + // 只有一张图时没有"跨图"可言,planner 误判也不该多烧一次生图。 + if (!list.length || live.length < 2) return; + + for (const item of list) { + try { + const made = await makeCharacterSheet(session, item, deps); + if (!made) { + deps.emit(session, 'think', '角色设定图「' + item.note + '」没返回可用图片,本组按原样走', { level: 'warn' }); + continue; + } + session.characterRefs.push(made); + const scope = item.images.length ? live.filter((image) => item.images.includes(image.id)) : live; + for (const image of scope) { + image.spec.refs = dedupe([...(image.spec.refs || []), made.id]); + image.spec.characterRefs = dedupe([...(image.spec.characterRefs || []), made.id]); + // 有参考图却还走 t2i,参考图根本不会被送进模型,等于白生成。 + image.spec.entry = 'edit'; + } + } catch (error) { + // 角色图失败绝不能卡住整组图:降级成原来的各画各的,并在交付里说清楚。 + deps.emit(session, 'think', '角色设定图「' + item.note + '」生成失败:' + describeError(error), { level: 'warn' }); + session.questions.push('角色设定图「' + item.note + '」没生成成功,这组图的' + item.note + '一致性可能下降。'); + } + } + + if (!session.characterRefs.length) return; + // 参考图集合变了,看图材料要重做,否则评审还在拿旧的对比图。 + session.refSheet = null; + deps.emit(session, 'think', '角色基准图就绪:' + session.characterRefs.map((ref) => ref.note).join('、'), { + detail: detail('角色基准', [ + thumbs( + '角色设定图(后续每张图的外观基准)', + session.characterRefs.map((ref) => ({ + key: 'refs/' + ref.file, + title: ref.id + ' · ' + ref.note, + note: '用于 ' + (ref.images.length ? ref.images.join('、') : '全部图'), + tag: 'pass', + })), + ), + ]), + }); +} + +// 参考图对比图只生成一次,之后所有轮次复用。 +async function ensureRefSheet(session, deps) { + if (!session.refs.length) return null; + if (session.refSheet) return session.refSheet; + try { + await mkdir(deps.dirs.sheets, { recursive: true }); + const result = await review.buildReferenceSheet(session.refs, join(deps.dirs.sheets, 'refs-sheet.png')); + session.refSheet = result ? result.sheet : null; + } catch { + session.refSheet = null; + } + return session.refSheet; +} + +export async function runIncrementalEdit(session, instruction, deps) { + const target = await editPlanNode(session, instruction, deps); + if (isCancelled(session)) return; + if (!target) { + deps.emit(session, 'edit', '没能定位到要改的图,已把问题放进交付'); + session.questions.push('增量编辑没解析出目标图:' + instruction); + await deliver(session, deps); + return; + } + const image = session.images.find((item) => item.id === target.imageId); + if (!image) { + session.questions.push('增量编辑引用了不存在的图:' + target.imageId); + await deliver(session, deps); + return; + } + image.spec.prompt = target.prompt || image.spec.prompt; + image.spec.entry = 'edit'; + // 只带编辑节点点名的参考图。把上一版的 R1..R4 一起留下,模型会分不清哪张是要改的底图, + // 转而照着参考图重画一张全新的图——实测「加个标题」被画成了另一台车、另一个人、另一个背景。 + image.spec.refs = dedupe(target.refs || []); + if (target.checklist && target.checklist.length) image.spec.checklist = target.checklist; + // 父图必须在清空轮次之前解析出来。清空之后 findCandidate 就找不到上一版的候选了, + // 编辑请求会不带原图,而轨迹上照样写着「在 A-r1-g1 上编辑」。 + const parent = + findCandidate(image, target.candidateId) || + image.chosen || + lastCandidate(image) || + lastDelivered(session, image, deps); + // 轮次显示从第 1 轮重新开始,但候选文件名要接着往上排, + // 否则新候选又叫 A-r1-g0,会把上一版交付的同名文件从磁盘上盖掉。 + image.roundBase = (image.roundBase || 0) + (image.rounds || []).length; + image.rounds = []; + image.status = 'running'; + image.chosen = null; + image.pendingAction = { + type: 'edit', + prompt: target.prompt, + parentId: parent ? parent.id : null, + parentPath: parent ? parent.path : null, + // 轮次马上要被清空,父图对象之后再也取不到,统计必须现在抄一份带下去。 + // 少了这一行,编辑轮的 parentStats 恒为 null,漂移判定会退回绝对模式。 + parentStats: (parent && parent.program && parent.program.stats) || null, + refs: image.spec.refs, + instruction: instruction, + }; + if (!parent) { + deps.emit(session, 'edit', '没找到要编辑的那张原图,这次只能按编辑指令重新生成', { level: 'warn' }); + } + await runImage(session, image, deps); + if (isCancelled(session)) return; + await deliver(session, deps); +} + +// ---------------------------------------------------------------- 01/02/03 规划 + +async function planNode(session, deps) { + const startedAt = Date.now(); + deps.emit(session, 'plan', '规划中:分诊欠定项、决定要不要检索参考图、给每张图出规格', { + detail: detail('规划节点 · 输入', [ + text('用户原话', session.instruction), + kv('已登记参考图', session.refs.length ? session.refs.map((ref) => ref.id).join('、') : '无'), + kv('工具调用预算', DEFAULTS.toolBudget.plan + ' 次(运行时硬中断)'), + ]), + }); + const refs = session.refs + .map((ref) => ref.id + ' -> ' + ref.path + (ref.note ? '(' + ref.note + ')' : '')) + .join('\n'); + const instruction = [ + '你是H3-Image-Executor 的规划节点。用户只给了一句自然语言需求,你要产出一份可执行的图片计划。', + '', + '用户原话:', + session.instruction, + '', + '已登记的参考图(本地绝对路径,需要判断"长什么样"时用 read 工具打开看):', + refs || '(无)', + '', + ...(session.answered || []).length + ? [ + '', + '已经问过用户并得到回答(直接按回答执行,不要再问同一件事):', + ...session.answered.map((item) => '- ' + item.question + ' → ' + item.answer), + ] + : [], + '要求:', + '1. 欠定项默认全部自己定,不要甩给用户。构图、光位、景别、镜头、风格、配色、材质、人物姿态与座位、', + ' 车内还是车外、白天还是夜晚、穿什么、要不要笑——用户没说就是让你决定,你选一个最合理的方案,', + ' 写进 assumptions(每条写"我定了什么"),然后照这个方案出图。', + '2. questions 只允许放"选错整张图就白做"的问题,而且必须同时满足三条:', + ' (a) 用户原话里这件事有两种以上都说得通的解读;(b) 选错会让整张图作废,而不是"不够好";(c) 你没有任何依据判断哪个对。', + ' 最多 2 条,通常应该是 0 条。每条形如 {"text":"问题","options":["选项一","选项二"],"default":"选项一"},', + ' options 给 2-4 个互斥的具体选项,default 写你倾向的那个(用户不答就按它走)。', + '2. 需求里出现真人、具体产品、logo、车型、地标这类“必须长成某个样子”的实体,而上面没有对应参考图时,', + ' 不要自己调工具检索——把检索意图写进顶层 ref_queries,系统会替你调搜图工具并下载到本地。', + ' 这是硬性分工:你只负责说清“要搜什么”,检索、下载、编号全部由运行时执行。', + ' ref_queries 每项形如 {"task_name":"tokyo_tower","query":"东京塔 白天 正面 全景","prompt":"必须是东京塔本体的清晰全景照片,不要夜景灯光秀,不要局部特写"}:', + ' - task_name:英文小写短名,本次规划内唯一;图片的 refs 就用这个名字引用检索结果。', + ' - query:中文实体名 + 用途词(正面照 / 内饰 / 车标 / logo / 门店)。', + ' - prompt:相关性过滤条件,写清楚要什么样的图。', + ' - 一个实体一项,最多 6 项;上面已经登记成 R1、R2… 的实体不要重复写。', + ' - 没有这类实体就写空数组,不要为了凑数硬编。', + '3. 图片的 refs 只写三种值:已登记的 R 编号、ref_queries 里的 task_name、公网图片地址(http/https)。', + ' 三种系统都会归一成 R 编号并下载到本地;写别的字符串会被忽略并告警。', + '4. 为每张要交付的图给出:id(大写字母,如 A、B)、slug、title、entry(t2i 或 edit)、aspect_ratio(如 "16:9",拿不准用 "auto")、slots、refs(引用 R 编号 / task_name / 图片地址)、text_layers(画面里必须出现的文字,没有就空数组)、checklist。', + '4b. slug 是这张图的英文短名,直接当交付文件名用:全小写、单词之间用短横线、2-4 个词、只概括画面内容(主体 + 场景或动作),', + ' 例:orange-cat-on-windowsill、girl-and-cat-sleeping、shiba-under-tokyo-tower。不要写尺寸、风格、镜头语言,不要重复 id。', + ' 同一批图里 slug 必须互不相同——重名会导致交付文件互相覆盖。', + '5. 不要自己写整段 prompt,改为填 slots —— 系统会按固定顺序拼成送进模型的长描述。槽位含义:', + ' style 整体风格与调色。例:高端汽车广告级写实摄影,暖中性色调,真实皮肤纹理', + ' shot 景别。例:车内广角中近景 / 半身特写 / 全景', + ' composition 机位与构图关系。例:从第二排右侧略低机位向前拍,两人形成左前到右后的对角线', + ' scene 环境与背景。例:一辆白色 MPV 行驶在城市高架上,窗外是被速度虚化的城市', + ' subject 主体,逐个人/物写清外貌、衣着、姿态、动作与相互关系。这是最长的一段,写细', + ' material 材质与细节。例:浅米白 Nappa 真皮带细密缝线与打孔,浅色木纹饰板(没有可留空)', + ' light 光线方向、性质与补光。例:午后侧逆光从天幕斜射,浅色内饰自然补光,面部清晰眼神有光', + ' lens 焦段、光圈、景深与动态模糊。例:24mm 等效广角、f/2.8,两人均在焦内', + ' text 图内必须出现的文字,没有就留空字符串', + ' negative 排除项。例:不要文字、不要水印、不要多余的人', + ' style/shot/composition/scene/subject/light/lens 是必填,缺一个系统会用兜底值并告警;material 可留空。', + ' 每个槽位写具体可执行的描述,不要写"合适的光线""精美的构图"这种空话。', + '5b. 色彩是硬要求:style 槽位必须写清白平衡(例:中性白平衡、真实色彩还原,不要暖黄调);', + ' negative 槽位必须包含"整体偏红、偏暖发黄"。系统还会再追加一条固定的白平衡要求,不要重复抄。', + '6. checklist 每项形如 {"id":"H1","kind":"hard","text":"...","verify":"vlm"};hard 是必须满足的(主体与数量、图内文字、参考图一致性、用户点名要求),soft 是风格偏好。', + ' hard 最多 4 条,而且只能来自用户原话里明确说过的东西——不要把自己想到的"最好也有"写成 hard。', + ' 每张图至少 1 条 hard。写不出 4 条就少写,不要凑数。', + ' 一条 hard 只写一个可判定条件,带多个限定词的必须拆开。评审是逐条核对的,复合条目会被"部分满足"蒙混过关:', + ' 反例「机位在车外左侧约45度,透过前挡风玻璃与驾驶位侧窗能清楚看到车内两人」——评审只核对了"车外"和"看到两人"就放行;', + ' 正例拆成两条「机位在车外左前方约45度,画面里能看到车头左前角与A柱」「透过前挡风玻璃与驾驶位侧窗能清楚看到车内两人」。', + ' 机位、角度、景别、构图这类必须各自单独成条,并写成"画面里能看到什么"这种可核对的事实,不要写成风格描述。', + ' 拆完超过 4 条时,把语义相邻的合并,但上面这类构图条目不要合并。', + '6b. 姿势正确性:需求里出现手语/手势、瑜伽或健身体式、乐器指法、舞蹈或武术动作、', + ' 攀岩抓点、握笔握筷这类「必须摆成某个特定姿势」的内容时,每个姿势单独写一条 hard,', + ' 而且必须写成「姿势本身对不对」,不是「图上有没有对应的文字标签」。', + ' 反例(错):「画面里出现文字「A」」——图上印着 A 就过,手型完全不对也照样放行;', + ' 正例(对):「A 格里的手型确实是 ASL 字母 A:握拳、拇指贴在食指外侧,其余四指收拢」。', + ' 这类需求里文字标签是次要的:先保证姿势正确,标签只写进 text_layers,不要占用 hard 名额。', + ' 同一张图里姿势多于 4 个时,按组分条(例:「A-E 五格手型分别符合 ASL 对应字母」),', + ' 但仍然必须是 hard,不许降级成 soft。', + '7. 默认 4K:除非用户明确要别的尺寸,area 一律写 "4K"。', + '8. route:需求简单、只出 1 张图写 "fast";多图或有参考图/文字/一致性要求写 "full"。', + '8b. 主体一致性(多图专用):images 达到 2 张,且同一个主体(人物 / 动物 / 拟人化物体)在 2 张以上的图里出现时,', + ' 必须输出 character_refs,为该主体单独出一张「角色设定图」,之后相关图全部以它为外观基准。', + ' 判据是"这个主体会不会被画成两个样子":漫画分镜、绘本、系列海报、同一产品的多角度图要写;', + ' 四张不同风景、四个互不相干的产品不要写。', + ' 每项形如 {"key":"girl","note":"16 岁少女","prompt":"...","images":["A","B","D"]};images 省略表示所有图都用它。', + ' prompt 必须写成角色设定稿:纯净浅灰单色背景、主体全身正面居中站姿、柔和平光无强阴影、无文字无水印,', + ' 并把该主体的外观特征全部写死(脸型、发型与发色、眼睛、体型比例、服装款式与配色、标志性配件)。', + ' 最多 2 个主体。没有跨图重复主体时输出空数组 []。', + '10. 规划阶段总工具调用上限 6 次,只用于 read 已登记的参考图。到达上限就用手上已有的信息出计划。', + '', + '只输出一个 JSON 对象,不要解释、不要 markdown 代码块:', + '{"route":"full","think":["..."],"assumptions":["柴犬坐在长椅左侧,把右侧留给塔身,因为需求要同时看清狗和塔"],"questions":[],"character_refs":[{"key":"girl","note":"16 岁少女","prompt":"角色设定稿:纯净浅灰背景,全身正面居中站姿…","images":["A","B"]}],"ref_queries":[{"task_name":"tokyo_tower","query":"东京塔 白天 正面 全景","prompt":"必须是东京塔本体的清晰全景照片"}],"images":[{"id":"A","slug":"shiba-under-tokyo-tower","title":"...","entry":"t2i","aspect_ratio":"16:9","area":"4K",', + ' "slots":{"style":"...","shot":"...","composition":"...","scene":"...","subject":"...","material":"...","light":"...","lens":"...","text":"","negative":"..."},', + ' "refs":[],"text_layers":[],"checklist":[{"id":"H1","kind":"hard","text":"...","verify":"vlm"}]}]}', + ].join('\n'); + + const { data } = await deps.agent.runJson(session, { step: 'plan', instruction, maxTools: DEFAULTS.toolBudget.plan }); + session.plan = data; + session.route = data.route === 'fast' ? 'fast' : 'full'; + const refQueries = normalizeRefQueries(data.ref_queries); + const refQuerySummary = refQueries.length + ? refQueries.map((item) => item.task_name + ':' + item.query).join(';') + : '无(本轮不检索参考图)'; + session.think = Array.isArray(data.think) ? data.think : []; + session.assumptions.push(...(Array.isArray(data.assumptions) ? data.assumptions : []).map((item) => String(item || '').trim()).filter(Boolean)); + session.blockers = normalizeBlockers(data.questions); + session.images = normalizePlan(data, session); + for (const image of session.images) { + if (!image.spec.missingSlots || !image.spec.missingSlots.length) continue; + deps.emit(session, 'plan', image.id + ' 缺失必填槽位:' + image.spec.missingSlots.join('、') + ',已用兜底值', { + level: 'warn', + detail: detail('槽位校验', [ + kv('图', image.id + ' · ' + image.title), + list('缺失', image.spec.missingSlots.map((label) => ({ tag: 'fail', text: label }))), + text('兜底后的 prompt', image.spec.prompt), + ]), + }); + } + deps.emit(session, 'plan', '规划完成:' + session.images.length + ' 张图,路线 ' + session.route, { + durMs: Date.now() - startedAt, + detail: detail('规划结果', [ + kv('路线', session.route === 'fast' ? 'fast · 单图快速' : 'full · 完整回路'), + kv('出图数', session.images.length), + kv('检索意图', refQuerySummary), + list('分诊思考', session.think), + list('我自己定的', session.assumptions.map((item) => ({ text: item }))), + list( + '出图前要问你', + session.blockers.map((item) => ({ text: item.text, tag: 'warn', note: (item.options || []).join(' / ') })), + ), + ...session.images.map((image) => + group(image.id + ' · ' + image.title, [ + kv('入口', image.spec.entry === 'edit' ? '带参考图编辑' : '文生图'), + kv('尺寸', image.spec.area + ' · ' + image.spec.aspectRatio), + kv('参考图', image.spec.refs.length ? image.spec.refs.join('、') : '无'), + image.blocked ? kv('阻塞原因', image.blockReason) : null, + image.spec.missingSlots && image.spec.missingSlots.length + ? kv('槽位告警', '缺失必填槽位 ' + image.spec.missingSlots.join('、') + ',已用兜底值') + : null, + list( + 'prompt 槽位', + SLOT_SPEC.map((spec) => ({ + tag: image.spec.slots && image.spec.slots[spec.key] ? '' : spec.required ? 'fail' : '', + text: spec.label + ':' + ((image.spec.slots && image.spec.slots[spec.key]) || '(空)'), + })).concat([ + { text: '图内文字:' + ((image.spec.slots && image.spec.slots.text) || '(无)') }, + { text: '排除项:' + ((image.spec.slots && image.spec.slots.negative) || '(无)') }, + ]), + ), + text('槽位拼装后的 prompt(送进模型)', image.spec.prompt), + list( + '验收清单', + image.spec.checklist.map((item) => ({ + tag: item.kind, + text: item.id + ' [' + item.kind + '] ' + item.text, + })), + ), + ]), + ), + ]), + }); + return data; +} + +function normalizePlan(data, session) { + const list = Array.isArray(data.images) ? data.images : []; + const used = new Set(); + return list.map((raw, index) => { + let id = String(raw.id || String.fromCharCode(65 + index)).toUpperCase().slice(0, 4); + while (used.has(id)) id += '1'; + used.add(id); + const refs = Array.isArray(raw.refs) ? raw.refs.filter(Boolean) : []; + const textLayers = Array.isArray(raw.text_layers) ? raw.text_layers : []; + const subjectText = [raw.title, raw.prompt, raw.slots && raw.slots.subject, raw.slots && raw.slots.scene] + .filter(Boolean) + .join(' '); + const checklist = normalizeChecklist(raw.checklist, textLayers, subjectText); + const questions = (Array.isArray(raw.questions) ? raw.questions : []) + .map((item) => (typeof item === 'string' ? item : (item && (item.text || item.question)) || '')) + .filter(Boolean); + const hasSlots = Boolean(raw.slots && typeof raw.slots === 'object'); + const slots = hasSlots ? normalizeSlots(raw.slots, textLayers) : null; + const composed = composePrompt(slots, raw.prompt, session.instruction, hasSlots); + return { + id, + title: raw.title || '图 ' + id, + blocked: Boolean(raw.blocked) || questions.length > 0, + blockReason: questions.join(';'), + spec: { + slug: normalizeSlug(raw.slug, id.toLowerCase()), + entry: raw.entry === 'edit' || refs.length ? 'edit' : 't2i', + prompt: composed.prompt || String(raw.prompt || session.instruction), + slots: composed.slots, + missingSlots: composed.missing, + hasSlots, + aspectRatio: raw.aspect_ratio || 'auto', + area: raw.area || DEFAULTS.area, + size: raw.size || '', + refs, + textLayers, + checklist, + }, + rounds: [], + status: 'pending', + chosen: null, + }; + }); +} + +// 文字层硬项上限。文字标签只是辅助信息,一条一条挂 hard 会把 hard 名额吃光, +// 反而把 planner 写的「手型对不对」这类真正的要求挤出局(真机 20261110-5e10ae: +// A 图 26 个字母标签生成 26 条 T 项,T1-T5 占满 hard 名额,4 条手型检查全被降级成 +// soft,soft 不参与放行,于是「手指摆成印刷体字母」照样判通过)。 +// 超过上限的部分合并成一条聚合项,仍然可判定,但只占一个名额。 +const MAX_TEXT_HARD = 3; + +function normalizeChecklist(checklist, textLayers, subjectText) { + const items = Array.isArray(checklist) ? checklist : []; + // 文字层是用户明确点名的可验证项,排在最前面,保证不会被 hard 上限截掉; + // 但数量必须封顶,否则它会把 planner 写的语义要求全部挤出去。 + const out = []; + const layers = []; + for (const layer of Array.isArray(textLayers) ? textLayers : []) { + const text = typeof layer === 'string' ? layer : layer && layer.text; + if (!text) continue; + if (layers.indexOf(text) < 0) layers.push(text); + } + const headLayers = layers.slice(0, MAX_TEXT_HARD); + const tailLayers = layers.slice(MAX_TEXT_HARD); + for (let i = 0; i < headLayers.length; i += 1) { + out.push({ + id: 'T' + (i + 1), + kind: 'hard', + text: '画面里出现文字「' + headLayers[i] + '」且字形正确可读', + verify: 'vlm', + }); + } + if (tailLayers.length) { + out.push({ + id: 'T' + (headLayers.length + 1), + kind: 'hard', + text: + '画面里的文字「' + tailLayers.join('」「') + '」全部出现,字形正确可读,没有错字、缺漏或重复', + verify: 'vlm', + }); + } + for (const item of items) { + if (!item || !item.text) continue; + const text = String(item.text); + if (out.some((existing) => existing.text === text)) continue; + out.push({ + id: String(item.id || (item.kind === 'soft' ? 'S' : 'H') + (out.length + 1)), + kind: item.kind === 'soft' ? 'soft' : 'hard', + text, + verify: item.verify === 'program' ? 'program' : 'vlm', + }); + } + if (!out.some((item) => item.kind === 'hard')) { + out.push({ id: 'H1', kind: 'hard', text: '画面内容与用户需求一致,主体完整无畸形', verify: 'vlm' }); + } + // hard 项越多,评审越容易挑刺、越容易把图打回;超出上限的部分降级成 soft。 + let hardSeen = 0; + const capped = []; + for (const item of out) { + if (item.kind !== 'hard') { + capped.push(item); + continue; + } + hardSeen += 1; + if (hardSeen <= DEFAULTS.maxHardItems) capped.push(item); + else capped.push({ ...item, kind: 'soft', id: item.id.startsWith('T') ? item.id : 'S-' + item.id }); + } + const limited = capped.slice(0, DEFAULTS.maxHardItems + 4); + // 色彩是程序最容易漏、评审又不会主动看的一维:程序层只做配平,配平不下来的偏色 + // 以前只留一个 soft warn 就交付了(实测「加字」那一版整体偏红 1.21 照样过)。 + // 固定挂一条 soft 项,评审必须对色调表态,但 soft 不参与放行,不会把图打回。 + if (!limited.some((item) => item.id === COLOR_CHECK.id)) limited.push({ ...COLOR_CHECK }); + // 色彩同时挂一条 hard:程序实测 redGain 超标就打回重画。 + // 必须放在 hard 上限裁剪之后,否则会被降级成 soft(soft 不参与放行)。 + if (!limited.some((item) => item.id === COLOR_HARD_CHECK.id)) limited.push({ ...COLOR_HARD_CHECK }); + // 解剖畸形是用户一眼能看出来、评审却最容易漏的一维(真机 20261110-c5bf7a 图 C:托猫的手 + // 多指畸形,清单里没有任何解剖项,评审只核对了主体存在性和风格就给 8.5 分放行)。 + // 只在画面里确实有人或动物时注入:纯风景、产品图挂这条只会制造无谓打回。 + if (subjectText && ANATOMY_SUBJECT.test(subjectText) + && !limited.some((item) => item.id === ANATOMY_CHECK.id)) { + limited.push({ ...ANATOMY_CHECK }); + } + return limited; +} + +// 交付文件名:H3-Image_<内容英文简写>_<时间戳>.。 +// slug 由规划节点产出;拿不到就退回图号,保证老会话和规划失败时照样能交付。 +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function normalizeSlug(raw, fallback) { + const text = String(raw || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + .replace(/-+$/, ''); + return text && SLUG_RE.test(text) ? text : String(fallback || 'image'); +} + +function fileStamp(date) { + const pad = (n) => String(n).padStart(2, '0'); + return String(date.getFullYear()) + pad(date.getMonth() + 1) + pad(date.getDate()) + + '-' + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()); +} + +// 画面里出现人或动物时才注入解剖硬项。纯风景、产品图不挂,避免无谓打回。 +const ANATOMY_SUBJECT = /(人物|人像|男人|女人|女孩|男孩|小孩|儿童|孩童|少年|少女|老人|婴儿|宝宝|角色|主角|群像|脸|面孔|手|手指|腿|脚|身体|肖像|全身|半身|坐姿|站姿|躺|卧|猫|狗|兔|熊|鸟|马|牛|羊|猪|鸡|鸭|鱼|龙|动物|宠物|拟人|玩偶|娃娃|person|people|man|men|woman|women|girl|boy|child|children|kid|baby|portrait|face|hand|finger|leg|foot|cat|dog|rabbit|bear|bird|horse|animal|pet)/i; + +const ANATOMY_CHECK = { + id: 'H-anatomy', + kind: 'hard', + verify: 'vlm', + text: '画面里的人物或动物肢体解剖正确:手指数量正确、指节与关节走向自然、四肢没有扭曲/多肢/缺失、五官位置正常;风格化的简化不算畸形', +}; + +// 固定挂在每张图验收清单末尾的色彩项。措辞上排除"刻意设计的暖调/冷调", +// 否则日落、烛光这类本来就要暖的题材会被反复判偏色。 +const COLOR_CHECK = { + id: 'S-color', + kind: 'soft', + verify: 'vlm', + text: '整体色彩自然:没有明显的整体偏色(发红、发绿、整体偏冷)或色阶断层;刻意设计的暖调/冷调不算偏色', +}; + +// 程序实测的硬性色彩项:只有红增益真的超标才 fail。 +// 判据要「相对父图变红」和「绝对 redGain ≥ RED_ABS_LIMIT」两条同时成立—— +// 只看相对漂移会被一张很蓝的父图带偏:真机 20261109-691283 第 3 轮的子图 redGain 只有 +// 0.9667(压根不红),却因为父图 0.8044 被标成「相对父图发红」,差点被当成偏红打回。 +const RED_ABS_LIMIT = 1.08; +// 不管有没有父图、红到什么程度一律判偏红。只靠相对漂移不够:上一轮如果已经交付了一张 +// 偏红的图,下一轮的父图本身就是红的,相对漂移恒为 0,检测会彻底失效(roundbase 回归实测)。 +// 1.2 高于常见「刻意暖调」的调色量级,日落、烛光这类题材不会被误伤。 +const RED_HARD_LIMIT = 1.2; +const COLOR_HARD_CHECK = { + id: 'H-color', + kind: 'hard', + verify: 'program', + text: + '整体白平衡中性:红通道不得明显压过绿蓝通道(程序实测:相对父图漂移成立时 redGain ≥ ' + + RED_ABS_LIMIT + + ',或绝对 redGain ≥ ' + + RED_HARD_LIMIT + + ',任一成立即判偏红)', +}; + +// ---------------------------------------------------------------- 04/05/06 单图循环 + +async function runImage(session, image, deps) { + const maxRefine = session.route === 'fast' ? DEFAULTS.maxRefineFast : DEFAULTS.maxRefine; + image.status = 'running'; + let action = image.pendingAction || { + type: image.spec.entry === 'edit' ? 'edit' : 'generate', + prompt: image.spec.prompt, + refs: image.spec.refs, + parentId: null, + }; + image.pendingAction = null; + let fullEdits = 0; + let staleRounds = 0; + let bestHard = -1; + // 偏红重画单独计数:它不算「没改善」,不该把 stale 判定和轮次预算搅乱。 + let colorRetries = 0; + const maxColorRetries = session.route === 'fast' ? 1 : 2; + let lastActionType = action.type; + + for (let round = 0; round <= maxRefine; round += 1) { + if (isCancelled(session)) return; + // 底图在这一步就解析好:轨迹必须显示真正送进模型的那张图, + // 不能显示一个解析不到的 id,否则「在 A-r1-g1 上编辑」会骗人。 + const parent = resolveParent(image, action); + const requestedParentId = action.parentId || null; + if (action.type === 'edit') { + action.parentId = parent ? parent.id || action.parentId : null; + action.parentPath = parent ? parent.path : null; + action.parentStats = (parent && parent.program && parent.program.stats) || action.parentStats || null; + } + const record = { index: round, action: summarizeAction(action), candidates: [], startedAt: Date.now() }; + image.rounds.push(record); + deps.emit(session, 'paint', image.id + ' 第 ' + (round + 1) + ' 轮:' + describeAction(action), { + detail: detail('本轮动作', [ + kv('类型', action.type !== 'edit' ? '重新生成' : parent ? '在已有图上编辑' : '按参考图生成'), + kv('底图', action.type !== 'edit' ? '无' : parent ? parent.id || '上一张' : '没找到,已退化成按参考图生成'), + kv('参考图', (action.refs || []).join('、') || '无'), + kv('候选数', session.route === 'fast' ? 1 : DEFAULTS.candidates), + text('送进模型的 prompt', action.prompt), + ]), + }); + + let candidates = []; + try { + candidates = await executeAction(session, image, action, deps); + } catch (error) { + record.error = describeError(error); + image.failReason = record.error; + deps.emit(session, 'paint', image.id + ' 生成失败:' + record.error, { level: 'error' }); + if (isFatal(error)) { + session.status = 'failed'; + session.fatal = record.error; + image.status = 'failed'; + return; + } + // 安全审核拒绝没有重试价值:prompt 一个字没改,再送 5 次也是同样的 422。 + // 立刻停手,把接口原文和「怎么改」一起顶到对话层,让用户改需求重来。 + if (isContentPolicy(error)) { + image.status = 'failed'; + image.contentPolicy = true; + deps.emit(session, 'paint', image.id + ' 被内容安全审核拒绝,已停止重试', { + level: 'error', + detail: detail('内容安全审核拒绝', [ + kv('图', image.id + ' · ' + image.title), + kv('接口返回', record.error), + text('送进模型的 prompt', action.prompt || image.spec.prompt), + text( + '怎么改', + '这类拒绝是确定性的,重画和换画法都不会过。请修改需求本身(例如换成原创角色或别的题材)后再重新发起。', + ), + ]), + }); + session.questions.push( + image.id + ' 被内容安全审核拒绝,需要改掉触发审核的描述后重画:' + record.error, + ); + return; + } + if (round >= maxRefine) break; + action = { type: 'generate', prompt: image.spec.prompt, refs: image.spec.refs, parentId: null }; + continue; + } + record.candidates = candidates; + // 生图请求停不下来,但它回来之后不该再起评审回合。 + if (isCancelled(session)) return; + if (!candidates.length) { + if (round >= maxRefine) break; + continue; + } + + // 程序实测和评审都是会抛的:Host 把 agent turn 判成 error(实测过一次卡 205 秒后 error)、 + // 回复不是合法 JSON、用户中途点停止。以前这里没有兜底,异常会一路抛出 runImage, + // 整张图直接判死、交付 0 张,连重试的机会都没有——图其实已经生成好了。 + // 现在按「这一轮失败」处理:还能重试就重来,轮次用完就交给选优。 + let verdict; + let judgeStartedAt = Date.now(); + try { + await Promise.all(candidates.map((candidate) => programChecks(session, image, candidate, deps))); + // 全部候选都被程序判成偏红时,没必要再花两三分钟让评审看一遍——它也看不出红。 + // 直接重画一轮,并把「上一轮偏红」写进 prompt。评审节点照样会在别的失败上跑。 + const redAll = + candidates.length > 0 && candidates.every((candidate) => candidate.program && candidate.program.redCast); + if (redAll && colorRetries < maxColorRetries && round < maxRefine) { + colorRetries += 1; + record.verdict = { note: '全部候选偏红,跳过评审直接重画' }; + deps.emit( + session, + 'refine', + image.id + ' 第 ' + (round + 1) + ' 轮候选全部偏红,跳过评审直接重画(' + colorRetries + '/' + maxColorRetries + ')', + { level: 'warn' }, + ); + // 只有「本来就指定了父图」的编辑轮才继续在同一张父图上重画; + // 那种因为 entry 被判成 edit、实际没有底图的轮次必须回到重新生成—— + // 在刚生成的偏红图上做全图 edit 只会继续累积偏色。 + action = requestedParentId + ? { ...action, prompt: withColorRetry(action.prompt) } + : { type: 'generate', prompt: withColorRetry(image.spec.prompt), refs: image.spec.refs, parentId: null }; + continue; + } + judgeStartedAt = Date.now(); + verdict = await judgeNode(session, image, record, deps); + record.verdict = verdict.summary; + } catch (error) { + const message = describeError(error); + record.error = message; + deps.emit(session, 'verify', image.id + ' 第 ' + (round + 1) + ' 轮评审异常:' + message, { level: 'error' }); + if (isFatal(error)) { + session.status = 'failed'; + session.fatal = message; + image.status = 'failed'; + return; + } + // 用户主动停的,别再自顾自重试。 + if (session.status === 'cancelled') return; + if (round >= maxRefine) break; + action = { type: 'generate', prompt: image.spec.prompt, refs: image.spec.refs, parentId: null }; + continue; + } + const bestVerdict = verdict.best ? verdict.best.verdict : null; + const hardPassed = bestVerdict ? bestVerdict.hardPassed : 0; + const total = verdict.summary.hardTotal; + deps.emit( + session, + 'verify', + image.id + ' 第 ' + (round + 1) + ' 轮评审:hard ' + hardPassed + '/' + total + + (bestVerdict && bestVerdict.hardUncertain.length ? '(' + bestVerdict.hardUncertain.length + ' 项拿不准放行)' : '') + + ',质量 ' + (bestVerdict ? bestVerdict.quality : '-'), + { + durMs: Date.now() - judgeStartedAt, + detail: detail('评审结论 · 第 ' + (round + 1) + ' 轮', [ + kv('判定', verdict.passed ? '通过,进入交付' : '打回,进入修正'), + kv('看图材料', candidates.map((candidate) => candidate.id + '-sheet.png').join('、')), + ...candidates.map((candidate) => + group(candidate.id, [ + kv( + 'hard', + candidate.verdict.hardPassed + '/' + candidate.verdict.hardTotal + + (candidate.verdict.hardUncertain.length + ? '(' + candidate.verdict.hardUncertain.length + ' 项拿不准但放行)' + : ''), + ), + kv('质量分', candidate.verdict.quality), + text('总评', candidate.verdict.summary), + list( + '逐项判定', + image.spec.checklist.map((item) => { + const hit = candidate.verdict.checks[item.id]; + const result = hit ? hit.result : '未评'; + return { + tag: result === 'pass' ? 'pass' : result === 'fail' ? 'fail' : result === 'uncertain' ? 'warn' : '', + text: item.id + ' [' + item.kind + '] ' + item.text, + note: hit && hit.note ? hit.note : '', + }; + }), + ), + candidate.verdict.extraFails && candidate.verdict.extraFails.length + ? list( + '评审发现的清单外问题(按 hard fail 处理)', + candidate.verdict.extraFails.map((id) => ({ + tag: 'fail', + text: id, + note: (candidate.verdict.checks[id] || {}).note || '', + })), + ) + : null, + ]), + ), + verdict.next + ? group('评审建议的下一步', [ + kv('动作', verdict.next.type), + kv('目标候选', verdict.next.parentId || '-'), + text('理由', verdict.next.reason), + text('指令', verdict.next.prompt), + ]) + : null, + ]), + }, + ); + + if (verdict.passed && verdict.best) { + image.chosen = verdict.best; + image.status = 'passed'; + deps.emit(session, 'verify', image.id + ' 通过:' + verdict.best.id); + return; + } + + if (hardPassed > bestHard) { + bestHard = hardPassed; + staleRounds = 0; + } else { + staleRounds += 1; + } + if (staleRounds >= DEFAULTS.staleStopRounds) { + deps.emit(session, 'refine', image.id + ' 连续 ' + staleRounds + ' 轮没有改善,提前停下并交给选优'); + break; + } + if (round >= maxRefine) break; + + let next = verdict.next; + if (!next || !isExecutable(next, image)) { + next = await safeRefine(session, image, record, verdict, deps); + } + if (!next || next.type === 'give_up') { + deps.emit(session, 'refine', image.id + ' 评审建议放弃本轮,进入选优'); + break; + } + if (staleRounds >= DEFAULTS.staleActionRounds && next.type === lastActionType) { + deps.emit(session, 'refine', image.id + ' 连续没有改善,强制换一种改法'); + next = (await safeRefine(session, image, record, verdict, deps, true)) || next; + } + if (next.type === 'edit') { + fullEdits += 1; + if (fullEdits > DEFAULTS.maxFullEdits) { + deps.emit(session, 'refine', image.id + ' 全图 edit 已达上限 ' + DEFAULTS.maxFullEdits + ' 次,改走重新生成'); + next = { type: 'generate', prompt: next.prompt || image.spec.prompt, refs: image.spec.refs, parentId: null }; + } + } + lastActionType = next.type; + action = next; + } + + await selectBestEffort(session, image, deps); +} + +// 修正节点也会抛(agent turn 被判 error / 回复不是 JSON)。它挂了不代表这一轮没救, +// 当成「评审没给可执行的下一步」处理,让上层走放弃本轮、交给选优。 +async function safeRefine(session, image, record, verdict, deps, forceChange) { + try { + return await refineNode(session, image, record, verdict, deps, forceChange); + } catch (error) { + deps.emit(session, 'refine', image.id + ' 修正节点异常:' + describeError(error), { level: 'error' }); + return null; + } +} + +// 选优是最后一道兜底,它自己挂了也必须把手上的候选交出去: +// 图已经生成好了,因为选优节点抽风就交付 0 张,是最糟的失败方式。 +async function selectBestEffort(session, image, deps) { + try { + await selectNode(session, image, deps); + return; + } catch (error) { + deps.emit(session, 'select', image.id + ' 选优节点异常:' + describeError(error), { level: 'error' }); + } + if (image.chosen) return; + let best = null; + for (const round of image.rounds) { + for (const candidate of round.candidates || []) { + if (!candidate || !candidate.path) continue; + if (!best) { + best = candidate; + continue; + } + const av = candidate.verdict || { hardPassed: 0, quality: 0 }; + const bv = best.verdict || { hardPassed: 0, quality: 0 }; + if (av.hardPassed > bv.hardPassed || (av.hardPassed === bv.hardPassed && av.quality > bv.quality)) best = candidate; + } + } + if (!best) { + image.status = 'failed'; + const why = lastFailReason(image); + image.failReason = why; + deps.emit(session, 'select', image.id + ' 没有任何可用候选' + (why ? ':' + why : ''), { + level: 'error', + detail: detail('没有可用候选', [ + kv('图', image.id + ' · ' + image.title), + kv('轮次', image.rounds.length), + why ? kv('最后一次报错', why) : kv('最后一次报错', '无报错,候选全部未通过程序校验'), + ]), + }); + return; + } + image.chosen = best; + image.status = 'unpassed'; + image.chosenReason = '选优节点异常,按验收分数回退'; + deps.emit(session, 'select', image.id + ' 选优节点异常,回退交付:' + best.id, { level: 'warn' }); +} + +// Host 回来的 error 常常是 { code, message } 这种对象,直接 String() 会变成 [object Object], +// 排查时什么都看不到(实测过一次 agent_error:[object Object],完全不知道出了啥)。 +// 统一在这里展开成「code:message」。 +function describeError(error) { + if (!error) return '未知错误'; + if (typeof error === 'string') return error; + const raw = error.message !== undefined ? error.message : error; + let base; + if (raw === null || raw === undefined) base = error.name || '未知错误'; + else if (typeof raw === 'string') base = raw; + else if (typeof raw === 'object') { + const code = raw.code || raw.name || ''; + const body = raw.message || raw.reason || raw.detail || ''; + if (code || body) base = code ? code + ':' + (body || '') : String(body); + else { + try { + base = JSON.stringify(raw); + } catch { + base = String(raw); + } + } + } else base = String(raw); + // Node 的 fetch 失败只给一句 "fetch failed",真正的原因全在 error.cause 里 + // (ECONNRESET / ENOTFOUND / UND_ERR_* / socket hang up)。不带上它,线上出问题 + // 只能看到一句废话——真机 20261110-c46dde 就是这样:4 张图里 3 张连续 5 轮 + // "fetch failed",refine 预算烧光、一次评审都没跑就交付了,事后完全查不出原因。 + const cause = error && typeof error === 'object' ? error.cause : null; + if (cause === null || cause === undefined) return base; + // cause 允许是字符串(new Error('x', { cause: 'why' }) 是合法写法), + // 这时它本身就是原因,不能因为「不是对象」就悄悄丢掉。 + const causeCode = typeof cause === 'object' ? cause.code || cause.name || '' : ''; + const causeMsg = typeof cause === 'string' + ? cause.trim() + : String((cause && cause.message) || '').trim(); + const extra = [causeCode, causeMsg].filter(Boolean).join(' ').trim(); + return extra && base.indexOf(extra) < 0 ? base + '(' + extra + ')' : base; +} + +function isFatal(error) { + // 用户在插件详情里关掉了 Agent 权限:后面每个节点都会以同样的原因失败, + // 继续跑只会白烧生图请求,必须当场停下,让页面显示去哪里重新打开。 + if (error && error.code === 'NOT_PERMITTED') return true; + const message = describeError(error); + // invalid params / unknown model 是请求本身不对(模型名下线、参数非法),重试多少次都一样。 + // 不当致命错误处理的话,会一路重试到 maxRefine,白烧十几次请求再交付 0 张图。 + return /missing_api_key|missing_base_url|HTTP 401|HTTP 403|HTTP 404|invalid params|unknown model|参考图上传失败|NOT_PERMITTED/.test(message); +} + +// 内容安全审核是确定性拒绝:同一个 prompt 送多少次都会被挡下来。 +// 实测「米老鼠和龙猫分别打着伞等雨停」被 H3 连拒 6 轮,白烧 100 秒, +// 最后交付 0 张图而且页面上看不出任何原因。这类必须第一轮就停手。 +function isContentPolicy(error) { + return /safety check|content[_ ]?policy|content[_ ]?filter|内容安全|审核不通过|敏感内容|违规内容/i.test( + describeError(error), + ); +} + +// 图没出来时要能说清「为什么」。优先报最后一次真实报错, +// 一条报错都没有(比如候选全被程序判死)才回落到笼统说法。 +function lastFailReason(image) { + if (image.failReason) return image.failReason; + for (let index = image.rounds.length - 1; index >= 0; index -= 1) { + const round = image.rounds[index]; + if (round && round.error) return round.error; + } + return ''; +} + +function isExecutable(next, image) { + if (!next || !next.type) return false; + if (next.type === 'generate') return Boolean(next.prompt); + if (next.type === 'edit') { + if (!next.prompt) return false; + if (next.parentId) return true; + return Boolean(image.rounds.length); + } + return next.type === 'give_up'; +} + +async function executeAction(session, image, action, deps) { + // 计量覆盖整个 DiT 环节:参考图上传 + 接口调用 + 图片下载。 + // 之前 started 写在接口调用之后,把最贵的那段漏掉了。 + const started = Date.now(); + const config = deps.config; + const n = session.route === 'fast' ? 1 : DEFAULTS.candidates; + const options = { + prompt: action.prompt, + aspectRatio: image.spec.aspectRatio, + area: image.spec.area, + n, + }; + if (image.spec.size) options.size = image.spec.size; + + let result; + let parent = null; + let endpoint = 'images/generations'; + if (action.type === 'edit') { + endpoint = 'images/edits'; + parent = resolveParent(image, action); + const refs = []; + // 父图是本地文件,必须先上传换成 mm_file://。直接把它当 image_url 发出去, + // 接口会回 HTTP 400「only http(s) URL, data:image/*;base64 or mm_file:/」。 + if (parent) refs.push(await h3.resolveReference(config, parent.path)); + // 编辑只带动作里点名的参考图。fallback 到 image.spec.refs 会把上一版的 R1..R4 全塞进来, + // 模型分不清哪张是底图,就会照着参考图重画。 + refs.push(...(await resolveActionRefs(session, image, action.refs, deps))); + if (!refs.length) throw new Error('edit 缺少可用参考图'); + // 同时送底图和身份参考图时,必须说清谁是谁:不说的话模型会拿参考图当底图重画。 + if (parent && refs.length > 1) { + options.prompt = + '第 1 张图是要修改的原图,请在它基础上修改,保持它的构图、背景、光线与画幅不变;' + + '后面的图只是身份/外观参考,不要采用它们的背景、机位或构图。\n\n' + + options.prompt; + } + result = await h3.edit(config, { ...options, images: refs.slice(0, 16) }); + } else { + // refine 点名的 refs 不能把角色基准图顶掉:修一轮之后主体就漂回原样了。 + const explicit = action.refs && action.refs.length ? action.refs : image.spec.refs || []; + const refIds = dedupe([...explicit, ...(image.spec.characterRefs || [])]); + const refs = await resolveActionRefs(session, image, refIds, deps); + if (refs.length) { + endpoint = 'images/edits'; + // 角色基准图是"没有底图的条件图":不说清楚,模型会连它的浅灰背景一起照搬过来。 + if (!parent && (image.spec.characterRefs || []).length) { + options.prompt = + '参考图只用于锁定主体的身份与外观(脸型、发型与发色、眼睛、体型比例、服装款式与配色、标志性配件),' + + '必须在所有图里保持完全一致;不要采用参考图的背景、场景、构图、机位与画幅。\n\n' + + options.prompt; + } + result = await h3.edit(config, { ...options, images: refs.slice(0, 16) }); + } else { + result = await h3.generate(config, options); + } + } + + const candidates = []; + for (let index = 0; index < result.items.length; index += 1) { + const item = result.items[index]; + if (item.error) { + deps.emit(session, 'paint', image.id + ' 第 ' + (index + 1) + ' 张失败:' + item.error, { level: 'warn' }); + continue; + } + // roundBase 让增量编辑的候选接着上一版往上编号,避免同名文件互相覆盖。 + const id = image.id + '-r' + ((image.roundBase || 0) + image.rounds.length) + '-g' + index; + // 先拿字节再定扩展名:接口回 JPEG 时落盘叫 .png 会让文件名说谎, + // 也会让 /api/file 按扩展名回错的 content-type(现在两边都按字节走了)。 + const buffer = await h3.download(item.url, null); + const file = id + extensionFor(buffer); + const filePath = join(deps.dirs.images, file); + await writeFile(filePath, buffer); + candidates.push({ + id, + path: filePath, + file, + size: item.size, + requestId: result.requestId, + revisedPrompt: item.revisedPrompt, + parentId: parent ? parent.id : null, + parentPath: parent ? parent.path : null, + // 统计值在 parent.program.stats 上(programChecks 写进去的),候选对象本身没有 stats 字段。 + // 写成 parent.stats 会恒为 undefined:漂移判定永远退回绝对模式,自动配平阈值从 + // 「相对父图 ≥0.05」变成「绝对 redGain ≥1.35」,编辑把画面推红也就没人拉回来了。 + parentStats: (parent && parent.program && parent.program.stats) || action.parentStats || null, + actionType: action.type, + actionPrompt: action.prompt, + round: image.rounds.length - 1, + createdAt: Date.now(), + buffer, + program: {}, + checks: [], + }); + } + const ditMs = Date.now() - started; + session.cost.ditImages += candidates.length; + session.cost.ditMs += ditMs; + session.cost.usage = mergeUsage(session.cost.usage, result.usage); + deps.emit(session, 'paint', image.id + ' 生图返回 ' + candidates.length + ' 张 · ' + seconds(ditMs), { + durMs: ditMs, + requestId: result.requestId || '', + detail: detail('DiT 输出', [ + kv('接口', endpoint), + kv('request_id', result.requestId || '-'), + kv('尺寸', candidates.map((candidate) => candidate.size).join('、') || '-'), + kv('耗时', seconds(ditMs)), + kv('token', result.usage ? result.usage.total_tokens : '-'), + candidates[0] && candidates[0].revisedPrompt + ? text('模型改写后的 prompt', candidates[0].revisedPrompt) + : null, + ]), + }); + return candidates; +} + +// 参考图引用只认两种:已登记的 R 编号(换成会话目录里的文件),以及能直接交给接口的地址。 +// 其余字符串一律返回空串,由调用方跳过并告警。refs 来自 Agent 的回复,是不可信输入: +// 以前不认识的字符串会原样当成本地路径,于是 uploadReference 会把那个本机文件读出来上传给接口。 +function resolveRefPath(session, ref) { + if (typeof ref !== 'string' || !ref) return ''; + if (/^(https?:\/\/|mm_file:\/\/|data:image\/)/i.test(ref)) return ref; + const found = session.refs.find((item) => item.id === ref.toUpperCase()); + return found ? found.path : ''; +} + +// 把一组引用解析成接口能用的参考图;不认识的跳过并在时间线上说一句。 +async function resolveActionRefs(session, image, list, deps) { + const out = []; + for (const ref of list || []) { + const target = resolveRefPath(session, ref); + if (!target) { + deps.emit(session, 'paint', image.id + ' 忽略了不认识的参考图引用「' + String(ref).slice(0, 60) + '」', { + level: 'warn', + }); + continue; + } + out.push(await h3.resolveReference(deps.config, target)); + } + return out; +} + +function findCandidate(image, id) { + if (!id) return null; + for (const round of image.rounds) { + const hit = round.candidates.find((candidate) => candidate.id === id); + if (hit) return hit; + } + return null; +} + +// 编辑要基于哪张图。增量编辑会把轮次清空,那时只能靠 pendingAction 里带下来的 parentPath, +// 所以这里必须认 parentPath,否则原图根本不会送进模型。 +function resolveParent(image, action) { + if (!action || action.type !== 'edit') return null; + const hit = findCandidate(image, action.parentId); + if (hit) return hit; + // 增量编辑会先清空 image.rounds,这里 findCandidate 必然落空,只能靠 pendingAction 带下来的统计。 + // 这个分支以前只返回 {id, path},没有 program,编辑轮的父图统计就永远是 null。 + if (action.parentPath) { + return { + id: action.parentId || '', + path: action.parentPath, + program: action.parentStats ? { stats: action.parentStats } : null, + }; + } + return lastCandidate(image); +} + +function lastCandidate(image) { + for (let i = image.rounds.length - 1; i >= 0; i -= 1) { + const round = image.rounds[i]; + if (round.candidates && round.candidates.length) return round.candidates[round.candidates.length - 1]; + } + return null; +} + +// 05 VERIFY 的程序部分:尺寸、色彩漂移(发红)、过度锐化、文字层是否写进 prompt。 +async function programChecks(session, image, candidate, deps) { + let decoded = null; + try { + // 复用下载时拿到的字节,省掉一次 4K 图的磁盘读 + 解码。 + // 必须走 decodeImage(PNG/JPEG 都认),不能只认 PNG:见文件头的说明。 + decoded = decodeImage(candidate.buffer || (await readFile(candidate.path))); + } catch (error) { + candidate.buffer = null; + candidate.program = { error: '图片解码失败:' + String(error && error.message ? error.message : error) }; + candidate.checks = [{ id: 'P-decode', kind: 'hard', verify: 'program', text: '图片可正常解码', result: 'fail' }]; + return; + } + candidate.buffer = null; + let stats = png.analyze(decoded); + const parentStats = candidate.parentStats || null; + const drift = png.detectDrift(stats, parentStats); + const sharp = png.detectOverSharpen(stats, parentStats); + + candidate.program = { + width: decoded.width, + height: decoded.height, + longEdge: Math.max(decoded.width, decoded.height), + stats, + drift, + sharpness: sharp, + redCast: false, + }; + + // 色彩只做检测,不动像素:偏红由 H-color 硬项判 fail,交给 runImage 重画。 + // 偏红不再用像素配平去救。实测 matchBalance 在「子图比父图亮」时三通道增益会一起撞到 + // clamp 下限([0.75,0.75,0.75]),退化成整体压暗:色偏一点没修,还把用户明确要的亮度 + // 压回去(真机 20261109-691283「改成烈日当空」那一轮,luma 被压到 ×0.748)。 + // 现在程序层只负责判,判成偏红就由 runImage 打回重画一轮。 + const redCast = stats.redGain >= RED_ABS_LIMIT && (drift.cast === 'red' || stats.redGain >= RED_HARD_LIMIT); + candidate.program.redCast = redCast; + + const checks = []; + const longEdge = candidate.program.longEdge; + checks.push({ + id: 'P-size', + kind: 'soft', + verify: 'program', + text: '输出尺寸 ' + decoded.width + 'x' + decoded.height, + result: longEdge >= 3840 ? 'pass' : 'warn', + }); + checks.push({ + id: 'H-color', + kind: 'hard', + verify: 'program', + text: redCast + ? drift.label + ':相对父图 +' + drift.strength + ',绝对 redGain ' + stats.redGain + ' ≥ ' + RED_ABS_LIMIT + ',判为偏红' + : '整体白平衡中性(redGain ' + stats.redGain + ')', + result: redCast ? 'fail' : 'pass', + }); + // 偏冷不自动打回:冷调题材很常见,交给评审表态就够了。 + if (!redCast && drift.cast === 'blue') { + checks.push({ id: 'P-cast', kind: 'soft', verify: 'program', text: drift.label + '(交评审判断,不自动打回)', result: 'warn' }); + } + if (sharp.overSharpened) { + checks.push({ + id: 'P-sharp', + kind: 'soft', + verify: 'program', + text: '相对父图锐度 ' + sharp.ratio + '×,疑似过度锐化', + result: 'warn', + }); + } + for (const layer of image.spec.textLayers || []) { + const text = typeof layer === 'string' ? layer : layer && layer.text; + if (!text) continue; + const inPrompt = String(candidate.revisedPrompt || '') .includes(text) || String(image.spec.prompt || '').includes(text); + checks.push({ + id: 'P-text-' + text.slice(0, 6), + kind: 'hard', + verify: 'program', + text: '文字「' + text + '」已写进 prompt', + result: inPrompt ? 'pass' : 'fail', + }); + } + candidate.checks = checks; + candidate.program.stats = stats; + deps.emit(session, 'verify', candidate.id + ' 程序实测完成 · ' + drift.label, { + detail: detail('程序实测 · ' + candidate.id, [ + kv('尺寸', decoded.width + 'x' + decoded.height + '(长边 ' + longEdge + ')'), + kv('色彩', drift.label + (drift.cast !== 'none' ? '(+' + drift.strength + ')' : '')), + redCast ? kv('偏红判定', '打回重画,不做像素配平') : null, + kv('锐度', stats.sharpness + (sharp.overSharpened ? '(相对父图 ' + sharp.ratio + '×,疑似过度锐化)' : '')), + kv('过曝像素占比', (Number(stats.clippedRatio || 0) * 100).toFixed(2) + '%'), + kv('通道均值', 'R ' + stats.meanR + ' / G ' + stats.meanG + ' / B ' + stats.meanB + ' · luma ' + stats.luma), + list( + '校验项', + checks.map((check) => ({ tag: check.result, text: check.id + ' · ' + check.text })), + ), + ]), + }); + await prepareAssets(candidate, decoded, deps); +} + +// 一次解码同时产出页面预览和评审看图材料,避免对同一张 4K 图解码两遍。 +async function prepareAssets(candidate, decoded, deps) { + try { + const small = png.downsample(decoded, DEFAULTS.previewEdge); + const previewPath = join(deps.dirs.previews, candidate.id + '.png'); + await writeFile(previewPath, png.encodePng(small, { level: 3 })); + candidate.preview = previewPath; + candidate.previewFile = candidate.id + '.png'; + } catch { + candidate.preview = null; + } + try { + await mkdir(deps.dirs.sheets, { recursive: true }); + let canvas = review.renderCandidateSheet(decoded); + if (Math.max(canvas.width, canvas.height) > DEFAULTS.sheetMaxEdge) { + canvas = png.downsample(canvas, DEFAULTS.sheetMaxEdge); + } + const sheetPath = join(deps.dirs.sheets, candidate.id + '-sheet.png'); + await writeFile(sheetPath, png.encodePng(canvas, { level: 6 })); + candidate.sheet = sheetPath; + } catch { + candidate.sheet = null; + } +} + +// ---------------------------------------------------------------- 评审 / 修正 / 选优 + +// 评审输出的 extra_fails 解析。放在模块级是为了让 hard 合并块只依赖 rawExtra 一个变量, +// 沙箱测试能整块抽出来跑。 +function extraFailText(item) { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object') return ''; + return String(item.text || item.note || '').trim(); +} + +// 取属于某个候选的清单外问题。candidateId 缺失或写错时按「不指定候选」处理, +// 挂到所有候选上——评审确实看到了问题,只是没说是哪张,保守判本轮不通过再修正。 +function extraFailsFor(rawExtra, candidateId) { + const out = []; + for (const item of Array.isArray(rawExtra) ? rawExtra : []) { + const text = extraFailText(item); + if (!text) continue; + const cid = item && typeof item === 'object' ? String(item.candidateId || '') : ''; + if (cid && cid !== candidateId) continue; + if (out.indexOf(text) < 0) out.push(text); + } + return out; +} + +async function judgeNode(session, image, record, deps) { + const candidates = record.candidates; + const refSheet = await ensureRefSheet(session, deps); + + // 评审只看 harness 备好的"看图材料",一张图就把整体、曝光和局部全看完了。 + const lines = candidates.map((candidate, index) => { + const program = candidate.program || {}; + const parts = [ + '候选 ' + (index + 1) + '(candidateId = ' + candidate.id + ')', + ' 看图材料:' + (candidate.sheet || candidate.path), + ]; + if (program.stats) { + parts.push( + ' 程序实测:尺寸 ' + program.width + 'x' + program.height + + ';色彩 ' + (program.drift ? program.drift.label : '正常') + + (program.redCast ? '(程序判定偏红)' : '') + + ';锐度 ' + program.stats.sharpness + + ';过曝像素占比 ' + (program.stats.clippedRatio !== undefined ? program.stats.clippedRatio : '未知'), + ); + } else if (program.error) { + parts.push(' 程序实测:' + program.error); + } + return parts.join('\n'); + }); + + const checklist = image.spec.checklist + .map((item) => item.id + ' [' + item.kind + '] ' + item.text + (item.verify === 'program' ? '(程序已验)' : '')) + .join('\n'); + const history = image.rounds + .map((round) => '第' + (round.index + 1) + '轮 ' + describeAction(round.action) + ' -> ' + (round.verdict ? round.verdict.note : round.error || '进行中')) + .join('\n'); + + const instruction = [ + '你是H3-Image-Executor 的评审节点。看图材料已经由程序准备好,你只要打开图、看、给结论。', + '', + '用户原话(最高优先级,最终按原话整体判断,不要被清单牵着走):', + session.instruction, + '', + '本轮候选(每个候选对应一张"看图材料"图,只 read 这张材料图,不要再去打开原始大图):', + lines.join('\n'), + '', + '看图材料的版式:', + review.CANDIDATE_SHEET_LAYOUT, + '', + '参考图材料(判断"是不是同一个人 / 同一台车 / 同一个 logo"时对照这一张就够):', + refSheet + ? ' ' + refSheet + '\n 编号与清单里的 R1、R2… 一一对应。\n 原始文件:' + session.refs.map((ref) => ref.id + ' -> ' + ref.path).join(';') + : '(无参考图)', + '', + '验收清单:', + checklist, + '', + '判定规则(严格照做,不要加戏):', + '1. hard 项是「要求」不是「缺陷」:把每条 hard 拆成限定词逐个核对,缺一个限定词就是 fail。', + ' 判 fail 的门槛是"你能说出缺了什么",不是"你能指出一处破损"。要求的东西没入画、角度不对、人物不像,都算明确的 fail。', + ' 反例:要求「机位在车外左侧约45度,透过前挡风玻璃看到两人」,而画面是正侧贴近侧窗拍的——', + ' 只满足"车外"和"看到两人",机位不是45度、也不是透过前挡风玻璃,必须判 fail,不能因为"大体符合"放行。', + ' note 必须写清楚:缺了哪个限定词、在第几块能看出来(引用编号,如"第 1 块")。', + '2. 构图、机位、角度、景别、主体在画幅里的位置,只能看第 1 块整体预览判断。', + ' 局部放大块(4-7)看不出构图,不要拿它们下构图结论。', + '3. 用户原话里点名的机位/角度/景别,逐字对照第 1 块;对不上就是 fail,这条优先于任何"看起来还行"的印象。', + '4. 只有在"图里根本看不清、被遮挡、分辨率不够、缺参考图,无法判断"时才用 uncertain。', + ' 看得清但不满足 → 必须 fail,不许用 uncertain 兜底。uncertain 记 pass,别拿它当免死金牌。', + ' 仍然不要为了保险把真拿不准的写成 fail——错杀一次就要重做一整轮,代价远大于放过。', + '5. soft 项只影响 quality 分(0-10),不影响通过与否。', + '6. 禁止写脚本、裁图、拼图、量像素、跑统计、联网搜索。尺寸、色彩、锐度、过曝程序都测过了,直接采信,不要推翻。', + '7. 工具调用上限 ' + DEFAULTS.judgeToolBudget + ' 次:每个候选的材料图 + 参考图材料,看完立刻给结论。', + '8. 你看不到送进模型的 prompt,只能看图。', + '9. 先把所有候选判完,再决定 next。', + '10. note 不超过 30 字,summary 不超过 40 字。', + '11. extra_fails 是「清单没写、但你一眼看出违背用户原话」的问题,按 hard fail 处理:', + ' 典型是姿势/手型/指法不对、动作雷同、该出现的东西没出现、人物明显不像。', + ' 每项形如 {"candidateId":"...","text":"第3格的 H 手型不是 ASL 的 H"},写清在第几块能看出来。', + ' 硬性要求:只要你在 summary 或 note 里写了「手型不对」「姿势不准」「略偏」「雷同」「没画出来」', + ' 这类话,就必须把同一条写进 extra_fails——不许只在 summary 里吐槽完就给 pass。', + ' 反过来也确实拿不准的(看不清、分辨率不够)不要写进 extra_fails。', + '', + '历史动作(判完候选后再看,只用于决定 next):', + history || '(首轮)', + '', + '只输出一个 JSON 对象,不要解释、不要 markdown 代码块:', + '{"verdicts":[{"candidateId":"' + candidates[0].id + '","checks":[{"id":"H1","result":"pass|fail|uncertain","note":"..."}],"quality":7.5,"summary":"..."}],', + ' "extra_fails":[{"candidateId":"' + candidates[0].id + '","text":"清单外但违背用户原话的问题"}],', + ' "next":{"type":"regenerate|edit|give_up","candidateId":"...","prompt":"给模型的中文编辑或生成指令","reason":"..."}}', + '', + '如果所有候选的 hard 项都通过,next 直接写 {"type":"give_up"}。', + ].join('\n'); + + const { data } = await deps.agent.runJson(session, { step: 'judge', instruction, maxTools: DEFAULTS.toolBudget.judge }); + const verdicts = Array.isArray(data.verdicts) ? data.verdicts : []; + const hardIds = image.spec.checklist.filter((item) => item.kind === 'hard').map((item) => item.id); + const softIds = image.spec.checklist.filter((item) => item.kind === 'soft').map((item) => item.id); + + // 评审看出来了、清单里却没写的问题(真机 20261110-5e10ae:A 图 g1 summary 写「手型几乎 + // 全是摊掌」、C 图写「H 手势不准」、E 图写「P/Q 手势雷同」,全部只写进 summary, + // hardFailed 恒为空,于是每张图都放行)。extra_fails 就是给评审的表达渠道,按 hard fail 处理。 + const validIds = candidates.map((candidate) => candidate.id); + const rawExtra = (Array.isArray(data.extra_fails) ? data.extra_fails : []).map((item) => { + if (!item || typeof item !== 'object') return item; + const cid = String(item.candidateId || ''); + // 写错的 candidateId 归一成「未指定」:否则这条问题会被静默丢掉, + // 而它恰恰是评审真正看出来的东西——这正是这次要修的漏检。 + return cid && validIds.indexOf(cid) < 0 ? { ...item, candidateId: '' } : item; + }); + + let best = null; + let passed = false; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + // 评审可能把候选 ID 写错或写漏,ID 对不上时按顺序兜底,避免整轮被误判为不通过。 + const verdict = + verdicts.find((item) => item.candidateId === candidate.id) || + verdicts[index] || + { checks: [], quality: 0 }; + const results = {}; + for (const check of verdict.checks || []) results[check.id] = check; + // 程序级 hard 项的 pass 也要并进来:H-color 是清单里的 hard,评审不会评它, + // 只并 fail 的话 results['H-color'] 恒不存在、isPass 判 false,每张图都会被误判不通过。 + for (const check of candidate.checks || []) { + if (check.kind !== 'hard') continue; + if (check.result === 'fail') results[check.id] = { result: 'fail', note: check.text }; + else if (check.result === 'pass' && !results[check.id]) results[check.id] = { result: 'pass', note: check.text }; + } + // uncertain 视为通过:评审拿不准不等于图有问题,把拿不准当失败是过度打回的主要来源。 + const isPass = (id) => results[id] && (results[id].result === 'pass' || results[id].result === 'uncertain'); + const hardPassed = hardIds.filter(isPass).length; + // 程序层临时加的 hard 项(典型是 P-decode:图根本解不开,等于一项都没验)不在规划清单里, + // 只按 hardIds 算就会把它漏掉——解不开的图照样标成「验证通过」交付出去。 + // 清单是规划写的,程序层的 fail 不该因为「不在清单里」就不拦。 + const programHardFailed = (candidate.checks || []) + .filter((check) => check.kind === 'hard' && check.result === 'fail' && hardIds.indexOf(check.id) < 0) + .map((check) => check.id); + // 清单外的评审发现:挂成 X1、X2… 直接算 hard fail,并计入 hardTotal, + // 这样「hard 6/6」会如实变成「hard 6/7」,事件里一眼能看出多出来的是哪一条。 + const extraForCandidate = extraFailsFor(rawExtra, candidate.id); + const extraIds = []; + for (const note of extraForCandidate) { + const id = 'X' + (extraIds.length + 1); + extraIds.push(id); + results[id] = { result: 'fail', note }; + } + const hardFailed = hardIds.filter((id) => !isPass(id)).concat(programHardFailed, extraIds); + const hardUncertain = hardIds.filter((id) => results[id] && results[id].result === 'uncertain'); + const softPassed = softIds.filter((id) => results[id] && results[id].result === 'pass').length; + candidate.verdict = { + quality: Number(verdict.quality) || 0, + summary: verdict.summary || '', + checks: results, + hardPassed, + hardTotal: hardIds.length + extraIds.length, + hardFailed, + hardUncertain, + extraFails: extraIds, + softPassed, + softTotal: softIds.length, + }; + const allHard = hardIds.length > 0 && hardFailed.length === 0; + if (allHard) passed = true; + if (!best || candidate.verdict.hardPassed > best.verdict.hardPassed || + (candidate.verdict.hardPassed === best.verdict.hardPassed && candidate.verdict.quality > best.verdict.quality)) { + best = candidate; + } + } + + const next = normalizeNext(data.next, candidates, image); + return { + passed, + best, + next, + summary: { + hardTotal: best ? best.verdict.hardTotal : hardIds.length, + note: best + ? 'hard ' + best.verdict.hardPassed + '/' + hardIds.length + + (best.verdict.hardUncertain.length ? '(' + best.verdict.hardUncertain.length + ' 项拿不准但放行)' : '') + + ',质量 ' + best.verdict.quality + : '无有效候选', + }, + }; +} + +function normalizeNext(raw, candidates, image) { + if (!raw || !raw.type) return null; + if (raw.type === 'give_up') return { type: 'give_up' }; + const prompt = String(raw.prompt || '').trim(); + if (!prompt) return null; + if (raw.type === 'edit') { + const parentId = raw.candidateId && candidates.some((c) => c.id === raw.candidateId) + ? raw.candidateId + : candidates.length + ? candidates[0].id + : null; + return { type: 'edit', prompt, parentId, reason: raw.reason || '' }; + } + return { type: 'generate', prompt, refs: image.spec.refs, parentId: null, reason: raw.reason || '' }; +} + +async function refineNode(session, image, record, verdict, deps, forceChange) { + const history = image.rounds + .map((round) => '第' + (round.index + 1) + '轮 ' + describeAction(round.action) + ' -> ' + (round.verdict ? round.verdict.note : '')) + .join('\n'); + const failed = verdict.best + ? verdict.best.verdict.hardFailed.map((id) => { + const item = image.spec.checklist.find((entry) => entry.id === id); + const detail = verdict.best.verdict.checks[id]; + // X 开头的是评审发现的清单外问题,清单里查不到,标清楚来源, + // 否则修正节点看到的是「X1 :xxx」这种半截句子。 + const label = item ? item.text : '评审发现的清单外问题'; + return id + ' ' + label + ':' + (detail && detail.note ? detail.note : '未通过'); + }) + : []; + const instruction = [ + '你是H3-Image-Executor 的修正节点。看之前的动作和各轮结果,给出一个能真正改善的修正动作。', + '', + '用户原话:', + session.instruction, + '', + '当前 prompt:', + image.spec.prompt, + '', + '最新候选(本地路径,可以 read 打开看):', + (record.candidates || []).map((candidate) => candidate.id + ' -> ' + candidate.path).join('\n') || '(无)', + '', + '没通过的 hard 项:', + failed.join('\n') || '(无)', + '', + '历史动作与结果:', + history || '(首轮)', + '', + forceChange + ? '注意:连续几轮没有改善,必须换一种做法(regenerate 重新生成 / edit 在某个候选上局部编辑 / give_up 放弃)。' + : '优先在最能救的候选上做局部编辑;构图本身不对才重新生成。', + '经验:不带贴回框的全图 edit 每次都会累积一点偏色和过度锐化,同一条链上不要超过 2 次。', + '', + '只输出一个 JSON 对象:', + '{"type":"regenerate|edit|give_up","candidateId":"...","prompt":"给模型的具体指令","reason":"..."}', + ].join('\n'); + + const startedAt = Date.now(); + const { data } = await deps.agent.runJson(session, { step: 'refine', instruction, maxTools: DEFAULTS.toolBudget.refine }); + const next = normalizeNext(data, record.candidates || [], image); + deps.emit(session, 'refine', image.id + ' 修正方案:' + (next ? describeAction(next) : '放弃本轮'), { + durMs: Date.now() - startedAt, + detail: detail('修正节点输出', [ + kv('动作', next ? next.type : 'give_up'), + kv('目标候选', (next && next.parentId) || '-'), + kv('强制换改法', forceChange ? '是' : '否'), + text('理由', (next && next.reason) || ''), + text('给模型的指令', (next && next.prompt) || ''), + list('没通过的 hard 项', failed), + ]), + }); + return next; +} + +async function selectNode(session, image, deps) { + const pool = []; + for (const round of image.rounds) { + const best = (round.candidates || []).slice().sort((a, b) => { + const av = a.verdict || { hardPassed: 0, quality: 0 }; + const bv = b.verdict || { hardPassed: 0, quality: 0 }; + return bv.hardPassed - av.hardPassed || bv.quality - av.quality; + })[0]; + if (best) pool.push(best); + } + if (!pool.length) { + image.status = 'failed'; + const why = lastFailReason(image); + image.failReason = why; + deps.emit(session, 'select', image.id + ' 没有任何可用候选' + (why ? ':' + why : ''), { + level: 'error', + detail: detail('没有可用候选', [ + kv('图', image.id + ' · ' + image.title), + kv('轮次', image.rounds.length), + why ? kv('最后一次报错', why) : kv('最后一次报错', '无报错,候选全部未通过程序校验'), + ]), + }); + return; + } + if (pool.length === 1) { + image.chosen = pool[0]; + image.status = 'unpassed'; + image.chosenReason = '只有一轮候选,按验收分数选出'; + deps.emit(session, 'select', image.id + ' 只有一轮候选,直接交付 ' + pool[0].id, { + detail: detail('选优', [ + kv('候选池', pool.map((candidate) => candidate.id).join('、')), + kv('选中', pool[0].id), + text('理由', image.chosenReason), + ]), + }); + return; + } + const shuffled = pool.slice().sort(() => Math.random() - 0.5).slice(0, 4); + // 选优同样只看一张并排对比图:顺序打乱、不给分数,避免评审被历史结论带偏。 + let sheetPath = ''; + try { + await mkdir(deps.dirs.sheets, { recursive: true }); + const built = await review.buildGridSheet( + shuffled.map((candidate, index) => ({ path: candidate.path, label: String(index + 1) })), + join(deps.dirs.sheets, image.id + '-select-sheet.png'), + { perRow: Math.min(shuffled.length, 4) }, + ); + sheetPath = built.sheet; + } catch { + sheetPath = ''; + } + const instruction = [ + '你是H3-Image-Executor 的选优节点。下面几张图来自不同轮次,历史评分不完全可比,请只看图并排对比后选一张交付。', + '', + '用户原话:', + session.instruction, + '', + sheetPath + ? '对比图(只 read 这一张,从左到右、从上到下依次编号,左上角有编号标签):\n ' + sheetPath + + '\n 编号对应:' + shuffled.map((candidate, index) => (index + 1) + '=' + candidate.id).join(',') + : '候选(本地绝对路径,必须用 read 逐张打开看):\n' + + shuffled.map((candidate, index) => '候选' + (index + 1) + ' -> ' + candidate.path).join('\n'), + '', + '不要写脚本、不要裁图、不要量像素。看图直接选,按用户原话判断哪张最合适,写明理由。', + '工具调用上限 2 次。只输出 JSON:', + '{"choice":1,"reason":"..."}', + ].join('\n'); + try { + const { data } = await deps.agent.runJson(session, { step: 'select', instruction, maxTools: DEFAULTS.toolBudget.select }); + const index = Math.max(0, Math.min(shuffled.length - 1, Number(data.choice || 1) - 1)); + image.chosen = shuffled[index]; + image.chosenReason = data.reason || ''; + } catch { + image.chosen = shuffled[0]; + image.chosenReason = '选优节点失败,按验收分数回退'; + } + image.status = 'unpassed'; + deps.emit(session, 'select', image.id + ' 选优交付:' + image.chosen.id + '(' + image.chosenReason + ')', { + detail: detail('选优', [ + kv('候选池', pool.map((candidate) => candidate.id).join('、')), + kv('送进对比图的顺序', shuffled.map((candidate, index) => (index + 1) + '=' + candidate.id).join(',')), + kv('选中', image.chosen.id), + text('理由', image.chosenReason), + list( + '各候选验收分', + pool.map((candidate) => ({ + text: + candidate.id + + ' · hard ' + + ((candidate.verdict && candidate.verdict.hardPassed) || 0) + + '/' + + ((candidate.verdict && candidate.verdict.hardTotal) || 0) + + ' · 质量 ' + + ((candidate.verdict && candidate.verdict.quality) || 0), + })), + ), + ]), + }); +} + +// ---------------------------------------------------------------- 08 增量编辑 + +// 某一轮生图全部失败时会交付 0 张图,image.chosen 变 null、image.rounds 也被清成空候选, +// 下一轮编辑的三条找底图路径(findCandidate / chosen / lastCandidate)就全落空了—— +// 但上一版交付图其实还好好躺在 deliver/vN/ 里。从历史轮次的交付记录把它捞回来, +// 编辑链才不会被一次失败打断(实测「换背景」那一轮就是这么退化成重新生成的)。 +function lastDelivered(session, image, deps) { + const turns = session.turns || []; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const delivery = turns[i] && turns[i].delivery; + if (!delivery || !Array.isArray(delivery.items)) continue; + const item = delivery.items.find((one) => one && one.id === image.id && one.file); + if (!item) continue; + return { + id: item.candidateId || image.id, + path: join(deps.dirs.deliver, item.file), + preview: item.previewFile ? join(deps.dirs.deliver, item.previewFile) : null, + size: item.size || '', + program: item.program || null, + fromDelivery: true, + }; + } + return null; +} + +async function editPlanNode(session, instruction, deps) { + const inventory = session.images + .map((image) => { + const chosen = image.chosen || lastDelivered(session, image, deps); + return image.id + '「' + image.title + '」' + (chosen ? ' 当前交付 ' + chosen.id + ' -> ' + chosen.path : '(无交付)'); + }) + .join('\n'); + const prompt = [ + '你是H3-Image-Executor 的增量编辑节点。用户要在已交付的图上做一处改动。', + '', + '用户这次的话:', + instruction, + '', + '已交付的图:', + inventory, + '', + '参考图:', + session.refs.map((ref) => ref.id + ' -> ' + ref.path).join('\n') || '(无)', + '', + '做指代解析:改哪张图、改哪部分、哪些必须不变。给出:目标图 imageId、要编辑的候选 candidateId(用当前交付的那张)、', + '给模型的编辑指令 prompt(只描述要改的地方和必须保持不变的部分)、以及这次新的验收清单 checklist。', + '', + 'refs 只在需要引入原图里没有的新实体时才写(例如"把柴犬换成橘猫"),写 R 编号或公网图片地址。', + '只是加文字、挪位置、调色这类改动,refs 一律留空——多塞参考图会让模型照着参考图重画一张全新的图。', + 'prompt 里不要用"这张图""原图"以外的模糊指代,直接说要改什么、什么必须保持不变。', + '', + '只输出 JSON:', + '{"imageId":"A","candidateId":"A-r1-g0","prompt":"...","refs":[],"keep":["..."],"checklist":[{"id":"H1","kind":"hard","text":"...","verify":"vlm"}]}', + ].join('\n'); + const { data } = await deps.agent.runJson(session, { step: 'edit', instruction: prompt, maxTools: DEFAULTS.toolBudget.edit }); + if (!data || !data.imageId) return null; + return { + imageId: String(data.imageId).toUpperCase(), + candidateId: data.candidateId || null, + prompt: data.prompt || instruction, + refs: Array.isArray(data.refs) ? data.refs : [], + checklist: Array.isArray(data.checklist) && data.checklist.length + ? normalizeChecklist(data.checklist, [], String(data.prompt || '')) + : null, + }; +} + +// ---------------------------------------------------------------- 07 交付 + +// ---------------------------------------------------------------- 跨图一致性 +// +// 逐图评审看不到别的图,所以"同一只狐狸从巴掌大画成大型犬"这种漂移,每张单看都挑不出毛病 +// (实测那组四格漫画 4 张 hard 全 5/5)。所有图出完之后并排比一次,是唯一抓得住它的位置。 +async function crossImageReview(session, deps) { + session.crossCheck = null; + const picked = session.images.filter((image) => image.chosen && !image.blocked); + if (picked.length < 2) return; + try { + await deps.ensureDir(deps.dirs.sheets); + const sheetPath = join(deps.dirs.sheets, 'cross-' + Date.now() + '.png'); + const sheet = await review.buildGridSheet( + picked.map((image) => ({ path: image.chosen.path, label: image.id })), + sheetPath, + { perRow: Math.min(4, picked.length) }, + ); + if (!sheet || !sheet.items.length) return; + + const characters = session.characterRefs || []; + const refSheet = characters.length ? await ensureRefSheet(session, deps) : null; + const instruction = [ + '你是H3-Image-Executor 的跨图一致性评审节点。下面这张并排对比图把同一次任务里的所有成图放在一起了,', + '每张左上角的编号就是图 id。你只看这一张,不要再去打开原始大图。', + '', + '用户原话:', + session.instruction, + '', + '并排对比图:' + sheetPath, + '参评图:' + picked.map((image) => image.id + '(' + image.title + ')').join('、'), + '', + characters.length + ? '本组有角色基准图,理论上主体外观应当与它一致。基准图材料:' + + (refSheet || '(无)') + + '\n原始文件:' + + characters.map((ref) => ref.id + ' -> ' + ref.path).join(';') + : '本组没有角色基准图,主体一致性完全靠各图自己的文字描述。', + '', + '只判断一件事:这几张图里的同一个主体,是不是同一个东西。', + '重点核对体型与比例(最容易被画大画小)、脸型、发型与发色、眼睛、服装款式与配色、标志性配件、画风与线条质感。', + '不要评价单张图好不好看、构图巧不巧妙——那些已经由逐图评审负责了。', + '', + '判定规则:', + '1. consistent 只在"所有重复出现的主体在各图之间看不出差别"时才为 true。', + '2. 只要有一个主体在任意两张图之间出现可辨认的差异,就是 false,并在 issues 里点名。', + '3. issues 每项形如 {"image":"C","subject":"小狐狸","problem":"..."};image 写偏差最明显的那张,', + ' problem 必须写清楚"和哪张比、差在哪"(例:与 A 相比狐狸从巴掌大变成中型犬大小)。', + '4. 某个主体只在其中一张图出现、别处没有可比对象时,不要为它编造不一致。', + '', + '只输出一个 JSON 对象,不要解释、不要 markdown 代码块:', + '{"consistent":true,"summary":"一句话结论","issues":[{"image":"C","subject":"小狐狸","problem":"..."}]}', + ].join('\n'); + + // 预算 4 次:对比图、基准图材料、必要时核对原图,一次收尾。实测 3 次会在读基准图时就被打断。 + const { data } = await deps.agent.runJson(session, { step: 'cross', instruction, maxTools: 4 }); + const issues = Array.isArray(data && data.issues) + ? data.issues + .map((item) => ({ + image: String((item && item.image) || '').trim().toUpperCase(), + subject: String((item && item.subject) || '').trim(), + problem: String((item && item.problem) || '').trim(), + })) + .filter((item) => item.problem) + : []; + const consistent = Boolean(data) && data.consistent !== false && !issues.length; + session.crossCheck = { + consistent, + summary: String((data && data.summary) || '').trim(), + issues, + sheet: sheetPath, + checkedAt: Date.now(), + }; + if (!consistent) { + for (const issue of issues.slice(0, 3)) { + session.questions.push( + '跨图一致性:' + (issue.image ? issue.image + ' 的' : '') + (issue.subject || '主体') + ' — ' + issue.problem, + ); + } + } + deps.emit( + session, + 'verify', + '跨图一致性:' + (consistent ? '主体在各图之间一致' : '发现 ' + issues.length + ' 处不一致'), + { + level: consistent ? 'info' : 'warn', + detail: detail('跨图一致性', [ + kv('结论', consistent ? '一致' : '不一致'), + kv('参评图', picked.map((image) => image.id).join('、')), + kv('角色基准', characters.length ? characters.map((ref) => ref.id + ' · ' + ref.note).join('、') : '无'), + text('说明', session.crossCheck.summary || ''), + issues.length + ? list( + '不一致明细', + issues.map((item) => ({ + tag: 'fail', + text: + (item.image ? '[' + item.image + '] ' : '') + + (item.subject ? item.subject + ':' : '') + + item.problem, + })), + ) + : null, + ]), + }, + ); + } catch (error) { + // 跨图评审是加分项:跑不成也要照常交付,绝不能因为它让整组图出不去。 + deps.emit(session, 'verify', '跨图一致性评审未完成:' + describeError(error), { level: 'warn' }); + } +} + +async function deliver(session, deps) { + session.deliveryVersion += 1; + const version = session.deliveryVersion; + const dir = join(deps.dirs.deliver, 'v' + version); + await deps.ensureDir(dir); + const items = []; + // 同一批交付共用一个时间戳:它们属于同一次交付,文件名带同一个时刻更好归档。 + const stampText = fileStamp(new Date()); + const usedNames = new Set(); + for (const image of session.images) { + if (!image.chosen) { + // reason 以前只取 blockReason,而生成失败根本没走阻塞那条路, + // 结果页面上只剩一句「未生成」,用户完全看不到接口报了什么。 + items.push({ + id: image.id, + title: image.title, + status: image.status, + blocked: image.blocked, + reason: image.blockReason || lastFailReason(image) || '所有轮次都没有产出可用候选', + contentPolicy: Boolean(image.contentPolicy), + rounds: image.rounds.length, + }); + continue; + } + // 交付文件名:H3-Image_<内容英文简写>_<时间戳>.<真实格式>。 + // 规划节点给的 slug 可能重复(同一个主体出现在多张图里),重名会互相覆盖,这里补序号。 + const chosenExt = extensionFor(await readFile(image.chosen.path)); + const slug = normalizeSlug(image.spec && image.spec.slug, String(image.id).toLowerCase()); + let base = 'H3-Image_' + slug + '_' + stampText; + for (let n = 2; usedNames.has(base); n += 1) base = 'H3-Image_' + slug + '-' + n + '_' + stampText; + usedNames.add(base); + const deliverName = base + chosenExt; + const previewName = base + '-preview.png'; + const target = join(dir, deliverName); + await copyFile(image.chosen.path, target); + const preview = join(dir, previewName); + if (image.chosen.preview) await copyFile(image.chosen.preview, preview); + items.push({ + id: image.id, + title: image.title, + status: image.status, + candidateId: image.chosen.id, + file: 'v' + version + '/' + deliverName, + previewFile: image.chosen.preview ? 'v' + version + '/' + previewName : null, + size: image.chosen.size, + requestId: image.chosen.requestId, + reason: image.chosenReason || (image.status === 'passed' ? 'hard 约束全部通过' : ''), + rounds: image.rounds.length, + verdict: image.chosen.verdict || null, + program: image.chosen.program || null, + prompt: image.chosen.actionPrompt || image.spec.prompt, + revisedPrompt: image.chosen.revisedPrompt, + }); + } + // 跨图结论要给页面看,但对比图是本地绝对路径,不能带出去。 + const cross = session.crossCheck + ? { + consistent: session.crossCheck.consistent, + summary: session.crossCheck.summary, + issues: session.crossCheck.issues, + } + : null; + const report = { + version, + createdAt: Date.now(), + instruction: session.instruction, + route: session.route, + items, + questions: session.questions, + crossCheck: cross, + cost: session.cost, + }; + await writeFile(join(dir, 'report.json'), JSON.stringify(report, null, 2)); + session.delivery = { + version, + dir, + items, + questions: session.questions.slice(), + crossCheck: cross, + cost: { ...session.cost }, + }; + // 交付要挂到当前这一轮对话上,页面上它才会出现在对应的用户消息下面。 + const turn = session.turns && session.turns[session.turns.length - 1]; + // cost 记的是"交付这一版时的会话累计成本",页面靠它算出这一轮自己花了多少。 + if (turn) { + turn.delivery = { + version, + items, + questions: session.questions.slice(), + crossCheck: cross, + cost: { ...session.cost }, + }; + } + // 一张都没出来就不能叫「已交付」:页面上会显示「交付 0 张」, + // 但没有任何解释,用户看到的是「跑完了却什么都没发生」。 + // 这类必须判成失败,并把每张图的失败原因带出去。 + const okCount = items.filter((item) => item.file).length; + const failedItems = items.filter((item) => !item.file); + if (!okCount && failedItems.length) { + session.status = 'failed'; + session.fatal = + session.fatal || + failedItems.map((item) => item.id + ' ' + item.reason).join(';').slice(0, 500); + } else { + session.status = session.fatal ? 'failed' : 'delivered'; + } + const deliverText = okCount + ? '交付 v' + version + ':' + okCount + ' 张图' + (failedItems.length ? '(' + failedItems.length + ' 张失败)' : '') + : '交付 v' + version + ':0 张图,' + failedItems.length + ' 张全部失败'; + const deliverLevel = okCount ? (failedItems.length ? 'warn' : 'info') : 'error'; + deps.emit(session, 'deliver', deliverText, { + version, + level: deliverLevel, + detail: detail('交付 v' + version, [ + ...items.map((item) => + group(item.id + ' · ' + item.title, [ + kv('状态', item.status === 'passed' ? '通过' : item.file ? '未通过(选优交付)' : '未生成'), + kv('尺寸', item.size || '-'), + kv('候选', item.candidateId || '-'), + kv('轮次', item.rounds || 0), + kv('request_id', item.requestId || '-'), + text('理由', item.reason || ''), + ]), + ), + cross + ? group('跨图一致性', [ + kv('结论', cross.consistent ? '主体在各图之间一致' : '发现 ' + cross.issues.length + ' 处不一致'), + text('说明', cross.summary || ''), + cross.issues.length + ? list( + '明细', + cross.issues.map((item) => ({ + tag: 'fail', + text: + (item.image ? '[' + item.image + '] ' : '') + + (item.subject ? item.subject + ':' : '') + + item.problem, + })), + ) + : null, + ]) + : null, + kv('生图', session.cost.ditImages + ' 张 · ' + seconds(session.cost.ditMs)), + kv('Agent', session.cost.agentCalls + ' 次 · ' + seconds(session.cost.agentMs) + ' · ' + (session.cost.agentTools || 0) + ' 次工具'), + session.cost.usage ? kv('token', session.cost.usage.total_tokens) : null, + list('待你确认', session.questions.map((item) => ({ text: item, tag: 'warn' }))), + ]), + }); + // 交付完立刻把分享页备好:发布是 Agent 侧的事,落盘提前做完, + // 用户点「分享」时链接往往已经回填,点一下就是复制,不用再等一轮。 + if (typeof deps.afterDeliver === 'function') { + try { + await deps.afterDeliver(session); + } catch (error) { + // 分享页生成失败不影响交付本身,用户仍可手动点「分享」重试。 + } + } +} + +function summarizeAction(action) { + return { + type: action.type, + prompt: action.prompt, + parentId: action.parentId || null, + refs: action.refs || [], + }; +} + +function describeAction(action) { + if (!action) return '未知动作'; + if (action.type === 'edit') { + if (action.parentPath) return '在 ' + (action.parentId || '上一张') + ' 上编辑'; + return '按参考图生成(没有可用底图)'; + } + if (action.type === 'generate') return '重新生成'; + return action.type; +} + +function mergeUsage(current, usage) { + if (!usage) return current || null; + const base = current || { total_tokens: 0, image_out: 0, text_in: 0, image_in: 0 }; + base.total_tokens += usage.total_tokens || 0; + base.image_out += (usage.output_tokens_details && usage.output_tokens_details.image_tokens) || 0; + base.text_in += (usage.input_tokens_details && usage.input_tokens_details.text_tokens) || 0; + base.image_in += (usage.input_tokens_details && usage.input_tokens_details.image_tokens) || 0; + return base; +} + +function dedupe(list) { + return Array.from(new Set(list.filter(Boolean))); +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/miniapp-api.ts b/plugins/liddalidd/h3-image-executor/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..f1eed85 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/miniapp-api.ts @@ -0,0 +1,640 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + /** The user switched this provider off for the Mini App; not dispatched. */ + | 'CONNECTOR_NOT_PERMITTED' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +/** What a call would meet now under the user's global permission rules; informational only. */ +export type MiniAppToolPermission = 'allow' | 'ask' | 'deny'; + +/** + * `name` is the runtime name an Agent sees (`mcp____`, `connector____`). + * Take it from `list()`; do not build it. + */ +export type MiniAppTool = + | { + readonly name: string; + readonly kind: 'connector'; + readonly provider: string; + readonly tool: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly permission: MiniAppToolPermission; + } + | { + readonly name: string; + readonly kind: 'mcp'; + /** Any installed MCP server: a Plugin's, the user's `mcp.json`, or a Host built-in one. */ + readonly server: string; + readonly tool: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly permission: MiniAppToolPermission; + }; + +export interface MiniAppToolListResult { + readonly tools: readonly MiniAppTool[]; + /** A source could not be listed; the listed tools are still callable. */ + readonly partial: boolean; +} + +export type MiniAppToolCallResult = + | { + readonly invocationId: string; + readonly kind: 'connector'; + /** Raw provider result, as in `HostConnectorCallResult.value`. */ + readonly value: JsonValue; + } + | { + readonly invocationId: string; + readonly kind: 'mcp'; + /** The MCP tool reported its own failure; `content` keeps its error text. */ + readonly isError: boolean; + /** MCP content blocks such as `{ type: 'text', text }`, passed through as JSON. */ + readonly content: readonly JsonValue[]; + readonly structuredContent?: JsonObject; + }; + +/** + * Thrown with the `HostConnectorError` shape; Connector tools keep their Connector codes too. The + * code names the cause; `disposition` alone says whether the tool may have run (`TOOL_TIMEOUT` and + * `REQUEST_CANCELLED` can be either `not_dispatched` or `unknown_after_dispatch`). + */ +export type MiniAppToolErrorCode = + | HostConnectorErrorCode + | 'INVALID_ARGUMENTS' + | 'TOOL_NOT_PERMITTED' + | 'TOOL_APPROVAL_REQUIRED' + | 'TOOL_REF_STALE' + | 'TOOL_UNAVAILABLE' + | 'TOOL_TIMEOUT' + | 'TOOL_RESULT_TOO_LARGE' + | 'TOOL_OUTCOME_UNKNOWN' + | 'MCP_SERVER_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'SERVICE_RESTARTED'; + +/** + * Present whenever the manifest declares `"toolAccess": ["call"]`, even when the user switched + * `tool:call` off: calls then fail with `TOOL_NOT_PERMITTED`. Every `call` is judged by the user's + * global permission rules: `deny` is `TOOL_NOT_PERMITTED`, `ask` shows a Host prompt, and nobody to + * ask is `TOOL_APPROVAL_REQUIRED`. `list` works before activation; call `call` only from request + * handling after activation. Connector tools time out after 30 s and MCP tools after at most 120 s, + * counted after any prompt. The Host never retries; reconcile an `unknown_after_dispatch` outcome + * before repeating a write. + */ +export interface MiniAppToolClient { + list(options?: HostConnectorCallOptions): Promise; + call( + name: string, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; + /** + * One Host prompt for the named tools (1–10, from `list()`) still at `ask`; the user ticks which + * to always allow. Resolves every name's state afterwards (for your UI only). When nobody can be + * asked it rejects with `TOOL_APPROVAL_REQUIRED` whose error carries `permissions`. With + * `tool:call` switched off it answers `deny` for every name without a prompt. + * Needs `capabilities.has('tool.request')`. + */ + request( + names: readonly string[], + options?: HostConnectorCallOptions, + ): Promise>; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +/** + * How the call's text ended: in its own turn (`finished` / `error` / `aborted`), or, only with + * `whenBusy`, joined into the turn that was already running (`steered`; that turn's own + * call gets the reply). + */ +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted' | 'steered'; + +/** + * The Desktop composer's follow-up behaviours while a session runs a turn. `queue`: the text runs + * as the next turn and the call follows it. `steer`: the running turn reads the text at its next + * input point (the call resolves `steered`), or, if it answered first, the text runs as the next + * turn and the call follows it. + */ +export type MiniAppAgentWhenBusy = 'queue' | 'steer'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran or the text was steered. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: the call waits at 1-based `position`, for a run slot or, with + * `whenBusy: 'queue'`, in the session queue (a `whenBusy` call can get both); sent before the + * turn starts, so it has no `turnId` and names a session only when continuing one. + * - `message.pending` / `message.steered`: with `whenBusy: 'steer'`, the text was handed to the + * running turn, then read by it; `message.steered` is the call's last event. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: last unless the call ended `steered`; same `status`, `text`, and `error` + * as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'message.pending' | 'message.steered'; + readonly sessionId: string; + readonly turnId: string; + } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: Exclude; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +/** `observe({ all: true })` only: the conversation's title, when it has one. */ +interface MiniAppAgentObservedTitle { + readonly title?: string; +} + +type MiniAppAgentRunEventOf = Extract< + MiniAppAgentRunEvent, + { readonly type: T } +>; + +/** + * What an observer receives: the run events without `run.queued` and without asks, which reach + * only the run that owns them. `turn.started.model` is present only when the Host could read it. + * Ignore unknown `type` values. + */ +export type MiniAppAgentObservedEvent = + | (Omit, 'model'> & { + readonly model?: MiniAppAgentModelSelection; + } & MiniAppAgentObservedTitle) + | MiniAppAgentRunEventOf<'text.delta' | 'tool.started' | 'tool.finished'> + | (MiniAppAgentRunEventOf<'message.committed'> & MiniAppAgentObservedTitle) + | (MiniAppAgentRunEventOf<'turn.finished'> & MiniAppAgentObservedTitle); + +/** + * `{ all: true }`: `turn.started`, `message.committed` (the final reply) and `turn.finished` of + * every user conversation, with its `title`. `{ sessionId }`: every progress event of that + * session, turn after turn. Exactly one of the two. + */ +export type MiniAppAgentObserveTarget = { readonly sessionId: string } | { readonly all: true }; + +/** One of the user's conversations; `running` while a turn is in flight. */ +export interface MiniAppAgentSessionSummary { + readonly sessionId: string; + readonly title?: string; + /** The Agent the session runs. */ + readonly agent: string; + readonly status: 'idle' | 'running' | 'error' | 'aborted'; + /** The conversation's project directory (absolute) as recorded; it may no longer exist. */ + readonly workspaceDir: string; + /** True when the conversation has no project and runs in the default workspace. */ + readonly isDefaultWorkspace: boolean; + /** Unix ms. */ + readonly createdAtMs: number; + /** Unix ms. */ + readonly updatedAtMs: number; +} + +export interface MiniAppAgentSessionDetail extends MiniAppAgentSessionSummary { + /** The conversation's current model; absent when the Host has none recorded. */ + readonly model?: MiniAppAgentModelSelection; + /** Only while `status` is `error` and the Host recorded a message. */ + readonly error?: { readonly message: string }; + /** Final text of the latest Agent reply, if any. */ + readonly lastAssistantText?: string; +} + +export interface MiniAppAgentSessions { + /** The 50 most recently updated user conversations (no Mini App, scheduled or sub-Agent ones). */ + readonly sessions: readonly MiniAppAgentSessionSummary[]; +} + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** + * Set on every `BUSY`: the session already has a run in flight, the wait queue is full, the + * per-minute start cap was hit, or `observe` found 4 observers already running. + */ + readonly reason?: 'session_busy' | 'queue_full' | 'rate_limited' | 'observer_limit'; +} + +/** + * Present when the manifest declares `agentAccess` (`"session.run"` and/or `"session.read"`). + * `run` / `reply` / `answer` / `models` / `agents` / `create` need `session.run`; `observe` and + * `sessions` need `session.read` except for this Mini App's own sessions (see + * docs/agent.md). + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. A `whenBusy` call on a session that already has a call in flight shares + * that call's slot. Cancelling a queued `whenBusy` text removes it (`REQUEST_CANCELLED`); a steered + * text cannot be withdrawn (`AGENT_OUTCOME_UNKNOWN`, the running turn goes on). + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + /** + * Only with `sessionId`: what to do when that session is already running a turn; on an idle + * session the call starts a turn as usual. Omit to reject with `BUSY` (`session_busy`). + */ + readonly whenBusy?: MiniAppAgentWhenBusy; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; + /** + * Watch the user's Agent at work (see `MiniAppAgentObserveTarget`). No request timeout; abort + * `signal` to stop, after which `onEvent` gets nothing more. Resolves when the subscription + * ended: `signal` aborted, the session was deleted, the process is stopping or being replaced, + * or `session.read` was switched off. + * Rejects with a `MiniAppAgentError` when it could not start or failed: `INVALID_ARGUMENTS` (not + * exactly one target), `NOT_PERMITTED`, `SESSION_NOT_FOUND`, retryable `BUSY` past 4 observers + * per Mini App (`reason: 'observer_limit'`) or `AGENT_UNAVAILABLE`. Observers use no run slots + * and no ordinary request budget. Check `context.capabilities?.has('agent.observe')` first. + */ + observe( + target: MiniAppAgentObserveTarget, + options: { + readonly onEvent: (event: MiniAppAgentObservedEvent) => void; + readonly signal?: AbortSignal; + }, + ): Promise; + /** Check `context.capabilities?.has('agent.sessions')` first. */ + sessions(): Promise; + /** + * One conversation with its model and latest reply. `sessionId` is required: Node serves every + * tab, so it has no current chat; a page passes its chat's id (from its own `session()`). + * Check `context.capabilities?.has('agent.session')`. + */ + session(sessionId: string): Promise; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + /** + * Synchronous probe of Host features, fixed per Host version. It says nothing about this + * Mini App's manifest or authorization: `has('agent.run')` is true on a Host that supports the + * Agent API even when `context.agent` is absent because `agentAccess` was not declared. + * Older Hosts do not inject `capabilities`; check `context.capabilities?.has(...)`. + */ + readonly capabilities?: MiniAppHostCapabilities; + /** + * Present when the manifest declares a `hostConnectorAccess` provider; a provider the user + * switched off is missing from `list()` and its calls fail with `CONNECTOR_NOT_PERMITTED`. + */ + readonly hostConnector?: HostConnectorClient; + /** Installed MCP and Connector tools, each call judged by the user's permission rules. */ + readonly tool?: MiniAppToolClient; + /** + * Present when the manifest declares `agentAccess: ["session.run"]`; while the user has it + * switched off, calls fail with `NOT_PERMITTED`. + */ + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppHostCapabilities { + has(name: string): boolean; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/server.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/server.mjs new file mode 100644 index 0000000..24b6d80 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/server.mjs @@ -0,0 +1,1464 @@ +// H3-Image-Executor Node 运行时:页面路由 + 会话状态机 + H3-Image 调用 + Agent 节点调度。 +import { readFile, writeFile, mkdir, readdir, rename } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join, extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import * as h3 from './lib/h3.mjs'; +import { createAgentRunner } from './lib/agent.mjs'; +import { runSession, runIncrementalEdit, DEFAULTS } from './lib/workflow.mjs'; +import { detail, text } from './lib/detail.mjs'; +import { buildShareHtml } from './lib/share.mjs'; +import { createRefCache } from './lib/refcache.mjs'; +import { readClipboard, classifyClipboard, disposeClipboard, writeClipboard } from './lib/clipboard.mjs'; +import { publishHtml } from './lib/publish.mjs'; + +// 文件名 / 会话 ID 只认这种形状。「.」和「..」本身也匹配字符集,必须单独排除: +// 它们拼进 join() 会指向上一级目录。 +const SAFE_NAME = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/; + +// 按实际字节定 content-type,不按扩展名:接口有时回 JPEG,而落盘文件名统一是 .png。 +// 浏览器对图片会自己嗅探,所以标错也能显示——但下载下来用错扩展名打开就会出问题, +// 而且把 JPEG 标成 image/png 会让任何按 content-type 走的下游(分享页、curl -OJ)拿到错的类型。 +function imageContentType(buffer) { + if (buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG') return 'image/png'; + if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'; + if (buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' + && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'; + return 'application/octet-stream'; +} +const MAX_BODY = 24 * 1024 * 1024; + +export async function start(context) { + const root = context.pluginRoot; + const clientDir = join(root, 'miniapp/client'); + const assets = { + '/dashboard': { body: await readFile(join(clientDir, 'index.html')), type: 'text/html; charset=utf-8' }, + '/app.js': { body: await readFile(join(clientDir, 'app.js')), type: 'text/javascript; charset=utf-8' }, + '/styles.css': { body: await readFile(join(clientDir, 'styles.css')), type: 'text/css; charset=utf-8' }, + '/blocks.js': { body: await readFile(join(clientDir, 'blocks.js')), type: 'text/javascript; charset=utf-8' }, + }; + + const dataRoot = join(context.dataDir, 'sessions'); + await mkdir(dataRoot, { recursive: true }); + // 接口凭据存在 Host 给的私有数据目录里,绝不进包体、也不回传给页面。 + const settingsFile = join(context.dataDir, 'settings.json'); + await loadSettings(); + + async function loadSettings() { + try { + const raw = await readFile(settingsFile, 'utf8'); + const parsed = JSON.parse(raw); + h3.setStoredSettings({ apiKey: parsed.apiKey, baseUrl: parsed.baseUrl }); + } catch { + /* 没配过就是没配过,不是错误 */ + } + } + + // 原子写:先写同目录临时文件再 rename。进程随时可能被 Host 停掉, + // 写一半的 settings.json 会让用户下次启动发现 Key 莫名其妙没了。 + async function saveSettings(value) { + const tmp = settingsFile + '.tmp'; + await writeFile(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }); + await rename(tmp, settingsFile); + } + // 参考图下载缓存跨会话共享:同一个实体在不同会话里会搜到同一批地址,重复下载既慢又浪费带宽。 + const cacheRoot = join(context.dataDir, 'refcache'); + const refCache = createRefCache(cacheRoot); + const sessions = new Map(); + // 运行时重启(publish / 崩溃)会清空内存里的会话,页面就变成"什么都没跑过"。 + // 所以要把磁盘上的 session.json 读回来,列表、看图、追问都还能用。 + // 恢复放在后台跑,不等它完成就开始监听:start() 只装路由、开始监听(docs/runtime.md), + // 会话多、session.json 大的时候,恢复要好几秒,等它会拖慢就绪甚至超时。 + // 所有 /api/ 请求先等恢复完成,页面看到的列表总是完整的。 + const restoring = restoreSessions().catch((error) => { + context.logger.error('h3.sessions.restore_failed', { + message: String(error && error.message ? error.message : error), + }); + }); + + const agent = createAgentRunner(context, { + onProgress(session, stage, text, extra) { + push(session, stage, text, extra); + }, + onPermission(session, record) { + agent.trackPermission(record); + push(session, 'permission', '需要你确认工具调用:' + record.toolName, { level: 'warn' }); + }, + onQuestion(session, pending) { + push(session, 'question', 'Agent 需要你补充信息,请在下面回答'); + }, + }); + + const MAX_EVENTS = 400; + + // 每条事件都带:绝对时间、相对会话起点的已运行时间、自身耗时、可展开的结构化详情。 + // 页面上的"执行过程"就是这份事件流,所以内容必须在这里就组装好。 + function push(session, stage, text, extra) { + const now = Date.now(); + session.eventSeq = (session.eventSeq || 0) + 1; + const event = { + seq: session.eventSeq, + t: now, + elapsedMs: now - session.createdAt, + stage, + text, + level: (extra && extra.level) || 'info', + durMs: (extra && extra.durMs) || 0, + detail: (extra && extra.detail) || null, + // 生图请求号要跟着事件一起走:出图后要拿它去对账、报障、查限流, + // 只藏在 detail 里等于没有——用户得先展开三层才看得到。 + requestId: (extra && extra.requestId) || '', + }; + session.events.push(event); + if (session.events.length > MAX_EVENTS) session.events.splice(0, session.events.length - MAX_EVENTS); + session.updatedAt = now; + context.logger.info('h3.session.event', { sessionId: session.id, stage }); + void persist(session); + } + + // 从磁盘把历史会话读回内存。session.json 可能在进程被强杀时写了一半, + // 所以用宽松解析:取第一个完整的 JSON 对象,后面的残字节丢掉。 + async function restoreSessions() { + let entries = []; + try { + entries = await readdir(dataRoot, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory() || !SAFE_NAME.test(entry.name)) continue; + const dir = join(dataRoot, entry.name); + let raw = ''; + try { + raw = await readFile(join(dir, 'session.json'), 'utf8'); + } catch { + continue; + } + const data = parseLooseJson(raw); + if (!data || !data.id || sessions.has(data.id)) continue; + const restored = await rehydrate(data, dir); + if (restored) sessions.set(restored.id, restored); + } + if (sessions.size) context.logger.info('h3.sessions.restored', { count: sessions.size }); + } + + // 阶段耗时:某个阶段第一条事件到最后一条事件的跨度。 + function stageStats(events) { + const map = new Map(); + for (const event of events) { + const hit = map.get(event.stage) || { stage: event.stage, count: 0, startAt: event.t, endAt: event.t, spanMs: 0 }; + hit.count += 1; + hit.endAt = event.t; + hit.spanMs = hit.endAt - hit.startAt; + map.set(event.stage, hit); + } + return Array.from(map.values()); + } + + // ---------------------------------------------------------------- 对话轮次 + // + // 页面要长得像正常聊天窗口:一句用户消息 → 一段过程 → 一次交付,再来一句追问就是新一轮。 + // 所以这里按"轮"记账:一轮 = { 用户这句话, 从哪条事件开始, 这一轮交付了什么 }。 + // 事件不需要额外打标:下一轮的 startSeq 就是上一轮的右边界。 + + function startTurn(session, instruction) { + const turn = { + instruction, + at: Date.now(), + startSeq: session.eventSeq || 0, + finishedAt: 0, + delivery: null, + // 成本是会话级累加的,扣掉这一轮开始时的基数才是"这一轮花了多少"。 + baseCost: { + ditImages: session.cost.ditImages, + ditMs: session.cost.ditMs, + agentCalls: session.cost.agentCalls, + agentMs: session.cost.agentMs, + }, + }; + session.turns.push(turn); + return turn; + } + + function finishTurn(session) { + const turn = session.turns[session.turns.length - 1]; + if (!turn) return; + if (!turn.finishedAt) turn.finishedAt = Date.now(); + turn.endCost = pickCost(session.cost); + } + + const ZERO_COST = { ditImages: 0, ditMs: 0, agentCalls: 0, agentMs: 0 }; + + function pickCost(cost) { + if (!cost) return null; + return { + ditImages: Number(cost.ditImages) || 0, + ditMs: Number(cost.ditMs) || 0, + agentCalls: Number(cost.agentCalls) || 0, + agentMs: Number(cost.agentMs) || 0, + }; + } + + function turnView(session, index, turns, full) { + const turn = turns[index]; + const next = turns[index + 1]; + const from = turn.startSeq || 0; + const to = next ? next.startSeq || 0 : Infinity; + const events = session.events.filter((event) => event.seq > from && event.seq <= to); + const last = index === turns.length - 1; + const running = Boolean(last && session.busy); + // 落盘/强杀之后 finishedAt 可能没写上,退回这一轮最后一条事件的时间。 + const endAt = turn.finishedAt || (events.length ? events[events.length - 1].t : turn.at); + // 这一轮自己花了多少 = 这一轮结束时的累计 - 开始时的累计。 + // 不能拿会话总成本减:那样除了最后一轮,其他轮都会显示成"整个会话花了多少"。 + const base = pickCost(turn.baseCost) || ZERO_COST; + const end = + pickCost(turn.delivery && turn.delivery.cost) || + pickCost(turn.endCost) || + (last ? pickCost(session.cost) : null) || + ZERO_COST; + const view = { + index, + instruction: turn.instruction || '', + at: turn.at, + // 下面几个是记账用的原始字段:rehydrate 全靠它们把事件重新切回各轮,不能省。 + startSeq: from, + finishedAt: turn.finishedAt || 0, + baseCost: pickCost(turn.baseCost), + endCost: pickCost(turn.endCost), + running, + // 中间轮次的状态只能自己推:交付过就是 delivered,没交付就说明那一轮挂了。 + status: running ? 'running' : last ? session.status : turn.delivery ? 'delivered' : 'failed', + elapsedMs: Math.max(0, (running ? Date.now() : endAt) - turn.at), + cost: { + ditImages: Math.max(0, end.ditImages - base.ditImages), + ditMs: Math.max(0, end.ditMs - base.ditMs), + agentCalls: Math.max(0, end.agentCalls - base.agentCalls), + agentMs: Math.max(0, end.agentMs - base.agentMs), + }, + delivery: turn.delivery + ? { + version: turn.delivery.version, + items: turn.delivery.items, + questions: turn.delivery.questions, + crossCheck: turn.delivery.crossCheck || null, + cost: pickCost(turn.delivery.cost), + } + : null, + }; + // full 是落盘和分享页用的:事件已经在顶层 events 里,turns 里再存一份会让 session.json 翻倍。 + if (!full) { + view.events = events; + view.stageStats = stageStats(events); + } + return view; + } + + function turnViews(session, full) { + const turns = session.turns || []; + return turns.map((turn, index) => turnView(session, index, turns, full)); + } + + // 事件是并发推入的,writeFile 不原子,直接并发写会把两个 JSON 拼在一起。 + // 按会话串行排队写,留档失败不影响主流程。 + function persist(session) { + session.persistChain = (session.persistChain || Promise.resolve()).then(async () => { + const target = join(session.dirs.root, 'session.json'); + const tmp = target + '.tmp'; + try { + // writeFile 不是原子的:进程在写到一半时被杀(publish / 崩溃), + // session.json 就会缺一截或多一截,重启后整个会话读不回来。 + // 先写临时文件再 rename——同一文件系统上 rename 是原子的。 + await writeFile(tmp, JSON.stringify(snapshot(session, true), null, 2)); + await rename(tmp, target); + } catch { + /* 留档失败不影响主流程 */ + } + }); + return session.persistChain; + } + + // 出图前的关键确认:工作流在闸门这里挂起,等页面回答,或者用户直接选"按默认方案"。 + const gates = new Map(); + + function askUser(session, questions) { + return new Promise((resolve) => { + session.gate = { id: randomUUID(), questions, at: Date.now() }; + gates.set(session.id, resolve); + void persist(session); + }); + } + + function resolveGate(session, answers) { + const resolve = gates.get(session.id); + gates.delete(session.id); + session.gate = null; + if (resolve) resolve(answers); + return Boolean(resolve); + } + + // 重跑:拿原会话的需求和参考图开一个新会话。参考图在磁盘上,转成 dataUrl 走同一条创建路径, + // 这样重跑出来的会话和手输需求创建的完全一致,不会多出一种特殊结构。 + async function rerunSession(source) { + const refs = []; + for (const ref of (source.refs || []).slice(0, 8)) { + try { + const buffer = await readFile(ref.path); + const ext = String(ref.file || '').split('.').pop().toLowerCase() || 'png'; + const mime = ext === 'jpg' ? 'jpeg' : ext; + refs.push({ + name: ref.note || ref.id, + dataUrl: 'data:image/' + mime + ';base64,' + buffer.toString('base64'), + }); + } catch (error) { + // 参考图读不到就跳过:重跑本身不该因为少一张图而失败。 + } + } + return createSession({ instruction: source.instruction, refs }); + } + + // 删除会话:先把整个目录挪进 .trash 再从内存摘掉,不直接 rm。 + // 误删一次就没法恢复的代价太大,挪走之后用户还能自己去磁盘找回来。 + async function deleteSession(id) { + const session = sessions.get(id); + if (!session) return { error: 'no_session' }; + // 正在跑的会话不允许删:agent 还在往这个目录里写,删了会写出一堆无主文件。 + // status 也要看:重启后 busy 会被恢复成 false,只看它会漏掉「看起来在跑」的那种。 + if (session.busy || session.taskActive || session.status === 'running') return { error: 'busy' }; + sessions.delete(id); + try { + const trash = join(dataRoot, '.trash'); + await mkdir(trash, { recursive: true }); + await rename(session.dirs.root, join(trash, id + '-' + Date.now())); + } catch (error) { + context.logger.error('h3.session.trash_failed', { + message: String(error && error.message ? error.message : error), + }); + } + return { ok: true }; + } + + // 生成分享页并落盘到会话目录。发布成公开网址是 Agent 侧的能力,这里只负责把自包含 HTML 写好, + // 让 Agent 拿到就能发。交付完成后自动跑一次,链接链路不用等用户点「分享」才开始。 + async function saveSharePage(session, options) { + const result = await buildShareHtml({ + snapshot: snapshot(session, true), + dirs: { ...session.dirs, cache: cacheRoot }, + dataDir: context.dataDir, + clientDir, + options, + }); + const dir = join(session.dirs.root, 'share'); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'index.html'); + // 原子写:交付后的后台构建和「发布」触发的构建可能前后脚写同一个文件, + // 发布轮次拷走的必须是一份完整的页面。 + const tmp = file + '.' + randomUUID().slice(0, 8) + '.tmp'; + await writeFile(tmp, result.html); + await rename(tmp, file); + return { saved: true, file, bytes: result.bytes, images: result.images, large: result.large, url: session.shareUrl || null }; + } + + // 发布分享页成公开链接。 + // + // 为什么是异步:这一步要起一个 Agent turn(起会话 → 跑模型 → 拷文件 → 调 website_deploy → + // 上传 3MB),量级在分钟级,HTTP 请求挂不住。所以这里只点火,页面轮询 shareState / + // shareUrl / shareError 拿结果——按钮变「发布中」,好了自动把链接写进剪贴板。 + // + // 重复点击保护:shareState 已经是 publishing 就直接拒,不然会并行跑两个 turn, + // 拿到两个不同的公开地址,用户不知道该分享哪个。 + function startPublish(session) { + if (session.shareState === 'publishing') return { error: 'share_publishing' }; + if (!context.agent || typeof context.agent.run !== 'function') return { error: 'agent_unavailable' }; + session.shareState = 'publishing'; + session.shareError = null; + push(session, 'share', '开始发布分享页,正在起一个 Agent 会话'); + void persist(session); + + void (async () => { + let file = null; + try { + // 每次发布都重新生成:会话可能在上次落盘之后又跑过追问,页面得是最新的。 + const saved = await saveSharePage(session); + file = saved.file; + push(session, 'share', '分享页已就绪(' + Math.round(saved.bytes / 1024) + ' KB,' + (saved.large || 0) + ' 张交付图带大图),开始部署'); + } catch (error) { + session.shareState = 'failed'; + session.shareError = 'share_failed:' + String(error && error.message ? error.message : error); + push(session, 'share', '分享页生成失败:' + session.shareError, { level: 'warn' }); + void persist(session); + return; + } + const result = await publishHtml({ + context, + htmlPath: file, + projectName: 'H3-Image 分享页', + onProgress: (message, extra) => push(session, 'share', message, extra), + // website_deploy 的权限请求和规划 / 评审节点的一样,放进 session.permissions, + // 页面画成确认卡片,用户点了才经 /api/session/permission 回给 Host。 + onPermission: (record) => { + const entry = { ...record, source: 'publish' }; + session.permissions = session.permissions || []; + session.permissions.push(entry); + agent.trackPermission(entry); + push(session, 'permission', '需要你确认:把分享页发布成公开链接(website_deploy)', { level: 'warn' }); + }, + }); + // 发布这一轮已经结束(成功、失败或超时),它留下的确认卡片不能再挂在页面上。 + session.permissions = (session.permissions || []).filter((item) => item.source !== 'publish'); + if (result.ok) { + session.shareUrl = result.url; + session.shareState = 'done'; + session.shareError = null; + session.sharePublishedAt = Date.now(); + // 链接必须由服务端写进剪贴板:页面侧两条路都被浏览器安全模型堵死—— + // navigator.clipboard.writeText 和 execCommand('copy') 都要求文档聚焦,而这一刻 + // 是 setInterval 回调,用户在这 1–3 分钟里早就切走了(实测 document.hasFocus() 为 false)。 + const clip = await writeClipboard(result.url); + session.shareClip = clip.ok ? 'ok' : String(clip.error || clip.reason || 'failed'); + push(session, 'share', '发布完成:' + result.url, { durMs: result.ms }); + push( + session, + 'share', + clip.ok ? '链接已放进剪贴板' : '链接没能放进剪贴板(' + session.shareClip + '),请手动复制', + { level: clip.ok ? 'info' : 'warn' }, + ); + } else { + session.shareState = 'failed'; + session.shareError = result.error || 'publish_failed'; + push(session, 'share', '发布失败:' + session.shareError, { level: 'warn', durMs: result.ms }); + } + void persist(session); + })(); + + return { ok: true, state: 'publishing' }; + } + + function deps(session) { return { + agent, + askUser: (target, questions) => askUser(target, questions), + // 参考图检索要直连 Connector 工具,只有运行时 context 拿得到,子会话里的 agent 拿不到。 + context, + config: session.config, + dirs: session.dirs, + cache: refCache, + // 多图并行上限:Mini App 运行时的 agentConcurrency 默认 4,每张图同一时刻最多 1 个 agent 节点,留一半余量。 + concurrency: DEFAULTS.imageConcurrency, + emit: (target, stage, text, extra) => push(target, stage, text, extra), + ensureDir: (dir) => mkdir(dir, { recursive: true }), + // 交付完就把分享页备好:用户点「分享」时链接可能已经在手,点一下就是复制。 + // 不等它完成:分享页要给每张交付图算一份大图,4K 原图解码一张就要一两秒, + // 等它会让「已交付」晚好几秒才出现。 + afterDeliver: (target) => { + void saveSharePage(target).catch((error) => { + context.logger.error('h3.share.autosave_failed', { + message: String(error && error.message ? error.message : error), + }); + }); + }, + }; + } + + async function createSession(payload) { + const id = new Date().toISOString().slice(0, 10).replace(/-/g, '') + '-' + randomUUID().slice(0, 6); + const dir = join(dataRoot, id); + const dirs = sessionDirs(dir); + for (const key of Object.keys(dirs)) await mkdir(dirs[key], { recursive: true }); + + const refs = []; + const incoming = Array.isArray(payload.refs) ? payload.refs.slice(0, 8) : []; + for (let index = 0; index < incoming.length; index += 1) { + const item = incoming[index]; + const match = /^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i.exec(String(item.dataUrl || '')); + if (!match) continue; + const ext = match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase(); + const refId = 'R' + (index + 1); + const file = refId + '.' + ext; + const buffer = Buffer.from(match[2], 'base64'); + if (buffer.length > 20 * 1024 * 1024) continue; + await writeFile(join(dirs.refs, file), buffer); + refs.push({ id: refId, file, path: join(dirs.refs, file), note: String(item.name || '').slice(0, 60), origin: 'user' }); + } + + const config = await h3.resolveConfig(true); + const session = { + id, + createdAt: Date.now(), + updatedAt: Date.now(), + instruction: String(payload.instruction || '').slice(0, 4000), + refs, + config, + dirs, + status: 'running', + route: 'full', + think: [], + plan: null, + images: [], + events: [], + eventSeq: 0, + finishedAt: 0, + archived: false, + questions: [], + assumptions: [], + blockers: [], + answered: [], + gate: null, + permissions: [], + pendingQuestion: null, + turns: [], + delivery: null, + deliveryVersion: 0, + // 角色基准图:多图时先出的外观基准,只做内部参考,不进交付。 + characterRefs: [], + // 跨图一致性评审结论,所有图出完之后才写得出来。 + crossCheck: null, + fatal: '', + busy: true, + cost: { ditImages: 0, ditMs: 0, agentCalls: 0, agentMs: 0, usage: null }, + }; + sessions.set(id, session); + startTurn(session, session.instruction); + push(session, 'input', '收到需求:' + session.instruction + (refs.length ? '(附 ' + refs.length + ' 张参考图)' : '')); + + // taskActive 一直到后台任务真正结束才放下。「停止」会立刻把 busy 置回 false, + // 但被中止的任务还要一点时间收尾;这期间不许追问、不许删除,否则同一个会话上会跑两个任务。 + session.taskActive = true; + session.task = (async () => { + try { + await runSession(session, deps(session)); + } catch (error) { + // 用户点了停止:被中止的回合抛出来的错误不算失败,会话保持「已取消」。 + if (session.status === 'cancelled') return; + session.fatal = String(error && error.message ? error.message : error); + session.status = 'failed'; + push(session, 'error', '流程中断:' + session.fatal, { level: 'error' }); + } finally { + session.taskActive = false; + session.busy = false; + session.finishedAt = Date.now(); + finishTurn(session); + await persist(session); + } + })(); + return session; + } + + async function followUp(session, instruction) { + if (session.busy || session.taskActive) throw new Error('busy'); + session.busy = true; + session.status = 'running'; + session.finishedAt = 0; + if (!session.config) session.config = await h3.resolveConfig(true); + // 这一轮的起点必须记在"收到追问"这条事件之前,它自己属于新一轮。 + startTurn(session, instruction); + push(session, 'edit', '收到追问:' + instruction, { + detail: detail('增量编辑请求', [text('用户这次的话', instruction)]), + }); + session.taskActive = true; + session.task = (async () => { + try { + await runIncrementalEdit(session, instruction, deps(session)); + } catch (error) { + if (session.status === 'cancelled') return; + session.fatal = String(error && error.message ? error.message : error); + session.status = 'failed'; + push(session, 'error', '增量编辑中断:' + session.fatal, { level: 'error' }); + } finally { + session.taskActive = false; + session.busy = false; + session.finishedAt = Date.now(); + finishTurn(session); + await persist(session); + } + })(); + } + + function snapshot(session, full) { + const now = Date.now(); + const finishedAt = session.finishedAt || 0; + return { + id: session.id, + status: session.status, + busy: session.busy, + archived: Boolean(session.archived), + route: session.route, + instruction: session.instruction, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + timing: { + startedAt: session.createdAt, + now, + running: Boolean(session.busy), + finishedAt, + elapsedMs: (session.busy ? now : finishedAt || now) - session.createdAt, + }, + stageStats: stageStats(session.events), + fatal: session.fatal, + // 规划原文要落盘:排查「检索了什么、refs 怎么写的」只能靠它, + // 从事件 detail 里反推非常费劲。 + plan: session.plan || null, + think: session.think, + questions: session.questions, + assumptions: session.assumptions, + answered: session.answered, + gate: session.gate, + permissions: session.permissions, + pendingQuestion: session.pendingQuestion, + // origin 决定这张图算"你给的"还是"系统搜的":用户气泡只能放前者,搜到的属于执行轨迹。 + refs: session.refs.map((ref) => ({ + id: ref.id, + file: 'refs/' + ref.file, + note: ref.note, + origin: ref.origin || (ref.source ? 'search' : 'user'), + })), + // 角色基准图要跟着会话活过重启:跨图评审靠它判断"本组本该一致", + // 前端也要靠它说明每张图的外观基准是谁。path 是绝对路径,不能带出去。 + characterRefs: (session.characterRefs || []).map((ref) => ({ + id: ref.id, + file: 'refs/' + ref.file, + note: ref.note, + images: ref.images || [], + })), + // 跨图结论同样要落盘。sheet 是本地绝对路径,只留结论。 + crossCheck: session.crossCheck + ? { + consistent: session.crossCheck.consistent, + summary: session.crossCheck.summary, + issues: session.crossCheck.issues || [], + checkedAt: session.crossCheck.checkedAt || 0, + } + : null, + // 页面按轮渲染,事件已经切进 turns 里;顶层 events 只在落盘和分享页(full)时给全量, + // 否则每 1.5s 的轮询要把同一批事件发两遍。 + events: full ? session.events : [], + turns: turnViews(session, full), + // 分享页发布成公开链接后回填的网址;页面据此显示「公开链接」。 + shareUrl: session.shareUrl || null, + // 发布是异步的(一次 Agent turn,分钟级),页面要靠这三个字段决定按钮长什么样: + // publishing 时变「发布中」,failed 时把原因摊开,done 时把链接写进剪贴板。 + shareState: session.shareState || 'idle', + shareError: session.shareError || null, + sharePublishedAt: session.sharePublishedAt || 0, + // 剪贴板有没有写成功。页面侧自己写需要文档聚焦,服务端写不需要; + // 页面要靠这个字段决定是直接报「已放进剪贴板」还是退回自己试一次。 + shareClip: session.shareClip || null, + cost: session.cost, + delivery: session.delivery + ? { + version: session.delivery.version, + items: session.delivery.items, + questions: session.delivery.questions, + crossCheck: session.delivery.crossCheck || null, + } + : null, + images: session.images.map((image) => ({ + id: image.id, + title: image.title, + status: image.status, + blocked: image.blocked, + blockReason: image.blockReason, + // 失败原因必须落盘:交付页要显示「为什么没画出来」, + // 重启后也要还在,否则已失败的会话会退化成一句「未生成」。 + failReason: image.failReason || '', + contentPolicy: Boolean(image.contentPolicy), + spec: { + // 交付文件名靠它拼(H3-Image__<时间戳>)。不落盘的话, + // 重启后增量编辑再交付会退回用图号命名,同一张图前后两个名字。 + slug: image.spec.slug || '', + entry: image.spec.entry, + prompt: image.spec.prompt, + slots: image.spec.slots || null, + missingSlots: image.spec.missingSlots || [], + aspectRatio: image.spec.aspectRatio, + area: image.spec.area, + refs: image.spec.refs, + // 必须落盘:rehydrate 全靠它把角色基准图重新绑回图上。 + // 丢了它,重启后追问时 executeAction 就不会再强制带上角色图,主体会漂回各画各的。 + characterRefs: image.spec.characterRefs || [], + textLayers: image.spec.textLayers, + checklist: image.spec.checklist, + }, + chosenReason: image.chosenReason || '', + chosenId: image.chosen ? image.chosen.id : null, + // 下一轮候选从哪个轮号继续编号。不存它,重启后增量编辑会从 r1 重新编, + // 把上一轮已经交付的同名文件覆盖掉。 + roundBase: image.roundBase || 0, + rounds: image.rounds.map((round) => ({ + index: round.index, + action: round.action, + error: round.error || '', + verdict: round.verdict || null, + candidates: (round.candidates || []).map((candidate) => ({ + id: candidate.id, + size: candidate.size, + file: candidate.file, + previewFile: candidate.previewFile || null, + sheetFile: candidate.sheet ? candidate.sheet.split('/').pop() : null, + requestId: candidate.requestId, + actionType: candidate.actionType, + parentId: candidate.parentId, + prompt: candidate.actionPrompt || '', + revisedPrompt: candidate.revisedPrompt, + program: candidate.program + ? { + width: candidate.program.width, + height: candidate.program.height, + drift: candidate.program.drift, + sharpness: candidate.program.sharpness, + redCast: Boolean(candidate.program.redCast), + stats: candidate.program.stats, + } + : null, + checks: candidate.checks || [], + verdict: candidate.verdict || null, + })), + })), + })), + }; + } + + async function serveFile(url, response) { + const kind = url.searchParams.get('kind') || 'preview'; + const name = url.searchParams.get('name') || ''; + const sessionId = url.searchParams.get('session') || ''; + if (!SAFE_NAME.test(name)) return json(response, 400, { error: 'bad_name' }); + // 缓存图按内容寻址、跨会话共享,所以不查会话表;文件名是地址的哈希,不含任何可读信息。 + if (kind === 'cache') { + try { + const body = await readFile(join(cacheRoot, name)); + response.writeHead(200, { + 'content-type': imageContentType(body), + 'cache-control': 'public, max-age=86400', + }); + response.end(body); + } catch { + json(response, 404, { error: 'not_found' }); + } + return; + } + if (!SAFE_NAME.test(sessionId)) return json(response, 400, { error: 'bad_name' }); + const session = sessions.get(sessionId); + if (!session) return json(response, 404, { error: 'no_session' }); + let base = session.dirs.previews; + if (kind === 'image') base = session.dirs.images; + else if (kind === 'refs') base = session.dirs.refs; + else if (kind === 'sheets') base = session.dirs.sheets; + else if (kind === 'deliver') { + const sub = url.searchParams.get('sub') || ''; + if (!SAFE_NAME.test(sub)) return json(response, 400, { error: 'bad_name' }); + base = join(session.dirs.deliver, sub); + } + try { + const body = await readFile(join(base, name)); + response.writeHead(200, { 'content-type': imageContentType(body), 'cache-control': 'no-store' }); + response.end(body); + } catch { + json(response, 404, { error: 'not_found' }); + } + } + + const server = createServer((request, response) => { + void handle(request, response).catch((error) => { + context.logger.error('h3.request.failed', { message: String(error && error.message ? error.message : error) }); + json(response, 500, { error: 'internal_error' }); + }); + }); + + async function handle(request, response) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const path = url.pathname; + + if (request.method === 'GET' && assets[path]) { + response.writeHead(200, { 'content-type': assets[path].type, 'cache-control': 'no-store' }); + response.end(assets[path].body); + return; + } + if (request.method === 'GET' && path === '/') { + response.writeHead(302, { location: '/dashboard' }); + response.end(); + return; + } + if (path.startsWith('/api/')) await restoring; + if (request.method === 'GET' && path === '/api/config') { + const config = await h3.resolveConfig(true); + json(response, 200, { + ...h3.configSummary(config), + defaults: DEFAULTS, + cache: await refCache.stats(), + // 页面需要知道「现在用的是哪一份配置」才能给出正确的引导, + // 但只能知道来源和掩码,永远拿不到 Key 本身。 + stored: Boolean(h3.getStoredSettings()), + defaultBaseUrl: h3.DEFAULT_BASE_URL || '', + }); + return; + } + // 保存 / 清除接口凭据。Key 只往里写,不往外读。 + if (request.method === 'POST' && path === '/api/config') { + const body = await readJson(request).catch(() => ({})); + if (body && body.clear) { + await saveSettings({}); + h3.setStoredSettings(null); + json(response, 200, { ok: true, cleared: true }); + return; + } + const apiKey = String((body && body.apiKey) || '').trim(); + const rawUrl = String((body && body.baseUrl) || '').trim(); + // 页面是不可信输入:Key 和地址都要先验一遍再落盘。 + if (!apiKey) return json(response, 400, { error: 'api_key_required' }); + if (/\s/.test(apiKey)) return json(response, 400, { error: 'api_key_has_space' }); + if (apiKey.length > 512) return json(response, 400, { error: 'api_key_too_long' }); + if (!rawUrl) return json(response, 400, { error: 'base_url_required' }); + if (!/^https:\/\//i.test(rawUrl)) return json(response, 400, { error: 'base_url_must_be_https' }); + // 先判「不完整」再交给 URL 解析:'https://' 会被 new URL() 抛成 invalid, + // 提示成「不是合法的 URL」会让人以为格式错了,其实只是没打完。 + if (!/^[^/]/.test(rawUrl.slice('https://'.length))) { + return json(response, 400, { error: 'base_url_incomplete' }); + } + try { + // eslint-disable-next-line no-new + new URL(rawUrl); + } catch { + return json(response, 400, { error: 'base_url_invalid' }); + } + // 落盘用用户原样输入(只削尾斜杠),不用 new URL() 的归一化结果: + // 归一化会改大小写、补端口、改路径,接口地址的路径未必大小写不敏感。 + const baseUrl = rawUrl.replace(/\/+$/, ''); + await saveSettings({ apiKey, baseUrl }); + h3.setStoredSettings({ apiKey, baseUrl }); + const config = await h3.resolveConfig(true); + json(response, 200, { ok: true, ...h3.configSummary(config), stored: true }); + return; + } + // 读系统剪贴板,只在用户点「剪贴板」按钮时调一次。 + // 页面自己读不到(deviceAccess 不含 clipboard),这是唯一通路。 + // 只把「像 Key」或「像接口地址」的内容回给页面,其余一律只回错误码。 + if (request.method === 'POST' && path === '/api/clipboard') { + const clip = await readClipboard(); + if (!clip.ok) { + const map = { + unavailable: 'clipboard_unavailable', + timeout: 'clipboard_timeout', + too_large: 'clipboard_too_long', + }; + return json(response, 400, { error: map[clip.reason] || 'clipboard_unavailable' }); + } + const picked = classifyClipboard(clip.text); + if (picked.error) return json(response, 400, { error: picked.error }); + json(response, 200, { ok: true, field: picked.field, value: picked.value }); + return; + } + if (request.method === 'GET' && path === '/api/file') { + await serveFile(url, response); + return; + } + // 分享页:把整条链路打包成一个自包含 HTML,图片降采样后内嵌,不泄露任何本地路径。 + if (request.method === 'GET' && path === '/api/session/share') { + const session = sessions.get(url.searchParams.get('id') || ''); + if (!session) return json(response, 404, { error: 'no_session' }); + // 页面是不可信输入:边长不设上限的话,一个 edge=100000 就会把原图整张内嵌进分享页。 + const edge = Math.min(2048, Math.max(0, Math.floor(Number(url.searchParams.get('edge') || 0)) || 0)); + const sheets = url.searchParams.get('sheets'); + const options = { + edge: edge > 0 ? edge : undefined, + includeSheets: sheets === '0' ? false : undefined, + }; + // save=1:把分享页落到会话目录下的 share/index.html,「发布公开链接」就从这个文件开始。 + if (url.searchParams.get('save') === '1') { + try { + return json(response, 200, await saveSharePage(session, options)); + } catch (error) { + context.logger.error('h3.share.failed', { message: String(error && error.message ? error.message : error) }); + return json(response, 500, { error: 'share_failed' }); + } + } + let result; + try { + result = await buildShareHtml({ + snapshot: snapshot(session, true), + dirs: { ...session.dirs, cache: cacheRoot }, + dataDir: context.dataDir, + clientDir, + options, + }); + } catch (error) { + context.logger.error('h3.share.failed', { message: String(error && error.message ? error.message : error) }); + return json(response, 500, { error: 'share_failed' }); + } + const inline = url.searchParams.get('inline') === '1'; + const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }; + if (!inline) headers['content-disposition'] = 'attachment; filename="h3-' + session.id + '.html"'; + response.writeHead(200, headers); + response.end(result.html); + return; + } + // 发布分享页成公开链接。页面和服务端自己都做不到部署(context 没有 drive / upload / + // storage 能力,website_deploy 只下发给 Agent turn),所以这里起一个 turn 去调它。 + // 异步:202 立刻返回,页面轮询 shareState 拿结果。 + if (request.method === 'POST' && path === '/api/session/share/publish') { + const body = await readJson(request).catch(() => ({})); + const session = sessions.get((body && body.id) || ''); + if (!session) return json(response, 404, { error: 'no_session' }); + const started = startPublish(session); + if (started.error) { + return json(response, 409, { error: started.error, state: session.shareState || 'idle' }); + } + return json(response, 202, { ok: true, state: 'publishing' }); + } + // 把会话已有的公开链接写进系统剪贴板。 + // 页面侧写剪贴板要求文档聚焦,面板里经常没有(实测 document.hasFocus() 为 false), + // 所以手动点「分享」复制已有链接时也要有服务端兜底。 + // 只写 session.shareUrl,绝不接受页面传来的文本——否则这个接口会变成一个通用的 + // 「往用户剪贴板里写任意内容」的后门。 + if (request.method === 'POST' && path === '/api/session/share/copy') { + const body = await readJson(request).catch(() => ({})); + const session = sessions.get((body && body.id) || ''); + if (!session) return json(response, 404, { error: 'no_session' }); + if (!session.shareUrl) return json(response, 409, { error: 'no_share_url' }); + const clip = await writeClipboard(session.shareUrl); + if (!clip.ok) { + return json(response, 500, { error: clip.error || clip.reason || 'clipboard_write_failed' }); + } + return json(response, 200, { ok: true, url: session.shareUrl }); + } + if (request.method === 'GET' && path === '/api/sessions') { + const now = Date.now(); + const list = Array.from(sessions.values()) + .sort((a, b) => b.createdAt - a.createdAt) + .map((session) => ({ + id: session.id, + status: session.status, + busy: session.busy, + archived: Boolean(session.archived), + instruction: session.instruction, + createdAt: session.createdAt, + elapsedMs: (session.busy ? now : session.finishedAt || session.updatedAt) - session.createdAt, + images: session.images.length, + delivered: session.delivery ? session.delivery.items.filter((item) => item.file).length : 0, + })); + json(response, 200, { sessions: list, now }); + return; + } + if (request.method === 'GET' && path === '/api/session') { + const session = sessions.get(url.searchParams.get('id') || ''); + if (!session) return json(response, 404, { error: 'no_session' }); + json(response, 200, snapshot(session)); + return; + } + if (request.method === 'POST' && path === '/api/session') { + const payload = await readJson(request); + if (!payload || !String(payload.instruction || '').trim()) return json(response, 400, { error: 'empty_instruction' }); + const session = await createSession(payload); + json(response, 200, { id: session.id }); + return; + } + if (request.method === 'POST' && path === '/api/session/followup') { + const payload = await readJson(request); + const session = sessions.get(String(payload.id || '')); + if (!session) return json(response, 404, { error: 'no_session' }); + if (session.busy || session.taskActive) return json(response, 409, { error: 'busy' }); + const instruction = String(payload.instruction || '').trim(); + if (!instruction) return json(response, 400, { error: 'empty_instruction' }); + await followUp(session, instruction); + json(response, 200, { ok: true }); + return; + } + if (request.method === 'POST' && path === '/api/session/permission') { + const payload = await readJson(request); + const decision = payload.decision === 'allowOnce' ? 'allowOnce' : 'deny'; + try { + await agent.decidePermission(String(payload.requestId || ''), decision); + } catch (error) { + return json(response, 200, { ok: false, code: String(error && error.code ? error.code : 'unknown') }); + } + const session = sessions.get(String(payload.id || '')); + if (session) { + session.permissions = (session.permissions || []).filter((item) => item.requestId !== payload.requestId); + push(session, 'permission', '已' + (decision === 'allowOnce' ? '允许' : '拒绝') + '工具调用'); + } + json(response, 200, { ok: true }); + return; + } + if (request.method === 'POST' && path === '/api/session/answer') { + const payload = await readJson(request); + const session = sessions.get(String(payload.id || '')); + if (!session) return json(response, 404, { error: 'no_session' }); + const answers = Array.isArray(payload.answers) ? payload.answers : []; + // Agent 的问卷带 requestId,出图前的闸门不带:按这个分流,问卷的回答才不会被当成闸门的回答。 + const requestId = String(payload.requestId || ''); + if (requestId) { + if (!agent.answerQuestion(session, requestId, answers)) return json(response, 404, { error: 'no_question' }); + push(session, 'question', '已提交回答,继续'); + json(response, 200, { ok: true }); + return; + } + // 出图前的关键确认走工作流闸门,不经过 agent 问卷。 + if (session.gate) { + const ok = resolveGate(session, answers); + push(session, 'question', answers.length ? '已收到确认,继续出图' : '按默认方案继续出图'); + json(response, 200, { ok }); + return; + } + return json(response, 404, { error: 'no_question' }); + } + if (request.method === 'POST' && path === '/api/session/cancel') { + const payload = await readJson(request); + const session = sessions.get(String(payload.id || '')); + if (session) { + // 先标成 cancelled 再放闸门、中止回合:工作流醒来时要能认出这是用户自己停的, + // 不能按默认方案接着出图,也不能把被中止的回合当成失败。 + session.status = 'cancelled'; + resolveGate(session, []); + agent.cancelSession(session); + session.busy = false; + push(session, 'cancel', '已取消当前流程', { level: 'warn' }); + } + json(response, 200, { ok: true }); + return; + } + // 重跑:同一个需求再画一次,开的是新会话,原会话原封不动留着。 + if (request.method === 'POST' && path === '/api/session/rerun') { + const payload = await readJson(request); + const source = sessions.get(String(payload.id || '')); + if (!source) return json(response, 404, { error: 'no_session' }); + if (!String(source.instruction || '').trim()) return json(response, 400, { error: 'empty_instruction' }); + const created = await rerunSession(source); + json(response, 200, { id: created.id }); + return; + } + // 删除:目录挪进 .trash,可在磁盘找回;正在跑的会话直接拒绝。 + if (request.method === 'POST' && path === '/api/session/delete') { + const payload = await readJson(request); + const result = await deleteSession(String(payload.id || '')); + if (result.error === 'no_session') return json(response, 404, { error: 'no_session' }); + if (result.error === 'busy') return json(response, 409, { error: 'busy' }); + json(response, 200, { ok: true }); + return; + } + json(response, 404, { error: 'not_found' }); + } + + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', onError); + resolve(); + }); + }); + context.logger.info('h3.runtime.listening', { port: context.listen.port }); + + let disposal = null; + const dispose = () => { + if (disposal) return disposal; + context.signal.removeEventListener('abort', onAbort); + agent.cancelAll(); + // 剪贴板子进程是我们自己拉起来的,Host 只 SIGTERM 这个 Node 入口,不会管孙进程。 + disposeClipboard(); + disposal = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return disposal; + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +// session.json 正常是标准 JSON;被强杀时可能尾部多出残字节。 +// 先按标准解析,失败再扫出第一个括号配平的对象。 +function parseLooseJson(raw) { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + /* 继续走宽松解析 */ + } + const start = raw.indexOf('{'); + if (start < 0) return null; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < raw.length; index += 1) { + const char = raw[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === '{') depth += 1; + else if (char === '}') { + depth -= 1; + if (depth === 0) { + try { + return JSON.parse(raw.slice(start, index + 1)); + } catch { + return null; + } + } + } + } + return null; +} + +function sessionDirs(dir) { + return { + root: dir, + images: join(dir, 'images'), + previews: join(dir, 'previews'), + refs: join(dir, 'refs'), + sheets: join(dir, 'sheets'), + deliver: join(dir, 'deliver'), + }; +} + +// 把落盘的 snapshot 还原成可继续操作的内存会话:看图、追问、再次交付都能用。 +async function rehydrate(data, dir) { + const dirs = sessionDirs(dir); + // 老会话落盘时没记 origin。检索来的参考图 note 就是规划里写的 query, + // 用它回填比"猜"可靠:用户上传的 note 是文件名,不会撞上 query。 + const searchNotes = new Set( + (((data.plan || {}).ref_queries) || []) + .map((item) => String((item && item.query) || '').trim()) + .filter(Boolean), + ); + const refs = (data.refs || []) + .map((ref) => { + const file = String(ref.file || '').split('/').pop(); + const note = String(ref.note || ''); + return { + id: ref.id, + file, + path: join(dirs.refs, file), + note, + source: ref.source || '', + origin: ref.origin || (ref.source || searchNotes.has(note.trim()) ? 'search' : 'user'), + }; + }) + .filter((ref) => ref.id && SAFE_NAME.test(ref.file)); + + // 候选文件名里带着轮号,扫一遍盘才能知道这张图历史上最高排到第几轮。 + // 只看 image.rounds 不够:增量编辑会把 rounds 清成只剩最新一轮, + // 早期轮次的候选不在里面,按它算出来的轮号会撞上磁盘上已有的文件。 + const diskRounds = new Map(); + try { + for (const name of await readdir(dirs.images)) { + // 扩展名不写死 .png:接口回 JPEG 时候选图落盘是 .jpg,写死会扫不到、轮号算错。 + const hit = /^([A-Za-z0-9]+)-r(\d+)-g\d+(?:-cc)?\.(?:png|jpe?g|webp)$/i.exec(name); + if (!hit) continue; + const seen = Number(hit[2]); + if (seen > (diskRounds.get(hit[1]) || 0)) diskRounds.set(hit[1], seen); + } + } catch { + /* 图片目录还不存在,按 0 处理 */ + } + + const images = (data.images || []).map((image) => { + const rounds = (image.rounds || []).map((round) => ({ + index: round.index, + action: round.action, + error: round.error || '', + verdict: round.verdict || null, + candidates: (round.candidates || []).map((candidate) => ({ + id: candidate.id, + file: candidate.file, + path: join(dirs.images, candidate.file), + previewFile: candidate.previewFile || null, + preview: candidate.previewFile ? join(dirs.previews, candidate.previewFile) : null, + sheet: candidate.sheetFile ? join(dirs.sheets, candidate.sheetFile) : null, + size: candidate.size, + requestId: candidate.requestId, + actionType: candidate.actionType, + parentId: candidate.parentId, + actionPrompt: candidate.prompt || '', + revisedPrompt: candidate.revisedPrompt, + round: round.index, + program: candidate.program || {}, + checks: candidate.checks || [], + verdict: candidate.verdict || null, + })), + })); + let chosen = null; + for (const round of rounds) { + const hit = round.candidates.find((candidate) => candidate.id === image.chosenId); + if (hit) chosen = hit; + } + // 候选文件名是 <图号>-r<轮号>-g<序号>.png,轮号 = roundBase + 当前 rounds.length。 + // 除了读回持久化的 roundBase,还要按已存在的候选 id 兜一个下限: + // 旧会话根本没存过这个字段,靠 id 反推才能自愈,否则编辑会覆盖历史交付的文件。 + let maxRound = 0; + for (const round of rounds) { + for (const candidate of round.candidates) { + const hit = /-r(\d+)-g\d+$/.exec(candidate.id || ''); + if (hit) maxRound = Math.max(maxRound, Number(hit[1])); + } + } + const diskMax = diskRounds.get(image.id) || 0; + const roundBase = Math.max(Number(image.roundBase) || 0, maxRound - rounds.length, diskMax - rounds.length); + return { + id: image.id, + title: image.title, + blocked: Boolean(image.blocked), + blockReason: image.blockReason || '', + // 老会话落盘时还没有 failReason 字段,但每轮的报错一直存在 rounds 里。 + // 恢复时补算一次,否则历史失败会话永远只显示一句「未生成」。 + failReason: (image && image.failReason) || lastRoundError(image), + contentPolicy: Boolean(image && image.contentPolicy) || isPolicyText(lastRoundError(image)), + spec: { + // 交付文件名靠它拼。老会话没这个字段,回退到图号小写即可。 + slug: (image.spec && image.spec.slug) || String(image.id || '').toLowerCase(), + entry: (image.spec && image.spec.entry) || 't2i', + prompt: (image.spec && image.spec.prompt) || '', + slots: (image.spec && image.spec.slots) || null, + missingSlots: (image.spec && image.spec.missingSlots) || [], + aspectRatio: (image.spec && image.spec.aspectRatio) || 'auto', + area: (image.spec && image.spec.area) || DEFAULTS.area, + size: (image.spec && image.spec.size) || '', + refs: (image.spec && image.spec.refs) || [], + // 角色基准图编号要跟着会话活过重启,否则追问时 refine 会把它顶掉,主体又漂回去。 + characterRefs: (image.spec && image.spec.characterRefs) || [], + textLayers: (image.spec && image.spec.textLayers) || [], + checklist: (image.spec && image.spec.checklist) || [], + }, + rounds, + roundBase, + status: image.status || 'pending', + chosen, + chosenReason: image.chosenReason || '', + }; + }); + + // 失败原因按图编号建索引:交付项里的 reason 是交付那一刻写下的, + // 老会话那一版还没写,只能拿图上的失败原因回填。 + const failReasons = new Map( + images + .filter((image) => image.failReason) + .map((image) => [image.id, { reason: image.failReason, contentPolicy: Boolean(image.contentPolicy) }]), + ); + const turns = await restoreTurns(data, dirs, failReasons); + // 老会话的 session.json 里没有 delivery,但 deliver/vN/report.json 在盘上, + // 反推出来的最后一轮交付就是当前交付。 + const latest = turns.length ? turns[turns.length - 1].delivery : null; + const source = data.delivery || latest; + const delivery = source + ? { + version: source.version, + dir: join(dirs.deliver, 'v' + source.version), + items: backfillFailReasons(source.items, failReasons), + questions: source.questions || [], + crossCheck: source.crossCheck || null, + cost: (data.delivery && data.delivery.cost) || null, + } + : null; + + // 老会话落盘时还没有「一张都没出来就判失败」这条规则,status 仍写着 delivered。 + // 恢复时按交付实况纠正,否则侧边栏会把 0 张图的会话当成正常完成,用户看不出它炸了。 + const deliveredItems = delivery ? (delivery.items || []).filter((item) => item.file) : []; + const declaredItems = delivery ? delivery.items || [] : []; + let status = data.status === 'running' ? 'failed' : data.status || 'delivered'; + if (status === 'delivered' && declaredItems.length && !deliveredItems.length) status = 'failed'; + // 失败会话必须带一句能看的原因,页面上才有东西可显示。 + const derivedFatal = + status === 'failed' && declaredItems.length && !deliveredItems.length + ? declaredItems + .filter((item) => !item.file) + .map((item) => item.id + ' ' + (item.reason || '未生成')) + .join(';') + .slice(0, 500) + : ''; + + return { + id: data.id, + createdAt: data.createdAt || Date.now(), + updatedAt: data.updatedAt || data.createdAt || Date.now(), + finishedAt: data.updatedAt || data.createdAt || Date.now(), + instruction: data.instruction || '', + refs, + config: null, + dirs, + // 进程重启后 agent 一定已经死了。磁盘上还写着 running 的,只能是被打断的残留: + // 老实标成 failed,否则页面会一直显示「运行中」,用户以为还在画,干等一个不会来的结果。 + status, + route: data.route || 'full', + think: data.think || [], + plan: data.plan || null, + images, + events: Array.isArray(data.events) ? data.events : [], + eventSeq: Array.isArray(data.events) ? data.events.length : 0, + questions: data.questions || [], + assumptions: data.assumptions || [], + blockers: [], + answered: data.answered || [], + gate: null, + permissions: [], + pendingQuestion: null, + // 公开链接要跟着会话一起活过重启,否则刷新一次页面就没了。 + shareUrl: data.shareUrl || null, + // 正在发布的时候进程被重启(publish / 崩溃):那一轮的 Agent turn 已经随进程没了, + // 没人会再回来写结果。直接落成 failed,不然按钮会永远停在「发布中」。 + shareState: data.shareState === 'publishing' ? 'failed' : data.shareState || 'idle', + shareError: + data.shareState === 'publishing' ? 'publish_interrupted' : data.shareError || null, + sharePublishedAt: data.sharePublishedAt || 0, + shareClip: data.shareClip || null, + turns, + delivery, + deliveryVersion: delivery ? delivery.version : 0, + characterRefs: Array.isArray(data.characterRefs) ? data.characterRefs : [], + crossCheck: data.crossCheck || (delivery && delivery.crossCheck) || null, + fatal: + data.status === 'running' + ? data.fatal || 'interrupted' + : data.fatal || derivedFatal || '', + busy: false, + archived: true, + cost: data.cost || { ditImages: 0, ditMs: 0, agentCalls: 0, agentMs: 0, usage: null }, + }; +} + +// 新会话直接读 session.json 里的 turns;老会话没这个字段,只能从事件流反推: +// 「收到追问:xxx」是每一轮的起点,「交付 vN」把交付挂到当前轮,缺的部分去 deliver/vN/report.json 补。 +async function restoreTurns(data, dirs, failReasons) { + const events = Array.isArray(data.events) ? data.events : []; + const bornAt = data.createdAt || Date.now(); + if (Array.isArray(data.turns) && data.turns.length) { + return data.turns.map((turn) => ({ + instruction: turn.instruction || '', + at: turn.at || bornAt, + // 追问事件自己属于新一轮,所以起点是它的前一条。 + startSeq: Number(turn.startSeq) || 0, + finishedAt: Number(turn.finishedAt) || 0, + baseCost: turn.baseCost || null, + endCost: turn.endCost || null, + delivery: normalizeTurnDelivery(turn.delivery, failReasons), + })); + } + const turns = [{ instruction: data.instruction || '', at: bornAt, startSeq: 0, finishedAt: 0, baseCost: null, delivery: null }]; + for (const event of events) { + if (event.stage !== 'edit') continue; + const match = /^收到追问:([\s\S]*)$/.exec(String(event.text || '')); + if (!match) continue; + turns.push({ + instruction: match[1].trim(), + at: event.t || bornAt, + startSeq: Math.max(0, (Number(event.seq) || 1) - 1), + finishedAt: 0, + baseCost: null, + delivery: null, + }); + } + for (const report of await readDeliveryReports(dirs.deliver)) { + const turn = turns[Math.min(report.version - 1, turns.length - 1)]; + if (turn && !turn.delivery) turn.delivery = normalizeTurnDelivery(report, failReasons); + } + // 成本是会话级累加的,老会话没记每轮起点。report.json 里的 cost 是"交付这一版时的累计值", + // 拿上一版的累计值当本轮基数,就能还原出每一轮自己花了多少。 + let previous = null; + for (const turn of turns) { + turn.baseCost = previous; + if (turn.delivery && turn.delivery.cost) previous = turn.delivery.cost; + } + return turns; +} + +function normalizeTurnDelivery(delivery, failReasons) { + if (!delivery || !delivery.version) return null; + return { + version: delivery.version, + items: backfillFailReasons(delivery.items, failReasons), + questions: delivery.questions || [], + crossCheck: delivery.crossCheck || null, + cost: delivery.cost || null, + }; +} + +// 图没画出来时,从后往前找最后一次真实报错。 +// 老会话的 image 上没有 failReason 字段,但 rounds[].error 一直在盘上。 +function lastRoundError(image) { + const rounds = (image && image.rounds) || []; + for (let index = rounds.length - 1; index >= 0; index -= 1) { + const round = rounds[index]; + if (round && round.error) return String(round.error); + } + return ''; +} + +function isPolicyText(text) { + return /safety check|content[_ ]?policy|content[_ ]?filter|内容安全|审核不通过|敏感内容|违规内容/i.test( + String(text || ''), + ); +} + +// 交付项里的 reason 是交付那一刻写下的。老会话那一版还没写这段, +// 页面只能显示「未生成」——这里按图编号回填,让历史失败会话也说得清原因。 +function backfillFailReasons(items, failReasons) { + const list = items || []; + if (!failReasons || !failReasons.size) return list; + return list.map((item) => { + if (!item || item.file || item.reason) return item; + const hit = failReasons.get(item.id); + if (!hit) return item; + return { ...item, reason: hit.reason, contentPolicy: item.contentPolicy || hit.contentPolicy }; + }); +} + +async function readDeliveryReports(dir) { + let entries = []; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const reports = []; + for (const entry of entries) { + if (!entry.isDirectory() || !/^v\d+$/.test(entry.name)) continue; + try { + const report = JSON.parse(await readFile(join(dir, entry.name, 'report.json'), 'utf8')); + reports.push({ + version: Number(report.version) || Number(entry.name.slice(1)), + items: report.items || [], + questions: report.questions || [], + cost: report.cost || null, + }); + } catch { + /* 报告缺失或写坏了就跳过,不影响看图 */ + } + } + return reports.sort((a, b) => a.version - b.version); +} + +function json(response, status, body) { + const payload = JSON.stringify(body); + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + response.end(payload); +} + +function readJson(request) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + request.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY) { + reject(new Error('body_too_large')); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on('end', () => { + if (!chunks.length) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch (error) { + reject(error); + } + }); + request.on('error', reject); + }); +} diff --git a/plugins/liddalidd/h3-image-executor/miniapp/node/tools/h3-review.mjs b/plugins/liddalidd/h3-image-executor/miniapp/node/tools/h3-review.mjs new file mode 100644 index 0000000..37a2190 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/miniapp/node/tools/h3-review.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// h3-review —— H3-Image-Executor 的"看图材料 / 程序测量"命令行工具(随包发布,零第三方依赖)。 +// +// 存在的意义:评审 agent 不需要每次现写裁图、拼图、量像素的脚本。 +// 所有材料由 harness 按固定版式生成,评审只要 read 一张图就能看全。 +// +// 用法: +// node tools/h3-review.mjs sheet <图片> [-o 输出.png] +// 生成候选看图材料:整体 / 暗部提亮 + 亮部压暗 / 四象限局部放大,共 7 块带编号。 +// node tools/h3-review.mjs refs <图片...> [-o 输出.png] [--per-row N] +// 把多张参考图拼成一张带编号的对比图,用于真人 / 车型 / logo 一致性核对。 +// node tools/h3-review.mjs grid <图片...> [-o 输出.png] [--per-row N] +// 并排对比多张候选,用于选优。 +// node tools/h3-review.mjs stats <图片> +// 输出尺寸、色彩统计、色偏判定、亮度直方图(JSON)。 +// node tools/h3-review.mjs compare <图片> <父图> +// 输出相对父图的色彩漂移、锐度变化、平均像素差(JSON)。 +// +// 评审规范与判定规则见同包文档:<插件根>/skills/h3-image-review/SKILL.md +// +// 约定:成功时 stdout 只输出一个 JSON 对象;失败时 stderr 输出原因并以非 0 退出。 +// 这是 Agent 用 node 单独运行的命令行工具,Mini App 入口(server.mjs)从不 import 它, +// 所以这里的 stdout 是这个子进程自己的,不是 Host 的。 +// 不带 -o 时:sheet 写在输入图旁边,refs / grid 写在当前工作目录。 +import { stat } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { buildCandidateSheet, buildGridSheet, imageStats, compareImages } from '../lib/review.mjs'; + +const USAGE = `h3-review —— 看图材料与程序测量工具 + + node tools/h3-review.mjs sheet <图片> [-o 输出.png] + node tools/h3-review.mjs refs <图片...> [-o 输出.png] [--per-row N] + node tools/h3-review.mjs grid <图片...> [-o 输出.png] [--per-row N] + node tools/h3-review.mjs stats <图片> + node tools/h3-review.mjs compare <图片> <父图> +`; + +function parseArgs(argv) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '-o' || token === '--out') { + flags.out = argv[++i]; + } else if (token === '--per-row') { + flags.perRow = Math.max(1, Number(argv[++i]) || 4); + } else if (token === '-h' || token === '--help') { + flags.help = true; + } else { + positional.push(token); + } + } + return { positional, flags }; +} + +async function ensureReadable(path) { + const info = await stat(path).catch(() => null); + if (!info || !info.isFile()) throw new Error('文件不存在或不是普通文件:' + path); + return path; +} + +async function main() { + const [command, ...rest] = process.argv.slice(2); + const { positional, flags } = parseArgs(rest); + if (!command || flags.help || command === '-h' || command === '--help' || command === 'help') { + process.stdout.write(USAGE); + return 0; + } + + if (command === 'sheet') { + if (positional.length !== 1) throw new Error('sheet 需要且只需要 1 个图片参数'); + const input = await ensureReadable(resolve(positional[0])); + const out = resolve(flags.out || join(dirname(input), 'review-sheet.png')); + return emit(await buildCandidateSheet(input, out)); + } + + if (command === 'refs' || command === 'grid') { + if (!positional.length) throw new Error(command + ' 至少需要 1 个图片参数'); + const inputs = []; + for (const item of positional) inputs.push(await ensureReadable(resolve(item))); + // 不带 -o 时写到当前工作目录。以前默认写到插件根目录,那是 Host 安装的包目录,不该往里写东西。 + const out = resolve(flags.out || join(process.cwd(), command + '-sheet.png')); + const items = inputs.map((path, index) => ({ path, label: command === 'refs' ? 'R' + (index + 1) : String(index + 1) })); + return emit(await buildGridSheet(items, out, { perRow: flags.perRow || 4 })); + } + + if (command === 'stats') { + if (positional.length !== 1) throw new Error('stats 需要且只需要 1 个图片参数'); + return emit(await imageStats(await ensureReadable(resolve(positional[0])))); + } + + if (command === 'compare') { + if (positional.length !== 2) throw new Error('compare 需要 2 个图片参数:<图片> <父图>'); + return emit( + await compareImages( + await ensureReadable(resolve(positional[0])), + await ensureReadable(resolve(positional[1])), + ), + ); + } + + throw new Error('未知子命令:' + command + '\n\n' + USAGE); +} + +function emit(payload) { + process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); + return 0; +} + +main() + .then((code) => process.exit(code)) + .catch((error) => { + process.stderr.write(String(error && error.message ? error.message : error) + '\n'); + process.exit(1); + }); diff --git a/plugins/liddalidd/h3-image-executor/package.json b/plugins/liddalidd/h3-image-executor/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/liddalidd/h3-image-executor/skills/h3-image-executor/SKILL.md b/plugins/liddalidd/h3-image-executor/skills/h3-image-executor/SKILL.md new file mode 100644 index 0000000..cf8cea0 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/skills/h3-image-executor/SKILL.md @@ -0,0 +1,169 @@ +--- +name: h3-image-executor +description: 用户要画图、生成图片、做张图、画漫画/海报/插画/信息图/写真时使用。把需求原样送进 H3-Image-Executor Mini App,跑完整的检索参考图→规划→生成→看图评审→色偏校正→修正循环→选优→4K 交付流水线。用户点名要用别的工具、或者说不想用这个面板时,按用户说的做。 +--- + +# 把画图需求交给 H3-Image-Executor + +## 默认规则 + +**画图需求默认交给 H3-Image-Executor,不要自己在聊天里生图。** + +用户在聊天里说「画一张…」「帮我生成…」「做张图…」「画个漫画…」时,默认不要调 `generate_image`、 +不要加载 `image-generation` skill、不要调别的生图 MCP 工具,也不要自己写 prompt 去画; +你在聊天里要做的是把需求**递进去**。 + +自己生图会绕过这条流水线的全部价值:参考图检索、分镜规划、看图评审、色偏校正、修正循环、 +跨图角色一致性、4K 交付和验收报告,一个都不会有。用户看到的会是一张没有 request_id、 +没法复现、也没法接着调的图。 + +**例外:用户说了算。** + +- 用户点名要用别的工具或模型(例如「用 xxx 画」),或者说不想用这个面板,就按用户说的做。 +- 用户说自己没有 H3-Image 接口、面板提示「未配置生图接口」而用户不打算配置时,告诉用户可以在面板左下角 + 配置接口,或者改用别的生图方式,由用户选。 + +## 怎么做 + +```js +miniapp({ + action: 'open', + pluginId: 'h3-image-executor', + path: '/dashboard?prompt=' + 需求原文 + '&len=' + 字符数 + '&auto=1' +}) +``` + +面板打开后会自己把需求填进输入框并提交,用户看到的是「面板打开 → 已经在画」。 +调用完就可以告诉用户「已经开始了,在右侧面板看进度」,**不要**再去轮询或自己画。 + +## 参数 + +| 参数 | 说明 | +| --- | --- | +| `prompt` | 需求文本。**逐字传递用户说的话**,不要改写、扩写,不要替用户补风格词 | +| `auto` | `1`(默认)填完直接提交;`0` 只填不提交,留给用户自己按发送 | +| `session` | 会话 ID(如 `20261109-877482`)。带上它就是**接着调那张图**,不带就是开新的一张 | +| `len` | `prompt` 的字符数(按字符算,不是字节)。面板会比对,对不上就在需求气泡上挂一条「需求可能在传输中损坏」,**不拦截**,只是提醒用户核对 | + +## 不要对中文做 percent 编码 + +`prompt` **原样拼进 query**。不要自己把中文转成 `%E6%A9%98` 这种形式。 + +宿主会自己完成编码:`橘猫趴在窗台上晒太阳` 原样传进去,地址栏会变成 +`?prompt=%E6%A9%98%E7%8C%AB...`,页面解出来与原文完全一致。 + +手工编码是错字的主要来源:你没有 JS 引擎,只能凭记忆把每个字换算成 UTF-8 字节, +错一两个字页面上看不出任何异常,错字会一路画到交付。比如 +`猫`(U+732B,应为 `E7 8C AB`) 被写成 `E7 8C AA` = `猪`(U+732A), +需求就变成「橘猪趴在窗台上晒太阳」,整张图白画。 + +只有这 4 个 ASCII 字符必须编码——宿主不会替你转,它们会破坏 query 结构: + +| 字符 | 写成 | 不编码的后果 | +| --- | --- | --- | +| `&` | `%26` | 后半截变成另一个参数,需求被截断 | +| `+` | `%2B` | 被解成空格 | +| `#` | `%23` | 变成 fragment,工具直接拒绝 | +| `%` | `%25` | 畸形百分号转义,工具直接拒绝 | + +`=` 不用管,值里的 `=` 能完整保留。 + +## 三条纪律 + +1. **需求原文逐字传。** 流水线第一步就是规划,用户的话会被拆成 prompt;你先「帮忙润色」一遍, + 等于把用户的原意换掉,而且事后没人知道改了哪。缺信息(尺寸、张数、风格)就问用户, + 不要自己替他决定。 + +2. **可以先查资料,但只把需求说清楚,不要把资料塞进 prompt。** + 比如「把今天的天气预报画成一张四格漫画」:可以先 `web_search` 弄清今天的天气, + 然后传一句绘画需求进去(说明画什么、什么形式)。 + 不要把新闻正文、检索结果整段拼进 `prompt`——那是规划器该消化的输入格式,不是给人写的。 + +3. **一次 `open` 就是一次作画。** 用户要四张图,流水线会自己拆成四张并锁定角色一致性, + 不要手动开四个会话,也不要分四次调用。 + +## 用户接着提修改 + +用户在聊天里说「把背景换成夜景」「再加一张」时,如果指的是刚才那张图,带上 `session`: + +```js +miniapp({ + action: 'open', + pluginId: 'h3-image-executor', + path: '/dashboard?session=' + sessionId + '&prompt=' + 修改要求 + '&len=' + 字符数 + '&auto=1' +}) +``` + +不带 `session` 会开成一张全新的图,用户会以为上一张白画了。 + +## 交付之后 + +面板里会出验收报告和每张图的 `request_id`。用户问「画得怎么样」「用的什么 prompt」 +「为什么这张失败了」,去读会话目录: + +``` +<插件数据目录>/sessions//session.json +``` + +- `turns[].events[]` —— 每个阶段的进展,带 `stage` 和 `requestId` +- `images[]` —— 每张图的 `id` / `status` / `failReason` / `requestId` +- `status` —— `delivered` / `failed` / `running` / `cancelled` + +不要把 `request_id` 截断或省略,用户要拿它去查账。 + +## 分享页与公开链接 + +每次交付都会自动把一份自包含的分享页落到: + +``` +<插件数据目录>/sessions//share/index.html +``` + +**面板上的「发布公开链接」按钮会自己完成发布,你不需要再手动部署。** +用户点一下,按钮变「发布中…」,服务端起一个 Agent turn 去调 `website_deploy`。 +这次调用**必须由用户在面板上点「允许一次」**才会执行:面板不会替用户放行,你也不要替用户回答。 +好了之后链接会自动写进剪贴板——用户直接粘给别人就行。按钮下面常驻一行 +「发布后任何拿到链接的人都能打开……」,写明了页面会公开哪些内容,这是用户点下去之前能看到的告知,不要把它删掉。 + +为什么能这么做:Mini App 的 `context` 只有 `agent / capabilities / hostConnector / +listen / logger / tool`,没有任何 drive / upload / storage 能力,页面和服务端自己做不到部署; +而 `website_deploy` 是 Host 的顶层控制工具,只下发给 Agent turn。所以唯一通路就是 +`context.agent.run`(见 `miniapp/node/lib/publish.mjs`)。 + +**你不要主动替用户发布。** 用户没点按钮、也没明确说要公开链接时,不要自己调 +`website_deploy`——公开发布是不可逆的外向动作。 + +### 什么时候还需要你介入 + +- **按钮发布失败**,用户让你排查。看会话事件流里 `stage=share` 的记录,失败原因会写在 + `shareError` 上(`publish_timeout` / `publish_no_url` / `publish_turn_failed` / `publish_asked_question` / + `publish_aborted` / `publish_not_permitted`)。`publish_timeout` 最常见的原因是用户没在面板上点「允许一次」。 +- **用户要重新发布或换站点。** 这时你自己调 `website_deploy`: + + ```bash + mkdir -p <工作区>/h3-share/ + cp <插件数据目录>/sessions//share/index.html <工作区>/h3-share// + ``` + + `website_deploy` 只接受当前工作目录下的路径,直接传插件数据目录会被拒: + `Cannot deploy: '...' is outside the workspace or invalid`。 + 要更新已有站点就传 `node_id`,那会**整体替换**线上内容;不传就是新建一个站点。 + 这两种结果不对称,先问用户要哪个。 + + 部署前核一遍这份 HTML 不含绝对路径、Key 和远程资源,三项都必须是 0: + + ```bash + python3 -c "import re;s=open('<路径>/index.html',encoding='utf8',errors='replace').read();\ + print('abs',len(re.findall(r'/Users/|[A-Za-z]:\\\\\\\\Users',s)),'key',len(re.findall(r'sk-[A-Za-z0-9_-]{16,}',s)),\ + 'remote',len(re.findall(r'https?://[a-z]',s)))" + ``` + + 拿到 URL 后直接告诉用户。面板只显示它自己发布出去的链接,不会显示你手动部署的这一个。 + +分享页体积通常 3–7 MB:内嵌图统一走 JPEG,每张交付图另带一份长边 2048px 的大图(点开时显示, +总量上限 4 MB,超出的几张点开仍是预览大小)。仍然偏大时告诉用户「这批图太多,分享页偏大」,不要闷头重试。 + +## 需要人工复核生成图时 + +流水线自己会做看图评审。确实需要你亲自看图时,用 `h3-image-review` skill 里的 +`h3-review` 命令产出固定版式的看图材料,不要自己写裁图、拼图、量像素的脚本。 diff --git a/plugins/liddalidd/h3-image-executor/skills/h3-image-review/SKILL.md b/plugins/liddalidd/h3-image-executor/skills/h3-image-review/SKILL.md new file mode 100644 index 0000000..eb2e914 --- /dev/null +++ b/plugins/liddalidd/h3-image-executor/skills/h3-image-review/SKILL.md @@ -0,0 +1,99 @@ +--- +name: h3-image-review +description: 评审、对比、测量 MiniMax-H3-Image 生成的图片;自带 h3-review 命令行工具产出固定版式的看图材料,评审只需读一张图就能看全整体、曝光与局部,不需要自己写裁图、拼图、量像素的脚本。 +--- + +# H3 生成图评审 + +## 先记住三条 + +1. **不要自己写测量脚本。** 尺寸、色偏、锐度、过曝、像素差都有现成命令,直接调。 +2. **不要自己裁图拼图。** `sheet` 会把整体、曝光两联、四象限局部放大拼成一张带编号的图,读一次就够。 +3. **判 fail 要有证据。** 指不出"在哪一块看到什么"就不能判 fail;拿不准记 uncertain 并放行。 + +## 工具位置与用法 + +工具随 H3-Image-Executor 插件一起发布,零第三方依赖(只用 Node 内置 + 自带的 PNG/JPEG 解码器): + +```bash +node "/miniapp/node/tools/h3-review.mjs" <子命令> [参数] +``` + +| 子命令 | 用途 | 输出 | +| --- | --- | --- | +| `sheet <图片> [-o 输出.png]` | 生成候选看图材料(7 块带编号) | `{sheet,width,height,layout}` | +| `refs <图片...> [-o 输出.png] [--per-row N]` | 把多张参考图拼成一张带 R1/R2… 编号的对比图 | `{sheet,items}` | +| `grid <图片...> [-o 输出.png] [--per-row N]` | 并排对比多个候选,用于选优 | `{sheet,items}` | +| `stats <图片>` | 尺寸、色彩统计、色偏判定、亮度直方图 | JSON | +| `compare <图片> <父图>` | 相对父图的色彩漂移、锐度变化、平均像素差 | JSON | + +成功时 stdout 只有一个 JSON 对象;失败时 stderr 给原因并以非 0 退出。 + +不带 `-o` 时:`sheet` 写在输入图旁边,`refs` / `grid` 写在当前工作目录。输出路径在返回的 JSON 里。 + +## 标准评审流程 + +1. **备料**:对每个候选跑 `sheet`,对参考图集合跑 `refs`。 +2. **读图**:用 read 打开每个候选的 sheet 和那张 refs sheet。**只读这些,不要去读原始大图。** +3. **采信程序结论**:`stats` / `compare` 给的尺寸、色偏、锐度、过曝直接当事实,不要用眼睛推翻。 +4. **逐条判清单**,然后给结论。 + +## 看图材料的版式 + +`sheet` 产出的图自上而下三段,每块左上角有数字标签,可以直接引用编号: + +- **1** = 整体预览 → 看构图、主体、氛围、主体数量 +- **2** = 暗部提亮(左) → 看暗处有没有藏东西、多余物体、水印 +- **3** = 亮部压暗(右) → 看高光有没有溢出死白 +- **4 / 5 / 6 / 7** = 局部放大,依次取自左上、右上、左下、右下象限中心 → 看畸形、糊字、接缝、材质、手部 + +## 判定规则 + +**hard 项(必须满足)** + +- 只有能在材料图上指出一个明确的、看得见的缺陷时才判 `fail`。 +- note 必须写清楚:**在哪一块(引用编号)+ 看到什么**。指不出来就不能判 fail。 +- 判不准用 `uncertain`,**uncertain 记 pass**,note 写清楚哪里拿不准。 +- 常见误判:把"不够惊艳""构图不是我最喜欢的那版""风格还可以更好"写成 fail。这些是 soft。 + +**soft 项(偏好)** + +- 只影响 quality 分(0-10),不影响通过与否。 + +**一致性核对(真人 / 车型 / logo)** + +- 对照 `refs` sheet 判。认识的人一眼能认出才算 pass。 +- 但"认不出"必须是**明确的五官/车型特征对不上**,不是"感觉不太像"。后者记 uncertain。 + +**图内文字** + +- 在 4/5/6/7 局部块上看字形。糊成一团、缺笔画、写成别的字 → fail。 +- 文字太小看不清 → 先看 `stats` 的尺寸确认是不是分辨率问题,再决定 fail 还是 uncertain。 + +## 反过度评审 + +过度打回的代价是整轮重做(几十秒到几分钟),远大于放过一次小瑕疵的代价。所以: + +- 工具调用控制在 5 次以内:每个候选的 sheet + 一张 refs sheet,看完立刻给结论。 +- 不要为了"稳妥"把 uncertain 写成 fail。 +- 不要提清单之外的新要求。清单之外的观察写进 summary,不作为打回理由。 +- 不要联网搜索、不要跑统计脚本、不要裁图量像素。 +- note 不超过 30 字,summary 不超过 40 字。 + +## 输出格式 + +```json +{ + "verdicts": [ + { + "candidateId": "A-r1-g0", + "checks": [{ "id": "H1", "result": "pass|fail|uncertain", "note": "第 5 块:右手六指" }], + "quality": 7.5, + "summary": "..." + } + ], + "next": { "type": "regenerate|edit|give_up", "candidateId": "...", "prompt": "...", "reason": "..." } +} +``` + +所有 hard 都通过时,`next` 直接写 `{"type":"give_up"}`。