Initial release

This commit is contained in:
kingOfSoySauce
2026-08-14 22:30:15 +08:00
commit f24aa0841d
76 changed files with 7582 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
.env
.env.*
!.env.example
.DS_Store
.idea/
.vscode/
*.log
*.tgz
coverage/
test-results/

76
README.md Normal file
View File

@@ -0,0 +1,76 @@
# 滑动变祖 · DeepSeek Harness 皮肤
把 Harness 的推理等级改为紧凑滑块,并用滑动变祖原项目的 0–30 强度映射同步人物、背景与界面配色。
## 交互规则
- 档位直接读取当前模型的 `reasoning.efforts`,不写死数量或名称。
- 任意 N 个档位均匀落在 0–30:两档为 `0 / 30`,三档为 `0 / 15 / 30`,五档为 `0 / 7.5 / 15 / 22.5 / 30`。
- 拖动期间只做连续视觉预览;指针松开、键盘操作完成或控件失焦时,才吸附到最近档位并通过官方 ModelDirectory 提交该档位原始 ID。
- 不支持推理或只有一个可选档位的模型不显示滑块。
- addressed subagent 不显示也不提交滑块。
## 外观开关
入口位于 `设置 → 通用设置 → 外观皮肤`:
- `滑动变祖`:显示滑块并启用人物、背景和自适应配色。
- `原生`:移除背景层、皮肤变量和滑块,恢复 Harness 原生界面。
选择保存在当前浏览器本地,不会改动模型配置。
## 安装
需要先安装 DeepSeek Harness CLI。发布到独立 GitHub 仓库后,可以直接从 Git 安装:
```sh
dsh plugin --profile web add github:kingOfSoySauce/dsh-liang-skin#COMMIT
dsh --profile web --dump-config
dsh --profile web
```
请将 `COMMIT` 换成具体 commit。固定 commit 可避免之后的推送在未经审查时改变已安装代码。
也可以克隆后从本地路径安装:
```sh
git clone https://github.com/kingOfSoySauce/dsh-liang-skin.git
cd dsh-liang-skin
dsh plugin --profile web add .
```
`--dump-config` 的输出中应出现 `dsh-client-liang-intensity-skin` 配置层。要卸载插件:
```sh
dsh plugin --profile web remove dsh-client-liang-intensity-skin
```
### 从 GitHub Release tarball 安装
发布者可在仓库根目录生成可分发包:
```sh
npm pack
```
将生成的 `.tgz` 上传为 GitHub Release 附件后,用户下载并安装:
```sh
dsh plugin --profile web add ./dsh-client-liang-intensity-skin-0.1.0.tgz
```
包内已包含构建好的 `lib/client.js`,安装时不需要执行第三方 `prepare` 脚本。
## 本地开发
```sh
npm install
npm test
npm run build
```
修改 client 源码后需要运行 `npm run build`,并一起提交更新后的 `lib/client.js` 和 source map。
## 素材
插件包含运行时所需的图片和视频素材,安装后不需要额外下载。

BIN
assets/liang-evolution.mp4 Normal file

Binary file not shown.

BIN
assets/liang-evolution.webm Normal file

Binary file not shown.

