Most "chat with images" flows assume the image is already finished when you send it. Mine usually isn't: I want to circle a face and say "this expression, but warmer", or mask a product and say "re-shoot it on a wooden table". Typing that description is slower than pointing at it.
So the plugin I built for DeepSeek Harness, dsh-img2img-config, adds a full-screen editor that opens on the images already staged in the composer. The editing itself was the easy part — canvas, markers, brush mask, resize. The interesting problem was the hand-off: how do the edits reach the model without turning the composer into a JSON dump?
This post is about that hand-off, plus the two surfaces the plugin ships and the host half that wires it to real provider credentials.
Two surfaces, one bundle
The package is one npm artifact with a browser half and a host half:
-
Browser half (
./client): the composer dock + editor, and a Settings panel. -
Host half (
./index.js): the provider store and HTTP routes behind/dsh-img2img-config/*.
Installation is a bundle patch — the profile gets a row pointing at the package:
# cordis.patch.yml
- insert:
- id: dsh-img2img-config
name: 'dsh-img2img-config'
and package.json declares both halves:
{
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
}
}
That dsh.bundle field is the part people forget. A plugin that only declares dsh.client is not installable — the client half has nowhere to attach. (I learned this the hard way; more in the last section.)
The dock renders nothing most of the time
The editor is a dock entry on conversation.input.dock. The rule that matters for a crowded composer: the strip renders nothing while the session holds no image drafts, so it never competes with the queue / todo / goal docks that may already be there. When there are image drafts, each one gets a chip; clicking a chip opens the editor.
Confirming the editor does three things:
- exports the edited files (
edited.png, plusmarked.pngwhen markers exist, plus a binary brush mask when strokes exist), - stages them as draft attachments, replacing the edited source,
- hands the brief to the composer.
Step 3 is where the design questions are.
Hand-off, three options
If you read the image plus the instructions as "one message to the model", there are three shapes:
- Write the instructions into the draft as text. Simple, but users hate it: a wall of coordinates in the input box they have to scroll past.
- Attach the images and say nothing. The model gets pixels, not intent. "Warmer" doesn't survive.
- Carry the instructions out-of-band and attach them as a file. The model still reads them; the human still gets a clean composer.
I went with a variant of 3 plus a reference chip. The composer shows one chip labelled with the edit summary. The chip owns a payload — the full brief text — and the model never sees the chip: at submit, the chip's codec expands it.
// src/client/brief-source.ts (trimmed)
export function briefSource(): TriggerSourceFace {
return {
trigger: '@',
name: 'img2img-brief',
order: 900,
showGroupTitle: false,
candidates: () => Promise.resolve([]), // no menu rows
onPick: () => undefined,
codec: {
// Projection used for copy, draft persistence, and "flatten to text"
clipboardText: (ref) => {
const first = ref.split('\n', 1)[0]
return first === undefined || first === '' ? BRIEF_MARK : first
},
// The payload already IS the model text
serialize: (ref) => Promise.resolve(ref),
},
}
}
Two details make this work in practice:
-
The first line is the marker. Every brief starts with
[img2img] 图片编辑请求 · img2img-studio. That line is short, language-neutral as an identifier, and is exactly what the chip flattens to when the draft is persisted or copied. The rest of the brief never enters the draft. -
Re-editing replaces, it doesn't stack.
mergeDraft()removes the previous[img2img] … [/img2img]block (and the legacy<!-- img2img-editor:begin -->block from an older version), then appends the new brief. Your own typed text is left alone.
The same facts are also attached as a machine-readable file, img2img-manifest.json, next to the exported PNGs. So the model can read pixels and fields:
{
"version": 1,
"createdAt": "2026-10-27T09:12:44.183Z",
"source": { "name": "table.png", "width": 1600, "height": 1200 },
"size": { "width": 1024, "height": 1024, "mode": "cover", "changed": true },
"bgRemove": { "requested": true, "localPreview": false, "tolerance": 18 },
"erase": { "strokes": 2, "prompt": "replace the price tag with the same wood grain" },
"markers": [
{ "id": 1, "x": 0.412, "y": 0.238, "text": "keep the glaze highlights here" }
],
"files": { "edited": "edited.png", "marked": "marked.png", "mask": "mask.png" }
}
Marker coordinates are normalized percentages with the origin at the top-left, so a model that works in relative terms doesn't need the source resolution. And the brief closes with an explicit instruction to the generation provider:
按标记与掩码做局部重绘,再做整体 i2i;标记与掩码只用于定位,不要出现在成图里。
(Local redraw per mark and mask first, then a whole-image i2i. Marks and masks are for localisation only — never let them into the output.)
That last sentence is doing real work. Without it, models happily draw the numbered pins into the final image.
Degrading instead of crashing
The chip path depends on services owned by the harness (inputTriggers, conversation, session). Those are not guaranteed to be present, or present at the moment this plugin applies — a batched client boot can materialise a service after your apply. So the client half resolves them lazily and falls back:
-
No trigger registry (older client build, moved service): the same brief is written into the draft as text between
[img2img]and[/img2img]. Re-editing replaces that block instead of stacking another one. -
Service appears late: register the codec source in
ctx.inject(['inputTriggers'], …)rather than reading it once duringapply. A one-shot read on such a boot finds nothing and silently leaves an unexpandable chip behind. - Draft attachments unavailable: keep the dock, but the confirm path drops to the draft-line carrier.
None of these paths crash; they get less pretty. That's the trade I wanted: the editor is a convenience layer on top of the composer, not a new failure mode for it.
The settings surface: from a panel to real credentials
The second surface is a Settings panel that configures both halves of the toolchain: vision (dsh-vision-skill) and generation (this skill). The panel talks to the host half through the plugin's own routes, and the host owns a provider store at ~/.dsh/dsh-img2img-config/providers.json.
What the panel actually manages, briefly:
- Three priority tiers (main / backup / fallback) with drag ordering — so "try ChatGPT web, then DashScope, then a local fallback" is a configuration, not code.
- Per-channel model fallback chains — a provider can list several models; the script walks them on failure.
- Arrears-aware probing — providers that report insufficient balance can be auto-disabled instead of retried into a wall.
- A ChatGPT-web account store — the channel that drives a real browser session, with per-account quota state.
Saving writes each provider back into the matching skill's scripts/.env, so the skill's own scripts pick the settings up with no extra step. The mapping — which keys to write, which to delete, which to leave alone — lives in one planSync() function, which is also exported to an offline regression script (scripts/verify-catalog.mjs). That last part matters: the panel can be tested without a browser.
What shipping it actually took
The code was the fun part. Distribution was the humbling part:
-
dsh.clientalone is not installable. The first version declared only the client half; the plugin resolved, but had nowhere to attach.dsh.bundle+cordis.patch.ymlis what makes it a bundle. -
Guessed peer upper bounds are a silent killer. I had
>=4.0.0-rc <5on the host package. That's a promise to be refused on the next major release — with no log line, because the harness refuses before importing your module. I dropped the upper bounds and pinned "no<in peer ranges" with a CI check instead. - Publishing needed three gates. Security-key 2FA, provenance (only from CI, via OIDC), and staged publishing for a brand-new package name. I wrote that one up separately: Three gates before your first npm publish.
-
Storefront metadata lives in your repo. A
screenshots.jsonnext topackage.json(1–8 images, relative paths or GitHub-hosted URLs) is what gives the plugin page App Store-style screenshots; without it, the storefront falls back to scraping your README.
What I would change next
- Make the brief locale-aware. The brief is written in Chinese today because the primary user is; a model-facing text probably should follow the session language, not the author's.
-
Version the manifest schema explicitly.
version: 1is there, but there's no migration path yet for when the editor grows a new operation. - Two carriers, one truth. The chip and the manifest file carry the same facts; I'd like to derive one from the other in exactly one place rather than keeping both builders in sync by hand.
The point of the plugin was to make "point at the image and describe the change" feel like part of writing, not a separate app. The dock and the chip get most of the way there; the degradation paths are what make it survive contact with a host that is still moving.
Package: https://www.npmjs.com/package/dsh-img2img-config
Repository: https://github.com/Dogwind221/img2img-studio/tree/main/plugins/dsh-img2img-config
Market entry: https://awesome-dsh-plugin.com/p/Dogwind221/img2img-studio--plugins-dsh-img2img-config/
Top comments (0)