Use host native Switch for reasoning binding

This commit is contained in:
Leon
2026-10-16 16:14:49 +08:00
parent d0b7849c02
commit aff1dbe78e
10 changed files with 224 additions and 428 deletions

View File

@@ -9,6 +9,7 @@
- 外观入口按原生三个主题按钮的结构识别,兼容构建类名哈希变化;停用时清理入口和监听。 - 外观入口按原生三个主题按钮的结构识别,兼容构建类名哈希变化;停用时清理入口和监听。
- 人像逐张加载和解码,单张失败不阻止其他帧使用;失败后分别等待 0.5、1.5、3 秒重试,最多额外三次。 - 人像逐张加载和解码,单张失败不阻止其他帧使用;失败后分别等待 0.5、1.5、3 秒重试,最多额外三次。
- 未加载的目标帧保留上一张可用人像;全部失败时保留配色背景。停用时取消重试和请求,重新启用后重新加载。 - 未加载的目标帧保留上一张可用人像;全部失败时保留配色背景。停用时取消重试和请求,重新启用后重新加载。
- 推理等级绑定使用宿主原生 Switch;仅点击开关切换,标题不可点击,移除附加说明与自绘开关样式。
- 保持素材原色、面板透明度及原有推理等级绑定语义。本轮不处理跨刷新外观记忆,也不修改皮肤市场。 - 保持素材原色、面板透明度及原有推理等级绑定语义。本轮不处理跨刷新外观记忆,也不修改皮肤市场。
## 本地验证包 ## 本地验证包
@@ -24,7 +25,7 @@ DSH Web:可用 `dsh plugin --profile web add /绝对路径/测试包.tgz` 安
1. 安装测试包并完全退出、重开官方 Desktop,确认加载的是测试包;保留其他皮肤停用的状态。 1. 安装测试包并完全退出、重开官方 Desktop,确认加载的是测试包;保留其他皮肤停用的状态。
2. 打开「设置 → 通用设置 → 外观皮肤」:应有「滑动变祖」和推理等级绑定开关;切原生后人物及滑块消失,点击滑动变祖后恢复。 2. 打开「设置 → 通用设置 → 外观皮肤」:应有「滑动变祖」和推理等级绑定开关;切原生后人物及滑块消失,点击滑动变祖后恢复。
3. 在浅色档和黑金档分别观察人物、侧栏和输入区;调整窗口大小、切换侧栏,确认无半屏断层、文字可读。 3. 在浅色档和黑金档分别观察人物、侧栏和输入区;调整窗口大小、切换侧栏,确认无半屏断层、文字可读。
4. 拖动滑块:人物应随档位改变;关闭绑定后只改变人物和配色,不修改模型推理等级。 4. 拖动滑块:人物应随档位改变;关闭绑定后只改变人物和配色,不修改模型推理等级;点击绑定标题不会切换开关,开关外观应与原生「开发者工具」一致。
5. 打开「完全权限」、模型选择和其他弹窗,确认显示层级正常且可以点击;标题栏仍可拖动,窗口按钮可用。 5. 打开「完全权限」、模型选择和其他弹窗,确认显示层级正常且可以点击;标题栏仍可拖动,窗口按钮可用。
6. 刷新或重启时,外观仍按本次激活默认启用皮肤;跨刷新记住原生选择属于暂缓的 #9。 6. 刷新或重启时,外观仍按本次激活默认启用皮肤;跨刷新记住原生选择属于暂缓的 #9。

View File