BIN
assets/liang-poster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -0,0 +1,98 @@
# 滑动变祖 · 人像与视频生成交接
## 结论
- 计划关键图:**24 张**
- 已生成:**24 / 24**
- 分辨率与色彩:**1024 × 1024,RGB**
- 生成图目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/`
- 原视频关键帧目录:`packages/dsh-client-liang-intensity-skin/assets/portrait-source-v2/references/`
- 原视频:`packages/dsh-client-liang-intensity-skin/assets/liang-evolution.webm`
`stage-low.png`、`stage-mid.png`、`stage-max.png` 是早期别名。后续生图和生视频只使用表格里的 24 个规范文件名。
## 公共提示词
下面这段作为每张图的公共提示词。把大括号变量替换为表格中对应内容;阶段服装、表情和装饰必须以该阶段原视频关键帧为准。
```text
Use case: identity-preserve
Asset type: square key portrait for a continuous image-to-video character evolution sequence
Primary request:
Using {REFERENCE_FRAME} as the authoritative stage reference, create the level {LEVEL} key portrait of the same Chinese adult man. Preserve his identity exactly: facial structure, glasses, hairstyle, skin tone, age, eye spacing, nose, mouth and overall proportions. Preserve the outfit, expression, posture and stage-specific accessories shown in the authoritative reference. Do not borrow clothing or accessories from a later stage.
Composition and camera:
Photorealistic 1:1 studio portrait, 1024×1024. Full centered half-body composition from the top of the head to the waist or slightly below the waist. The complete head, shoulders, upper arms and torso must remain inside the frame. Keep generous and balanced safety margins on the top, left and right. The head must be clearly smaller than a headshot and must not dominate the frame. Front-facing, eye-level camera, symmetrical posture, 85mm portrait-lens look, no perspective distortion.
Continuity:
Use {PREVIOUS_KEY} and {NEXT_KEY} only as continuity references. Keep eye line, head size, shoulder width, body scale, camera distance, background horizon and light direction consistent across the sequence. Changes from adjacent keys must be gradual and attributable only to increasing intensity. Do not change identity. Do not introduce an intermediate transition pose or motion blur.
Scene and lighting:
Clean full-frame studio background with a subtle neutral gradient appropriate to this stage. The background must fill the entire square evenly; do not bake a left-side blank layout or right-side placement into the image. Soft frontal key light, restrained rim light, realistic skin texture, natural fabric texture, high-end editorial photography.
Constraints:
No close-up crop. No cropped head, shoulders, arms or torso. No side pose. No extra person. No duplicated limbs or accessories. No text, logo, UI, frame, border or watermark. No motion blur. No transition frame. No exaggerated fantasy elements beyond those already visible in the authoritative stage reference. Output only the final square image to {OUTPUT_PATH}.
```
## 24 张关键图状态表
| 顺序 | 强度 | 实际时间戳 | 状态 | 规范输出文件 | 权威阶段参考图 | 连续性参考 |
|---:|---:|---:|---|---|---|---|
| 1 | 00 | 0.000s | 已生成 | `stage-00.png` | `references/stage-00-0.000s.png` | 下一张 `level-01.png` |
| 2 | 01 | 0.268s | 已生成 | `level-01.png` | `references/level-01-0.268s.png` | 前一张 `stage-00.png`;下一张 `level-03.png` |
| 3 | 03 | 0.803s | 已生成 | `level-03.png` | `references/level-03-0.803s.png` | 前一张 `level-01.png`;下一张 `level-04.png` |
| 4 | 04 | 1.071s | 已生成 | `level-04.png` | `references/level-04-1.071s.png` | 前一张 `level-03.png`;下一张 `stage-06.png` |
| 5 | 06 | 1.600s | 已生成 | `stage-06.png` | `references/stage-06-1.600s.png` | 前一张 `level-04.png`;下一张 `level-07.png` |
| 6 | 07 | 1.874s | 已生成 | `level-07.png` | `references/level-07-1.874s.png` | 前一张 `stage-06.png`;下一张 `level-09.png` |
| 7 | 09 | 2.410s | 已生成 | `level-09.png` | `references/level-09-2.410s.png` | 前一张 `level-07.png`;下一张 `level-10.png` |
| 8 | 10 | 2.678s | 已生成 | `level-10.png` | `references/level-10-2.678s.png` | 前一张 `level-09.png`;下一张 `stage-12.png` |
| 9 | 12 | 3.200s | 已生成 | `stage-12.png` | `references/stage-12-3.200s.png` | 前一张 `level-10.png`;下一张 `level-13.png` |
| 10 | 13 | 3.481s | 已生成 | `level-13.png` | `references/level-13-3.481s.png` | 前一张 `stage-12.png`;下一张 `level-14.png` |
| 11 | 14 | 3.749s | 已生成 | `level-14.png` | `references/level-14-3.749s.png` | 前一张 `level-13.png`;下一张 `bridge-15.png` |
| 12 | 15(桥接) | 4.000s | 已生成 | `bridge-15.png` | `references/bridge-15-4.000s.png` | 前一张 `level-14.png`;下一张 `level-16.png` |
| 13 | 16 | 4.284s | 已生成 | `level-16.png` | `references/level-16-4.284s.png` | 前一张 `bridge-15.png`;下一张 `level-17.png` |
| 14 | 17 | 4.552s | 已生成 | `level-17.png` | `references/level-17-4.552s.png` | 前一张 `level-16.png`;下一张 `stage-18.png` |
| 15 | 18 | 4.800s | 已生成 | `stage-18.png` | `references/stage-18-4.800s.png` | 前一张 `level-17.png`;下一张 `level-19.png` |
| 16 | 19 | 5.088s | 已生成 | `level-19.png` | `references/level-19-5.088s.png` | 前一张 `stage-18.png`;下一张 `level-21.png` |
| 17 | 21 | 5.623s | 已生成 | `level-21.png` | `references/level-21-5.623s.png` | 前一张 `level-19.png`;下一张 `level-22.png` |
| 18 | 22 | 5.891s | 已生成 | `level-22.png` | `references/level-22-5.891s.png` | 前一张 `level-21.png`;下一张 `stage-24.png` |
| 19 | 24 | 6.400s | 已生成 | `stage-24.png` | `references/stage-24-6.400s.png` | 前一张 `level-22.png`;下一张 `level-25.png` |
| 20 | 25 | 6.694s | 已生成 | `level-25.png` | `references/level-25-6.694s.png` | 前一张 `stage-24.png`;下一张 `bridge-27.png` |
| 21 | 27(桥接) | 7.200s | 已生成 | `bridge-27.png` | `references/bridge-27-7.200s.png` | 前一张 `level-25.png`;下一张 `level-28.png` |
| 22 | 28 | 7.497s | 已生成 | `level-28.png` | `references/level-28-7.497s.png` | 前一张 `bridge-27.png`;下一张 `level-29.png` |
| 23 | 29 | 7.765s | 已生成 | `level-29.png` | `references/level-29-7.765s.png` | 前一张 `level-28.png`;下一张 `stage-30.png` |
| 24 | 30 | 8.000s | 已生成 | `stage-30.png` | `references/stage-30-8.000s.png` | 前一张 `level-29.png` |
所有表格路径均相对于本文件所在目录。
## 生视频输入顺序
```text
stage-00.png
level-01.png
level-03.png
level-04.png
stage-06.png
level-07.png
level-09.png
level-10.png
stage-12.png
level-13.png
level-14.png
bridge-15.png
level-16.png
level-17.png
stage-18.png
level-19.png
level-21.png
level-22.png
stage-24.png
level-25.png
bridge-27.png
level-28.png
level-29.png
stage-30.png
```
视频 agent 应锁定镜头、构图和人物位置,只在相邻关键图之间做外观演化;不要加入推拉、横移、眨眼、转头、嘴部动作或随机肢体运动。输出仍按当前 UI 使用的 1:1 方形画幅制作,左侧留白和人物右移由 UI 的裁切、定位与渐变遮罩完成。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

3
cordis.patch.yml Normal file
View File

@@ -0,0 +1,3 @@
- insert:
- id: liang-intensity-skin
name: dsh-client-liang-intensity-skin

BIN
design-qa-comparison.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
design-qa-final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

41
design-qa.md Normal file
View File

@@ -0,0 +1,41 @@
# 滑动变祖 · Design QA
final result: passed
## Verified build
- DSH `0.1.0-rc.6`, isolated profile on `127.0.0.1:3081`
- Plugin client bundle loads and the dynamic reasoning slider is present
- 8/8 unit cases pass; production client bundle builds successfully
## Interaction checks
- Drag/input updates only the continuous local preview
- Tooltip is mounted only while pointer or keyboard interaction is active
- Tooltip label combines the continuous Liang rank with the nearest dynamic model effort, for example `梁祖 · Max`
- Release snaps to the nearest effort and performs one model-selection commit
- Two-, three-, and five-effort models derive their stops from provider metadata
- UI remains native-light through level 23 and switches to the black/gold theme at level 24 (`梁神`)
- Portrait frames switch directly; no crossfade is retained
## Visual checks
- Light state keeps native white composer, pale message bubble, readable dark text, and a visible half-body portrait
- Dark state keeps the portrait behind the usable conversation area and aligns controls, sidebar, borders, and accents to the charcoal/gold reference
- Sidebar and main canvas share a continuous tonal family
- Tooltip is compact, follows the thumb, and does not obscure model selection or send controls
- Tooltip may extend beyond the slider box at either endpoint so its center remains aligned with the thumb
- Tooltip now follows the active skin surface: light surface with dark text in the light shell, dark surface with light text in the dark shell
- Light mode retains Harness blue accents and a black badge with light lettering
- Dark mode uses the calibrator's `#c19a49` / `#d5b56e` gold for the badge, hero glow, effort label, slider, and send action
- Slider thumb, rail, and ticks use one subdued accent family; the send arrow is dark on gold
- Light mode slider, rail, and ticks use the secondary text color; the send arrow remains white on blue
- Portraits render without brightness, contrast, or saturation filters; source artwork supplies the intended tonal grade
- Side-by-side reference comparison: `design-qa-comparison.png`
- Final implementation capture: `design-qa-final.png`
## Asset handoff
- 24/24 portrait sources are present at `assets/portrait-source-v2/`
- Final contact sheet: `assets/portrait-source-v2/references/contact-sheet-final-24.png`
- Generation/video handoff: `assets/portrait-source-v2/GENERATION-BRIEF.md`

627
lib/client.js Normal file

File diff suppressed because one or more lines are too long

7
lib/client.js.map Normal file

File diff suppressed because one or more lines are too long

5111
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

76
package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "dsh-client-liang-intensity-skin",
"version": "0.1.0",
"private": true,
"description": "滑动变祖:an opt-in adaptive reasoning slider skin for DeepSeek Harness",
"repository": {
"type": "git",
"url": "git+https://github.com/kingOfSoySauce/dsh-liang-skin.git"
},
"homepage": "https://github.com/kingOfSoySauce/dsh-liang-skin#readme",
"bugs": "https://github.com/kingOfSoySauce/dsh-liang-skin/issues",
"type": "module",
"main": "./src/index.js",
"exports": {
".": "./src/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"files": [
"assets/liang-evolution.mp4",
"assets/liang-evolution.webm",
"assets/liang-poster.png",
"assets/portrait-source-v2/stage-00.png",
"assets/portrait-source-v2/stage-06.png",
"assets/portrait-source-v2/stage-12.png",
"assets/portrait-source-v2/bridge-15.png",
"assets/portrait-source-v2/stage-18.png",
"assets/portrait-source-v2/stage-24.png",
"assets/portrait-source-v2/bridge-27.png",
"assets/portrait-source-v2/stage-30.png",
"cordis.patch.yml",
"lib/client.js",
"lib/client.js.map",
"src/index.js",
"src/range.js"
],
"scripts": {
"build": "node ./scripts/build-client.mjs",
"test": "vitest run"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-model-selection",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-settings-general",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes"
]
}
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings-general": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"react": "^18.2.0"
},
"devDependencies": {
"@types/react": "~18.3.1",
"esbuild": "^0.25.0",
"vitest": "^4.1.10"
}
}

