dsh-deepseek-webchat
A DSH sidebar plugin that shows the real
chat.deepseek.com web app next to your session,
binds one DeepSeek conversation to each DSH Session, and lets you push selected
messages into the DeepSeek composer as a blockquote — which you then send
yourself.
No API key, no proxy, no scraping. It embeds the actual website through the
desktop shell's native browser guest, the same channel DSH's built-in side-card
browser uses.
What it does
| |
|---|
| Real web app | chat.deepseek.com itself, in a sidebar tab. Not an iframe, not the API. |
| Login persists | Sign in once; the plugin restores your session on later launches. |
| One conversation per DSH Session | Each DSH Session gets its own DeepSeek conversation, remembered by id. |
| Follows the session you switch to | Switch DSH Sessions and the sidebar follows to that Session's conversation. |
| Manual context push | Tick rows, add a question, preview it, then fill and send. |
| Questions included | The agent's questions to you, their options, and your answers are quotable too. |
| Todo list included | The agent's todo list is quotable without turning anything on. |
| One click to send | 「填入并发送」 fills the composer and submits it; 「仅填入」 stops there. |
| Bulk selection | Select all, clear, or take the last 5/10/20/50 rows in one click. |
| Advanced opt-in | A switch reveals thinking, tool calls and results — off by default. |
| Explained context | An optional note tells DeepSeek what the quotes are and where they came from. |
| No back-flow | DeepSeek's answers are never written back into your DSH conversation. |
Install
Requires DSH Desktop 0.2.0-rc.1 or newer, and a profile that is already
initialised (the desktop profile is created by the app on first run).
dsh plugin --profile desktop add dsh-deepseek-webchat
Then restart DSH Desktop. The tab appears in the sidebar column's guide, and a
chat icon in the conversation header opens it directly.
lib/ is committed to this repository on purpose, and there is no prepare
script, so the install needs no build step and no pnpm allowBuilds
approval.
Installing from a local clone instead
git clone https://github.com/Ckarefulon/dsh-deepseek-webchat
dsh plugin --profile desktop add /absolute/path/to/dsh-deepseek-webchat
Uninstalling
dsh plugin --profile desktop remove dsh-deepseek-webchat
Your bindings and saved login live in ~/.dsh/dsh-deepseek-webchat/ and are
left in place; delete that directory to remove them too.
Troubleshooting: DSH will not start after installing
DSH shows "The application could not start or stopped unexpectedly" and
refuses to open. The crash log (the path is shown in the dialog, under
%APPDATA%\@deepseek-ai\dsh-desktop\logs\) contains
client-modules: client bundle not found.
That means the installed copy's exports["./client"] does not point at a real
file. Fix it by reinstalling the plugin from a commit that has the flat layout:
dsh plugin --profile desktop remove dsh-deepseek-webchat
dsh plugin --profile desktop add dsh-deepseek-webchat
To confirm the layout before restarting, from the installed package directory:
node -e "const p=require('./package.json');const r=p.exports['./client'].default;console.log(r, require('fs').existsSync(r))"
It must print ./lib/client.js true. If it prints false, do not restart DSH —
the boot will abort again.
The dialog's "Disable third-party plugins, back up profile patch, and
restart" button also works: it starts DSH with third-party plugins unmounted
and saves your profile patch alongside as cordis.patch.yml.bak-<timestamp>.
That backup is your configuration, not the plugin, so keep it.
Using it
- Open the tab. Click the chat icon in the conversation header, or pick
「DeepSeek 网页版」 from the sidebar column's guide.
- Sign in to DeepSeek the first time. It is a normal browser login, inside
the panel.
- Send one message in the DeepSeek page. DeepSeek creates a conversation
lazily, so this is the moment its id exists — the plugin notices and binds it
to the current DSH Session. The toolbar dot turns green.
- Quote context. Press 「引用上下文」. The list opens scrolled to the newest
rows. Tick what you want — or use 「全选」, 「清除」, or the number box plus
「选末尾」 to take the last few in one go — optionally type a question or
instruction, and check the preview. The list holds your turns, the assistant's
replies, the agent's todo list, the questions it asked you (with the
options it offered), and your answers to them.
- Press 「填入并发送」. The text goes into DeepSeek's composer and is
submitted for you; the panel returns to the page and the selection clears, so
the next push starts from a clean slate. Use 「仅填入」 instead if you would
rather read it over in the composer first.
- That is the send. The plugin watches the composer to confirm it went out,
and says so — or tells you plainly that it did not, so you can press Enter
there yourself.
The toolbar also has 「刷新」 (reload the page) and 「解除绑定」 (forget this
Session's conversation, so the next visit starts a new one).
Switching to a different DSH Session moves the panel to that Session's
conversation automatically.
What gets sent, and how
Every push is still something you asked for: the plugin fills the composer and
submits it only when you click 「填入并发送」. There is no timer, no
background upload, and nothing is sent merely because the panel is open.
Sending goes through DeepSeek's own composer handler rather than a synthetic
shortcut invented here. Its composer runs an onKeyDown that, for a plain
Enter, calls the same function its send button calls — so the plugin dispatches
a bubbling Enter at the textarea and lets DeepSeek do the rest. (Its button's
class name is a build-time hash, so guessing at it would break on any rebuild.)
Because a synthetic key press could in principle be ignored, the plugin does not
assume it worked. DeepSeek clears its textarea once a send is accepted, so the
plugin reads the composer back: cleared means sent. If it still holds text, the
plugin falls back to clicking DeepSeek's own send button, and if that fails too
it says so instead of pretending — leaving the text sitting in the composer for
you to send by hand.
「附上说明」 (on by default) puts a short note in front of the quoted rows:
以下是我在另一个 AI 助手(DSH)里的对话片段,引用给你作为背景参考。以「> 」
开头的行是原文,【】里标出的是这段内容属于谁…
Without it, DeepSeek receives a wall of quotes and 【】 labels with no
explanation of what they are. Turn it off if you would rather send the raw
quotes.
The advanced switch
By default the picker lists the conversation as a person reads it, plus the
agent's todo list. Tick 「高级:包含思考过程与工具调用」 to also list the
machinery behind it: the model's reasoning blocks, its tool calls, the tool
results, slash commands, and compaction summaries — including tool output that
may contain file contents. It is off on every open, and the host does the
filtering, so an advanced row is never even sent to the browser unless you asked
for it.
Each row is marked with an icon from the shell's own icon set plus a short
label, so a quoted answer and a quoted tool result stay distinguishable in the
blockquote:
> 【提问】Confirm:Which layout?
> · Sidebar — right column
> · Center
> 【回答】Sidebar, please
> 【待办】[x] write the picker
> [~] test it
Privacy
This plugin is built so that nothing leaves your machine unless you press a
button, and so that the things you would least want to leak are never even
offered.
What is sent, and when. Only the rows you tick, and only when you click
「填入并发送」 (or 「仅填入」, which stops at the composer). Both are explicit
actions; the plugin has no timer and no background upload.
What is never offered. The host half filters the session log before it
reaches the picker, dropping in both modes:
system/message and developer/message events,
- injected
user/message events — agent instructions, skill catalogs, goal
rounds, time context, compaction checkpoints, team and webhook deliveries,
which DSH records as user messages but which you never typed. Only events whose
source.kind is user (your turns) or user-question-reply (your answers to
the agent) are treated as yours,
- approval records, request headers, model and sandbox selections.
What is off by default. The model's private reasoning blocks, tool calls and
tool results are not offered until you turn on the advanced switch. When you
do, remember that a tool result can contain file contents the agent read — that
is exactly the kind of thing the default set keeps out, and the switch is your
explicit say-so. A todo list is the exception: it is the agent's own plan, which
you are already reading on screen, so it is offered by default.
So a system prompt, an API key in your environment, or a file the agent read
cannot be quoted by accident.
What is stored locally. Two JSON files in ~/.dsh/dsh-deepseek-webchat/:
bindings.json — the DSH Session id → DeepSeek conversation id map. No message
content, ever.
session.json — the guest's DeepSeek login state (its site storage and
non-HttpOnly cookie string), so you stay signed in. This is a credential;
treat the file as you would a browser profile. It is written with the same
permissions as any other file your user creates.
No telemetry. The plugin makes no network request of its own. The only
traffic is the DeepSeek web app talking to DeepSeek, inside its own guest.
The page runs sandboxed. The desktop shell approves the guest with
nodeIntegration: false, contextIsolation: true, sandbox: true and
webSecurity: true, and refuses every permission request. The page cannot reach
your files or your DSH process.
Known limitations
These are real, and worth knowing before you rely on the plugin.
HttpOnly cookies cannot be restored. The DSH Host runs as a separate Node
child process, not inside Electron, so the plugin cannot reach Electron's cookie
store. It can only read document.cookie from inside the guest, and HttpOnly
cookies are invisible there by design. In practice DeepSeek keeps its credential
in localStorage.userToken and sends it as a bearer token, so replaying site
storage is what restores your login — but if DeepSeek ever moves its credential
to an HttpOnly cookie, this plugin will start asking you to sign in again after
each restart.
The guest partition is per-run. The shell hands out a process-lifetime
session partition and the plugin cannot ask for a different one. Login survival
is therefore the snapshot-and-replay above, not a persistent browser profile.
One DeepSeek login, shared. Every DSH Session shares a single DeepSeek
account and browser session; the per-Session split is the conversation, not the
login. Separate DSH Sessions do not get separate DeepSeek accounts.
Binding happens on the first message. DeepSeek creates a conversation only
when you send something, so a DSH Session stays unbound until you send at least
one message in the page. Until then the toolbar dot is hollow.
DeepSeek can change its page. The composer fill targets
textarea.ds-textarea__textarea, and the conversation id is read from DeepSeek's
/a/<agent>/s/<id> route. If DeepSeek redesigns either, the fill or the binding
stops working until this plugin is updated. The rest of the plugin — embedding,
login, per-Session tabs — is unaffected.
Desktop only. The embedded page needs the desktop browser guest channel. In
a plain dsh web profile the panel says so instead of falling back to an iframe
(which DeepSeek would refuse anyway, via frame-ancestors 'none').
One DeepSeek tab per DSH Session. The tab kind is single-instance, so you
cannot open two DeepSeek panels side by side for the same Session.
How it works
Architecture
Two halves, as DSH plugins are:
Host half (lib/index.js, exports .) runs in the DSH Host process and
serves five routes under /api/dsh-deepseek-webchat/:
| Route | Purpose |
|---|
state | What the panel needs to describe itself. |
messages?sessionId=[&advanced=1] | The Session's quotable rows, filtered. |
binding | Read/write/forget the DSH→DeepSeek conversation map. |
session/restore | Hand back the saved login state. |
session/save | Store the guest's login state. |
It reads messages through ctx.sessionQuery.readSession(), which is
live-preferred, and reduces the raw event log with the same text-extraction
rules the harness itself uses.
The advanced flag is honoured host-side, so the default response never
contains a reasoning or tool row at all — the browser half cannot accidentally
reveal one it was never given. The picker re-requests the list when the switch
flips rather than filtering locally.
Rows carry a kind alongside role: user and assistant for dialogue,
question and answer for the agent's questions and your replies (default),
then reasoning, tool-call, tool-result, todo, command and summary
(advanced). A question row is read from the tool/call event whose
name === 'ask_user_question', parsed with the same questionsOf shape the
harness's own user-questions service uses; the answer arrives as an ordinary
user/message tagged source.kind === 'user-question-reply', which the plain
human filter would otherwise drop.
Browser half (lib/client.js, exports ./client) registers the sidebar tab
type, its body, and a header door.
The guest, and why it is not an iframe. chat.deepseek.com sends
Content-Security-Policy: frame-ancestors 'none', so an <iframe> is refused by
the page. Instead the bundle asks
for a reservation and attaches a whose is
and whose is the issued one. The shell's
matches the lease, applies hardened preferences, and lets the navigation
through.
Development
Plain JavaScript ESM. No TypeScript, no bundler, no dependencies.
npm run build # copy src/ -> lib/
npm test # node --test
lib/ is a byte-for-byte copy of src/ and is committed, because a
git-hosted plugin must not need a build step on install. npm test asserts the
two still match, so they cannot drift.
After editing src/, run npm run build and commit both trees.
src/index.js host half -> lib/index.js
src/client/index.js browser half -> lib/client.js (note: flat, not lib/client/index.js)
scripts/build.mjs the copy
test/index.test.js host: urls, message filtering, bindings, snapshot, mirror
test/routes.test.js host: every HTTP route, driven through apply()
test/client.test.js browser: bundle load, registrations, fill + send, row icons
Two checks worth running before a restart
A broken manifest does not fail loudly at build time — it aborts the Host boot,
so DSH simply never opens. Both checks are standalone: no dependencies, no
node_modules, nothing to install.
npm run check:bundle # this repo
node scripts/check-client-bundle.mjs <packageRoot> # any DSH plugin
node scripts/check-profile-patch.mjs <patch.yml>
check-client-bundle.mjs replicates resolveMeta() from
@deepseek-ai/dsh-client-modules verbatim: it joins exports["./client"] onto
the package root and calls statSync on the result, with no extension
completion and no directory-index fallback. A missing file throws
MissingClientBundleError, and because modules is a required plugin the whole
Host boot aborts. It also checks the neighbouring invariants — that the bundle
declares its own module/exports (the loader passes require only), returns
module.exports, and registers its own id. This is the check that reproduces
the startup abort on the old lib/client/index.js layout.
check-profile-patch.mjs parses a profile cordis.patch.yml the way the
loader must: YAML with the !!js custom tag treated as an opaque scalar. It
prints every entry id, so a patch can be reviewed before it is trusted — which
is what you need after DSH's crash dialog has replaced a full patch with a
minimal one. js-yaml is not a dependency of this repo; it is resolved from an
installed DSH profile, or point DSH_JS_YAML at one.
Why the browser half is lib/client.js and not lib/client/index.js
This is load-bearing, and getting it wrong makes DSH fail to start at all.
DSH resolves a package's browser half by joining exports["./client"] onto the
package root and reading the result as an exact file path — there is no
extension and no directory-index fallback. If the manifest says
./lib/client.js while the build writes ./lib/client/index.js, the client
module registry throws MissingClientBundleError while the Host is starting,
and because that registry is a required plugin the whole boot aborts. The
window shows "The application could not start or stopped unexpectedly" and
names client-hmr as waiting on clientModules; the crash log reports:
client-modules: client bundle not found; run `pnpm run build` before launch:
package: <name>
path: .../lib/client.js
Every package DSH ships uses the flat ./lib/client.js form. npm test asserts
that every path in exports exists and is a regular file, so this cannot
regress.
If you hit that dialog, your installed copy is stale. Reinstall (see
Install) after pulling the fixed commit.
Publishing
This package is published to npm as dsh-deepseek-webchat. package.json
carries publishConfig (access: public, registry: https://registry.npmjs.org/),
so a plain npm publish targets the right registry with no extra flags — even
though a local ~/.npmrc may point at a mirror.
Later releases go through .github/workflows/publish.yml, which authenticates
with npm Trusted Publishing (OIDC): GitHub mints a short-lived token for the
job and npm exchanges it for a publish token scoped to this one package. There
is no NPM_TOKEN secret anywhere. The workflow deliberately does not use
actions/setup-node's registry-url input — that writes
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} into ~/.npmrc, and with
no such variable defined the placeholder survives as a literal string that npm
would send as the token. publishConfig.registry already names the registry.
Release procedure:
- Bump
version in package.json, run npm run build, and commit src/ and
lib/ together (npm test fails if they differ).
- Publish a GitHub Release for that commit. The workflow runs the suite and
then
npm publish; npm refuses to republish a version that already exists,
so a re-run cannot clobber what is live.
One-time setup: attaching the trusted publisher
A trusted publisher can only be attached to a package that already exists on
the registry, so the very first release is published by hand (npm publish
from a checkout); only afterwards does the workflow take over. On npmjs.com:
package → Settings → Trusted Publisher → GitHub Actions, then
| Field | Value |
|---|
| Repository | Ckarefulon/dsh-deepseek-webchat |
| Workflow filename | publish.yml |
| Environment | (leave blank) |
License
MIT. See LICENSE.
Not affiliated with DeepSeek. "DeepSeek" is a trademark of its owner.