/** * dsh-status-rotator — browser half. * * Swaps the turn-status label in the DSH chat UI (en "Deep diving...", zh * "深度求索中...", resolved via the dsh "chat" locale dictionary) for phrases * that fit the current turn phase, typewriter-style, rotating * every intervalMs. Optional rainbow-gradient text (config.gradient). The * elapsed-time clock (appears after 15s) is left untouched — but it IS used * to detect the phase: * * thinking → turn just started (no clock yet) * running → clock present, under config.longAfterMs * long → clock reached config.longAfterMs (stuck / slow turn) * * Everything is configurable from a JSON config file (see config.json / * config.example.json): * { "config": { intervalMs, typeSpeedMs, longAfterMs, reloadIntervalMs, * liveTickMs, debug, fontWeight, gradient, title, danmaku }, * "phrases": { zh: {thinking, running, long}, en: ... }, * "presets": [{ id, label?, config?, phrases? }], * "activePreset": "…" | null, * "schedule": [{ preset, days?, from, to }] } * * Phrases and title templates support placeholders: {elapsed} {phase} * {phaseLabel} {locale} {date} {time}; the time-varying ones ({elapsed}, * {date}, {time}) refresh every liveTickMs. Presets carry their own config * and phrases; schedule rules switch the active preset by weekday/time. * * Phrase lists are NOT bundled in this source — they come from config.json, * which the node half serves automatically at LOCAL_CONFIG_URL (drop the file * beside the package and refresh; no manual step). Source priority, highest * first: * 1. localStorage "dsh-status-rotator.texts[.]" phrase overrides * 2. localStorage "dsh-status-rotator.config" full config+phrases * 3. external JSON: localStorage "dsh-status-rotator.url" > EXTERNAL_URL > * LOCAL_CONFIG_URL (the auto-served package config.json) * 4. built-in DEFAULT_CONFIG only (no phrases) * With no phrase source, the label is left untouched ("Deep diving..."). * Phrase lists follow the DSH UI language (zh / en) live; unknown locales * fall back to zh. Legacy flat-array phrase lists are treated as thinking. */ window.__ModuleLoader__.load({ id: "dsh-status-rotator", factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); /** 设置页组件需要 React(DSH 模块加载器提供,与内置设置页共用同一份) */ const react = require("react"); // ══ 弹幕类型:滚动(原有) / 顶部 / 底部 ══ /** 类型标识;scroll 是原有且唯一的默认值,历史配置(没有该字段)一律按它处理 */ const DANMAKU_MODES = ["scroll", "top", "bottom"]; /** bilibili 弹幕协议 mode 属性 → 本插件类型标识(1 = 滚动 / 4 = 底部 / 5 = 顶部) */ const DANMAKU_MODE_ALIASES = { "1": "scroll", "4": "bottom", "5": "top" }; /** * 顶部 / 底部弹幕(bilibili 风格固定弹幕)的集中默认值 —— 全项目唯一出处。 * * 观感对齐 bilibili:白字 + 四向 1px 黑描边、无背景块、水平居中、不随播放 * 进度变形(整条固定不动),到停留时长后消失。用户配置里的同名字段可逐项覆盖, * 改这里即改默认值(一句话改掉)。 * * ⚠️ 下列数值 [待确认]:字号 / 边距 / 堆叠间距 / 停留时长 / 同屏上限都是按 * bilibili 观感取的合理默认值,未查证到官方实现的确切数字,不要当成官方数值。 */ const DANMAKU_FIXED_DEFAULTS = { /** 字号(px):与滚动弹幕共用同一套渲染管线,仅默认值不同 */ fontSize: 25, /** 文字颜色:白字 */ color: "#ffffff", /** 描边/阴影:四向 1px 黑描边(无背景块) */ shadow: "1px 0 1px rgba(0,0,0,.85),-1px 0 1px rgba(0,0,0,.85),0 1px 1px rgba(0,0,0,.85),0 -1px 1px rgba(0,0,0,.85)", /** 顶部弹幕距播放区域上边的边距(px) */ marginTop: 16, /** 底部弹幕距播放区域下边的边距(px) */ marginBottom: 160, /** 多条堆叠间距(px) */ gap: 4, /** 单条停留时长(ms):到点整条消失(不动画) */ durationMs: 4500, /** 同类弹幕同屏最大条数 */ maxCount: 3, /** 超限处理:drop = 丢弃这一拍(与现有滚动弹幕一致) */ overflow: "drop", /** * 层级:顶部 / 底部弹幕默认浮在聊天内容之上(这时才像 bilibili 那样压着画面)。 * dsh 外壳自己的层级是 overlay 层 20、侧栏拖拽手柄 11,所以取 10:压住聊天内容、 * 又不糊住设置弹窗;面板上的 isolation:isolate 会把这个层级关在面板内部。 * 负数 = 与滚动弹幕同层(塞回界面后面,和旧行为一致)。 */ zIndex: 10, /** * 滚动弹幕是否避开顶部 / 底部弹幕占用的那条竖直带。 * true(默认)= 各占各的高度,不会两条文案叠在一起;false = 老行为(随机落点, * 滚动弹幕可能从固定弹幕后面穿过)。 */ reserveBands: true, /** * 底部弹幕是否贴住「输入区上沿」(dsh 状态行上方)而不是只用 marginBottom。 * 半透明弹幕压在状态行的 shimmer 上会看着像「特效映射到了弹幕上」,所以默认贴住它; * 找不到状态行时自动回落到 marginBottom。 */ anchorBottomToHost: true, }; /** 类型标识合法值 → 归一化结果;未知值返回 null(调用方决定怎么兜底) */ function danmakuModeToken(value) { if (typeof value === "string") { const s = value.trim().toLowerCase(); if (DANMAKU_MODES.includes(s)) return s; if (Object.prototype.hasOwnProperty.call(DANMAKU_MODE_ALIASES, s)) return DANMAKU_MODE_ALIASES[s]; return null; } if (typeof value === "number" && Object.prototype.hasOwnProperty.call(DANMAKU_MODE_ALIASES, String(value))) { return DANMAKU_MODE_ALIASES[String(value)]; } return null; } /** 类型标识归一化:非法 / 缺省一律回落 scroll(保证历史配置行为不变) */ function normalizeDanmakuMode(value) { const token = danmakuModeToken(value); return token === null ? "scroll" : token; } // ══ 炫彩渐变:白天 / 黑夜两套配色 ══ /** 配色模式标识;auto = 跟随宿主深浅色主题 */ const GRADIENT_MODES = ["auto", "day", "night"]; /** 模式标识归一化:非法 / 缺省一律回落 auto(历史配置行为 = 跟随界面) */ function normalizeGradientMode(value) { return GRADIENT_MODES.indexOf(value) >= 0 ? value : "auto"; } /** 流动方向标识;rtl = 从右向左(默认,历史行为),ltr = 从左向右 */ const GRADIENT_DIRECTIONS = ["rtl", "ltr"]; /** 方向标识归一化:非法 / 缺省一律回落 rtl(不改变既有观感) */ function normalizeGradientDirection(value) { return GRADIENT_DIRECTIONS.indexOf(value) >= 0 ? value : "rtl"; } /** 流动方向 → CSS animation-direction:ltr 把同一段循环倒放(首尾仍无缝) */ function gradientAnimationDirection(value) { return normalizeGradientDirection(value) === "ltr" ? "reverse" : "normal"; } /** 文案 span 的类名(渐变与截断都挂在它上面);运行期多处引用,统一放在顶层 */ const TEXT_SPAN_CLASS = "dsh-status-rotator-text"; /** * 渐变文字 CSS(纯函数,便于测试与运行期复用):rtl 默认从右往左; * ltr 用 animation-direction:reverse 倒放同一段循环,让流光与从左往右的打字机同向。 */ function gradientTextCss(colors, speed, direction) { const list = (Array.isArray(colors) ? colors : []).filter(isSafeColorToken); const dur = Math.max(0.5, Number(speed) || 4); const gradient = "linear-gradient(90deg, " + list.join(", ") + ", " + list[0] + ")"; return "." + TEXT_SPAN_CLASS + ".dsh-status-rotator-rainbow {" + "background-image: " + gradient + ";" + "background-size: 200% 100%;" + "background-repeat: repeat-x;" + "-webkit-background-clip: text;" + "background-clip: text;" + "color: transparent;" + "animation: dsh-status-rotator-flow " + dur + "s linear infinite;" + "animation-direction: " + gradientAnimationDirection(direction) + ";" + "}" + "@keyframes dsh-status-rotator-flow { to { background-position: 200% 0; } }"; } /** * 按模式 + 当前是否深色主题选出生效配色:auto 跟随主题,day / night 强制。 * 选中的那套没配(不是数组)时回退另一套 —— 老配置只写了 colors 时行为不变; * 配了但非法(< 2 个合法色)一律视为不可用,返回空数组,调用方据此不接管文字 * (文字是 color:transparent,配了坏色板还硬接管会直接看不见)。 * 纯函数,dark 由调用方注入(运行时读 body[data-ds-dark-theme],测试直接传布尔)。 */ function resolveGradientColors(gradient, dark) { const g = gradient !== null && typeof gradient === "object" && !Array.isArray(gradient) ? gradient : {}; const mode = normalizeGradientMode(g.mode); const preferDay = mode === "day" || (mode === "auto" && dark !== true); const chosen = preferDay ? g.dayColors : g.colors; const list = Array.isArray(chosen) ? chosen : (preferDay ? g.colors : g.dayColors); const colors = (Array.isArray(list) ? list : []).filter(isSafeColorToken); return colors.length >= 2 ? colors : []; } // ══ 默认配置(可用配置文件 / 外部 JSON / localStorage 覆盖,见 README)══ const DEFAULT_CONFIG = { /** 每隔多少毫秒换一句 */ intervalMs: 10000, /** 打字机:每个字符间隔(毫秒),0 = 关闭打字机 */ typeSpeedMs: 30, /** 运行超过多少毫秒进入 long 阶段 */ longAfterMs: 60000, /** 页面打开时自动重读 config.json 的间隔(毫秒);0 = 关闭 */ reloadIntervalMs: 15000, /** 动态占位符({elapsed}/{date}/{time})的刷新间隔(毫秒);0 = 只随轮换刷新 */ liveTickMs: 1000, /** * 兜底重扫间隔(毫秒)。常规接管 / 时长 / 截断 / 遮罩都由事件驱动 * (MutationObserver / sourceObserver / ResizeObserver / 限次重试), * 这一拍只兜「事件漏掉的边角」;0 = 彻底关掉。 * 旧版本这里硬编码 2000ms 常驻轮询,长会话下每拍两次全树遍历(issue #166)。 */ rescanIntervalMs: 10000, /** 加权随机:文案可写成 { text, weight } 对象,按权重比例抽取;false = 完全均匀 */ weightedRandom: true, /** * 反重复(洗牌袋):同一「语言 + 相位」下,袋里的句子抽完之前不会重复; * 袋子抽空重开时先剔掉最近 recentLimit 条,保证跨袋也不撞上一句。 * recentLimit 0~50(0 = 只靠袋子,不做跨袋记忆) * persist true = 把记忆写进 localStorage,刷新页面后也不立刻重复 */ antiRepeat: { recentLimit: 3, persist: false, }, /** * 外观(视觉主题化)。**默认值全是"什么都不改"**:没有这个块的旧配置升级后 * 观感与之前完全一致;主题包画廊(设置页「外观」)一次改写这里的几个值 + gradient。 */ appearance: { fontFamily: "", fontSize: 0, glow: false, glowColor: "", animation: "none", spinner: "none", }, /** 诊断日志开关 */ debug: false, /** * 保留宿主的「鲸鱼尾巴」(dsh 0.2.0 运行行里那个 14px 的 DeepSeek 尾巴图标)并让它跟着炫彩。 * false(默认)= 插件接管时把整行(含尾巴)藏掉,只显示自己的文案行; * true = 尾巴留着,和插件文案同一行,并用 gradient 的色板做流光(尾巴 SVG 的 stroke 是 * currentColor,所以动画 color 即可);gradient 关着时尾巴保持宿主原色。 */ whaleTail: false, /** Wagging is opt-in so existing installs keep their current appearance. */ whaleTailMotion: { enabled: false, mode: "tps", /** Fixed actions or random selection from randomActions. */ animation: "wag", randomActions: ["wag", "sway", "twist", "official"], officialRecommendedSpeed: true, toolSwitchEnabled: false, toolSwitchChance: 0.35, /** Full back-and-forth cycles per second when mode is fixed. */ fixedSpeed: 1.5, /** 0 = continuous; 2–5 = speed presets with higher caps. */ tpsTiers: 3, }, /** 状态文字/弹幕字体粗细:100~900 数字或 CSS 关键字;inherit = 跟随界面 */ fontWeight: "inherit", /** * 炫彩渐变文字:false 关闭;true 用默认配色; * 或 { enabled, mode, colors, dayColors, speed }(dayColors 缺省 = 浅色主题沿用 colors)。 */ gradient: { enabled: true, /** 配色模式:auto = 跟随界面深浅色自动切换;day / night = 强制其中一套 */ mode: "auto", /** 流动方向:rtl = 从右向左(默认,历史行为);ltr = 从左向右,与打字机同向 */ direction: "rtl", /** 黑夜(深色主题)渐变颜色序列(至少 2 个),循环首尾 */ colors: ["#ff5f6d", "#ffc371", "#ffdd55", "#7dff7d", "#5fd4ff", "#a78bfa", "#ff8adb"], /** 渐变流动速度(秒/圈) */ speed: 4, }, /** 标签页标题:false 关闭;或 { enabled, templates, idleTemplate, intervalMs } */ title: { enabled: false, /** 标题模板(按 intervalMs 轮换),支持与文案相同的占位符 */ templates: ["⏳ {phase} {elapsed}"], /** 无回合进行中时的标题模板;"" = 恢复原始标题 */ idleTemplate: "", /** 标题模板轮换间隔(毫秒) */ intervalMs: 8000, }, /** * 观测通道(参考 deepseek-harness discussion #3669「重试 / 降级藏在 Deep diving… 后面」): * 把宿主暴露的**结构化**运行信号显示在状态行上 —— 目前是 dsh 的 * `llm/retry` / `llm/retry-started` 会话事件(重试次数、provider、失败 code)。 * 三条原则照抄那份讨论:渲染归插件、词汇表 provider 中立(provider / code * 原样透传,不枚举产品专属码)、拿不到信号就什么都不显示(绝不从日志或 * 界面文本里猜次数)。 */ details: { /** 总开关:关掉后连占位符也恒为空串 */ enabled: true, /** * 徽标模板,显示在时钟后面:{retry} {max} {provider} {code} {delay}; * "" = 不显示徽标(占位符仍可用于文案 / 标题) */ badge: "⟳ {retry}/{max}", }, /** * 状态行文案来源: * "phrases"(默认)= 从短语库里抽一句轮换 —— 插件本来的样子; * "host" = 只用宿主原文「Deep diving…」/「深度求索中」,不轮换。 * 位置上仍在输入框上方(0.1.6 的位置)、样式仍照抄 0.1.6 的 .turnStatus, * 所以 "host" 就是「0.1.6 的观感,但跑在 0.1.7 上」。 * 两种模式下,短语库为空(没 config.json / 没文案)时插件自己那条线都会 * 回落宿主原文,不再出现空状态行。 */ labelSource: "phrases", /** 弹幕:文案以视频网站弹幕形式在页面背景飘过;false 关闭;或 { enabled, ... } */ danmaku: { /** 总开关 */ enabled: true, /** * ── 交互增强:这五项默认全关 ── * 前两项会让弹幕层从「不拦截指针」变成「拦截指针」,与 README 的承诺相反, * 所以必须显式打开;后三项只影响观感,默认关 = 升级后观感与之前一致。 */ /** 鼠标悬停在某颗弹幕上时把它冻住(移开继续飞) */ hoverPause: false, /** 点击弹幕复制它的文案 */ clickCopy: false, /** 按相位分色:{ thinking, running, long } → 颜色;null = 不用 */ phaseColors: null, /** 密度自适应:同屏越接近上限发得越稀,页面不可见时几乎不发 */ adaptDensity: false, /** 鼠标避让:落点避开指针所在的高度带 */ avoidPointer: false, /** 发射间隔(毫秒):每过多久弹一颗;偏小 = 刷屏 */ intervalMs: 2500, /** 从右到左穿过屏幕的时长(毫秒);偏大 = 飘得慢 */ speedMs: 18000, /** 随机字号下限(px) */ fontSizeMin: 14, /** 随机字号上限(px) */ fontSizeMax: 30, /** 炫彩:每颗弹幕从 colors 里随机取色;false = 全部用 color 单色 */ rainbow: true, /** 炫彩色板(至少 1 个;rainbow 时生效) */ colors: ["#ff5f6d", "#ffc371", "#ffdd55", "#7dff7d", "#5fd4ff", "#a78bfa", "#ff8adb"], /** 非炫彩模式下的文字颜色 */ color: "#ffffff", /** 整体不透明度(0.05 ~ 1);每颗弹幕在此基础上做 ±25% 抖动,更有层次 */ opacity: 0.3, /** 同屏弹幕数量上限 */ maxCount: 12, /** 层级:负数 = 界面后面(默认 -1,弹幕夹在应用背景与聊天内容之间);非负数 = 浮于界面之上 */ zIndex: -1, /** 文案范围:all = 当前语言全部文案;phase = 只取当前阶段(带回退) */ scope: "all", /** 垂直活动区顶部留白(px) */ marginTop: 16, /** 垂直活动区底部留白(px),避开输入区 */ marginBottom: 160, /** * 宿主弹出「全屏 backdrop-filter 遮罩」时暂停弹幕(默认 true,issue #60)。 * dsh 的设置弹窗就是这种层:遮罩铺满视口、只带 blur(2px)、不透明度才 24%, * 弹幕层在它后面持续位移会让 Chromium 每帧重算全屏模糊 —— 设置弹窗因此持续闪烁。 * 开着 = 检测到这种遮罩就整体停摆(拆层 + 停在途动画),遮罩关掉立刻恢复; * 设 false = 老行为(弹幕照跑,闪烁自担)。 */ pauseBehindMask: true, /** * 类型分发:滚动 / 顶部 / 底部(weight 是相对权重,越大越常出现)。 * 三种类型共用同一套字号范围、色板、透明度与渲染管线; * enabled:false 即整个类型不再发射;权重全为 0 时回落滚动。 */ types: { scroll: { enabled: true, weight: 2 }, top: { enabled: true, weight: 1 }, bottom: { enabled: true, weight: 1 }, }, /** * 顶部 / 底部弹幕样式(集中默认值见 DANMAKU_FIXED_DEFAULTS,数值 [待确认])。 * 用户只写其中几项时,其余项在渲染时与默认值合并(不会丢键)。 */ fixed: { ...DANMAKU_FIXED_DEFAULTS }, }, }; /** * 样式归属 id:与包名一致。宿主自带的客户端插件也是用包名声明归属 * (例如 `dataset.plugin = "@deepseek-ai/dsh-client-locale"`)。 */ const STYLE_OWNER_ID = "dsh-status-rotator"; /** * 建一个**已声明归属**的 `