29
scripts/build-client.mjs Normal file
View File

@@ -0,0 +1,29 @@
import { build } from "esbuild";
const id = "dsh-client-liang-intensity-skin";
await build({
entryPoints: ["src/client/index.tsx"],
outfile: "lib/client.js",
bundle: true,
format: "cjs",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
loader: { ".css": "text" },
external: [
"react",
"react/jsx-runtime",
"@deepseek-ai/dsh-client-runtime/client",
],
define: {
"process.env.NODE_ENV": '"production"',
},
banner: {
js: `window.__ModuleLoader__.load({id:${JSON.stringify(id)},factory:(require)=>{var module={exports:{}};var exports=module.exports;`,
},
footer: {
js: "return module.exports;}});",
},
});

590
src/client/index.tsx Normal file
View File

@@ -0,0 +1,590 @@
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ClientContext, SnapshotStore } from "@deepseek-ai/dsh-client-runtime/client";
import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
import type {} from "@deepseek-ai/dsh-client-ui-model-selection/client";
import styles from "./skin.css";
import {
PREVIEW_MAX_FRAME,
frameForEffort,
indicatorLabel,
nearestEffortIndex,
paletteForFrame,
portraitBlendForLevel,
selectedEffortIndex,
type EffortLike,
} from "./logic";
const PACKAGE_ID = "dsh-client-liang-intensity-skin";
const LOCALE_NAMESPACE = "liang.skin";
const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
const VIDEO_DURATION = 8.033;
const PREFERENCE_KEY = "dsh-liang-intensity-skin.enabled";
const PORTRAIT_ANCHORS = [
{ level: 0, file: "stage-00.png" },
{ level: 6, file: "stage-06.png" },
{ level: 12, file: "stage-12.png" },
{ level: 15, file: "bridge-15.png" },
{ level: 18, file: "stage-18.png" },
{ level: 24, file: "stage-24.png" },
{ level: 27, file: "bridge-27.png" },
{ level: 30, file: "stage-30.png" },
] as const;
interface SkinSettings {
enabled: boolean;
}
interface PreferenceStore {
getSnapshot(): SkinSettings;
subscribe(listener: () => void): () => void;
set(enabled: boolean): Promise<void>;
dispose(): void;
}
interface ModelSelection {
provider: string;
model: string;
reasoningEffort?: string;
}
interface CatalogModel {
id: string;
reasoning?: {
efforts: EffortLike[];
defaultEffort?: string;
};
}
interface ModelDirectoryState {
current: ModelSelection | null;
groups: readonly { id: string; models: CatalogModel[] }[];
status: "idle" | "loading" | "ready" | "selecting" | "error";
error: string | null;
}
interface ModelDirectory {
store: SnapshotStore<ModelDirectoryState>;
load(): Promise<unknown>;
select(selection: ModelSelection): Promise<void>;
}
interface SliderProps {
directory: SnapshotStore<ModelDirectoryState>;
load: () => void;
select: (selection: ModelSelection) => Promise<boolean>;
presenter: SkinPresenter;
scope: PreferenceStore;
}
interface SettingsRowProps {
scope: PreferenceStore;
presenter: SkinPresenter;
t: (key: string) => string;
}
const cssVariables = [
"--liang-strength",
"--liang-page",
"--liang-bg-base",
"--liang-layer-1",
"--liang-layer-2",
"--liang-layer-3",
"--liang-sidebar",
"--liang-ink",
"--liang-secondary",
"--liang-tertiary",
"--liang-border",
"--liang-accent",
"--liang-accent-hover",
"--liang-hover",
"--liang-portrait-opacity",
] as const;
class SkinPresenter {
private readonly scope: PreferenceStore;
private readonly root: HTMLDivElement;
private readonly video: HTMLVideoElement;
private readonly poster: HTMLImageElement;
private readonly portrait: HTMLImageElement;
private readonly preloads: HTMLImageElement[];
private enabled = true;
private frame = 0;
private pendingFrame = 0;
private raf = 0;
private disposed = false;
private unsubscribe: () => void;
constructor(scope: PreferenceStore) {
this.scope = scope;
this.root = document.createElement("div");
this.root.className = "liang-skin-backdrop";
this.root.dataset.plugin = PACKAGE_ID;
this.root.dataset.media = "sequence";
this.root.setAttribute("aria-hidden", "true");
this.video = document.createElement("video");
this.video.muted = true;
this.video.playsInline = true;
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.
const webm = document.createElement("source");
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
webm.type = "video/webm";
const mp4 = document.createElement("source");
mp4.src = `${ASSET_PREFIX}/liang-evolution.mp4`;
mp4.type = "video/mp4";
this.video.append(webm, mp4);
this.poster = document.createElement("img");
this.poster.className = "liang-skin-poster";
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);
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
const image = new Image();
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
return image;
});
this.root.append(this.video, this.portrait, this.poster);
document.body.prepend(this.root);
this.video.addEventListener("loadedmetadata", this.handleMetadata);
this.video.addEventListener("error", this.handleVideoError);
this.poster.addEventListener("error", this.handlePosterError);
this.video.load();
this.unsubscribe = scope.subscribe(() => this.syncSettings());
this.syncSettings();
}
private readonly handleMetadata = () => {
this.seek(this.frame);
};
private readonly handleVideoError = () => {
this.root.dataset.media = "poster";
};
private readonly handleSequenceError = () => {
this.root.dataset.media = "video";
};
private readonly handlePosterError = () => {
this.root.dataset.media = "color";
};
private syncSettings() {
this.setEnabled(this.scope.getSnapshot().enabled);
}
isEnabled() {
return this.enabled;
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
if (enabled) {
if (!this.root.isConnected) document.body.prepend(this.root);
document.body.dataset.liangSkin = "on";
this.applyFrame();
} else {
this.root.remove();
delete document.body.dataset.liangSkin;
delete document.body.dataset.liangStage;
for (const name of cssVariables) document.body.style.removeProperty(name);
}
}
setFrame(frame: number) {
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
this.frame = this.pendingFrame;
if (!this.enabled) return;
if (this.raf !== 0) return;
this.raf = requestAnimationFrame(() => {
this.raf = 0;
this.frame = this.pendingFrame;
this.applyFrame();
});
}
private applyFrame() {
const palette = paletteForFrame(this.frame);
const body = document.body;
body.dataset.liangStage = String(palette.stage);
body.style.setProperty("--liang-strength", String(palette.strength));
body.style.setProperty("--liang-page", palette.page);
body.style.setProperty("--liang-bg-base", palette.base);
body.style.setProperty("--liang-layer-1", palette.layer1);
body.style.setProperty("--liang-layer-2", palette.layer2);
body.style.setProperty("--liang-layer-3", palette.layer3);
body.style.setProperty("--liang-sidebar", palette.sidebar);
body.style.setProperty("--liang-ink", palette.ink);
body.style.setProperty("--liang-secondary", palette.secondary);
body.style.setProperty("--liang-tertiary", palette.tertiary);
body.style.setProperty("--liang-border", palette.border);
body.style.setProperty("--liang-accent", palette.accent);
body.style.setProperty("--liang-accent-hover", palette.accentHover);
body.style.setProperty("--liang-hover", palette.hover);
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
this.updatePortrait(palette.level);
this.seek(this.frame);
}
private updatePortrait(level: number) {
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
level,
PORTRAIT_ANCHORS.map((anchor) => anchor.level),
);
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;
}
private seek(frame: number) {
if (this.video.readyState < 1 || this.video.duration === 0) return;
const duration = Number.isFinite(this.video.duration) ? this.video.duration : VIDEO_DURATION;
const target = Math.min(Math.max(0, duration - 0.001), (frame / PREVIEW_MAX_FRAME) * duration);
if (Math.abs(this.video.currentTime - target) > 0.025) this.video.currentTime = target;
}
async choose(enabled: boolean) {
this.setEnabled(enabled);
try {
await this.scope.set(enabled);
} catch (error) {
this.syncSettings();
throw error;
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.unsubscribe();
if (this.raf !== 0) cancelAnimationFrame(this.raf);
this.video.pause();
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
this.video.removeEventListener("error", this.handleVideoError);
this.portrait.removeEventListener("error", this.handleSequenceError);
this.poster.removeEventListener("error", this.handlePosterError);
for (const image of this.preloads) image.src = "";
this.root.remove();
document.body.removeAttribute("data-liang-skin");
delete document.body.dataset.liangStage;
for (const name of cssVariables) document.body.style.removeProperty(name);
}
}
function modelReasoning(state: ModelDirectoryState) {
const current = state.current;
if (current === null) return null;
const group = state.groups.find((item) => item.id === current.provider);
const model = group?.models.find((item) => item.id === current.model);
if (model?.reasoning === undefined) return null;
return {
selection: current,
efforts: model.reasoning.efforts,
defaultEffort: model.reasoning.defaultEffort,
};
}
function LiangEffortSlider({ directory, load, select, presenter, scope }: SliderProps) {
const state = useSyncExternalStore(
(listener) => directory.subscribe(listener),
() => directory.getSnapshot(),
);
const skin = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
);
const reasoning = useMemo(() => modelReasoning(state), [state]);
const efforts = reasoning?.efforts ?? [];
const committedIndex = selectedEffortIndex(
efforts,
reasoning?.selection.reasoningEffort,
reasoning?.defaultEffort,
);
const committedFrame = committedIndex < 0
? 0
: frameForEffort(committedIndex, efforts.length);
const [frame, setFrame] = useState(committedFrame);
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const [interacting, setInteracting] = useState(false);
const dragging = useRef(false);
const enabled = skin.enabled;
useEffect(() => {
if (enabled) load();
}, [enabled, load]);
useEffect(() => {
if (dragging.current || pending) return;
setFrame(committedFrame);
presenter.setFrame(committedFrame);
}, [committedFrame, pending, presenter]);
if (!enabled) return null;
if (reasoning === null || efforts.length < 2) return null;
const previewIndex = nearestEffortIndex(frame, efforts);
const previewEffort = efforts[previewIndex];
const progress = `${(frame / PREVIEW_MAX_FRAME) * 100}%`;
const commit = async (rawFrame: number) => {
const targetIndex = nearestEffortIndex(rawFrame, efforts);
const target = efforts[targetIndex];
if (target === undefined) return;
const targetFrame = frameForEffort(targetIndex, efforts.length);
dragging.current = false;
setFrame(targetFrame);
presenter.setFrame(targetFrame);
if (targetIndex === committedIndex || pending) return;
setPending(true);
setFailed(false);
const accepted = await select({
provider: reasoning.selection.provider,
model: reasoning.selection.model,
reasoningEffort: target.id,
});
setPending(false);
if (!accepted) {
setFailed(true);
setFrame(committedFrame);
presenter.setFrame(committedFrame);
}
};
return (
<div
className="liang-effort-control"
data-plugin={PACKAGE_ID}
data-state={failed ? "error" : pending ? "pending" : "ready"}
title={previewEffort?.name}
>
{interacting && (
<output
className="liang-effort-control__tooltip"
style={{ "--liang-tooltip-position": progress } as React.CSSProperties}
>
{indicatorLabel(frame, efforts)}
</output>
)}
<div className="liang-effort-control__ticks" aria-hidden="true">
{efforts.map((effort, index) => (
<i
className="liang-effort-control__tick"
key={effort.id}
style={{ left: `${(frameForEffort(index, efforts.length) / PREVIEW_MAX_FRAME) * 100}%` }}
/>
))}
</div>
<input
className="liang-effort-control__range"
type="range"
min={0}
max={PREVIEW_MAX_FRAME}
step={1}
value={frame}
disabled={pending || state.status === "selecting"}
aria-label="思考等级"
aria-valuetext={previewEffort?.name ?? ""}
style={{ "--liang-slider-progress": progress } as React.CSSProperties}
onPointerDown={() => {
dragging.current = true;
setInteracting(true);
}}
onInput={(event) => {
dragging.current = true;
const next = Number(event.currentTarget.value);
setFrame(next);
presenter.setFrame(next);
}}
onPointerUp={(event) => {
setInteracting(false);
void commit(Number(event.currentTarget.value));
}}
onPointerCancel={() => {
setInteracting(false);
dragging.current = false;
setFrame(committedFrame);
presenter.setFrame(committedFrame);
}}
onKeyUp={(event) => {
setInteracting(false);
if (event.key !== "Escape") void commit(Number(event.currentTarget.value));
}}
onBlur={(event) => {
setInteracting(false);
if (dragging.current) void commit(Number(event.currentTarget.value));
}}
onKeyDown={(event) => {
setInteracting(true);
if (event.key === "Escape" && !pending) {
setInteracting(false);
dragging.current = false;
setFrame(committedFrame);
presenter.setFrame(committedFrame);
}
}}
/>
</div>
);
}
function AppearanceSkinRow({ scope, presenter, t }: SettingsRowProps) {
const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
);
const enabled = snapshot.enabled;
const [pending, setPending] = useState(false);
const choose = async (next: boolean) => {
if (next === enabled || pending) return;
setPending(true);
try {
await presenter.choose(next);
} finally {
setPending(false);
}
};
return (
<div className="liang-settings-row" data-plugin={PACKAGE_ID}>
<span className="liang-settings-row__title">{t("appearance.title")}</span>
<div className="liang-settings-row__choices">
<button
className="liang-settings-row__choice"
type="button"
aria-pressed={!enabled}
disabled={pending}
onClick={() => void choose(false)}
>
{t("appearance.native")}
</button>
<button
className="liang-settings-row__choice"
type="button"
aria-pressed={enabled}
disabled={pending}
onClick={() => void choose(true)}
>
{t("appearance.liang")}
</button>
</div>
</div>
);
}
export const inject = [
"slots",
"sessions",
"modelDirectories",
"locale",
];
function createPreferenceStore(): PreferenceStore {
let snapshot: SkinSettings = {
enabled: localStorage.getItem(PREFERENCE_KEY) !== "0",
};
const listeners = new Set<() => void>();
const onStorage = (event: StorageEvent) => {
if (event.key !== PREFERENCE_KEY) return;
const enabled = event.newValue !== "0";
if (enabled === snapshot.enabled) return;
snapshot = { enabled };
for (const listener of listeners) listener();
};
window.addEventListener("storage", onStorage);
return {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async set(enabled) {
if (enabled === snapshot.enabled) return;
localStorage.setItem(PREFERENCE_KEY, enabled ? "1" : "0");
snapshot = { enabled };
for (const listener of listeners) listener();
},
dispose() {
window.removeEventListener("storage", onStorage);
listeners.clear();
},
};
}
export function apply(ctx: ClientContext) {
const style = document.createElement("style");
style.dataset.plugin = PACKAGE_ID;
style.textContent = styles;
document.head.append(style);
ctx.effect(() => () => style.remove(), "liang-intensity-skin: scoped styles");
const scope = createPreferenceStore();
ctx.effect(() => () => scope.dispose(), "liang-intensity-skin: appearance preference");
const presenter = new SkinPresenter(scope);
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
ctx.effect(() => ctx.locale.register(LOCALE_NAMESPACE, {
zh: {
"appearance.title": "外观皮肤",
"appearance.native": "原生",
"appearance.liang": "滑动变祖",
},
en: {
"appearance.title": "Visual skin",
"appearance.native": "Native",
"appearance.liang": "Slider",
},
}), "liang-intensity-skin: settings locale");
ctx.slots.inject("settings.general.item", () => ctx.slots.register({
name: "settings.general.item",
id: "liang-intensity-appearance",
order: 11,
locale: LOCALE_NAMESPACE,
inject: () => ({ scope, presenter }),
}, AppearanceSkinRow));
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
name: "conversation.input.right",
id: "liang-intensity-control",
order: 10,
inject: (sessionId: string) => {
const available = ctx.sessions.subagentAddress(sessionId) === undefined;
const directory = ctx.modelDirectories.directoryFor(sessionId) as ModelDirectory;
return {
directory: directory.store,
presenter,
scope,
load: () => {
if (available) void directory.load().catch(() => undefined);
},
select: (selection: ModelSelection) => available
? directory.select(selection).then(() => true, () => false)
: Promise.resolve(false),
};
},
}, LiangEffortSlider));
}

