diff --git a/README.md b/README.md index a20371d..e33fa29 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ beautiCode 是一个本地背景工具,**主要面向 DeepSeek Harness和Codex**。 -它不包含、不安装、也不启动 DSH。请先自行安装 DeepSeek Harness 并运行 `dsh web`,再启动 beautiCode,选中 DeepSeek Harness 或 Codex,就可以把电脑里的: +它不包含、不安装、也不启动 DSH。请先自行安装 DeepSeek Harness 并运行 `dsh web`。插件装好后,DSH 侧栏「设置」上方会出现「背景」,不必再开托盘。Codex Desktop 仍走 beautiCode 托盘。可以把电脑里的: * 图片 * 动态壁纸 @@ -120,7 +120,7 @@ Ctrl + Shift + Space 深夜电台 ``` -保存后,可以直接从托盘菜单切换。 +保存后,可以从 DSH 侧栏「背景」或托盘菜单切换。 对于视频主题,beautiCode 还会记录播放进度。 @@ -152,7 +152,7 @@ Ctrl + Shift + Space 想直接使用 beautiCode,可以下载 [Windows 安装包](https://github.com/starsstreaming/beautiCode/releases/latest)。 安装包自带 Node.js,不需要另外安装 Node.js、npm 或 pnpm。 Codex Desktop 路径与原来一样:启动后选 **Codex Desktop**,托盘会按需拉起 Codex。 -DeepSeek Harness 由你自己运行:把插件装进 DSH profile,再启动 `dsh web`,然后在选择框里选 **DeepSeek Harness**。beautiCode 绝不替你启动 DSH。页面打开后可以用 `/bg`、`/bg-theme`、`/bg-clear`,或直接跟 AI 说把本机图片/视频设成背景。 +DeepSeek Harness 由你自己运行:把插件装进 DSH profile,再启动 `dsh web`。beautiCode 绝不替你启动 DSH。页面打开后,用侧栏「背景」从文件夹选图片或 MP4;也可以用 `/bg`、`/bg-theme`、`/bg-clear`,或直接跟 AI 说把本机图片/视频设成背景。下次打开 DSH 会恢复上次的背景。

Windows 安装包 @@ -182,7 +182,9 @@ dsh web npx @deepseek-ai/dsh web ``` -再启动 beautiCode:选 **Codex Desktop** 仍由托盘拉起 Codex;选 **DeepSeek Harness** 则连接你已经启动的 DSH 网页。 +打开 DSH 网页后,侧栏「设置」上方有「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换已保存主题。网页控制台没有摸鱼。外观浅色/深色仍用 DSH 自己的设置。下次启动会恢复上次背景。 + +Codex Desktop 仍要开 beautiCode 托盘。选 **DeepSeek Harness** 时托盘只连接你已经启动的 DSH 网页,不会替你启动 DSH。 若自动写入失败,才需要手动安装插件。把路径换成实际安装目录(默认是 `%LOCALAPPDATA%\Programs\beautiCode`): @@ -216,7 +218,8 @@ npm install # 若要手动安装(dsh plugin add 需要 pnpm): # npx @deepseek-ai/dsh plugin --profile web add file:%CD%/integrations/deepseek-harness npx @deepseek-ai/dsh web -npm run tray +# DSH 侧栏「背景」即可换图换视频。托盘只在要用 Codex 或摸鱼热键时再开: +# npm run tray ``` 或打开宿主选择器: diff --git a/docs/deepseek-harness.md b/docs/deepseek-harness.md index 3ad758e..322b9df 100644 --- a/docs/deepseek-harness.md +++ b/docs/deepseek-harness.md @@ -5,13 +5,14 @@ beautiCode 通过 DeepSeek Harness 的 Cordis 插件接口接入(`@beauticode/ ## 已实现能力 - 图片背景、MP4 视频背景与清除。首页(`data-phase="hero"`)壁纸保持原亮度;进入会话(`active` / `settling`)后才压暗。 +- 网页控制台:插件装好后,DSH 侧栏「设置」上方出现「背景」。可从系统文件夹选择图片或 MP4、清除、开关声音、切换已保存主题。不需要托盘。网页控制台没有摸鱼。 - 对话工具与斜杠命令:在 DSH 里说「把某个本机 MP4 设成背景」,或输入 `/bg`、`/bg-theme`、`/bg-clear`。插件自己完成导入,不需要托盘。若托盘已经在跑,则复用托盘,避免两套写入打架。 - 视频默认静音;可请求开启声音。若浏览器自动播放策略阻止开启声音,会继续静音播放并返回 `blocked: true`。 - 视频播放位置随已保存主题记录;切换主题、重新应用与页面恢复时从最近位置继续。 -- 摸鱼模式:隐藏 DSH 的 `#root`,背景舞台继续显示和播放;`Ctrl+Shift+Space` 可退出。 -- 深色、浅色、跟随系统三种背景色调。 +- 摸鱼模式:隐藏 DSH 的 `#root`,背景舞台继续显示和播放;`Ctrl+Shift+Space` 可退出(托盘全局热键)。网页控制台不提供摸鱼。 +- 外观浅色 / 深色跟随 DSH 自己的设置,插件不再改写 DSH 主题 DOM。 - 图片/视频主题的保存、切换和删除。 -- 页面刷新或稍后打开时,会恢复当前背景与模式。找不到 `#root` 时应用失败并回滚,不会静默画坏页。 +- 页面刷新或稍后打开时,会恢复当前背景。找不到 `#root` 时应用失败并回滚,不会静默画坏页。 同一个 beautiCode 数据目录一次只能运行一个宿主会话,避免 Codex 与 DSH 同时写入造成状态损坏。 @@ -48,9 +49,13 @@ npx @deepseek-ai/dsh plugin --profile web add file:%LOCALAPPDATA%\Programs\beaut 也可以参考 [`integrations/deepseek-harness/cordis.patch.example.yml`](../integrations/deepseek-harness/cordis.patch.example.yml)。 +## 网页控制台 + +插件注入 `console.js`,把「背景」插在侧栏「设置」同一格里(`display: contents`,不另铺底色)。点开后的面板挂在 `document.body` 上,用实色,避免吃半透明壁纸 token。同源 `POST /__beauticode/ui/*` 转到已有的 `createBeauticodeActions()`。页面连上 SSE 后会 `reapply` 上次背景。DSH 会话列表底部的 fade 在有壁纸时关掉,避免叠出一条暗影。 + ## 控制端 -启动 beautiCode,在选择框里选 DeepSeek Harness(或直接 `start-tray.ps1 -TargetHost dsh`)。托盘只连接你正在运行的 DSH 网页: +托盘可选。启动 beautiCode,在选择框里选 DeepSeek Harness(或直接 `start-tray.ps1 -TargetHost dsh`)。托盘只连接你正在运行的 DSH 网页: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File apps\tray\start-tray.ps1 -TargetHost dsh -DshUrl http://127.0.0.1:3080 diff --git a/integrations/deepseek-harness/README.zh-CN.md b/integrations/deepseek-harness/README.zh-CN.md index de14508..49ed217 100644 --- a/integrations/deepseek-harness/README.zh-CN.md +++ b/integrations/deepseek-harness/README.zh-CN.md @@ -1,10 +1,12 @@ # DeepSeek Harness 桥接插件 -该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式、深浅色调以及清除背景。 +该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。 -插件会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`),把本机文件导入为背景。不需要托盘;托盘若已在跑则复用它。 +装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换已保存主题。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。 -beautiCode **不启动** DeepSeek Harness。请先自己运行 `dsh web`,再打开 beautiCode 选择 DeepSeek Harness。 +插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。 + +beautiCode **不启动** DeepSeek Harness。请先自己运行 `dsh web`。 安装包和托盘会把插件写入你的 DSH profile,不需要 pnpm。源码环境也可以手动(`dsh plugin add` 需要 pnpm): @@ -20,6 +22,6 @@ npx @deepseek-ai/dsh web 1. 修改 `cordis.patch.example.yml` 中的 `file:///.../index.mjs`。 2. 确保 DSH 和 beautiCode 使用相同的 `BEAUTICODE_DATA_ROOT`。 3. 自己运行 `dsh web`(或带 `--patch`)。 -4. 打开页面后输入 `/bg <本机绝对路径>`,或直接跟 AI 说把某个视频/图片设为背景。 +4. 打开页面后用侧栏「背景」,或输入 `/bg <本机绝对路径>`,或直接跟 AI 说把某个视频/图片设为背景。 安全边界:DSH Web 必须绑定本机回环地址;控制端点需要随机令牌;媒体 URL 仅允许带令牌的回环 HTTP 地址;浏览器回执只接受同源请求。 diff --git a/integrations/deepseek-harness/client.js b/integrations/deepseek-harness/client.js index 7e3ce68..7c6f34a 100644 --- a/integrations/deepseek-harness/client.js +++ b/integrations/deepseek-harness/client.js @@ -7,7 +7,7 @@ const clientId = globalThis.crypto?.randomUUID?.() || `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - const desiredModes = { fish: false, muted: true, tone: "dark" }; + const desiredModes = { fish: false, muted: true, tone: "auto" }; let activePayload = null; let playbackBlocked = false; const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null; @@ -59,31 +59,30 @@ html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!imp #beauticode-bg-stage img{z-index:0} #beauticode-bg-stage video{z-index:1} html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important} +html[data-bc-active="true"] [class*="_fade"]{display:none!important} html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important} `; document.head.append(style); - function resolvedTone() { - if (desiredModes.tone !== "auto") return desiredModes.tone; + function dshAppearance() { + const body = document.body; + if (body?.hasAttribute("data-ds-dark-theme")) return "dark"; + const scheme = document.documentElement.style.colorScheme; + if (scheme === "dark" || scheme === "light") return scheme; return systemDarkMedia?.matches ? "dark" : "light"; } + function resolvedTone() { + return dshAppearance(); + } + function isDshThemeSynced(tone = resolvedTone()) { - const body = document.body; - if (!body) return false; - return ( - body.hasAttribute("data-ds-dark-theme") === (tone === "dark") && - document.documentElement.style.colorScheme === tone - ); + return document.documentElement.dataset.bcResolvedTone === tone; } function syncDshTheme() { const tone = resolvedTone(); - const root = document.documentElement; - const body = document.body; - root.dataset.bcResolvedTone = tone; - if (root.style.colorScheme !== tone) root.style.colorScheme = tone; - if (body) body.toggleAttribute("data-ds-dark-theme", tone === "dark"); + document.documentElement.dataset.bcResolvedTone = tone; return isDshThemeSynced(tone); } diff --git a/integrations/deepseek-harness/console.js b/integrations/deepseek-harness/console.js new file mode 100644 index 0000000..4a52384 --- /dev/null +++ b/integrations/deepseek-harness/console.js @@ -0,0 +1,290 @@ +(() => { + "use strict"; + if (window.__beauticodeConsoleLoaded) return; + window.__beauticodeConsoleLoaded = true; + + const IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.avif,image/jpeg,image/png,image/webp,image/avif"; + const VIDEO_ACCEPT = ".mp4,video/mp4"; + + const style = document.createElement("style"); + style.dataset.beauticodeConsole = "true"; + style.textContent = ` +#beauticode-console{display:contents} +#beauticode-console .bc-trigger{cursor:pointer;width:calc(100% + 8px);height:34px;margin:4px -4px;padding:6px 2px 6px 10px;border:none;border-radius:12px;background:transparent;color:inherit;font:inherit;font-size:14px;line-height:22px;align-items:center;gap:8px;display:flex;overflow:hidden} +#beauticode-console .bc-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)} +#beauticode-console.rail .bc-trigger{width:36px;height:36px;margin:8px 0 10px;padding:0;border-radius:50%;justify-content:center;gap:0} +#beauticode-console.rail .bc-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} +#beauticode-console .bc-icon{flex:none;display:block} +#beauticode-console .bc-label{white-space:nowrap;overflow:hidden} +#beauticode-console-pop{position:fixed;z-index:2000;box-sizing:border-box;width:220px;padding:10px;border:1px solid rgba(255,255,255,.08);border-radius:16px;background:#2c323c;color:#e8eaed;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.28));font:inherit;font-size:13px} +#beauticode-console-pop *{box-sizing:border-box;font-family:inherit} +body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.08)} +#beauticode-console-pop .bc-status{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:18px;margin:0 0 8px} +#beauticode-console-pop .bc-row{display:flex;gap:6px;margin:0 0 6px} +#beauticode-console-pop .bc-btn,#beauticode-console-pop .bc-theme-toggle{cursor:pointer;flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid rgba(255,255,255,.1);border-radius:12px;background:rgba(255,255,255,.06);color:inherit;font-size:13px;line-height:20px} +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn, +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle{border-color:rgba(0,0,0,.08);background:#f4f6f8} +#beauticode-console-pop .bc-btn:hover,#beauticode-console-pop .bc-theme-toggle:hover{background:rgba(255,255,255,.12)} +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-btn:hover, +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{background:#eceff3} +#beauticode-console-pop .bc-btn:disabled,#beauticode-console-pop .bc-theme-toggle:disabled,#beauticode-console-pop .bc-theme-item:disabled{opacity:.45;cursor:default} +#beauticode-console-pop .bc-btn.on{background:rgba(255,255,255,.16)} +#beauticode-console-pop .bc-theme-toggle{width:100%;text-align:left} +#beauticode-console-pop .bc-theme-list{margin:6px 0 0;max-height:160px;overflow:auto;border:1px solid rgba(255,255,255,.08);border-radius:12px;background:#232830;padding:4px} +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8} +#beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;width:100%;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)} +body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)} +#beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden} +#beauticode-console-file{display:none !important} +`; + document.head.append(style); + + const host = document.createElement("div"); + host.id = "beauticode-console"; + host.innerHTML = + ''; + + const pop = document.createElement("div"); + pop.id = "beauticode-console-pop"; + pop.hidden = true; + pop.innerHTML = + '

