Repository navigation
Releases: yupaoa/dsh-persona-switcher
Release list
0.3.4 — 输入栏人设按钮与「当前无人设」修复
中文说明
- 输入栏人设按钮:在权限与表情按钮之后、模型选择器之前新增「人设」入口,可直接搜索并切换当前会话角色;未绑定时显示「人设」,绑定后显示角色名。支持名称 / id / 描述搜索、当前角色勾选、键盘操作、失败重试与空库提示。
- 设置页开关:设置 → 人设切换 → 常规新增「输入框」卡片,可关闭输入栏的人设选择器。这是本机偏好,刷新与跨窗口保持,不影响
/role命令或已绑定人设。 /role none修复:切到none后立绘现在会立即消失;重启恢复、切换会话或继承默认角色时也不会残留旧立绘。- 明确的「当前无人设」段落:
/role none不再只清空人设段,而是明确告诉模型——部署人设才是当前身份,角色目录只是菜单,不能从目录里挑一个角色来扮演。修复切到none后模型有时自称阿米娅、有时自称鲸鱼娘的问题。 - 文档与版本:README 整理,补充 0.3.4 变更说明与输入栏开关文档。
English
- Composer persona picker: a new Persona button after permissions / reactions and before the model selector. It searches and switches the current session's role, showing the role name when bound and Persona otherwise, with search by name / id / description, current-role tick, keyboard support, retry and empty-library states.
- Settings toggle: Settings → Persona Switcher → General now has a Composer card to hide the picker. This is a per-browser preference that survives refreshes and syncs across windows;
/roleand an already-bound persona are unaffected. /role nonefixes: the mascot now disappears immediately, including after restart, session switches, or when a default role was inherited.- Explicit no-role section:
/role noneno longer leaves the persona section empty. It states that the deployment persona is in force and that the role catalogue is a menu, not a list of candidate identities. This fixes the model occasionally answering as Amiya or whale-girl after switching tonone. - Docs and version: README cleanup plus 0.3.4 notes and composer-toggle documentation.
升级说明 / Upgrade
安装或升级后请完全退出并重启 DSH,仅刷新页面不够。
Fully quit and restart DSH after installing or upgrading; a page refresh is not enough.
0.3.1 — 斜杠菜单点选角色
新增
/role可以从菜单点选了。 输入框打出/后的命令列表里点 role,弹出角色选择器:顶部搜索框(角色名 / id / 描述片段都能搜),当前生效的角色带勾并标「当前」,第一行固定是none(回到部署人设),点一下就执行/role <id>;列表每次打开都重读角色库,设置页里增删角色即时生效。- 装饰宿主命令,而不是替换它。
/role list、/role <id>、/role default <id>、/role none全部照旧落到宿主 handler;只有裸/role改由弹窗回答。点选本身就是一次真实命令执行,所以角色变更提示、sessions.json记忆写入和转录里的命令节点都保留,浏览器侧没有复制切换逻辑。 GET /session新增liveRoleId。roleId仍是记忆里的选择,liveRoleId是此刻在生效的角色;列表的勾读后者,所以默认角色 //role切换 //role none三种状态都显示正确。- 弹窗只在真正执行后才关闭。 宿主「收了命令但没匹配上」(
value.matched === false)时不再静默关闭,而是保留列表与搜索词、显示原因,回车即可重试;判断用=== false,旧宿主返回结构没有该字段时不会被误判成失败。
升级说明
装到桌面端后需完整重启 DSH(宿主半边新增了 /session 的 liveRoleId 字段),再刷新页面。
v0.3.0 — 角色切换翻纸动画与动画三档开关
Added
- Replacing a role's mascot now turns a card instead of hard-cutting. A
/roleswitch (or an artwork
replacement from the settings page) used to swap the<img>srcthe instant the client noticed it — one
frame of the old art, then the new one, with nothing in between. The mascot now keeps the OLD artwork on
screen as the front face of a card and turns the NEW one into view as the back face: the card rotates 180°
about its own vertical mid-line over ~1s, eased in-out so it moves fastest through the edge-on 90°, where
the axis reads as the picture's own centre. The card stays flat throughout — no lift, no shadow. Detection
is untouched: the/revisioncounter still notices a switch in about a quarter of a second and the turn
starts then — this is a presentation layer over it, not a change to it. - The turn is self-healing and never gets stuck. The new artwork is decoded BEFORE the turn starts (so the
back face is never an empty box), the turn commits on the card's ownanimationend, and a fallback timer
commits itFLIP_TOTAL_MS + 200msafter the newest back face is set — a hidden tab pausing CSS animations
can no longer leave the mascot mid-turn showing two half-faded faces. A URL that will not decode skips the
turn and settles directly, letting the settled image's ownonErrordrop a truly broken role. Switches that
arrive mid-turn retarget the back face (latest wins); a superseded preload never commits. - Only a REPLACEMENT turns. A first appearance (nothing → art) and an unbind (art → nothing) keep the plain
opacity fade the wrapper already had — there is no card to turn when nothing is on screen. Under
prefers-reduced-motionthe swap happens in place, same as the hover growth: the feedback survives, the 1s
turn does not (Chromium infers this from Windows' own animation setting, so "reduce" is not rare — see the
0.2.1 note on the hover lesson). A new settings control overrides that reading per-plugin, described next. - A per-plugin animation switch: 跟随系统 / 始终开 / 始终关 (System / Always / Never). The reduced-motion
reading above is a system-wide accessibility preference, and a machine whose Windows animation switch is off
(Chromium then reportsprefers-reduced-motion: reduce) would lose the turn with no way to keep it short of
flipping a global setting. The mascot card on the settings page now carries a three-way choice stored under
dsh-persona-switcher:mascot-motion:alwaysturns even under system reduce,neverswaps in place even
where the system allows animation, and the defaultsystemfollows the OS exactly as before. A corrupt or
hand-edited value folds back ontosystem— it can never force a choice in either direction. - None of it costs the coexistence rules. No CSS
filteranywhere (nor any shadow — the turn is a plain
rotation), the turn
animates a new inner stage/card/face layer so the wrapper'stransform: scale()stays the size multiplier and
the drag maths never sees it, the faces arepointer-events: noneso they never eat the drag, and hovering
keeps growing the settled artwork exactly as before — but never a face mid-turn, where a hover scale would
fight the rotation.
Changed
- The mascot client checks ask a different question about a switch. A replacement is on screen (as the back
face) before the turn commits, so the switch-detection polls now ask "is this art painted by any face"
(paintsSrc) rather than "is it the first<img>" — the old question conflated detection latency with the
animation. The flip itself is verified by a new section (the pureplanFlipstate machine, a first show
rendering no card, a replacement rendering front=old/back=new with the box already sized to the incoming art,
theanimationendcommit, the fallback-timer commit, and reduced motion swapping in place) plus stylesheet
assertions pinning the axis, the duration, and the absence offilter.
v0.2.1
新增:屏幕吉祥物变得「即时」且「有反馈」
本版把屏幕吉祥物从"能用"推到"顺手":
- 🐳
/role切换后吉祥物立即跟随。 以前切角色后,立绘要退出会话再进来才换——因为客户端只在
屏幕上那个会话变化时读一次绑定角色。现在宿主新开了一个计数器端点GET /persona-switcher/revision
(只返回一个整数,不读磁盘),客户端每 250 ms 问一次,只有数字变了才重新读角色。
于是切换大约四分之一秒就落到屏幕上。
旧版宿主没有这个端点时会回 404,客户端把它当正常结果,自动降级为每 1.5 s 重读一次——不需要任何配置。 - 🖱️ 鼠标悬停时吉祥物微微放大。 立绘
transform: scale(1.06)、120 ms 缓动,给指针一个"我响应你"的
小确认;放大只作用在图片上,不作用在拖拽框上,所以框的大小、位置计算和元素本身都不受影响。 - 📊 新增计数器端点:
GET /persona-switcher/revision→{"ok":true,"revision":<int>}。
修复
- 收工审计:1 个 Critical(配置成空前缀会劫持整个 Web GUI)与一批 Important
(Windows 上的原子写、路径护送、轮询泄漏、崩溃隔离)已修。详见下方明细。 - 另外修了一个真实机器上暴露的问题:开启"减弱动画"的用户悬停放大完全不生效——
reduce分支把
transform也一起取消了。现在只去掉过渡动画,不再取消放大本身。
完整变更明细(英文)
[0.2.1] - 2026-10-16
Added
- The mascot now follows
/roleimmediately. Switching a role used to change the artwork only after you
left the session and came back, because the client read the bound role once, when the on-screen session
changed. It now asks a new counter endpoint,GET /persona-switcher/revision— one integer, no disk read —
every 250 ms, and re-reads the role only when the number moves. A switch therefore reaches the screen in
about a quarter of a second. The endpoint is described under Fixed below. - The mascot reacts to the pointer. Hovering the artwork scales it to
1.06over a 120 ms ease — a small
confirmation that it is interactive, on the image only, never on the dragged box.
Fixed
-
A configuration value can no longer take the Web GUI down.
routePrefixwas normalized but never validated,
so""or"/"registered a PREFIX route with an empty path. The Host matches a prefix route with
pathname.startsWith(prefix + '/'), which an empty prefix satisfies for every URL the app does not otherwise
claim — this plugin then answered404for the frontend's own assets and shadowed the SPA fallback, and a second
plugin registering the same path would have failed. An unusable prefix is now refused (one path segment, or
nothing is mounted) with a warning saying so. -
mascot.imageURLs now follow the mounted prefix instead of a hardcoded one. The route was mounted at the
configured prefix while every artwork URL was built as/persona-switcher/..., so any other prefix served every
thumbnail as a 404. Both the host half and the client half are pinned to the same default by a test, so the two
cannot drift apart silently again. -
The session memory is written atomically on Windows.
save()claimed "temp file plus rename", butrename
does not replace an existing file on Windows (typically EPERM/EACCES, more often while a scanner or an editor
holds it) and the temp name was a fixed${file}.tmpshared by concurrent writers. Two sessions switching at
nearly the same moment could therefore lose the write, surfacing asRole switch failedor "could not be
remembered" with the persona already installed. The destination is removed first, the temp name is unique, the
file is cleaned up on failure, and writes are serialized. -
The artwork map is written atomically as well, and its writers are serialized. It was written straight to the
map file: two concurrent imports truncated the same path, so one of them returned200for an entry that was
gone — and a torn file degrades to "empty map", which would hide every role's mascot at once. -
GET /roleandDELETE /rolewait for the first library scan. Both used the synchronous lookup while every
other role route used the async one, so a request landing before the initial scan reported a role that IS on
disk as unknown. -
Destructive routes re-validate a role id before it is joined onto a path. The catalogue is built from files a
person can place by hand andDELETE /roleremoves RECURSIVELY, so a role file declaringid: ../..aimed that
removal outside the role library. Such an id is now dropped while the library is scanned, and the delete and
artwork-pruning paths refuse anything that is not a single path segment. -
GET /mascot-file/<name>validates the RESOLVED path, not the string...%2F..%2Fsessions.jsonpassed the
string test; it was only harmless because no layer decoded the request and the extension map rejected.json.
The guard now survives any future decoding. -
A hand-edited box is normalized or reported, never silently dropped.
"9999x9999"(out of range) used to
read as "this role has no artwork" with no warning at all, which is indistinguishable from a missing file; a
padded"0080x0120"is now normalized to80x120rather than discarded. -
A blank
DSH_HOMEno longer scatters data next to the app.??only guards null/undefined, soDSH_HOME=''
made every path relative to the working directory. -
The request wrapper contains every failure. A route that throws now answers
500JSON instead of relying on
the Host's own catch (which answers a bodiless400), and no request can be left hanging. The wrapper is driven
directly with a throwing handler in the tests, because no route is reachable in a state that throws. -
The mascot's role read backs off when the Host stops answering. A failed read was collapsed into "the answer
is null", so the retry branch was unreachable: a Host that went away while the fast cadence was live was asked
four times a second, indefinitely. Failures are now reported as failures, the cadence is read from a ref rather
than a captured value (so an old Host really does slow down), and a hidden tab is slow for the session read too. -
Each artwork import no longer adds a permanent poll loop. Every
CHANGED_EVENTbroadcast started a second
/roleschain while the previous timer was still pending, and the orphaned timer was unreachable from cleanup —
ten imports meant ten concurrent loops. A broadcast now cancels the queued read first, and only one chain exists. -
A
visibilitychangeno longer doubles the revision poll. The timer id was left in place while its callback
awaited a response, so returning to the tab armed a second chain that grew with every hide/show. -
The master switch reacts to its own in-window broadcast. Its listeners guarded on
event.key, which a
CustomEventdoes not have, so the plugin's own event was rejected as "a storage event for another key" and a
second settings surface kept a stale toggle until the page was reloaded. -
The settings screen cannot be taken down by a hand-edited role file. A role with no
namethrew
editing.name.trim()during render, and a non-array/rolesresponse would have thrown inroles.map. Both
are guarded now, like the mascot half already was. -
The mascot no longer disappears when its host effect is run twice. Symptom on a real machine: no mascot at
all, no error anywhere, settings page perfectly fine. The dock element was created and then removed. Cause: the
disposer returned byinstallMascotdeleted the dock node, so a second install (a reload, a re-apply, any host
that re-runs effects) created a NEW React root on the SAME node while the first root's deferred unmount was still
queued — and that stale teardown then removed the shared node.
The dock and its root are now owned by the MODULE: an install reuses them, and disposal unmounts the CONTENT
without removing the node. A synchronous render failure still cleans both up.
This slipped through because nothing in the suite ran the built bundle through install → dispose → install, and
the first guard written for it checked "doesgetElementByIdknow the id" — which a detachingremoveChild
leaves true, so the guard proved nothing until it was rewritten to testparentNode.
scripts/simulate-client.mjsnow applieslib/client.jsthe way the host does and asserts the dock survives
that cycle;scripts/verify-mascot-client.mjsasserts the disposer empties the dock but keeps the node. -
The artwork read keeps retrying after a failure, instead of stopping for good. The
/rolespoll set its
next timer AFTER theawait, so a single failed read (a restarting host, a transient error) ended the loop
permanently — it could only come back if the on-screen session changed. The visible symptom would have been
"I imported artwork for this role and the mascot never appeared". The loop now reschedules from its outcome
(5 s after success, 8 s after a failure), ignores a second read while one is in flight, and stops on unmount.
scripts/verify-mascot-client.mjsfails the artwork read once and asserts both the retry and that the backoff
is not a hot loop. -
The mascot now follows a
/roleswitch immediately, without re-entering the conversation./role <id>
rebinds a session inside the Host process and broadcasts nothing to the browser, so the client can only ask.
It now asks a new counter endpoint,GET /persona-switcher/revision— one integer, no disk read — every
250 ms, and re-reads the role only when the number MOVES. The counter is bumped in exactly the two places the
mascot draws from: a session's role binding (the/rolepath,/role noneincluded) and the artwork map
(import, clear, or a hand-editedmascot-map.json). A switch therefore reaches the screen in about a quarter
of a second, and the previous behaviour — read the role once, when the seat changed — is gone. Only a CHANGE
republishes, so an identical answer every cycle neither re-renders the mascot nor restarts the artwork's fade.
Nothing is scheduled while the tab is hidden. -
A host without the counter endpoint still works. An older build answers 404, which the client treats as a
normal outcome rather than an error: it falls back to re-reading the role every 1.5 s (backing off to 8 s when
a read fails) and still catches a live switch within a few seconds. No configuration, no version negotiation. -
**The mascot grows slightly ...
v0.1.9 — 角色不再遮蔽部署身份行:改用自有 prompt section
安装
dsh plugin add dsh-persona-switcher[0.1.9] - 2026-10-13
Fixed
- A role no longer deletes the deployment's own identity line. A persona was installed as a per-agent
deployment:persona-prefixprompt section — the same name and order as the deployment's own registration —
which shadows it for that session. That section carries the deployment'spersonaPrefix
(You are a coding agent powered by the {{model}} model.), so every switched session lost the line and the
model could no longer say which model it was running on; only/role none, which disposes the override,
brought it back. A role now registers this plugin's own section,persona-switcher:persona, at order 137 —
after the deployment persona prefix (DEPLOYMENT_PERSONA_PREFIX, 0) and before the first-party guidance
(PLAN_POLICY, 500) — so the deployment prefix stays in force for every session and a switch adds persona
prose after it instead of replacing it./role nonenow means an empty persona rather than an empty
identity: nothing is registered for the session, and the deployment's line is still there.role_probe's
report, the README, the diagram and the guards follow suit, andnpm run verifynow fails the build if the
deployment persona prefix is registered from here again.
v0.1.8 — 角色变更声明覆盖全部方向:/role none 退回部署人设也会声明
安装
dsh plugin add dsh-persona-switcher[0.1.8] - 2026-10-09
Fixed
/role nonenow announces the change, like every other persona transition. The notice was
wired into the switch path alone, so it covered role-to-role switches and nothing else: the clear
path dropped the binding and wrote the opt-out without ever queueing a notice, and the notice
builder itself returned an empty string whenever thefromside was unknown, which made the
omission structural rather than accidental. The symptom was precisely what the notice exists to
prevent — the prompt goes back to the deployment persona while the reply keeps arriving in the
dismissed role's voice, because this conversation's own earlier assistant turns still demonstrate
that voice far more concretely than a system prefix establishes the new one. The notice is now
built from both sides of the transition (nullstanding for the deployment persona), and every
deliberate change queues one: role → role, deployment persona → role, role →/role none, and a
remembered role that vanished from the library handing the session to another one. The cases that
must stay quiet still do: an automatic rebind that leaves the session's role unchanged (the same
role restored after a restart, or a configured default applied to a session that never chose one)
changes nothing the model can read, so announcing it would put a bogus "role change" message in
front of every resumed session.
Added
scripts/verify-role-notice.mjs, a behavioural guard for the notice contract. Seven checks: the
/role nonenotice and its one-shot nature, the deployment-persona → role direction, the
role-to-role notice naming both roles, silence when the session already ran the deployment persona
(no-op opt-out), silence underannounceRoleChange: false, silence for a same-role restore, and
the vanished-role hand-over. Run against the 0.1.7 code it fails exactly three of them, which is what makes
it a regression guard rather than a description. Wired intonpm testandprepublishOnly.
v0.1.7 — 切换后注入角色变更声明 + README 补权能边界
安装
dsh plugin add dsh-persona-switcherChanged
announceRoleChangenow defaults totrue. The role-change notice used to
be off by default, on the assumption that replacing the head system node is
enough: the superseded persona text does leave the prompt, so the model cannot
read it again. A measured switch shows that assumption is only half right. On a
session switched fromwhale-girltoamiya, the next step's system prompt
already carried the new persona — zero occurrences of the old persona prose;
the single hit for the old role's name was the role catalogue listing — and the
reply still came back in the old role's voice. The transcript's own earlier
assistant turns demonstrate that voice far more concretely than a system prefix
establishes the new one, so the model follows the evidence closest to the
conversation. The notice is the in-band statement that resolves the conflict in
favour of the current role, which makes it the right default; set
announceRoleChange: falseonly where the prompt swap alone is trusted and the
extra message is unwanted weight. A profile that already pinned the old default
explicitly is unaffected. Verified end-to-end afterwards in a headless profile
that pins nothing: a real/role troll-helperswitch produced exactly one
notice in the transcript
(source: {kind: "persona-switcher", form: "role-change", from: "whale-girl", to: "troll-helper"})
and the next reply came back in the new role's voice.
Added
- A "what it touches on disk" boundary section in the README (Chinese and
English). It names the four write locations — the session state file, a role's
ROLE.md, a role directory on delete, and the profile patch file that the host
writes on the plugin's behalf — plus the read-only surfaces, the inbound
loopback route and its four endpoints, the fact that the route carries no auth
of its own, the extra capabilityrole_probeholds (itscommandargument
executes any registered slash command, not just/role), and how to tighten or
fully undo all of it. The point is that the plugin's reach can be audited from
the README before someone decides to trust it.
Fixed
- Removed a duplicated sentence in the middle of the switching walkthrough.
Published to npm as dsh-persona-switcher@0.1.7 from this tag by
.github/workflows/release.yml, with provenance.
v0.1.6
安装
dsh plugin add dsh-persona-switcherFixed
- The plugin inventory now shows this plugin's own title and description. The
Settings plugin inventory, the Plugin Manager card and the bundle details read
that text fromlocale/en.jsonandlocale/zh.jsonwithout activating the
plugin, resolving every file through the package'sexportsmap
(<name>/locale/en.json)../locale/*.jsonwas not an exported subpath, so
that lookup failed and the row silently fell back topackage.jsonnameand
description— it readdsh-persona-switcherwith the bilingual npm
description instead of人设切换/Persona Switcher. The subpath is exported
now, so the row reads人设切换in Chinese andPersona Switcherin English.
Nothing in the runtime code changed: this release is the manifest plus locale
data.
Added
scripts/verify-bundle.mjshard rule 5:package.jsonmust export
./locale/*.jsonand./package.json, both locale files must carry a
non-emptymeta.titleandmeta.description, andfilesmust shiplocale/.
The fallback topackage.jsonhappens without any diagnostic, so this
invariant is what turns the same regression into a red build instead of a row
that quietly shows the package name.
Changed
- The 0.1.3, 0.1.4 and 0.1.5 headings are dated
2026-10-07, the day those
versions were released; they had been dated one day ahead.
v0.1.5 — 重启后第一步即带人设
人设(角色)现在在 agent 创建时就装好,抢在宿主组装提示词之前。DSH 完全重启后,该会话的第一步回复就已经是人设,不再迟一步。
Fixed
- 修掉「重启后第一步仍是部署人设」的一步延迟:恢复动作从
agent/pre-step提前到agent/created。
Added
- 加载时扫描已存活的 agent(热重载、或行加载前就已恢复的会话)并同样绑定。
scripts/verify-created-bind.mjs:8 项检查,覆盖创建期绑定的幂等性、子 agent 不受影响、/role none的选择不被defaultRole抢回、未知角色被遗忘,以及「创建期监听器永不 reject,因此不会回滚会话创建事务」。verify-bundle硬规则扩展:缺少agent/created或活 agent 扫描即构建失败。
同时包含 0.1.4(此前未单独发布)
- 角色选择落盘到
$DSH_HOME/persona-switcher/sessions.json,重启后按会话恢复。 /role none记为{"role": null}(显式选择部署人设),不会再被defaultRole抢回。- 新增
lib/session-state.js:原子写入、损坏文件隔离为.corrupt、180 天过期清理。
安装
dsh plugin add --profile desktop dsh-persona-switcher@0.1.5若本机 npm 源指向镜像站且尚未同步到 0.1.5,追加 --registry https://registry.npmjs.org/。
校验
- 经 GitHub Actions 可信发布(OIDC trusted publishing),带 SLSA provenance 证明。
- tarball shasum
bdd36824166d90eeb607ec02227c90b0a9e823f2,17 files,unpackedSize 234778。 - 实机验证:装好后完全重启 DSH,回同一个会话,第一步的提示词即含人设(
7382字符、三条人设探针全中),与重启前一致。
完整变更见 CHANGELOG.md。
v0.1.2
Packaging-only release; the plugin code is identical to 0.1.1.
Changed
@deepseek-ai/cordisis declared as^4.0.4instead of~4.0.4, matching the range style used by published DSH plugins and keeping the plugin installable when the host moves to a later 4.x line.
Published to npm with provenance: https://www.npmjs.com/package/dsh-persona-switcher/v/0.1.2