67
src/client/logic.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
frameForEffort,
indicatorLabel,
liangRankForFrame,
nearestEffortIndex,
paletteForFrame,
portraitBlendForLevel,
selectedEffortIndex,
} from "./logic";
const efforts = [
{ id: "off", name: "Off" },
{ id: "high", name: "High" },
{ id: "max", name: "Max" },
];
describe("滑动变祖 effort mapping", () => {
it("spreads dynamic effort metadata across all 241 preview frames", () => {
expect(frameForEffort(0, 3)).toBe(0);
expect(frameForEffort(1, 3)).toBe(120);
expect(frameForEffort(2, 3)).toBe(240);
expect(nearestEffortIndex(119, efforts)).toBe(1);
expect(nearestEffortIndex(181, efforts)).toBe(2);
});
it("recomputes anchors for two-level and five-level models", () => {
expect([0, 1].map((index) => frameForEffort(index, 2))).toEqual([0, 240]);
expect([0, 1, 2, 3, 4].map((index) => frameForEffort(index, 5))).toEqual([
0, 60, 120, 180, 240,
]);
});
it("uses the provider default only when the current selection omits an effort", () => {
expect(selectedEffortIndex(efforts, "max", "high")).toBe(2);
expect(selectedEffortIndex(efforts, undefined, "high")).toBe(1);
expect(selectedEffortIndex(efforts)).toBe(-1);
});
it("carries the original light-to-charcoal-to-gold stage mapping", () => {
expect(paletteForFrame(0)).toMatchObject({ stage: 0, strength: 0 });
expect(paletteForFrame(120)).toMatchObject({ stage: 0, level: 15 });
expect(paletteForFrame(191)).toMatchObject({ stage: 0 });
expect(paletteForFrame(192)).toMatchObject({ stage: 5 });
expect(paletteForFrame(239)).toMatchObject({ stage: 5 });
expect(paletteForFrame(240)).toMatchObject({ stage: 5, strength: 1 });
expect(paletteForFrame(0).accent).toBe("rgb(65 118 230)");
expect(paletteForFrame(240).accent).toBe("rgb(193 154 73)");
});
it("finds the nearest available portrait keys", () => {
const anchors = [0, 6, 12, 15, 18, 24, 27, 30];
expect(portraitBlendForLevel(0, anchors)).toEqual({ lowerIndex: 0, upperIndex: 0, mix: 0 });
expect(portraitBlendForLevel(13.5, anchors)).toEqual({ lowerIndex: 2, upperIndex: 3, mix: 0.5 });
expect(portraitBlendForLevel(31, anchors)).toEqual({ lowerIndex: 7, upperIndex: 7, mix: 0 });
});
it("labels continuous Liang ranks independently from model efforts", () => {
expect(liangRankForFrame(0)).toBe("小难梁");
expect(liangRankForFrame(48)).toBe("牢梁");
expect(liangRankForFrame(191)).toBe("梁圣");
expect(liangRankForFrame(192)).toBe("梁神");
expect(liangRankForFrame(240)).toBe("梁祖");
expect(indicatorLabel(240, efforts)).toBe("梁祖 · Max");
expect(indicatorLabel(119, efforts)).toBe("梁子 · High");
});
});

