Merge remote-tracking branch 'origin/pr/5' into codex/pr5-rework

This commit is contained in:
Leon
2026-08-18 19:01:21 +08:00
9 changed files with 1164 additions and 714 deletions

72
CHANGELOG.md Normal file
View File

@@ -0,0 +1,72 @@
# 滑动变祖 · 社区优化版 0.2.0
基于上游 [kingOfSoySauce/dsh-liang-skin](https://github.com/kingOfSoySauce/dsh-liang-skin) v0.1.5 优化,
已针对 DeepSeek Harness `0.1.0-rc.6` 验证。本包为**本地优化构建**,未推送上游仓库。
## 优化清单
1. **图像过渡更自然(交叉淡化)**
- 原版:切换推理等级时直接硬换人像 `src`,跳变生硬。
- 现版:双图层交叉淡化(备用层先加载、加载完成才淡入,不出现白闪),并叠加轻微
缩放(1.018→1)缓动。拖动中快速过渡(120ms,跟手),松手落定时慢速过渡
(340ms ease-out);`prefers-reduced-motion` 用户自动降级为直切。
2. **滑块位置固定,切换等级不再移位**
- 根源:原生「模型 · 等级」座位宽度随等级文本变化,在右锚定 flex 行里把滑块挤动。
- 修复:① 原生座位按钮固定 180px 足迹(模型名省略号吸收变化);② 滑块自身
`flex: 0 0 124px` 固定尺寸;③ 交互/提交期间 rAF 逐帧检测漂移并用 `translateX`
补偿,把滑块钉在屏幕上,结束后 200ms 平滑回位。
- 注意:固定宽度的选择器必须限定为 `button[class*="_7KE1Ra_trigger"]`——宿主的
`_7KE1Ra_triggerLabel`/`_7KE1Ra_triggerEffort` 子元素类名包含同样子串,宽选择器
会误伤并把等级文本挤出按钮。
3. **原生推理等级与滑块实时同步(新增)**
- 拖动预览期间把最近档位乐观回写共享 directory store(`SnapshotStore.update`),
原生座位实时跟随滑块档位变化;松手才经官方 `ModelDirectory.select` 提交,
失败 / Esc / 取消时自动回滚到交互前快照。
4. **滑动更丝滑**
- 移除图片序列模式下每帧对隐藏 `<video>` 的无谓 seek(原版最大卡顿源),
仅视频回退模式保留 seek;24 张人像锚点全量预加载 + `decoding="async"`;
交叉淡化走 opacity/transform 合成器动画(`will-change`),不触发重排。
5. **滑块自定义光标**
- 使用 `.cur` 标准光标(type=cursor、热点 16,16 居中),挂载于滑块交互区,
加载失败自动回退 `ew-resize`。源素材为 32×32 的 `.ico`,已在打包时一并附带。
6. **修复:模型名与推理等级拆开显示(回归修复)**
- 问题:固定原生座位宽度时子串选择器误伤子元素,等级文本被挤出按钮、孤立显示。
- 修复:选择器限定按钮本身,「DeepSeek-V4-Pro · 高」恢复一体显示。
## 与上游 v0.1.5 的差异
| 文件 | 说明 |
| --- | --- |
| `src/client/index.tsx` | 双图层 crossfade 呈现器、seek 条件化、预览乐观同步 + 回滚、位置锁定 |
| `src/client/skin.css` | crossfade 过渡、滑块固定尺寸、按钮级固定宽度、自定义光标 |
| `src/index.js` | 静态路由新增 `liang-cursor.cur` / `liang-cursor.ico` |
| `assets/liang-cursor.cur` / `.ico` | 新增光标资产 |
| `lib/client.js` / `.map` | 重新构建的客户端产物 |
## 安装(tarball)
```sh
dsh plugin --profile web add ./dsh-client-liang-intensity-skin-0.2.0.tgz
```
安装后重启 DSH Web 生效;入口仍为「设置 → 通用设置 → 外观皮肤 → 滑动变祖」,
「滑动变祖绑定思考等级」开关控制滑块是否联动推理等级。
## 卸载 / 回退
```sh
dsh plugin --profile web remove dsh-client-liang-intensity-skin
```
## 构建与测试
```sh
npm install
npm test # 8/8 通过(vitest,沙箱环境需 --pool=threads)
npm run build # esbuild 产出 lib/client.js
```

BIN
assets/liang-cursor.cur Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
assets/liang-cursor.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{
"name": "dsh-client-liang-intensity-skin",
"version": "0.1.5",
"version": "0.2.0",
"private": true,
"description": "滑动变祖:an opt-in adaptive reasoning slider skin for DeepSeek Harness",
"description": "滑动变祖(社区优化版):an opt-in adaptive reasoning slider skin for DeepSeek Harness",
"repository": {
"type": "git",
"url": "git+https://github.com/kingOfSoySauce/dsh-liang-skin.git"
@@ -17,6 +17,8 @@
"./package.json": "./package.json"
},
"files": [
"assets/liang-cursor.cur",
"assets/liang-cursor.ico",
"assets/liang-evolution.mp4",
"assets/liang-evolution.webm",
"assets/liang-poster.png",
@@ -44,6 +46,7 @@
"assets/portrait-source-v2/level-28.png",
"assets/portrait-source-v2/level-29.png",
"assets/portrait-source-v2/stage-30.png",
"CHANGELOG.md",
"cordis.patch.yml",
"lib/client.js",
"lib/client.js.map",

View File

@@ -54,6 +54,8 @@ const PORTRAIT_ANCHORS = [
{ level: 30, file: "stage-30.png" },
] as const;
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
interface SkinSettings {
enabled: boolean;
bindEffort: boolean;
@@ -141,13 +143,16 @@ class SkinPresenter {
private readonly root: HTMLDivElement;
private readonly video: HTMLVideoElement;
private readonly poster: HTMLImageElement;
private readonly portrait: HTMLImageElement;
private readonly frames: HTMLImageElement[];
private readonly preloads: HTMLImageElement[];
private enabled = true;
// Default to the max frame so the first paint after load is the dark shell;
// starting at 0 flashed the light palette before the directory resolved.
private frame = PREVIEW_MAX_FRAME;
private pendingFrame = PREVIEW_MAX_FRAME;
private pendingMode: "drag" | "settle" = "settle";
private activeFrame = 0;
private pendingSource: string | null = null;
private raf = 0;
private disposed = false;
private unsubscribe: () => void;
@@ -159,6 +164,7 @@ class SkinPresenter {
this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence";
this.root.dataset.crossfade = "slow";
this.root.setAttribute("aria-hidden", "true");
this.video = document.createElement("video");
@@ -167,7 +173,7 @@ class SkinPresenter {
this.video.preload = "auto";
// A video poster stays painted while a paused video is scrubbed in some
// browsers, which would make every effort show the same portrait. Keep the
// separate image element as the true load-error fallback instead.
// separate image elements as the true load-error fallback instead.
const webm = document.createElement("source");
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
webm.type = "video/webm";
@@ -181,19 +187,29 @@ class SkinPresenter {
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
this.poster.alt = "";
this.portrait = document.createElement("img");
this.portrait.className = "liang-skin-sequence-frame";
this.portrait.alt = "";
this.portrait.draggable = false;
this.portrait.addEventListener("error", this.handleSequenceError);
// Two stacked crossfade layers: the standby layer loads the next portrait
// while the active one stays painted, then an opacity/scale transition
// hands over — no hard src swap, no white flash.
this.frames = [];
for (let index = 0; index < 2; index += 1) {
const image = document.createElement("img");
image.className = "liang-skin-sequence-frame";
image.alt = "";
image.draggable = false;
image.decoding = "async";
image.addEventListener("error", this.handleSequenceError);
image.addEventListener("load", () => this.handleFrameLoaded(image));
this.frames.push(image);
}
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
const image = new Image();
image.decoding = "async";
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
return image;
});
this.root.append(this.video, this.portrait, this.poster);
this.root.append(this.video, this.frames[0], this.frames[1], this.poster);
document.body.prepend(this.root);
this.video.addEventListener("loadedmetadata", this.handleMetadata);
@@ -246,9 +262,10 @@ class SkinPresenter {
}
}
setFrame(frame: number) {
setFrame(frame: number, mode: "drag" | "settle" = "drag") {
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
this.frame = this.pendingFrame;
this.pendingMode = mode;
if (!this.enabled) return;
if (this.raf !== 0) return;
this.raf = requestAnimationFrame(() => {
@@ -278,8 +295,11 @@ class SkinPresenter {
body.style.setProperty("--liang-accent-hover", palette.accentHover);
body.style.setProperty("--liang-hover", palette.hover);
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
this.root.dataset.crossfade = this.pendingMode === "settle" ? "slow" : "fast";
this.updatePortrait(palette.level);
this.seek(this.frame);
// Seeking a hidden <video> is pure cost (and visibly janky) during drags
// in the default image-sequence mode; only the video fallback needs it.
if (this.root.dataset.media === "video") this.seek(this.frame);
}
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
@@ -288,15 +308,47 @@ class SkinPresenter {
}
private updatePortrait(level: number) {
if (this.root.dataset.media !== "sequence") return;
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
level,
PORTRAIT_ANCHORS.map((anchor) => anchor.level),
ANCHOR_LEVELS,
);
const lower = PORTRAIT_ANCHORS[lowerIndex];
const upper = PORTRAIT_ANCHORS[upperIndex];
const selected = mix >= 0.5 ? upper : lower;
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
const active = this.frames[this.activeFrame];
if (active.getAttribute("src") === source) {
// Already showing this anchor; drop any stale queued handover.
this.pendingSource = null;
return;
}
if (this.pendingSource === source) return;
const standbyIndex = 1 - this.activeFrame;
const standby = this.frames[standbyIndex];
this.pendingSource = source;
if (standby.getAttribute("src") !== source) standby.src = source;
// Cache hit (preloaded): hand over immediately — the CSS transition still
// eases the swap, so a fast drag stays glued to the pointer.
if (standby.complete && standby.naturalWidth > 0) {
this.activate(standbyIndex);
}
}
private readonly handleFrameLoaded = (image: HTMLImageElement) => {
if (
this.pendingSource === null
|| image.getAttribute("src") !== this.pendingSource
) return;
this.activate(this.frames.indexOf(image));
};
private activate(index: number) {
if (index < 0 || index === this.activeFrame) return;
this.frames[this.activeFrame].classList.remove("is-active");
this.activeFrame = index;
this.frames[index].classList.add("is-active");
this.pendingSource = null;
}
private seek(frame: number) {
@@ -324,7 +376,10 @@ class SkinPresenter {
this.video.pause();
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
this.video.removeEventListener("error", this.handleVideoError);
this.portrait.removeEventListener("error", this.handleSequenceError);
for (const image of this.frames) {
image.removeEventListener("error", this.handleSequenceError);
image.src = "";
}
this.poster.removeEventListener("error", this.handlePosterError);
for (const image of this.preloads) image.src = "";
this.root.remove();
@@ -376,6 +431,13 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
const [interacting, setInteracting] = useState(false);
const dragging = useRef(false);
const dragStartFrame = useRef(frame);
const rootRef = useRef<HTMLDivElement>(null);
const interactionArmed = useRef(false);
const dragStartCommittedId = useRef<string | undefined>(undefined);
const dragStartCurrent = useRef<ModelSelection | null>(null);
const previewSyncedId = useRef<string | undefined>(undefined);
const lockRaf = useRef(0);
const lockBaseLeft = useRef<number | null>(null);
const enabled = skin.enabled;
useEffect(() => {
@@ -385,9 +447,44 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
useEffect(() => {
if (!bindEffort || dragging.current || pending) return;
setFrame(committedFrame);
presenter.setFrame(committedFrame);
presenter.setFrame(committedFrame, "settle");
}, [bindEffort, committedFrame, pending, presenter]);
// While the user interacts (or a commit is in flight), the native model
// seat to the right may change its label width and reflow this flex row.
// Pin the control's on-screen position each frame and compensate any drift
// with a transform, then glide back smoothly when the interaction ends.
const locked = interacting || pending;
useEffect(() => {
if (!locked) return;
const root = rootRef.current;
if (root === null) return;
root.style.transition = "";
root.style.willChange = "transform";
lockBaseLeft.current = root.getBoundingClientRect().left;
const step = () => {
lockRaf.current = requestAnimationFrame(step);
const base = lockBaseLeft.current;
if (base === null) return;
const drift = base - root.getBoundingClientRect().left;
if (Math.abs(drift) > 0.5) {
root.style.transform = `translateX(${drift.toFixed(2)}px)`;
}
};
lockRaf.current = requestAnimationFrame(step);
return () => {
cancelAnimationFrame(lockRaf.current);
lockRaf.current = 0;
root.style.transition = "transform 200ms ease-out";
root.style.transform = "";
window.setTimeout(() => {
root.style.transition = "";
root.style.willChange = "";
}, 220);
lockBaseLeft.current = null;
};
}, [locked]);
if (!enabled) return null;
if (bindEffort && (reasoning === null || efforts.length < 2)) return null;
@@ -396,21 +493,62 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
const progressRatio = frame / PREVIEW_MAX_FRAME;
const tooltipLabel = indicatorLabel(frame, bindEffort ? efforts : []);
const commit = async (rawFrame: number) => {
if (!bindEffort) {
const beginInteraction = () => {
if (interactionArmed.current) return;
interactionArmed.current = true;
dragging.current = true;
dragStartFrame.current = frame;
const snapshot = directory.getSnapshot();
dragStartCurrent.current = snapshot.current;
const snapshotReasoning = modelReasoning(snapshot);
dragStartCommittedId.current = snapshotReasoning?.selection.reasoningEffort
?? snapshotReasoning?.defaultEffort;
previewSyncedId.current = undefined;
};
// Roll the shared directory store back to the value captured before this
// interaction (undoes any optimistic preview echo).
const restorePreviewSync = () => {
const original = dragStartCurrent.current;
if (original === null || previewSyncedId.current === undefined) return;
directory.store.update((draft) => {
draft.current = original;
});
previewSyncedId.current = undefined;
};
const cancelInteraction = () => {
setInteracting(false);
interactionArmed.current = false;
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current, "settle");
restorePreviewSync();
};
const commit = async (rawFrame: number) => {
interactionArmed.current = false;
dragging.current = false;
if (!bindEffort) {
setFrame(rawFrame);
presenter.setFrame(rawFrame);
presenter.setFrame(rawFrame, "settle");
return;
}
const targetIndex = nearestEffortIndex(rawFrame, efforts);
const target = efforts[targetIndex];
if (target === undefined) return;
if (target === undefined) {
restorePreviewSync();
return;
}
const targetFrame = frameForEffort(targetIndex, efforts.length);
dragging.current = false;
setFrame(targetFrame);
presenter.setFrame(targetFrame);
if (targetIndex === committedIndex || pending) return;
presenter.setFrame(targetFrame, "settle");
if (target.id === dragStartCommittedId.current) {
// No real change: just undo any optimistic preview echo.
restorePreviewSync();
return;
}
if (pending) return;
setPending(true);
setFailed(false);
const accepted = await select({
@@ -421,13 +559,25 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
setPending(false);
if (!accepted) {
setFailed(true);
setFrame(committedFrame);
presenter.setFrame(committedFrame);
restorePreviewSync();
const rollbackIndex = selectedEffortIndex(
efforts,
dragStartCommittedId.current,
undefined,
);
const rollbackFrame = rollbackIndex < 0
? PREVIEW_MAX_FRAME
: frameForEffort(rollbackIndex, efforts.length);
setFrame(rollbackFrame);
presenter.setFrame(rollbackFrame, "settle");
} else {
previewSyncedId.current = undefined;
}
};
return (
<div
ref={rootRef}
className="liang-effort-control"
data-plugin={PACKAGE_ID}
data-state={failed ? "error" : pending ? "pending" : "ready"}
@@ -461,25 +611,38 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
aria-label={bindEffort ? "思考等级" : "皮肤进度"}
aria-valuetext={bindEffort ? previewEffort?.name ?? "" : tooltipLabel}
onPointerDown={() => {
dragging.current = true;
dragStartFrame.current = frame;
beginInteraction();
setInteracting(true);
}}
onInput={(event) => {
dragging.current = true;
const next = Number(event.currentTarget.value);
setFrame(next);
presenter.setFrame(next);
presenter.setFrame(next, "drag");
if (!bindEffort || reasoning === null) return;
// Optimistically echo the nearest preview effort into the shared
// directory store so the native model seat updates in lockstep
// while dragging; commit/rollback settles the final value.
const index = nearestEffortIndex(next, efforts);
const effort = efforts[index];
if (effort === undefined || effort.id === previewSyncedId.current) return;
previewSyncedId.current = effort.id;
directory.store.update((draft) => {
const current = draft.current;
if (
current === null
|| current.provider !== reasoning.selection.provider
|| current.model !== reasoning.selection.model
) return;
current.reasoningEffort = effort.id;
});
}}
onPointerUp={(event) => {
setInteracting(false);
void commit(Number(event.currentTarget.value));
}}
onPointerCancel={() => {
setInteracting(false);
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current);
cancelInteraction();
}}
onKeyUp={(event) => {
setInteracting(false);
@@ -491,11 +654,9 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
}}
onKeyDown={(event) => {
setInteracting(true);
beginInteraction();
if (event.key === "Escape" && !pending) {
setInteracting(false);
dragging.current = false;
setFrame(dragStartFrame.current);
presenter.setFrame(dragStartFrame.current);
cancelInteraction();
}
}}
/>

View File

@@ -41,7 +41,24 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame {
display: block;
/* Crossfade layers: inactive sits transparent and slightly enlarged, the
active one eases back to a calm 1:1. Dragging swaps fast so the preview
stays glued to the pointer; settling swaps slow for a gentle landing. */
opacity: 0;
transform: translateY(-50%) scale(1.018);
transition:
opacity 120ms ease-out,
transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: opacity, transform;
}
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame.is-active {
opacity: var(--liang-portrait-opacity, 0.82);
transform: translateY(-50%) scale(1);
}
.liang-skin-backdrop[data-crossfade="slow"] .liang-skin-sequence-frame {
transition-duration: 340ms, 520ms;
}
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
@@ -337,7 +354,14 @@ body[data-liang-skin="on"]:is(
--liang-control-rail: var(--dsw-alias-border-l3, rgb(0 0 0 / 16%));
position: relative;
display: flex;
/* Fixed footprint: the native model seat to the right changes its label
width when the reasoning effort changes, which reflows the trailing flex
row. Pin this control's own size so it never contributes to that shift
(absolute position is additionally locked during interaction in JS). */
flex: 0 0 124px;
width: 124px;
min-width: 124px;
max-width: 124px;
height: 28px;
align-items: center;
margin: 0 3px;
@@ -349,6 +373,19 @@ body[data-liang-skin="on"] .liang-effort-control {
--liang-control-rail: color-mix(in srgb, var(--liang-secondary) 28%, transparent);
}
/* The native model seat (composer input.model) widens with the effort label
text; give it a stable footprint under the Liang skin so switching effort
never reflows the slider out of position. The label ellipsizes instead.
Button-scoped on purpose: the inner label/effort spans also contain this
class substring and must never be sized (sizing them pushed the effort text
out of the button, splitting the model+effort pair apart). */
body[data-liang-skin="on"] button[class*="_7KE1Ra_trigger"] {
box-sizing: border-box;
width: 180px;
min-width: 180px;
max-width: 180px;
}
.liang-effort-control__ticks {
position: absolute;
z-index: 0;
@@ -415,7 +452,9 @@ body[data-liang-skin="on"] .liang-effort-control {
width: calc(100% - 7px);
height: 28px;
margin: 0 3.5px;
cursor: ew-resize;
/* Branded pointer (centered hotspot baked into the .cur); fall back to the
native resize cursor if the asset fails to load. */
cursor: url("/plugins/dsh-client-liang-intensity-skin/assets/liang-cursor.cur") 16 16, ew-resize;
appearance: none;
background: transparent;
touch-action: pan-y;
@@ -620,7 +659,10 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
@media (max-width: 760px) {
.liang-effort-control {
flex-basis: 92px;
width: 92px;
min-width: 92px;
max-width: 92px;
}
.liang-skin-backdrop video,
@@ -636,6 +678,7 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
.liang-skin-backdrop,
.liang-skin-backdrop video,
.liang-skin-backdrop img,
.liang-skin-backdrop .liang-skin-sequence-frame,
body[data-liang-skin="on"] button,
body[data-liang-skin="on"] input,
body[data-liang-skin="on"] textarea {

View File

@@ -9,6 +9,8 @@ export const SETTINGS_NAMESPACE = "liang-intensity-skin";
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
const ASSET_SPECS = [
["liang-cursor.cur", "image/x-icon"],
["liang-cursor.ico", "image/x-icon"],
["liang-evolution.webm", "video/webm"],
["liang-evolution.mp4", "video/mp4"],
["liang-poster.png", "image/png"],