@@ -25,6 +25,7 @@ __export(index_exports, {
}); });
module.exports = __toCommonJS(index_exports); module.exports = __toCommonJS(index_exports);
var import_react = require("react"); var import_react = require("react");
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
// src/client/skin.css // src/client/skin.css
var skin_default = `.liang-skin-backdrop { var skin_default = `.liang-skin-backdrop {
@@ -526,90 +527,14 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
} }
.liang-appearance-binding { .liang-appearance-binding {
box-sizing: border-box;
display: flex; display: flex;
width: 100%;
min-height: 28px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 24px;
padding: 4px 0 0; padding: 16px 0;
color: var(--dsw-alias-label-primary); border-bottom: 0.5px solid var(--dsw-alias-border-l2);
font: inherit;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 20px;
cursor: pointer;
}
.liang-appearance-binding__label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.liang-appearance-binding__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.liang-appearance-binding__description {
display: block;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.liang-appearance-binding__input {
position: relative;
flex: 0 0 36px;
width: 36px;
height: 20px;
margin: 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
outline: none;
cursor: pointer;
appearance: none;
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-module-platform));
}
.liang-appearance-binding__input::before {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
content: "";
background: var(--dsw-alias-label-primary);
box-shadow: 0 1px 3px rgb(0 0 0 / 18%);
transition: transform 160ms ease, background-color 160ms ease;
}
.liang-appearance-binding__input:checked {
border-color: var(--dsw-static-neutral-bluish-400);
background: var(--dsw-static-neutral-bluish-400);
}
.liang-appearance-binding__input:checked::before {
background: var(--dsw-alias-bg-layer-1, #fff);
transform: translateX(16px);
}
.liang-appearance-binding__input:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-interactive-bg-hover);
}
.liang-appearance-binding__input:disabled {
cursor: progress;
opacity: 0.7;
} }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -639,8 +564,6 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
// src/client/appearance.ts // src/client/appearance.ts
var NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]'; var NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]';
var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice"; var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
var LIANG_BINDING_CONTROL = "liang-appearance-binding";
var LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function hasClassSuffix(node, suffix) { function hasClassSuffix(node, suffix) {
return [...node.classList].some((name) => name.endsWith(suffix)); return [...node.classList].some((name) => name.endsWith(suffix));
} }
@@ -672,9 +595,7 @@ function installLiangAppearanceButton(scope, presenter, packageId) {
nativeClickHandlers.clear(); nativeClickHandlers.clear();
if (mounted === null) return; if (mounted === null) return;
mounted.button.removeEventListener("click", mounted.handleCustomClick); mounted.button.removeEventListener("click", mounted.handleCustomClick);
mounted.bindingInput.removeEventListener("change", mounted.handleBindingChange);
mounted.button.remove(); mounted.button.remove();
mounted.binding.remove();
mounted = null; mounted = null;
}; };
const choose = async (enabled) => { const choose = async (enabled) => {
@@ -708,41 +629,7 @@ function installLiangAppearanceButton(scope, presenter, packageId) {
void choose(true); void choose(true);
}; };
button.addEventListener("click", handleCustomClick); button.addEventListener("click", handleCustomClick);
const binding = document.createElement("label"); return { row, group, button, handleCustomClick };
binding.className = LIANG_BINDING_CONTROL;
binding.dataset.plugin = packageId;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = english ? "Bind slider to reasoning level" : "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = english ? "When off, the slider does not change the reasoning level." : "\u5173\u95ED\u4E4B\u540E\u6ED1\u5757\u4E0D\u8054\u52A8\u601D\u8003\u7B49\u7EA7";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
const updateBinding = async () => {
if (disposed || pending) return;
try {
await scope.setBindEffort(bindingInput.checked);
} catch {
} finally {
if (!disposed) sync();
}
};
const handleBindingChange = () => {
void updateBinding();
};
bindingInput.addEventListener("change", handleBindingChange);
binding.append(bindingCopy, bindingInput);
return { row, group, button, binding, bindingInput, handleCustomClick, handleBindingChange };
}; };
const sync = () => { const sync = () => {
if (disposed) return; if (disposed) return;
@@ -755,19 +642,11 @@ function installLiangAppearanceButton(scope, presenter, packageId) {
unmount(); unmount();
} }
mounted ??= createMount(target.row, target.group); mounted ??= createMount(target.row, target.group);
const { row, group, button, binding, bindingInput } = mounted; const { row, button } = mounted;
if (button.parentElement !== row) row.append(button); if (button.parentElement !== row) row.append(button);
const snapshot = scope.getSnapshot(); const snapshot = scope.getSnapshot();
button.disabled = pending; button.disabled = pending;
button.setAttribute("aria-pressed", String(snapshot.enabled)); button.setAttribute("aria-pressed", String(snapshot.enabled));
if (snapshot.enabled) {
if (binding.parentElement !== group) group.append(binding);
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
} else {
binding.remove();
}
for (const [nativeButton, handler] of nativeClickHandlers) { for (const [nativeButton, handler] of nativeClickHandlers) {
if (target.nativeButtons.includes(nativeButton)) continue; if (target.nativeButtons.includes(nativeButton)) continue;
nativeButton.removeEventListener("click", handler, { capture: true }); nativeButton.removeEventListener("click", handler, { capture: true });
@@ -1405,6 +1284,30 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }) {
} }
); );
} }
function EffortBindingRow({ scope, t }) {
const snapshot = (0, import_react.useSyncExternalStore)(
(listener) => scope.subscribe(listener),
() => scope.getSnapshot()
);
const [pending, setPending] = (0, import_react.useState)(false);
if (!snapshot.enabled) return null;
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "liang-appearance-binding", "data-plugin": PACKAGE_ID, children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("effort.binding") }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_dsh_client_ui_primitives.Switch,
{
checked: snapshot.bindEffort,
disabled: pending,
label: t("effort.binding"),
onChange: (next) => {
if (pending) return;
setPending(true);
void scope.setBindEffort(next).catch(() => void 0).finally(() => setPending(false));
}
}
)
] });
}
var inject = [ var inject = [
"slots", "slots",
"sessions", "sessions",
@@ -1473,23 +1376,18 @@ function apply(ctx) {
); );
ctx.effect( ctx.effect(
() => ctx.locale.register(LOCALE_NAMESPACE, { () => ctx.locale.register(LOCALE_NAMESPACE, {
zh: { zh: { "effort.binding": "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7" },
"appearance.title": "\u5916\u89C2", en: { "effort.binding": "Bind slider to reasoning level" }
"appearance.light": "\u6D45\u8272",
"appearance.dark": "\u6DF1\u8272",
"appearance.system": "\u8DDF\u968F\u7CFB\u7EDF",
"appearance.liang": "\u6ED1\u52A8\u53D8\u7956"
},
en: {
"appearance.title": "Appearance",
"appearance.light": "Light",
"appearance.dark": "Dark",
"appearance.system": "System",
"appearance.liang": "Slider"
}
}), }),
"liang-intensity-skin: settings locale" "liang-intensity-skin: settings locale"
); );
ctx.slots.inject("settings.general.item", () => ctx.slots.register({
name: "settings.general.item",
id: "liang-effort-binding",
order: 10.5,
locale: LOCALE_NAMESPACE,
inject: () => ({ scope })
}, EffortBindingRow));
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
name: "conversation.input.right", name: "conversation.input.right",
id: "liang-intensity-control", id: "liang-intensity-control",

File diff suppressed because one or more lines are too long

View File

@@ -63,6 +63,7 @@
"@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-theme", "@deepseek-ai/dsh-client-ui-theme",
"@deepseek-ai/dsh-client-ui-settings-general", "@deepseek-ai/dsh-client-ui-settings-general",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes" "@deepseek-ai/dsh-api-remotes"
] ]

View File