189
src/client/logic.ts Normal file
View File

@@ -0,0 +1,189 @@
export const PREVIEW_MAX_FRAME = 240;
export const MAX_LEVEL = 30;
const LIANG_RANKS = ["小难梁", "牢梁", "梁子", "梁圣", "梁神", "梁祖"] as const;
export interface EffortLike {
id: string;
name: string;
description?: string;
}
export interface Palette {
level: number;
stage: number;
strength: number;
page: string;
base: string;
layer1: string;
layer2: string;
layer3: string;
sidebar: string;
ink: string;
secondary: string;
tertiary: string;
border: string;
accent: string;
accentHover: string;
hover: string;
portraitOpacity: string;
}
type Rgb = readonly [number, number, number];
interface PaletteStop {
at: number;
page: Rgb;
surface: Rgb;
surface2: Rgb;
ink: Rgb;
secondary: Rgb;
accent: Rgb;
portraitOpacity: number;
}
// These are the original calibrator's six stage anchors: pale neutral at the
// low end, progressively smoked surfaces, then charcoal and antique gold.
const STOPS: readonly PaletteStop[] = [
{ at: 0, page: [232, 233, 229], surface: [248, 248, 245], surface2: [238, 239, 235], ink: [23, 24, 22], secondary: [112, 116, 111], accent: [181, 43, 36], portraitOpacity: 0.92 },
{ at: 6, page: [211, 211, 206], surface: [239, 239, 234], surface2: [224, 224, 218], ink: [26, 26, 24], secondary: [101, 101, 96], accent: [181, 43, 36], portraitOpacity: 0.93 },
{ at: 12, page: [171, 168, 162], surface: [211, 208, 201], surface2: [190, 186, 179], ink: [27, 26, 24], secondary: [83, 79, 74], accent: [166, 54, 42], portraitOpacity: 0.94 },
{ at: 18, page: [117, 112, 106], surface: [154, 148, 140], surface2: [128, 122, 115], ink: [24, 22, 20], secondary: [65, 61, 56], accent: [154, 56, 42], portraitOpacity: 0.94 },
{ at: 24, page: [43, 39, 37], surface: [48, 43, 40], surface2: [58, 51, 46], ink: [244, 241, 232], secondary: [184, 180, 169], accent: [181, 92, 54], portraitOpacity: 0.94 },
{ at: 30, page: [17, 17, 17], surface: [30, 27, 25], surface2: [41, 35, 30], ink: [244, 241, 232], secondary: [184, 180, 169], accent: [193, 154, 73], portraitOpacity: 0.94 },
];
export function clampFrame(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(value)));
}
export function frameForEffort(index: number, count: number): number {
if (count <= 1 || !Number.isFinite(index)) return 0;
const safe = Math.min(count - 1, Math.max(0, Math.round(index)));
return Math.round((safe / (count - 1)) * PREVIEW_MAX_FRAME);
}
export function nearestEffortIndex(frame: number, efforts: readonly EffortLike[]): number {
if (efforts.length === 0) return -1;
const safe = clampFrame(frame);
let best = 0;
let distance = Math.abs(safe - frameForEffort(0, efforts.length));
for (let index = 1; index < efforts.length; index += 1) {
const next = Math.abs(safe - frameForEffort(index, efforts.length));
if (next < distance) {
best = index;
distance = next;
}
}
return best;
}
export function selectedEffortIndex(
efforts: readonly EffortLike[],
selectedId?: string,
defaultId?: string,
): number {
const id = selectedId ?? defaultId;
return id === undefined ? -1 : efforts.findIndex((effort) => effort.id === id);
}
export function portraitBlendForLevel(level: number, anchors: readonly number[]) {
if (anchors.length === 0) return { lowerIndex: -1, upperIndex: -1, mix: 0 };
const safeLevel = Number.isFinite(level)
? Math.min(anchors[anchors.length - 1], Math.max(anchors[0], level))
: anchors[0];
let upperIndex = anchors.findIndex((anchor) => anchor >= safeLevel);
if (upperIndex < 0) upperIndex = anchors.length - 1;
const lowerIndex = Math.max(0, upperIndex - (anchors[upperIndex] > safeLevel ? 1 : 0));
const span = anchors[upperIndex] - anchors[lowerIndex];
return {
lowerIndex,
upperIndex,
mix: span === 0 ? 0 : (safeLevel - anchors[lowerIndex]) / span,
};
}
export function liangRankForFrame(rawFrame: number): string {
const level = (clampFrame(rawFrame) / PREVIEW_MAX_FRAME) * MAX_LEVEL;
const index = level >= MAX_LEVEL ? LIANG_RANKS.length - 1 : Math.floor(level / 6);
return LIANG_RANKS[Math.min(LIANG_RANKS.length - 1, Math.max(0, index))];
}
export function indicatorLabel(rawFrame: number, efforts: readonly EffortLike[]): string {
const effort = efforts[nearestEffortIndex(rawFrame, efforts)];
return effort === undefined
? liangRankForFrame(rawFrame)
: `${liangRankForFrame(rawFrame)} · ${effort.name}`;
}
function lerp(a: number, b: number, amount: number): number {
return a + (b - a) * amount;
}
function mix(a: Rgb, b: Rgb, amount: number): Rgb {
return [
Math.round(lerp(a[0], b[0], amount)),
Math.round(lerp(a[1], b[1], amount)),
Math.round(lerp(a[2], b[2], amount)),
];
}
function rgb(value: Rgb, alpha = 1): string {
return alpha === 1
? `rgb(${value[0]} ${value[1]} ${value[2]})`
: `rgb(${value[0]} ${value[1]} ${value[2]} / ${alpha})`;
}
export function paletteForFrame(rawFrame: number): Palette {
const frame = clampFrame(rawFrame);
const level = (frame / PREVIEW_MAX_FRAME) * MAX_LEVEL;
const portraitStage = Math.min(5, Math.floor(level / 6));
const portraitFrom = STOPS[portraitStage];
const portraitTo = STOPS[Math.min(5, portraitStage + 1)];
const portraitAmount = portraitFrom === portraitTo
? 0
: (level - portraitFrom.at) / (portraitTo.at - portraitFrom.at);
// The UI itself is intentionally binary: levels 0–23 keep the native light
// shell; entering the original 梁神/梁祖 region at level 24 switches black/gold.
const dark = level >= 24;
const stage = dark ? 5 : 0;
const ui = dark ? STOPS[5] : STOPS[0];
const page = ui.page;
const surface = ui.surface;
const surface2 = ui.surface2;
const sidebar = mix(page, surface2, 0.25);
const ink = ui.ink;
const secondary = ui.secondary;
// Keep Harness' native blue in the light shell. The original calibrator's
// antique gold only becomes the product accent once the dark region begins.
const accent: Rgb = dark ? [193, 154, 73] : [65, 118, 230];
const accentHover = mix(accent, ink, 0.13);
return {
level,
stage,
strength: level / MAX_LEVEL,
page: rgb(page),
// Keep the shell readable while allowing the right-side portrait to remain
// visibly present. Dense controls use the opaque layer tokens below.
base: rgb(page, dark ? 0.42 : 0.28),
layer1: rgb(surface, 0.94),
layer2: rgb(surface2, 0.96),
layer3: rgb(surface2, 0.99),
sidebar: rgb(sidebar, 0.96),
ink: rgb(ink),
secondary: rgb(secondary),
tertiary: rgb(mix(secondary, page, 0.28)),
border: rgb(ink, dark ? 0.15 : 0.12),
accent: rgb(accent),
accentHover: rgb(accentHover),
hover: rgb(ink, dark ? 0.09 : 0.07),
portraitOpacity: String(lerp(
portraitFrom.portraitOpacity,
portraitTo.portraitOpacity,
portraitAmount,
)),
};
}