未就绪

' + + '
' + + '' + + '' + + "
" + + '
' + + '' + + '' + + "
" + + '" + + ''; + + const fileInput = document.createElement("input"); + fileInput.id = "beauticode-console-file"; + fileInput.type = "file"; + + document.body.append(pop, fileInput); + + const trigger = host.querySelector(".bc-trigger"); + const statusEl = pop.querySelector(".bc-status"); + const soundBtn = pop.querySelector('[data-act="sound"]'); + const themesBox = pop.querySelector(".bc-themes"); + const themeToggle = pop.querySelector(".bc-theme-toggle"); + const themeList = pop.querySelector(".bc-theme-list"); + const msgEl = pop.querySelector(".bc-msg"); + let busy = false; + let muted = true; + let currentThemeId = ""; + + function findSettingsTrigger() { + const buttons = [...document.querySelectorAll('button[aria-haspopup="dialog"]')]; + const candidates = buttons.filter((button) => { + if (host.contains(button) || pop.contains(button)) return false; + const rect = button.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left < 320 && rect.bottom > window.innerHeight * 0.35; + }); + candidates.sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom); + return candidates[0] || null; + } + + function placePop() { + const rect = trigger.getBoundingClientRect(); + if (!rect.width) return; + pop.style.left = `${Math.round(rect.left)}px`; + pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`; + } + + function place() { + const settings = findSettingsTrigger(); + if (!settings || !settings.parentElement) { + if (host.parentElement) host.remove(); + return; + } + if (host.parentElement !== settings.parentElement || host.nextElementSibling !== settings) { + settings.parentElement.insertBefore(host, settings); + } + host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40); + if (!pop.hidden) placePop(); + } + + function setOpen(open) { + pop.hidden = !open; + trigger.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) { + placePop(); + void refresh(); + } + } + + function showMessage(text) { + if (!text) { + msgEl.hidden = true; + msgEl.textContent = ""; + return; + } + msgEl.hidden = false; + msgEl.textContent = text; + } + + function renderStatus(data) { + if (!data?.ok) { + statusEl.textContent = data?.error || "未就绪"; + return; + } + const label = data.media === "video" ? "视频" : data.media === "image" ? "图片" : "无背景"; + statusEl.textContent = label; + muted = data.muted !== false; + soundBtn.classList.toggle("on", !muted); + soundBtn.textContent = muted ? "声音" : "声音开"; + const themes = Array.isArray(data.themes) ? data.themes : []; + if (themes.length === 0) { + themesBox.hidden = true; + themeList.innerHTML = ""; + themeList.hidden = true; + themeToggle.setAttribute("aria-expanded", "false"); + return; + } + themesBox.hidden = false; + themeList.innerHTML = themes + .map( + (theme) => + ``, + ) + .join(""); + const selected = themes.find((theme) => theme.id === currentThemeId); + themeToggle.textContent = selected ? selected.name : "已保存主题"; + } + + function escapeText(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + } + + function escapeAttr(value) { + return escapeText(value).replaceAll('"', """); + } + + async function request(path, init) { + const response = await fetch(path, { + ...init, + headers: { + ...(init?.headers || {}), + }, + }); + const body = await response.json().catch(() => null); + if (!response.ok || body?.ok === false) { + throw new Error(body?.error || `请求失败(${response.status})`); + } + return body; + } + + async function refresh() { + try { + renderStatus(await request("/__beauticode/ui/status")); + } catch (error) { + renderStatus({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + async function run(task) { + if (busy) return; + busy = true; + for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = true; + showMessage(""); + try { + const result = await task(); + if (result?.message) showMessage(result.message); + await refresh(); + } catch (error) { + showMessage(error instanceof Error ? error.message : String(error)); + } finally { + busy = false; + for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item")) button.disabled = false; + } + } + + trigger.addEventListener("click", (event) => { + event.stopPropagation(); + setOpen(pop.hidden); + }); + pop.querySelector('[data-act="image"]').addEventListener("click", () => { + fileInput.accept = IMAGE_ACCEPT; + fileInput.click(); + }); + pop.querySelector('[data-act="video"]').addEventListener("click", () => { + fileInput.accept = VIDEO_ACCEPT; + fileInput.click(); + }); + pop.querySelector('[data-act="clear"]').addEventListener("click", () => { + void run(() => request("/__beauticode/ui/clear", { method: "POST" })); + }); + soundBtn.addEventListener("click", () => { + void run(() => + request("/__beauticode/ui/mode", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ muted: !muted }), + }), + ); + }); + themeToggle.addEventListener("click", (event) => { + event.stopPropagation(); + const open = themeList.hidden; + themeList.hidden = !open; + themeToggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + themeList.addEventListener("click", (event) => { + const item = event.target.closest("[data-theme-id]"); + if (!item) return; + currentThemeId = item.getAttribute("data-theme-id") || ""; + themeList.hidden = true; + themeToggle.setAttribute("aria-expanded", "false"); + void run(() => + request("/__beauticode/ui/theme/use", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: currentThemeId }), + }), + ); + }); + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + void run(() => + request("/__beauticode/ui/import", { + method: "POST", + headers: { "x-beauticode-filename": encodeURIComponent(file.name) }, + body: file, + }), + ); + }); + + document.addEventListener("click", (event) => { + if (pop.hidden) return; + if (pop.contains(event.target) || trigger.contains(event.target)) return; + setOpen(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !pop.hidden) setOpen(false); + }); + + const observer = new MutationObserver(() => place()); + observer.observe(document.documentElement, { childList: true, subtree: true }); + window.addEventListener("resize", place); + setInterval(place, 500); + place(); +})(); diff --git a/integrations/deepseek-harness/host-apply.mjs b/integrations/deepseek-harness/host-apply.mjs index ed7fa99..2e4d04d 100644 --- a/integrations/deepseek-harness/host-apply.mjs +++ b/integrations/deepseek-harness/host-apply.mjs @@ -80,13 +80,12 @@ export async function ensureInProcessSession(options) { return current.promise; } - if (await hasLiveTray(dataRoot) || await hasLiveTrayClaim(dataRoot)) { - const error = new Error(TRAY_STARTING_MESSAGE); - error.code = "TRAY_CLAIMED"; - throw error; - } - const promise = (async () => { + if (await hasLiveTray(dataRoot) || await hasLiveTrayClaim(dataRoot)) { + const error = new Error(TRAY_STARTING_MESSAGE); + error.code = "TRAY_CLAIMED"; + throw error; + } const adapter = await loadAdapter(); const session = new adapter.DshSession({ dataRoot, diff --git a/integrations/deepseek-harness/index.mjs b/integrations/deepseek-harness/index.mjs index 896fffb..a1ba26c 100644 --- a/integrations/deepseek-harness/index.mjs +++ b/integrations/deepseek-harness/index.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { registerAgentSurfaces } from "./agent.mjs"; import { resolvePluginBaseUrl } from "./host-apply.mjs"; +import { createBeauticodeUi } from "./ui-host.mjs"; export const name = "beauticode-bridge"; export const inject = ["webServer"]; @@ -184,10 +185,16 @@ export function apply(ctx, config = {}) { const clients = new Map(); const clientStates = new Map(); let current = null; - const modes = { fish: false, muted: true, tone: "dark" }; - registerAgentSurfaces(ctx, { - dataRoot: path.dirname(tokenFile), - baseUrl: resolvePluginBaseUrl(ctx), + const modes = { fish: false, muted: true, tone: "auto" }; + const dataRoot = path.dirname(tokenFile); + const baseUrl = resolvePluginBaseUrl(ctx); + registerAgentSurfaces(ctx, { dataRoot, baseUrl }); + const ui = createBeauticodeUi({ + dataRoot, + getBaseUrl: () => resolvePluginBaseUrl(ctx), + sendJson, + isSameOrigin, + readJson, }); const broadcast = (payload) => { @@ -198,7 +205,9 @@ export function apply(ctx, config = {}) { ctx.effect(() => { const disposeTap = ctx.webServer.tapIndex((html) => { if (html.includes("data-beauticode-bridge")) return html; - const script = ''; + const script = + '' + + ''; return html.includes("") ? html.replace("", `${script}`) : `${html}${script}`; @@ -238,6 +247,48 @@ export function apply(ctx, config = {}) { res.end(req.method === "HEAD" ? undefined : source); }, }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/console.js", + handler: async (req, res) => { + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + const source = await fs.readFile(path.join(here, "console.js")); + res.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": source.length, + }); + res.end(req.method === "HEAD" ? undefined : source); + }, + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/status", + handler: (req, res) => ui.status(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/import", + handler: (req, res) => ui.importFile(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/clear", + handler: (req, res) => ui.clear(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/mode", + handler: (req, res) => ui.mode(req, res), + }), + ctx.webServer.register({ + kind: "exact", + path: "/__beauticode/ui/theme/use", + handler: (req, res) => ui.useTheme(req, res), + }), ctx.webServer.register({ kind: "exact", path: "/__beauticode/events", @@ -265,6 +316,7 @@ export function apply(ctx, config = {}) { res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`); } res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`); + ui.scheduleRestore(); res.on("close", () => { if (clients.get(clientId) === res) { clients.delete(clientId); @@ -440,6 +492,7 @@ export function apply(ctx, config = {}) { for (const response of clients.values()) response.destroy(); clients.clear(); clientStates.clear(); + void ui.dispose(); }; }, "beauticode-bridge: routes and browser injection"); } diff --git a/integrations/deepseek-harness/package.json b/integrations/deepseek-harness/package.json index 17e40c4..2b7e906 100644 --- a/integrations/deepseek-harness/package.json +++ b/integrations/deepseek-harness/package.json @@ -12,9 +12,11 @@ "files": [ "index.mjs", "client.js", + "console.js", "agent.mjs", "control-client.mjs", - "host-apply.mjs" + "host-apply.mjs", + "ui-host.mjs" ], "engines": { "node": ">=22" diff --git a/integrations/deepseek-harness/test/plugin.test.mjs b/integrations/deepseek-harness/test/plugin.test.mjs index cb65bc1..d6e9960 100644 --- a/integrations/deepseek-harness/test/plugin.test.mjs +++ b/integrations/deepseek-harness/test/plugin.test.mjs @@ -88,6 +88,7 @@ test("plugin injects its client script exactly once", async (t) => { const once = tap(""); const twice = tap(once); assert.equal((twice.match(/data-beauticode-bridge/g) || []).length, 1); + assert.match(once, /__beauticode\/console\.js/); const response = await fetch(`${plugin.origin}/__beauticode/client.js`); assert.equal(response.status, 200); const source = await response.text(); @@ -96,8 +97,10 @@ test("plugin injects its client script exactly once", async (t) => { assert.match(source, /:has\(#root \[data-phase="settling"\]\)/); assert.doesNotMatch(source, /:has\(#root \[data-phase="hero"\]\)/); assert.match(source, /#beauticode-bg-stage::after\{background:rgba\(0,0,0,\.42\)\}/); + assert.match(source, /\[class\*=\"_fade\"\]\{display:none!important\}/); assert.match(source, /data-bc-resolved-tone/); - assert.match(source, /toggleAttribute\("data-ds-dark-theme"/); + assert.match(source, /data-ds-dark-theme/); + assert.doesNotMatch(source, /toggleAttribute\("data-ds-dark-theme"/); assert.match(source, /prefers-color-scheme: dark/); assert.match(source, /new MutationObserver\(scheduleDshThemeSync\)/); assert.match(source, /function dshStructureIssue\(\)/); @@ -119,7 +122,7 @@ test("plugin injects its client script exactly once", async (t) => { ); }); -test("browser client keeps the DSH palette synchronized with beautiCode tone", async () => { +test("browser client follows DSH appearance and does not overwrite it", async () => { const source = await fs.readFile(new URL("../client.js", import.meta.url), "utf8"); const attributes = new Set(); const body = { @@ -132,13 +135,13 @@ test("browser client keeps the DSH palette synchronized with beautiCode tone", a }; const documentElement = { dataset: {}, - style: { colorScheme: "" }, + style: { colorScheme: "light" }, removeAttribute(name) { if (name === "data-bc-fish") delete this.dataset.bcFish; }, }; const media = { - matches: false, + matches: true, listener: null, addEventListener(_name, listener) { this.listener = listener; @@ -179,37 +182,27 @@ test("browser client keeps the DSH palette synchronized with beautiCode tone", a context.globalThis = context; vm.runInNewContext(source, context); - const applyTone = async (tone) => { - events.onmessage({ - data: JSON.stringify({ type: "mode", fish: false, muted: true, tone }), - }); - await new Promise((resolve) => setImmediate(resolve)); - }; - - await applyTone("light"); assert.equal(documentElement.dataset.bcResolvedTone, "light"); assert.equal(documentElement.style.colorScheme, "light"); assert.equal(body.hasAttribute("data-ds-dark-theme"), false); - await applyTone("dark"); + attributes.add("data-ds-dark-theme"); + documentElement.style.colorScheme = "dark"; + observerCallback(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(documentElement.dataset.bcResolvedTone, "dark"); assert.equal(documentElement.style.colorScheme, "dark"); assert.equal(body.hasAttribute("data-ds-dark-theme"), true); attributes.delete("data-ds-dark-theme"); documentElement.style.colorScheme = "light"; - observerCallback(); - assert.equal(documentElement.style.colorScheme, "dark"); - assert.equal(body.hasAttribute("data-ds-dark-theme"), true); - - await applyTone("auto"); + events.onmessage({ + data: JSON.stringify({ type: "mode", fish: false, muted: true, tone: "dark" }), + }); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(documentElement.dataset.bcResolvedTone, "light"); + assert.equal(documentElement.style.colorScheme, "light"); assert.equal(body.hasAttribute("data-ds-dark-theme"), false); - media.matches = true; - media.listener(); - assert.equal(documentElement.dataset.bcResolvedTone, "dark"); - assert.equal(documentElement.style.colorScheme, "dark"); - assert.equal(body.hasAttribute("data-ds-dark-theme"), true); }); test("authenticated apply reaches SSE client and same-origin ack becomes ready", async (t) => { @@ -266,7 +259,7 @@ test("authenticated apply reaches SSE client and same-origin ack becomes ready", modeReadyClients: 0, blockedClients: 0, resolvedTone: null, - modes: { fish: false, muted: true, tone: "dark" }, + modes: { fish: false, muted: true, tone: "auto" }, playback: null, }); }); diff --git a/integrations/deepseek-harness/test/ui-host.test.mjs b/integrations/deepseek-harness/test/ui-host.test.mjs new file mode 100644 index 0000000..131959e --- /dev/null +++ b/integrations/deepseek-harness/test/ui-host.test.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { apply } from "../index.mjs"; +import { parseImportFilename } from "../ui-host.mjs"; +import { + writeDshControlFile, +} from "../control-client.mjs"; + +const TOKEN = "b".repeat(64); +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +class FakeWebServer { + routes = new Map(); + taps = []; + + register(route) { + this.routes.set(route.path, route.handler); + return () => this.routes.delete(route.path); + } + + tapIndex(tap) { + this.taps.push(tap); + return () => this.taps.splice(this.taps.indexOf(tap), 1); + } +} + +async function createPluginServer(tokenFile) { + const webServer = new FakeWebServer(); + const effects = []; + apply( + { + webServer, + effect(factory) { + effects.push(factory()); + }, + }, + { tokenFile }, + ); + const server = http.createServer(async (req, res) => { + const pathname = new URL(req.url || "/", "http://x").pathname; + const handler = webServer.routes.get(pathname); + if (!handler) return res.writeHead(404).end(); + try { + await handler(req, res); + } catch (error) { + res.writeHead(error.statusCode || 500).end(String(error.message || error)); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + origin: `http://127.0.0.1:${server.address().port}`, + dispose: async () => { + for (const effect of effects.reverse()) await effect?.(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test("parseImportFilename accepts images and mp4 only", () => { + assert.equal(parseImportFilename("雨夜.png").kind, "image"); + assert.equal(parseImportFilename("C:\\\\films\\\\clip.MP4").kind, "video"); + assert.equal(parseImportFilename("..\\\\evil.txt").ok, false); + assert.match(parseImportFilename("").error, /缺少文件名/); +}); + +test("plugin injects a compact sidebar console script", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-ui-")); + const tokenFile = path.join(root, "token"); + await fs.writeFile(tokenFile, TOKEN); + const plugin = await createPluginServer(tokenFile); + t.after(async () => { + await plugin.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + const tap = new FakeWebServer(); + apply({ webServer: tap, effect(factory) { factory(); } }, { tokenFile }); + const injected = tap.taps[0](""); + assert.match(injected, /__beauticode\/client\.js/); + assert.match(injected, /__beauticode\/console\.js/); + + const response = await fetch(`${plugin.origin}/__beauticode/console.js`); + assert.equal(response.status, 200); + const source = await response.text(); + assert.doesNotThrow(() => new Function(source)); + assert.match(source, /beauticode-console/); + assert.match(source, /button\[aria-haspopup="dialog"\]/); + assert.doesNotMatch(source, /摸鱼/); + assert.doesNotMatch(source, /