@@ -15,6 +15,7 @@ await build({
external: [ external: [
"react", "react",
"react/jsx-runtime", "react/jsx-runtime",
"@deepseek-ai/dsh-client-ui-primitives",
], ],
define: { define: {
"process.env.NODE_ENV": '"production"', "process.env.NODE_ENV": '"production"',

View File

@@ -67,10 +67,10 @@ async function prepare(context, options = {}) {
await page.setContent(fixture(options)); await page.setContent(fixture(options));
await page.addScriptTag({ content: compiled.outputFiles[0].text }); await page.addScriptTag({ content: compiled.outputFiles[0].text });
await page.evaluate(({ packageId, enabled }) => { await page.evaluate(({ packageId, enabled }) => {
let snapshot = { enabled, bindEffort: true }; let snapshot = { enabled };
const listeners = new Set(); const listeners = new Set();
const calls = []; const calls = [];
let releaseChoice, holdNextChoice = false, failNextChoice = false, failNextBinding = false; let releaseChoice, holdNextChoice = false, failNextChoice = false;
let dispose = () => {}; let dispose = () => {};
const publish = patch => { const publish = patch => {
snapshot = { ...snapshot, ...patch }; snapshot = { ...snapshot, ...patch };
@@ -81,11 +81,6 @@ async function prepare(context, options = {}) {
const scope = { const scope = {
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
async setBindEffort(value) {
calls.push(["binding", value]);
if (failNextBinding) { failNextBinding = false; throw new Error("fixture write rejected"); }
publish({ bindEffort: value });
},
}; };
const presenter = { const presenter = {
async choose(value) { async choose(value) {
@@ -120,7 +115,7 @@ async function prepare(context, options = {}) {
}, },
dispose() { dispose(); }, setEnabled(value) { publish({ enabled: value }); }, dispose() { dispose(); }, setEnabled(value) { publish({ enabled: value }); },
holdChoice() { holdNextChoice = true; }, releaseChoice() { releaseChoice?.(); }, holdChoice() { holdNextChoice = true; }, releaseChoice() { releaseChoice?.(); },
rejectChoice() { failNextChoice = true; }, rejectBinding() { failNextBinding = true; }, rejectChoice() { failNextChoice = true; },
}; };
document.querySelector("#portal").addEventListener("click", () => window.fixture.portalClicks++); document.querySelector("#portal").addEventListener("click", () => window.fixture.portalClicks++);
publish({}); publish({});
@@ -183,45 +178,38 @@ async function assertLayout(page, options, enabled = true) {
async function appearanceChecks(page, hash) { async function appearanceChecks(page, hash) {
await page.evaluate(hash => window.fixture.install(hash), hash); await page.evaluate(hash => window.fixture.install(hash), hash);
const custom = page.locator("#native-row > .liang-appearance-choice"), binding = page.locator(".liang-appearance-binding__input"); const custom = page.locator("#native-row > .liang-appearance-choice");
await custom.waitFor(); await custom.waitFor();
assert.equal(await page.locator(".liang-appearance-choice").count(), 1); assert.equal(await page.locator(".liang-appearance-choice").count(), 1);
assert.equal(await page.locator("#unrelated .liang-appearance-choice, #nested-unrelated .liang-appearance-choice").count(), 0); assert.equal(await page.locator("#unrelated .liang-appearance-choice, #nested-unrelated .liang-appearance-choice").count(), 0);
assert.equal(await custom.getAttribute("data-plugin"), packageId); assert.equal(await custom.getAttribute("data-plugin"), packageId);
assert.equal(await custom.getAttribute("aria-pressed"), "true"); assert.equal(await custom.getAttribute("aria-pressed"), "true");
await binding.uncheck(); assert.equal(await page.locator(".liang-appearance-binding, input[role=switch]").count(), 0,
await page.waitForFunction(() => !window.fixture.snapshot().bindEffort); "The imperative appearance extension must not draw its own binding switch");
assert.equal(await binding.getAttribute("aria-checked"), "false");
await binding.check(); await page.waitForFunction(() => window.fixture.snapshot().bindEffort);
for (const theme of ["light", "dark", "system"]) { for (const theme of ["light", "dark", "system"]) {
await page.locator(`[data-theme="${theme}"]`).click(); await page.locator(`[data-theme="${theme}"]`).click();
await page.waitForFunction(() => !window.fixture.snapshot().enabled); await page.waitForFunction(() => !window.fixture.snapshot().enabled);
assert.equal(await page.locator(".liang-appearance-binding").count(), 0); assert.equal(await page.locator(".liang-appearance-binding").count(), 0);
assert.equal(await custom.getAttribute("aria-pressed"), "false"); assert.equal(await custom.getAttribute("aria-pressed"), "false");
assert.equal(await page.evaluate(() => window.fixture.nativeTheme), theme); assert.equal(await page.evaluate(() => window.fixture.nativeTheme), theme);
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled); await binding.waitFor(); await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
} }
assert.equal(await page.evaluate(() => window.fixture.nativeClicks), 3, "Native theme handlers still run"); assert.equal(await page.evaluate(() => window.fixture.nativeClicks), 3, "Native theme handlers still run");
await page.evaluate(() => { await page.evaluate(() => {
window.oldNative = document.querySelector('[data-theme="light"]'); window.oldNative = document.querySelector('[data-theme="light"]');
window.oldCustom = document.querySelector(".liang-appearance-choice"); window.oldCustom = document.querySelector(".liang-appearance-choice");
window.oldBinding = document.querySelector(".liang-appearance-binding__input"); window.fixture.rebuild(); window.fixture.rebuild();
}); });
await page.locator("#native-row._newHash42_cubeRow > .liang-appearance-choice").waitFor(); await page.locator("#native-row._newHash42_cubeRow > .liang-appearance-choice").waitFor();
assert.equal(await page.locator(".liang-appearance-choice").count(), 1, "DOM reconstruction produces one control"); assert.equal(await page.locator(".liang-appearance-choice").count(), 1, "DOM reconstruction produces one control");
const beforeStaleClicks = await page.evaluate(() => window.fixture.calls.length); const beforeStaleClicks = await page.evaluate(() => window.fixture.calls.length);
await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); window.oldBinding.dispatchEvent(new Event("change", { bubbles: true })); }); await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); });
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeStaleClicks, "Replaced nodes lose plugin listeners"); assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeStaleClicks, "Replaced nodes lose plugin listeners");
await page.locator('[data-theme="dark"]').click(); await page.waitForFunction(() => !window.fixture.snapshot().enabled); await page.locator('[data-theme="dark"]').click(); await page.waitForFunction(() => !window.fixture.snapshot().enabled);
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled); await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
await page.evaluate(() => window.fixture.removeRow()); await page.evaluate(() => window.fixture.removeRow());
await page.waitForFunction(() => document.querySelector(".liang-appearance-choice") === null); await page.waitForFunction(() => document.querySelector(".liang-appearance-choice") === null);
await page.evaluate(() => window.fixture.restoreRow()); await custom.waitFor(); await page.evaluate(() => window.fixture.restoreRow()); await custom.waitFor();
await page.evaluate(() => {
window.fixture.rejectBinding(); const input = document.querySelector(".liang-appearance-binding__input");
input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.waitForFunction(() => document.querySelector(".liang-appearance-binding__input").checked);
await page.evaluate(() => window.fixture.rejectChoice()); await page.locator('[data-theme="light"]').click(); await page.evaluate(() => window.fixture.rejectChoice()); await page.locator('[data-theme="light"]').click();
await page.waitForFunction(() => !document.querySelector(".liang-appearance-choice").disabled); await page.waitForFunction(() => !document.querySelector(".liang-appearance-choice").disabled);
assert.equal(await custom.getAttribute("aria-pressed"), "true", "Rejected preference preserves skin state"); assert.equal(await custom.getAttribute("aria-pressed"), "true", "Rejected preference preserves skin state");
@@ -266,7 +254,7 @@ try {
for (const hash of ["_8HJdBW", "_currentHash89"]) { for (const hash of ["_8HJdBW", "_currentHash89"]) {
const { page, errors } = await prepare(context); await appearanceChecks(page, hash); const { page, errors } = await prepare(context); await appearanceChecks(page, hash);
assert.deepEqual(errors, [], `${hash}: browser errors`); await page.close(); assert.deepEqual(errors, [], `${hash}: browser errors`); await page.close();
console.log(`PASS appearance ${hash}: multi-class discovery, choices, binding, DOM rebuild, disposal race`); console.log(`PASS appearance ${hash}: multi-class discovery, choices, DOM rebuild, disposal race`);
} }
const first = await prepare(context), second = await prepare(context, { desktop: true, windows: true, top: 40 }); const first = await prepare(context), second = await prepare(context, { desktop: true, windows: true, top: 40 });
await first.page.evaluate(() => window.fixture.install()); await second.page.evaluate(() => window.fixture.install("_otherWindow")); await first.page.evaluate(() => window.fixture.install()); await second.page.evaluate(() => window.fixture.install("_otherWindow"));

View File

@@ -87,6 +87,8 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
cleanups: [], decoded: new WeakSet(), decodedImages: [], cleanups: [], decoded: new WeakSet(), decodedImages: [],
registered: null, scope: null, presenter: null, module: null, registered: null, scope: null, presenter: null, module: null,
activeLocales: 0, activeSlots: 0, errors: [], activeLocales: 0, activeSlots: 0, errors: [],
registrations: new Map(), dictionaries: new Map(), binding: null,
bindingHooks: [], bindingHookIndex: 0, renderingBinding: false,
}; };
window.__runtime = runtime; window.__runtime = runtime;
window.addEventListener('unhandledrejection', (event) => runtime.errors.push(String(event.reason))); window.addEventListener('unhandledrejection', (event) => runtime.errors.push(String(event.reason)));
@@ -100,17 +102,34 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
return value; return value;
}); });
}; };
const unexpectedRender = () => { throw new Error('The runtime fixture must not render React components'); }; const unexpectedRender = () => { throw new Error('The runtime fixture must not render a host React component'); };
// This identity stands for the host's imported component. Never implement
// its DOM or styles here: native Switch visuals are verified in real DSH.
runtime.nativeSwitch = unexpectedRender;
const jsx = (type, props, key) => ({ type, props, key });
const useState = (initial) => {
if (!runtime.renderingBinding) return unexpectedRender();
const index = runtime.bindingHookIndex++;
if (!(index in runtime.bindingHooks)) runtime.bindingHooks[index] = initial;
return [runtime.bindingHooks[index], (next) => {
runtime.bindingHooks[index] = typeof next === 'function' ? next(runtime.bindingHooks[index]) : next;
}];
};
const useSyncExternalStore = (_subscribe, getSnapshot) => {
if (!runtime.renderingBinding) return unexpectedRender();
return getSnapshot();
};
window.__ModuleLoader__ = { window.__ModuleLoader__ = {
load({ id, factory }) { load({ id, factory }) {
runtime.moduleId = id; runtime.moduleId = id;
runtime.module = factory((name) => { runtime.module = factory((name) => {
if (name === 'react') return { if (name === 'react') return {
useEffect: unexpectedRender, useMemo: unexpectedRender, useEffect: unexpectedRender, useMemo: unexpectedRender,
useRef: unexpectedRender, useState: unexpectedRender, useRef: unexpectedRender, useState,
useSyncExternalStore: unexpectedRender, useSyncExternalStore,
}; };
if (name === 'react/jsx-runtime') return { jsx: unexpectedRender, jsxs: unexpectedRender }; if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch: runtime.nativeSwitch };
throw new Error(`Unexpected client import: ${name}`); throw new Error(`Unexpected client import: ${name}`);
}); });
}, },
@@ -137,9 +156,13 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
throw new Error(`Unexpected host service: ${name}`); throw new Error(`Unexpected host service: ${name}`);
}, },
locale: { locale: {
register() { register(namespace, dictionaries) {
runtime.activeLocales += 1; runtime.activeLocales += 1;
return () => { runtime.activeLocales -= 1; }; runtime.dictionaries.set(namespace, dictionaries);
return () => {
runtime.activeLocales -= 1;
runtime.dictionaries.delete(namespace);
};
}, },
}, },
slots: { slots: {
@@ -147,12 +170,24 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
const cleanup = callback(); const cleanup = callback();
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup); if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
}, },
register(definition) { register(definition, component) {
runtime.activeSlots += 1; runtime.activeSlots += 1;
runtime.registered = definition.inject('fixture-session'); const props = definition.inject?.('fixture-session') ?? {};
runtime.scope = runtime.registered.scope; const registration = { definition, component, props };
runtime.presenter = runtime.registered.presenter; runtime.registrations.set(definition.id, registration);
return () => { runtime.activeSlots -= 1; }; if (definition.id === 'liang-intensity-control') {
runtime.registered = props;
runtime.scope = props.scope;
runtime.presenter = props.presenter;
} else if (definition.id === 'liang-effort-binding') {
runtime.binding = registration;
} else {
throw new Error(`Unexpected client slot: ${definition.id}`);
}
return () => {
runtime.activeSlots -= 1;
runtime.registrations.delete(definition.id);
};
}, },
}, },
sessions: { subagentAddress: () => undefined }, sessions: { subagentAddress: () => undefined },
@@ -166,6 +201,17 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
}; };
if (typeof runtime.module?.apply !== 'function') throw new Error('Built client did not export apply(ctx)'); if (typeof runtime.module?.apply !== 'function') throw new Error('Built client did not export apply(ctx)');
runtime.module.apply(ctx); runtime.module.apply(ctx);
runtime.renderBinding = (language = 'zh') => {
if (runtime.binding === null) throw new Error('The native binding row was not registered');
const { definition, component, props } = runtime.binding;
runtime.bindingHookIndex = 0;
runtime.renderingBinding = true;
try {
return component({ ...props, t: (key) => runtime.dictionaries.get(definition.locale)?.[language]?.[key] ?? key });
} finally {
runtime.renderingBinding = false;
}
};
runtime.dispose = () => { runtime.dispose = () => {
for (const cleanup of runtime.cleanups.splice(0).reverse()) cleanup(); for (const cleanup of runtime.cleanups.splice(0).reverse()) cleanup();
}; };
@@ -205,7 +251,7 @@ async function waitForPortrait(page, filename, retry = null) {
} }
async function assertRemoved(page, fullyDisposed = false) { async function assertRemoved(page, fullyDisposed = false) {
const result = await page.evaluate(() => ({ const result = await page.evaluate((fullyDisposed) => ({
backdrop: document.querySelectorAll('.liang-skin-backdrop').length, backdrop: document.querySelectorAll('.liang-skin-backdrop').length,
skin: document.body.hasAttribute('data-liang-skin'), skin: document.body.hasAttribute('data-liang-skin'),
stage: document.body.hasAttribute('data-liang-stage'), stage: document.body.hasAttribute('data-liang-stage'),
@@ -215,12 +261,14 @@ async function assertRemoved(page, fullyDisposed = false) {
controls: document.querySelectorAll('.liang-appearance-choice, .liang-appearance-binding').length, controls: document.querySelectorAll('.liang-appearance-choice, .liang-appearance-binding').length,
locales: window.__runtime.activeLocales, locales: window.__runtime.activeLocales,
slots: window.__runtime.activeSlots, slots: window.__runtime.activeSlots,
})); bindingVisible: !fullyDisposed && window.__runtime.renderBinding() !== null,
}), fullyDisposed);
assert.equal(result.backdrop, 0, 'No portrait backdrop remains'); assert.equal(result.backdrop, 0, 'No portrait backdrop remains');
assert.equal(result.skin, false, 'Skin marker is removed'); assert.equal(result.skin, false, 'Skin marker is removed');
assert.equal(result.stage, false, 'Stage marker is removed'); assert.equal(result.stage, false, 'Stage marker is removed');
assert.deepEqual(result.variables, [], 'All presenter CSS variables are removed'); assert.deepEqual(result.variables, [], 'All presenter CSS variables are removed');
assert.equal(result.retainedSources, 0, 'Decoded images release their network sources'); assert.equal(result.retainedSources, 0, 'Decoded images release their network sources');
assert.equal(result.bindingVisible, false, 'The binding slot returns null while the skin is off');
if (fullyDisposed) { if (fullyDisposed) {
assert.equal(result.styles, 0, 'Client stylesheet is removed'); assert.equal(result.styles, 0, 'Client stylesheet is removed');
assert.equal(result.controls, 0, 'Appearance controls are removed'); assert.equal(result.controls, 0, 'Appearance controls are removed');
@@ -229,6 +277,79 @@ async function assertRemoved(page, fullyDisposed = false) {
} }
} }
async function assertNativeBinding(page) {
const result = await page.evaluate(async () => {
const runtime = window.__runtime;
const check = (condition, message) => { if (!condition) throw new Error(message); };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const binding = runtime.binding;
check(binding?.definition.name === 'settings.general.item'
&& binding.definition.id === 'liang-effort-binding'
&& binding.definition.order === 10.5, 'Binding uses the native General Settings slot after appearance');
check(binding.props.scope === runtime.scope, 'Binding and portrait share the real preference store');
const controls = (language = 'zh') => {
const tree = runtime.renderBinding(language);
const nodes = [];
const visit = (node) => {
if (Array.isArray(node)) return node.forEach(visit);
if (node === null || typeof node !== 'object') return;
nodes.push(node);
visit(node.props?.children);
};
visit(tree);
const switches = nodes.filter((node) => node.type === runtime.nativeSwitch);
check(switches.length === 1, 'Binding must reference the imported host Switch exactly once');
check(tree.type === 'div', 'The binding row is a non-interactive div');
check(nodes.every((node) => !['input', 'button', 'label'].includes(node.type)
&& node.props?.onClick === undefined && node.props?.htmlFor === undefined),
'No hand-drawn switch or whole-row/text click handler');
check(switches[0].props.className === undefined && switches[0].props.style === undefined,
'Native Switch receives no skin shape or color overrides');
const text = nodes.find((node) => node.type === 'span');
check(text?.props.children === switches[0].props.label, 'Visible text is the native Switch accessible name');
return { text, control: switches[0] };
};
let { text, control } = controls();
check(text.props.children === '滑动变祖绑定思考等级', 'Original Chinese binding label is preserved');
check(controls('en').text.props.children === 'Bind slider to reasoning level', 'English binding label comes from locale');
check(control.props.checked === true && control.props.disabled === false, 'Binding starts with persisted state');
text.props.onClick?.();
check(runtime.scope.getSnapshot().bindEffort === true, 'Text has no binding write callback');
control.props.onChange(false);
check(controls().control.props.disabled === true, 'Native Switch is disabled while saving');
await flush();
control = controls().control;
check(runtime.scope.getSnapshot().bindEffort === false && control.props.checked === false
&& control.props.disabled === false, 'Only Switch onChange writes the binding preference');
const setBindEffort = runtime.scope.setBindEffort;
let rejectWrite, writes = 0;
runtime.scope.setBindEffort = () => {
writes += 1;
return new Promise((_, reject) => { rejectWrite = reject; });
};
try {
control.props.onChange(true);
control = controls().control;
check(control.props.disabled === true, 'Pending write keeps the native control disabled');
control.props.onChange(true);
check(writes === 1, 'Pending change cannot enqueue a second write');
rejectWrite(new Error('fixture: binding write rejected'));
await flush();
control = controls().control;
check(control.props.checked === false && control.props.disabled === false,
'Failed write preserves accepted state and unlocks the native control');
} finally {
runtime.scope.setBindEffort = setBindEffort;
}
control.props.onChange(true);
await flush();
check(runtime.scope.getSnapshot().bindEffort === true, 'Binding remains usable after a failed write');
return { passed: true, slots: runtime.activeSlots };
});
assert.equal(result.passed, true);
assert.equal(result.slots, 2, 'Runtime registers both conversation and native binding slots');
}
async function assertNoErrors(test) { async function assertNoErrors(test) {
assert.deepEqual(test.errors, [], 'No browser runtime exception'); assert.deepEqual(test.errors, [], 'No browser runtime exception');
assert.deepEqual(await test.page.evaluate(() => window.__runtime.errors), [], 'No unhandled runtime promise rejection'); assert.deepEqual(await test.page.evaluate(() => window.__runtime.errors), [], 'No unhandled runtime promise rejection');
@@ -241,6 +362,7 @@ try {
await waitForPortrait(success.page, 'stage-30.webp'); await waitForPortrait(success.page, 'stage-30.webp');
assert.equal(success.requests.length, 24, 'Decoded display nodes do not start duplicate requests'); assert.equal(success.requests.length, 24, 'Decoded display nodes do not start duplicate requests');
assert.equal(new Set(success.requests.map(({ name }) => name)).size, 24, 'Every shipped anchor is requested'); assert.equal(new Set(success.requests.map(({ name }) => name)).size, 24, 'Every shipped anchor is requested');
await assertNativeBinding(success.page);
await success.page.evaluate(() => window.__runtime.presenter.setFrame(48)); await success.page.evaluate(() => window.__runtime.presenter.setFrame(48));
await waitForPortrait(success.page, 'stage-06.webp'); await waitForPortrait(success.page, 'stage-06.webp');
await success.page.evaluate(() => window.__runtime.presenter.choose(false)); await success.page.evaluate(() => window.__runtime.presenter.choose(false));
@@ -301,7 +423,7 @@ try {
} finally { } finally {
await recovery.close(); await recovery.close();
} }
console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, disable/enable, and disposal with late responses.'); console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, native Switch binding contract, disable/enable, and disposal with late responses.');
} finally { } finally {
await browser.close(); await browser.close();
} }

View File

@@ -1,12 +1,10 @@
export interface AppearanceSnapshot { export interface AppearanceSnapshot {
enabled: boolean; enabled: boolean;
bindEffort: boolean;
} }
export interface AppearanceScope { export interface AppearanceScope {
getSnapshot(): AppearanceSnapshot; getSnapshot(): AppearanceSnapshot;
subscribe(listener: () => void): () => void; subscribe(listener: () => void): () => void;
setBindEffort(enabled: boolean): Promise<void>;
} }
export interface AppearancePresenter { export interface AppearancePresenter {
@@ -15,8 +13,6 @@ export interface AppearancePresenter {
const NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]'; const NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]';
const LIANG_APPEARANCE_BUTTON = "liang-appearance-choice"; const LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
const LIANG_BINDING_CONTROL = "liang-appearance-binding";
const LIANG_BINDING_INPUT = "liang-appearance-binding__input";
function hasClassSuffix(node: Element, suffix: string) { function hasClassSuffix(node: Element, suffix: string) {
return [...node.classList].some((name) => name.endsWith(suffix)); return [...node.classList].some((name) => name.endsWith(suffix));
@@ -47,10 +43,7 @@ interface MountedAppearance {
row: HTMLElement; row: HTMLElement;
group: HTMLElement; group: HTMLElement;
button: HTMLButtonElement; button: HTMLButtonElement;
binding: HTMLLabelElement;
bindingInput: HTMLInputElement;
handleCustomClick: () => void; handleCustomClick: () => void;
handleBindingChange: () => void;
} }
export function installLiangAppearanceButton( export function installLiangAppearanceButton(
@@ -70,9 +63,7 @@ export function installLiangAppearanceButton(
nativeClickHandlers.clear(); nativeClickHandlers.clear();
if (mounted === null) return; if (mounted === null) return;
mounted.button.removeEventListener("click", mounted.handleCustomClick); mounted.button.removeEventListener("click", mounted.handleCustomClick);
mounted.bindingInput.removeEventListener("change", mounted.handleBindingChange);
mounted.button.remove(); mounted.button.remove();
mounted.binding.remove();
mounted = null; mounted = null;
}; };
@@ -110,46 +101,7 @@ export function installLiangAppearanceButton(
const handleCustomClick = () => { void choose(true); }; const handleCustomClick = () => { void choose(true); };
button.addEventListener("click", handleCustomClick); button.addEventListener("click", handleCustomClick);
const binding = document.createElement("label"); return { row, group, button, handleCustomClick };
binding.className = LIANG_BINDING_CONTROL;
binding.dataset.plugin = packageId;
const bindingCopy = document.createElement("span");
bindingCopy.className = "liang-appearance-binding__copy";
const bindingLabel = document.createElement("span");
bindingLabel.className = "liang-appearance-binding__label";
const bindingText = english ? "Bind slider to reasoning level" : "滑动变祖绑定思考等级";
bindingLabel.textContent = bindingText;
const bindingDescription = document.createElement("span");
bindingDescription.className = "liang-appearance-binding__description";
bindingDescription.id = "liang-appearance-binding-description";
bindingDescription.textContent = english
? "When off, the slider does not change the reasoning level."
: "关闭之后滑块不联动思考等级";
bindingCopy.append(bindingLabel, bindingDescription);
const bindingInput = document.createElement("input");
bindingInput.className = LIANG_BINDING_INPUT;
bindingInput.type = "checkbox";
bindingInput.setAttribute("role", "switch");
bindingInput.setAttribute("aria-label", bindingText);
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
const updateBinding = async () => {
if (disposed || pending) return;
try {
await scope.setBindEffort(bindingInput.checked);
} catch {
// Restore the persisted selection if writing the preference fails.
} finally {
if (!disposed) sync();
}
};
const handleBindingChange = () => { void updateBinding(); };
bindingInput.addEventListener("change", handleBindingChange);
binding.append(bindingCopy, bindingInput);
return { row, group, button, binding, bindingInput, handleCustomClick, handleBindingChange };
}; };
const sync = () => { const sync = () => {
@@ -163,20 +115,12 @@ export function installLiangAppearanceButton(
unmount(); unmount();
} }
mounted ??= createMount(target.row, target.group); mounted ??= createMount(target.row, target.group);
const { row, group, button, binding, bindingInput } = mounted; const { row, button } = mounted;
if (button.parentElement !== row) row.append(button); if (button.parentElement !== row) row.append(button);
const snapshot = scope.getSnapshot(); const snapshot = scope.getSnapshot();
button.disabled = pending; button.disabled = pending;
button.setAttribute("aria-pressed", String(snapshot.enabled)); button.setAttribute("aria-pressed", String(snapshot.enabled));
if (snapshot.enabled) {
if (binding.parentElement !== group) group.append(binding);
bindingInput.checked = snapshot.bindEffort;
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
bindingInput.disabled = pending;
} else {
binding.remove();
}
for (const [nativeButton, handler] of nativeClickHandlers) { for (const [nativeButton, handler] of nativeClickHandlers) {
if (target.nativeButtons.includes(nativeButton)) continue; if (target.nativeButtons.includes(nativeButton)) continue;

View File

@@ -5,6 +5,7 @@ import {
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { Switch } from "@deepseek-ai/dsh-client-ui-primitives";
import type { Context as ClientContext } from "@deepseek-ai/cordis"; import type { Context as ClientContext } from "@deepseek-ai/cordis";
import type { SnapshotStore } from "@deepseek-ai/dsh-client-store"; import type { SnapshotStore } from "@deepseek-ai/dsh-client-store";
import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
@@ -110,11 +111,8 @@ interface SliderProps {
scope: PreferenceStore; scope: PreferenceStore;
} }
interface SettingsRowProps { interface EffortBindingRowProps {
scope: PreferenceStore; scope: PreferenceStore;
presenter: SkinPresenter;
theme: ThemeService;
subscribeTheme: (listener: () => void) => () => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -455,104 +453,27 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
); );
} }
type AppearanceChoice = NativeThemeId | "system" | "liang"; function EffortBindingRow({ scope, t }: EffortBindingRowProps) {
const APPEARANCE_CHOICES: readonly { id: AppearanceChoice; labelKey: string }[] = [
{ id: "light", labelKey: "appearance.light" },
{ id: "dark", labelKey: "appearance.dark" },
{ id: "system", labelKey: "appearance.system" },
{ id: "liang", labelKey: "appearance.liang" },
];
function NativeAppearanceIcon({ id }: { id: Exclude<AppearanceChoice, "liang"> }) {
if (id === "light") {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M11.3496 8C11.3496 6.14985 9.85015 4.65039 8 4.65039C6.14985 4.65039 4.65039 6.14985 4.65039 8C4.65039 9.85015 6.14985 11.3496 8 11.3496C9.85015 11.3496 11.3496 9.85015 11.3496 8ZM12.6504 8C12.6504 10.5681 10.5681 12.6504 8 12.6504C5.43188 12.6504 3.34961 10.5681 3.34961 8C3.34961 5.43188 5.43188 3.34961 8 3.34961C10.5681 3.34961 12.6504 5.43188 12.6504 8Z" fill="currentColor" />
<path d="M8.65039 0.5V2.5H7.34961V0.5H8.65039Z" fill="currentColor" />
<path d="M8.65039 13.5V15.5H7.34961V13.5H8.65039Z" fill="currentColor" />
<path d="M3.15808 2.24035L4.57229 3.65456L3.6525 4.57435L2.23829 3.16014L3.15808 2.24035Z" fill="currentColor" />
<path d="M12.3505 11.4327L13.7647 12.8469L12.8449 13.7667L11.4307 12.3525L12.3505 11.4327Z" fill="currentColor" />
<path d="M2.24537 12.8469L3.65958 11.4327L4.57937 12.3525L3.16516 13.7667L2.24537 12.8469Z" fill="currentColor" />
<path d="M11.4377 3.65455L12.852 2.24033L13.7718 3.16012L12.3575 4.57434L11.4377 3.65455Z" fill="currentColor" />
<path d="M0.5 7.35461H2.5V8.6554H0.5L0.5 7.35461Z" fill="currentColor" />
<path d="M13.5 7.35461H15.5V8.6554H13.5V7.35461Z" fill="currentColor" />
</svg>
);
}
if (id === "dark") {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M13.2764 9.52324C12.5607 9.97754 11.7177 10.242 10.7812 10.242C8.11386 10.2419 5.95042 8.07997 5.9502 5.41289C5.9502 4.48128 6.21453 3.61071 6.67188 2.87285C4.30332 3.4658 2.54992 5.60845 2.5498 8.16093C2.5498 11.1712 4.99103 13.6102 8 13.6102C10.5383 13.6102 12.6709 11.8724 13.2764 9.52324ZM7.05078 5.41289C7.051 7.47224 8.72116 9.1423 10.7812 9.14238C11.9248 9.14238 12.887 8.63397 13.5781 7.8084C13.7266 7.63106 13.9701 7.56547 14.1875 7.64433C14.4049 7.72329 14.5497 7.9297 14.5498 8.16093C14.5498 11.7766 11.6161 14.7098 8 14.7098C4.38402 14.7098 1.4502 11.7792 1.4502 8.16093C1.45033 4.54322 4.3812 1.61015 8 1.61015C8.23027 1.61015 8.43585 1.75352 8.51562 1.96953C8.59536 2.18554 8.53241 2.42829 8.35742 2.57793C7.55573 3.26311 7.05078 4.27876 7.05078 5.41289Z" fill="currentColor" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12.1665 13.5811V14.7803H3.66651V13.5811H12.1665Z" fill="currentColor" />
<path d="M13.4453 7.02379C13.4453 6.04702 13.4452 5.3616 13.3887 4.83434C13.3333 4.31828 13.2302 4.02378 13.0723 3.80309C12.9446 3.62475 12.7877 3.46883 12.6094 3.34117C12.3887 3.18328 12.0942 3.08007 11.5781 3.02477C11.0508 2.96829 10.3655 2.96715 9.38867 2.96715H6.61035C5.63359 2.96715 4.94816 2.96827 4.4209 3.02477C3.90486 3.0801 3.61034 3.18321 3.38965 3.34117C3.21143 3.46878 3.05534 3.62487 2.92774 3.80309C2.76977 4.02377 2.66667 4.3183 2.61133 4.83434C2.55483 5.3616 2.55371 6.04702 2.55371 7.02379C2.55371 8.0006 2.55485 8.68596 2.61133 9.21324C2.66663 9.72936 2.76983 10.0238 2.92774 10.2445C3.0554 10.4228 3.21131 10.5797 3.38965 10.7074C3.61034 10.8654 3.90484 10.9685 4.4209 11.0238C4.94816 11.0803 5.63359 11.0804 6.61035 11.0804H9.38867C10.3654 11.0804 11.0508 11.0803 11.5781 11.0238C12.0941 10.9685 12.3887 10.8652 12.6094 10.7074C12.7877 10.5797 12.9446 10.4229 13.0723 10.2445C13.2301 10.0238 13.3334 9.72927 13.3887 9.21324C13.4452 8.68596 13.4453 8.00058 13.4453 7.02379ZM14.6455 7.02379C14.6455 7.97428 14.646 8.73509 14.5811 9.34117C14.5149 9.95828 14.3756 10.4858 14.0479 10.9437C13.8436 11.229 13.5938 11.4788 13.3086 11.683C12.8507 12.0108 12.3232 12.15 11.7061 12.2162C11.1 12.2811 10.3391 12.2806 9.38867 12.2806H6.61035C5.66018 12.2806 4.89991 12.2811 4.29395 12.2162C3.67684 12.15 3.14935 12.0108 2.69141 11.683C2.40613 11.4788 2.15639 11.229 1.95215 10.9437C1.62436 10.4858 1.4841 9.95828 1.41797 9.34117C1.35305 8.73511 1.35449 7.97424 1.35449 7.02379C1.35449 6.07366 1.35308 5.31333 1.41797 4.70738C1.4841 4.09028 1.62436 3.56279 1.95215 3.10485C2.15638 2.81956 2.40613 2.56982 2.69141 2.36559C3.14935 2.03779 3.67684 1.89753 4.29395 1.83141C4.8999 1.76652 5.66022 1.76793 6.61035 1.76793H9.38867C10.3391 1.76793 11.1 1.76649 11.7061 1.83141C12.3232 1.89753 12.8507 2.03779 13.3086 2.36559C13.5939 2.56982 13.8436 2.81957 14.0479 3.10485C14.3756 3.56279 14.5149 4.09028 14.5811 4.70738C14.646 5.31335 14.6455 6.07362 14.6455 7.02379Z" fill="currentColor" />
</svg>
);
}
function AppearanceSkinRow({ scope, presenter, theme, subscribeTheme, t }: SettingsRowProps) {
const snapshot = useSyncExternalStore( const snapshot = useSyncExternalStore(
(listener) => scope.subscribe(listener), (listener) => scope.subscribe(listener),
() => scope.getSnapshot(), () => scope.getSnapshot(),
); );
const preference = useSyncExternalStore(
subscribeTheme,
() => theme.getTheme().preference,
);
const selected = snapshot.enabled
? "liang"
: preference === "light" || preference === "dark" || preference === "system"
? preference
: "system";
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
if (!snapshot.enabled) return null;
const choose = async (next: AppearanceChoice) => {
if (next === selected || pending) return;
setPending(true);
try {
if (next === "liang") {
await presenter.choose(true);
} else {
// Disable the custom skin first. This is intentionally unconditional:
// it makes the native choice the only writer after this click, even if
// the external preference store is one render behind.
await presenter.choose(false);
theme.setTheme(next);
}
} finally {
setPending(false);
}
};
return ( return (
<div className="liang-settings-row" data-plugin={PACKAGE_ID}> <div className="liang-appearance-binding" data-plugin={PACKAGE_ID}>
<span className="liang-settings-row__title">{t("appearance.title")}</span> <span>{t("effort.binding")}</span>
<div className="liang-settings-row__choices"> <Switch
{APPEARANCE_CHOICES.map(({ id, labelKey }) => ( checked={snapshot.bindEffort}
<button
className={`liang-settings-row__choice${id === "liang" ? " liang-settings-row__choice--liang" : ""}`}
type="button"
aria-pressed={selected === id}
disabled={pending} disabled={pending}
onClick={() => void choose(id)} label={t("effort.binding")}
key={id} onChange={(next) => {
> if (pending) return;
{id === "liang" ? ( setPending(true);
<span className="liang-settings-row__liang-icon" aria-hidden="true">◈</span> void scope.setBindEffort(next).catch(() => undefined).finally(() => setPending(false));
) : ( }}
<NativeAppearanceIcon id={id} /> />
)}
{t(labelKey)}
</button>
))}
</div>
</div> </div>
); );
} }
@@ -632,22 +553,18 @@ export function apply(ctx: ClientContext) {
); );
ctx.effect( ctx.effect(
() => ctx.locale.register(LOCALE_NAMESPACE, { () => ctx.locale.register(LOCALE_NAMESPACE, {
zh: { zh: { "effort.binding": "滑动变祖绑定思考等级" },
"appearance.title": "外观", en: { "effort.binding": "Bind slider to reasoning level" },
"appearance.light": "浅色",
"appearance.dark": "深色",
"appearance.system": "跟随系统",
"appearance.liang": "滑动变祖",
},
en: {
"appearance.title": "Appearance",
"appearance.light": "Light",
"appearance.dark": "Dark",
"appearance.system": "System",
"appearance.liang": "Slider",
},
}), "liang-intensity-skin: settings locale"); }), "liang-intensity-skin: settings locale");
ctx.slots.inject("settings.general.item", () => ctx.slots.register({
name: "settings.general.item",
id: "liang-effort-binding",
order: 10.5,
locale: LOCALE_NAMESPACE,
inject: () => ({ scope }),
}, EffortBindingRow));
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
name: "conversation.input.right", name: "conversation.input.right",
id: "liang-intensity-control", id: "liang-intensity-control",

View File

@@ -497,90 +497,14 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
} }
.liang-appearance-binding { .liang-appearance-binding {
box-sizing: border-box;
display: flex; display: flex;
width: 100%;
min-height: 28px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 24px;
padding: 4px 0 0; padding: 16px 0;
color: var(--dsw-alias-label-primary); border-bottom: 0.5px solid var(--dsw-alias-border-l2);
font: inherit;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 20px;
cursor: pointer;
}
.liang-appearance-binding__label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.liang-appearance-binding__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.liang-appearance-binding__description {
display: block;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.liang-appearance-binding__input {
position: relative;
flex: 0 0 36px;
width: 36px;
height: 20px;
margin: 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
outline: none;
cursor: pointer;
appearance: none;
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-module-platform));
}
.liang-appearance-binding__input::before {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
content: "";
background: var(--dsw-alias-label-primary);
box-shadow: 0 1px 3px rgb(0 0 0 / 18%);
transition: transform 160ms ease, background-color 160ms ease;
}
.liang-appearance-binding__input:checked {
border-color: var(--dsw-static-neutral-bluish-400);
background: var(--dsw-static-neutral-bluish-400);
}
.liang-appearance-binding__input:checked::before {
background: var(--dsw-alias-bg-layer-1, #fff);
transform: translateX(16px);
}
.liang-appearance-binding__input:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-interactive-bg-hover);
}
.liang-appearance-binding__input:disabled {
cursor: progress;
opacity: 0.7;
} }
@media (max-width: 760px) { @media (max-width: 760px) {