472
src/client/skin.css Normal file
View File

@@ -0,0 +1,472 @@
.liang-skin-backdrop {
position: fixed;
z-index: 0;
inset: 0;
overflow: hidden;
pointer-events: none;
background: var(--liang-page, transparent);
opacity: 0;
transition: opacity 220ms ease, background-color 180ms linear;
}
body[data-liang-skin="on"] .liang-skin-backdrop {
opacity: 1;
}
.liang-skin-backdrop video,
.liang-skin-backdrop img {
position: absolute;
top: 50%;
right: 0;
display: block;
width: min(42vw, 720px);
height: 100vh;
object-fit: cover;
object-position: 50% 42%;
opacity: var(--liang-portrait-opacity, 0.45);
transform: translateY(-50%);
filter: none;
mask-image: linear-gradient(90deg, transparent 0%, rgb(0 0 0 / 20%) 10%, black 30%, black 100%);
transition: filter 180ms linear;
}
.liang-skin-backdrop img {
display: none;
}
.liang-skin-backdrop[data-media="sequence"] video,
.liang-skin-backdrop[data-media="sequence"] .liang-skin-poster {
display: none;
}
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame {
display: block;
opacity: var(--liang-portrait-opacity, 0.82);
}
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
.liang-skin-backdrop[data-media="video"] .liang-skin-poster {
display: none;
}
.liang-skin-backdrop[data-media="poster"] video,
.liang-skin-backdrop[data-media="poster"] .liang-skin-sequence-frame {
display: none;
}
.liang-skin-backdrop[data-media="poster"] .liang-skin-poster {
display: block;
}
.liang-skin-backdrop[data-media="color"] video,
.liang-skin-backdrop[data-media="color"] img {
display: none;
}
.liang-skin-backdrop::after {
position: absolute;
inset: 0;
content: "";
background: linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%);
}
body[data-liang-skin="on"] {
--dsw-alias-bg-base: var(--liang-bg-base) !important;
--dsw-alias-bg-layer-1: var(--liang-layer-1) !important;
--dsw-alias-bg-layer-2: var(--liang-layer-2) !important;
--dsw-alias-bg-layer-3: var(--liang-layer-3) !important;
--dsw-specific-sidebar-fill: var(--liang-sidebar) !important;
--dsw-alias-label-primary: var(--liang-ink) !important;
--dsw-alias-label-primary-dimmed: var(--liang-ink) !important;
--dsw-alias-label-secondary: var(--liang-secondary) !important;
--dsw-alias-label-tertiary: var(--liang-tertiary) !important;
--dsw-alias-label-caption: var(--liang-tertiary) !important;
--dsw-alias-border-l1: var(--liang-border) !important;
--dsw-alias-border-l2-darkmode-thin: var(--liang-border) !important;
--dsw-alias-border-l2: var(--liang-border) !important;
--dsw-alias-border-l3: var(--liang-border) !important;
--dsw-alias-button-primary-fill: var(--liang-accent) !important;
--dsw-alias-button-primary-hover: var(--liang-accent-hover) !important;
--dsw-alias-button-info-fill: var(--liang-accent) !important;
--dsw-alias-button-info-hover: var(--liang-accent-hover) !important;
--dsw-alias-state-business-primary: var(--liang-accent) !important;
--dsw-alias-interactive-bg-hover: var(--liang-hover) !important;
--dsw-alias-interactive-bg-active: var(--liang-hover) !important;
--dsw-alias-button-elevated-fill: var(--liang-layer-1) !important;
--dsw-alias-button-floating-fill: var(--liang-layer-2) !important;
--dsw-alias-button-floating-hover: var(--liang-layer-3) !important;
--dsw-alias-interactive-bg-hover-solid: var(--liang-layer-2) !important;
--dsw-specific-bubble: var(--liang-layer-2) !important;
--dsw-specific-input-major: var(--liang-layer-1) !important;
--dsw-specific-menu: var(--liang-layer-3) !important;
--dsw-specific-selector: var(--liang-layer-2) !important;
background: var(--liang-page) !important;
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) {
--dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65 118 230) !important;
--dsw-alias-brand-primary: rgb(15 17 21) !important;
--dsw-alias-brand-text: rgb(15 17 21) !important;
--dsw-alias-button-primary-fill: rgb(15 17 21) !important;
--dsw-alias-button-primary-hover: rgb(53 54 56) !important;
--dsw-alias-button-info-fill: rgb(65 118 230) !important;
--dsw-alias-button-info-hover: rgb(103 158 254) !important;
--dsw-alias-state-business-primary: rgb(65 118 230) !important;
--dsw-alias-label-primary-inverted: rgb(249 250 251) !important;
--dsw-alias-button-elevated-fill: rgb(255 255 255) !important;
--dsw-alias-button-floating-fill: rgb(255 255 255) !important;
--dsw-alias-button-floating-hover: rgb(241 243 245) !important;
--dsw-alias-interactive-bg-hover-solid: rgb(241 243 245) !important;
--dsw-specific-bubble: rgb(237 243 254) !important;
--dsw-specific-input-major: rgb(255 255 255) !important;
--dsw-specific-menu: rgb(255 255 255) !important;
--dsw-specific-selector: rgb(245 246 247) !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] {
--dsw-alias-brand-primary-new-colorprimary-new-color: #c19a49 !important;
--dsw-alias-brand-primary: #c19a49 !important;
--dsw-alias-brand-text: #d5b56e !important;
--dsw-alias-button-info-fill: #c19a49 !important;
--dsw-alias-button-info-hover: #d5b56e !important;
--dsw-alias-state-business-primary: #c19a49 !important;
}
/* Preserve the wordmark badge contrast even when the underlying Harness
preference is dark while our low-intensity shell is light. */
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) button[aria-label="新建会话"] svg[viewBox="0 0 182 24"] > rect[width="52"] {
fill: rgb(15 17 21) !important;
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) button[aria-label="新建会话"] svg[viewBox="0 0 182 24"] > g[clip-path*="badge"] path {
fill: rgb(249 250 251) !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] button[aria-label="新建会话"] svg[viewBox="0 0 182 24"] > rect[width="52"] {
fill: #c19a49 !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] button[aria-label="新建会话"] svg[viewBox="0 0 182 24"] > g[clip-path*="badge"] path {
fill: #171816 !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] [aria-label^="选择模型"] > span:nth-of-type(2) {
color: #d5b56e !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] [class*="heroGlow"] ellipse {
fill: #c19a49 !important;
fill-opacity: 0.16 !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] span[class*="_previewBadge"] {
color: #171816 !important;
background: #c19a49 !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] button[aria-label="发送消息"] {
color: #171816 !important;
}
body[data-liang-skin="on"][data-liang-stage="5"] button[aria-label="发送消息"]:disabled {
opacity: 0.62;
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) button[aria-label="发送消息"] {
color: #fff !important;
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) button[aria-label="发送消息"]:disabled {
color: rgb(255 255 255 / 92%) !important;
background: rgb(65 118 230 / 42%) !important;
opacity: 1;
}
body[data-liang-skin="on"] > #root {
position: relative;
z-index: 1;
background: transparent !important;
}
body[data-liang-skin="on"] button,
body[data-liang-skin="on"] input,
body[data-liang-skin="on"] textarea {
transition: color 160ms linear, background-color 160ms linear, border-color 160ms linear;
}
/* Harness can itself be in dark mode while the low intensity stages are light.
The composer card has a native theme-mode fill, so explicitly bring only
those light stages back to the original light surface and readable ink. */
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) [data-composer-card="true"] {
border-color: var(--liang-border) !important;
color: var(--liang-ink) !important;
background: var(--liang-layer-1) !important;
box-shadow: 0 8px 28px rgb(20 22 20 / 8%);
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) [data-composer-card="true"] :is(textarea, button, [data-input-mirror="true"]) {
color: var(--liang-ink) !important;
caret-color: var(--liang-accent) !important;
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) [data-composer-card="true"] textarea::placeholder {
color: var(--liang-tertiary) !important;
opacity: 1;
}
.liang-effort-control {
--liang-control-accent: var(--dsw-alias-brand-primary-new-colorprimary-new-color, #4176e6);
--liang-control-rail: var(--dsw-alias-border-l3, rgb(0 0 0 / 16%));
position: relative;
display: flex;
width: 124px;
height: 28px;
align-items: center;
margin: 0 3px;
overflow: visible;
}
body[data-liang-skin="on"] .liang-effort-control {
--liang-control-accent: var(--liang-accent);
--liang-control-rail: color-mix(in srgb, var(--liang-accent) 34%, transparent);
}
body[data-liang-skin="on"]:is(
[data-liang-stage="0"],
[data-liang-stage="1"],
[data-liang-stage="2"],
[data-liang-stage="3"]
) .liang-effort-control {
--liang-control-accent: var(--liang-secondary);
--liang-control-rail: color-mix(in srgb, var(--liang-secondary) 28%, transparent);
}
.liang-effort-control__ticks {
position: absolute;
z-index: 0;
inset: 0 10px;
pointer-events: none;
}
.liang-effort-control__tooltip {
position: absolute;
z-index: 4;
bottom: calc(100% + 7px);
left: var(--liang-tooltip-position);
min-width: max-content;
padding: 5px 8px;
border: 1px solid var(--liang-border, rgb(0 0 0 / 12%));
border-radius: 7px;
color: var(--liang-ink, #171816);
background: var(--liang-layer-1, #fff);
box-shadow: 0 5px 16px rgb(0 0 0 / 16%);
font-size: 11px;
font-weight: 600;
line-height: 16px;
letter-spacing: 0.01em;
pointer-events: none;
transform: translateX(-50%);
white-space: nowrap;
}
.liang-effort-control__tooltip::after {
position: absolute;
top: 100%;
left: 50%;
width: 7px;
height: 7px;
content: "";
background: inherit;
transform: translate(-50%, -4px) rotate(45deg);
}
.liang-effort-control__tick {
position: absolute;
top: 50%;
width: 1px;
height: 7px;
background: var(--liang-control-accent);
opacity: 0.42;
transform: translate(-50%, -50%);
}
.liang-effort-control__range {
position: relative;
z-index: 1;
width: 100%;
height: 28px;
margin: 0;
cursor: ew-resize;
appearance: none;
background: transparent;
touch-action: pan-y;
}
.liang-effort-control__range:disabled {
cursor: progress;
opacity: 0.58;
}
.liang-effort-control__range::-webkit-slider-runnable-track {
height: 1px;
border-radius: 1px;
background: linear-gradient(90deg, var(--liang-control-accent) 0 var(--liang-slider-progress), var(--liang-control-rail) var(--liang-slider-progress) 100%);
}
.liang-effort-control__range::-webkit-slider-thumb {
width: 13px;
height: 13px;
margin-top: -6px;
border: 1.5px solid color-mix(in srgb, var(--liang-control-accent) 72%, transparent);
border-radius: 50%;
appearance: none;
background: radial-gradient(circle, var(--liang-control-accent) 0 2px, color-mix(in srgb, var(--liang-control-accent) 22%, var(--dsw-alias-bg-layer-1, #fff)) 2.5px);
box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
}
body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
width: 13px;
height: 13px;
margin-top: -6px;
border-color: color-mix(in srgb, var(--liang-control-accent) 72%, transparent);
background: radial-gradient(circle, var(--liang-control-accent) 0 2px, color-mix(in srgb, var(--liang-control-accent) 22%, var(--liang-layer-1)) 2.5px);
}
.liang-effort-control__range::-moz-range-track {
height: 1px;
background: var(--liang-control-rail);
}
.liang-effort-control__range::-moz-range-progress {
height: 1px;
background: var(--liang-control-accent);
}
.liang-effort-control__range::-moz-range-thumb {
width: 11px;
height: 11px;
border: 1.5px solid color-mix(in srgb, var(--liang-control-accent) 72%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--liang-control-accent) 24%, var(--dsw-alias-bg-layer-1, #fff));
}
.liang-effort-control__range:focus-visible {
outline: none;
}
.liang-effort-control[data-state="error"] {
--liang-control-accent: var(--dsw-alias-state-error-primary, #e43c3c);
}
.liang-settings-row {
display: flex;
min-height: 82px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l1, rgb(0 0 0 / 6%));
color: var(--dsw-alias-label-primary);
}
.liang-settings-row__title {
flex: 0 0 auto;
font-size: 14px;
font-weight: 500;
}
.liang-settings-row__choices {
display: flex;
gap: 8px;
}
.liang-settings-row__choice {
min-width: 76px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l2, rgb(0 0 0 / 10%));
border-radius: 10px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.liang-settings-row__choice:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.liang-settings-row__choice[aria-pressed="true"] {
border-color: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary);
box-shadow: inset 0 0 0 1px var(--dsw-alias-label-primary);
}
@media (max-width: 760px) {
.liang-effort-control {
width: 92px;
}
.liang-skin-backdrop video,
.liang-skin-backdrop img {
right: -18vw;
width: 92vw;
opacity: calc(var(--liang-portrait-opacity, 0.45) * 0.72);
}
.liang-settings-row {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.liang-skin-backdrop,
.liang-skin-backdrop video,
.liang-skin-backdrop img,
body[data-liang-skin="on"] button,
body[data-liang-skin="on"] input,
body[data-liang-skin="on"] textarea {
transition: none;
}
}

146
src/index.js Normal file
View File

@@ -0,0 +1,146 @@
import { createReadStream, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseSingleRange } from "./range.js";
export { parseSingleRange } from "./range.js";
export const PACKAGE_ID = "dsh-client-liang-intensity-skin";
export const SETTINGS_NAMESPACE = "liang-intensity-skin";
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
const ASSET_SPECS = [
["liang-evolution.webm", "video/webm"],
["liang-evolution.mp4", "video/mp4"],
["liang-poster.png", "image/png"],
["portrait-source-v2/stage-00.png", "image/png"],
["portrait-source-v2/stage-06.png", "image/png"],
["portrait-source-v2/stage-12.png", "image/png"],
["portrait-source-v2/bridge-15.png", "image/png"],
["portrait-source-v2/stage-18.png", "image/png"],
["portrait-source-v2/stage-24.png", "image/png"],
["portrait-source-v2/bridge-27.png", "image/png"],
["portrait-source-v2/stage-30.png", "image/png"],
];
function send(res, status, headers = {}) {
res.writeHead(status, {
"X-Content-Type-Options": "nosniff",
...headers,
});
res.end();
}
function buildAssets() {
const assetDirectory = fileURLToPath(new URL("../assets/", import.meta.url));
const assets = new Map();
for (const [name, type] of ASSET_SPECS) {
const path = fileURLToPath(new URL(`../assets/${name}`, import.meta.url));
try {
const info = statSync(path);
if (!info.isFile()) continue;
assets.set(`${ASSET_PREFIX}${name}`, {
path,
type,
size: info.size,
etag: `W/\"${info.size.toString(16)}-${Math.trunc(info.mtimeMs).toString(16)}\"`,
});
} catch {
// A missing optional medium is handled by the browser fallback chain.
}
}
return { assetDirectory, assets };
}
function createAssetHandler(assets, activeStreams) {
return (req, res) => {
const method = req.method ?? "GET";
if (method !== "GET" && method !== "HEAD") {
send(res, 405, { Allow: "GET, HEAD" });
return;
}
let pathname;
try {
pathname = new URL(req.url ?? "/", "http://dsh.local").pathname;
} catch {
send(res, 404);
return;
}
const asset = assets.get(pathname);
if (asset === undefined) {
send(res, 404);
return;
}
if (req.headers["if-none-match"] === asset.etag) {
send(res, 304, { ETag: asset.etag });
return;
}
const ifRange = req.headers["if-range"];
const rangeHeader = ifRange !== undefined && ifRange !== asset.etag
? undefined
: req.headers.range;
const range = parseSingleRange(rangeHeader, asset.size);
if (range === false) {
send(res, 416, { "Content-Range": `bytes */${asset.size}` });
return;
}
const start = range?.start ?? 0;
const end = range?.end ?? asset.size - 1;
const status = range === null ? 200 : 206;
const headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600, must-revalidate",
"Content-Length": String(end - start + 1),
"Content-Type": asset.type,
ETag: asset.etag,
...(range === null ? {} : { "Content-Range": `bytes ${start}-${end}/${asset.size}` }),
};
if (method === "HEAD") {
send(res, status, headers);
return;
}
res.writeHead(status, {
"X-Content-Type-Options": "nosniff",
...headers,
});
const stream = createReadStream(asset.path, { start, end });
activeStreams.add(stream);
const release = () => activeStreams.delete(stream);
stream.once("close", release);
stream.once("end", release);
stream.once("error", () => {
release();
if (!res.headersSent) send(res, 500);
else res.destroy();
});
res.once("close", () => {
if (!stream.destroyed) stream.destroy();
});
stream.pipe(res);
};
}
export const inject = ["webServer"];
export function apply(ctx) {
const { assets } = buildAssets();
const activeStreams = new Set();
ctx.effect(() => {
const handler = createAssetHandler(assets, activeStreams);
const unregister = [...assets.keys()].map((path) => ctx.webServer.register({
kind: "exact",
path,
handler,
}));
return () => {
for (const dispose of unregister) dispose();
for (const stream of activeStreams) stream.destroy();
activeStreams.clear();
};
}, "liang-intensity-skin: static media route");
}

17
src/index.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { parseSingleRange } from "./range.js";
describe("Liang media range parser", () => {
it("accepts closed, open, and suffix ranges", () => {
expect(parseSingleRange("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
expect(parseSingleRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseSingleRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
});
it("rejects malformed, multi-part, and unsatisfiable ranges", () => {
expect(parseSingleRange("bytes=0-1,4-5", 100)).toBe(false);
expect(parseSingleRange("items=0-4", 100)).toBe(false);
expect(parseSingleRange("bytes=100-", 100)).toBe(false);
expect(parseSingleRange(undefined, 100)).toBe(null);
});
});

22
src/range.js Normal file
View File

@@ -0,0 +1,22 @@
export function parseSingleRange(value, size) {
if (value === undefined) return null;
if (!value.startsWith("bytes=") || value.includes(",")) return false;
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
if (match === null || (match[1] === "" && match[2] === "")) return false;
let start;
let end;
if (match[1] === "") {
const suffix = Number(match[2]);
if (!Number.isSafeInteger(suffix) || suffix <= 0) return false;
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === "" ? size - 1 : Number(match[2]);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return false;
if (start >= size || end < start) return false;
end = Math.min(end, size - 1);
}
return { start, end };
}