看着AI写代码太孤独了,来欣赏星云吧:一个DSH 装饰插件

0 阅读18分钟

前言

小时候我很喜欢星空。后来每天都在跟 AI 说话,忽然想:不如让每句话,在天上多长出一颗星。

于是有了 SemanticNebula · 语义星云(包名 dsh-spiral),一个给 DeepSeek Harness (DSH) Web GUI 用的纯装饰性插件。你在输入框里正常敲字、回车,右下角那片深空就会多长出一个属于这句话的星系,旁边再落下一个语义坐标。

它不提高任何效率,不总结、不检索、不帮你改 prompt。它只是让 DSH 的角落里,有一片会自己转的深空。

这篇聊聊它为什么被做出来、有哪些自认为讲究的地方、从一句话到画面中间都做了什么,最后正面回答两个几乎每个看完演示的人都会问的问题:「8000 多维,是不是每个维度就是星图里的一个光点?」 和 「所以每张星图都是独一无二的吗?」 这两个问题的答案,比一句「是的」有意思得多。

在这里插入图片描述


一、创作背景:想做「真语义」,最后选了「不打扰」

最初的设想其实挺正经:本地跑一个 bge-small-zh-v1.5-int8.onnx,把提示词编码成真正的语义向量,再配一张预计算好的 UMAP 地形,让语义相近的句子落在相邻的星区。听起来很合理,也很有「作品感」。

真动手才发现,对一件只是挂在角落里的装饰来说,这个代价太重了:要下载模型文件、要托管权重、要在浏览器里起推理、要处理首次加载的白屏,还要让用户相信「这个插件不会把我的对话内容发出去」。一个装饰物,凭什么要用户付出这些?

于是把目标反过来写了一遍:

  • 不拦截、不包装输入框,用户该怎么打字还怎么打字;
  • 不读取、不上传任何消息内容;
  • 不联网、不调用任何模型;
  • 产物足够小,小到可以当作不存在。

最后的方案是确定性本地哈希嵌入 + 固定投影几何:没有任何模型文件,没有任何网络请求,客户端 bundle 约 130 KB,零运行时依赖。代价也很直白——它不是语义模型,哈希只能保证「同样的字得到同样的形状」,不能保证「意思相近的句子长得像」。这个取舍我放在最后一节坦白讲,不藏在附录里。


二、它长什么样:一片会转的深空,和一张足迹图

点开右下角那枚乳白色悬浮球,是一个 520 × 352 的小窗口,左边星云、右边语义足迹图。

左边是一片四臂旋涡星系:中心有一枚柔光核心,四条旋臂拖着自转方向缓慢展开,臂上有密集的尘埃亮点,更外面是散景光斑和针尖似的背景星。整体以约 30 秒的周期做 ±1.6% 的呼吸,四条臂上各有一颗微光点向外缓行,平均每十来秒斜掠过一颗流星。鼠标移上去,整片场会朝指针方向轻微平移,靠近的星点会亮一点;轻点一下,从落点荡开一圈涟漪随即散去。

右边是一张语义足迹图:历史提交按时间连成轨迹,当前这一句落在坐标网格的某个点上,收起后悬浮球会染上这句话的语义色。

做的时候有几处较真过。哪几处?

  1. 隐私边界画得死:采集入口只有「提交」——SessionSnapshot.pendingSubmissions 与 user/message 事件。没有 onInput,不抓输入框内容,不处理流式 token。持久化只写本机 localStorage:提示词原文、时间、以及 256 段量化轮廓。
  2. 物理上不自相矛盾:旋臂的缠绕方向与自转方向一致,臂始终拖在旋转后面;作为密度波的整体图案刚性旋转,内圈细丝略快、外圈浮尘略慢,体现差速旋转。曾经有一版按配色翻转方向,切个颜色整片星系瞬间反转,看着特别假,后来把 angle 收敛成唯一的角速度来源才修好。
  3. 那外观呢?面板读宿主的 CSS 设计令牌,浅色是白玻璃、深色是深玻璃;但两块画布永远是深空——舷窗之外是太空,只有窗框换漆。适配只用 CSS,读 <body> 上的 data-ds-dark-theme,不订阅主题事件、不多触发一次渲染。
  4. 尊重 prefers-reduced-motion:尾迹、视差、呼吸、流星、光点与涟漪全部关闭,颗粒也不叠加,只保留极慢漂移,而不是冻成一张静图。
  5. 语义漫游:点击播放,按提交顺序逐 token 走一遍形状变化;模型回答完成后,按钮变成「播放回答语义漫游」。窗口可拖动、可缩放、可收起为悬浮球。

设置里可以关掉整个功能、决定是否自动展开、是否保存足迹,以及切换「8192 极致 / 5120 流畅」和「深空 / 极光 / 日冕」三套配色。


在这里插入图片描述

三、从一句话到 8192 维频谱

整段计算都在浏览器里,粗略画成这样:

提交事件 → 文本归一化 → 特征抽取(中英 n-gram)
        → 稀疏哈希脉冲(8192 桶) → FFT 高斯平滑 → L2 归一化
        → 折叠成 256 段亮度轮廓 + 3 维语义方向
        → Canvas 2D:用轮廓调制一组固定粒子

3.1 采集:只在「提交」时动手

控制器按会话绑定宿主的 sessions.binding(sessionId),订阅 session 快照和事件窗口。提交的最早可靠信号是 Host 已接受的 pending echo,user/message 事件作为兜底并用来重建历史。

// controller.ts(节选)
private capture(sessionId: string, rawText: string, options: {...}) {
  if (!this.settingsValue.enabled) return
  const text = normalizeText(rawText)       // 去 NUL、折叠空白
  if (text.length === 0) return
  const store = this.storeFor(sessionId)
  if (store.hasId(options.id)) return       // 同一条消息只算一次
  if (!options.live && this.consumePending(text)) return
  const analysis = analyze(text, this.settingsValue.dims)
  store.add({ id: options.id, text: text.slice(0, 4000), ...analysis })
}

去重按消息 id,另有 15 秒的相邻重复窗口;历史最多留 72 个点。也就是说,打字过程完全不参与计算,只有回车那一下才生成一颗星。

3.2 特征:中英文分开照顾

collectFeatures() 先做 NFKC 归一化、转小写、折叠空白,然后按不同粒度抽特征:

特征含义权重
w:拉丁词 / 数字词1.35
t:词内三字符 gram0.42
p:长词的首尾三字符0.30
c:单个汉字1.05
b:汉字二元组1.65
g: / s:汉字三元组 / 跳字二元组0.72 / 0.32
x: / y:整句前 48 字符 / 后 32 字符指纹0.38 / 0.26

重复出现的特征按 1 + ln(count) × 0.55 加权,排序后最多保留 640 个。中英文混排时,汉字二元组权重给得比英文词还高,是因为中文prompt的信息密度本来就更集中在相邻字上。首尾指纹是专门给长文本留的:只靠局部 n-gram,两段很长的不同文字可能抽到相似的片段集合,整句指纹把它们拉开。

3.3 8192 维:稀疏脉冲 + FFT 高斯平滑

这里的「8192 维」不是 BERT 那种语义维度,而是哈希坐标桶。每个特征用 FNV-1a 变体加三轮雪崩算出 32 位哈希,再撒出 7 个带符号的脉冲到 8192 个桶里:

// engine.ts(节选)
for (const feature of features) {
  const h = hashString(feature.key)
  for (let k = 0; k < 7; k++) {
    const idx = (h + Math.imul(k, 0x9e3779b1)) & (FFT_SIZE - 1)  // FFT_SIZE = 8192
    const sign = (h >>> (k * 3)) & 1 ? 1 : -1
    sparse[idx] += sign * feature.weight / (1 + k * 0.36)
  }
}

脉冲本身是离散的几点,直接拿来画会是一堆噪点。所以做了一次循环高斯平滑:预先把 σ = 4.6 的高斯核做 FFT,频域相乘,再逆变换回来。每一点扩散成一个小包,相邻桶彼此相关,于是离散脉冲变成一条连续的「频谱」。随后叠回一点稀疏原值(SPARSE_GAIN = 0.075)保留细节,再补一个由整句哈希定相位的低频倾斜,保证大尺度上不会退化成一个规整的圆环,最后 L2 归一化成单位向量。

一次完整的 FFT 正变换 + 频域乘 + 逆变换,8192 点,纯 JS 手写迭代实现。仓库自带的 scripts/test-engine.mjs 在本机连跑 60 次 analyze(),实测约 0.8 ms/次——这点开销放在「回车那一下」,用户完全感知不到。

3.4 折叠:8192 → 256 段轮廓

8192 个数没法直接驱动画面,也不该直接驱动。profileOf() 把它按每 32 个桶一组,折叠成 256 段亮度轮廓:

// engine.ts(节选)
for (let i = 0; i < dims; i++) {
  const band = Math.floor(i * PROFILE_BANDS / dims)   // PROFILE_BANDS = 256
  sums[band] += v
  squares[band] += v * v
}
// 每段取 RMS 作为能量,取均值作为有符号分量
profile[b] = Math.pow(rms / maxEnergy, 0.72)          // gamma 压一下动态范围
smooth[b] = prev * 0.22 + profile[b] * 0.56 + next * 0.22  // 三点模糊

得到两条 256 段数组:profile(0..1,星云的亮度轮廓)和 signed(-1..1,只用于投影)。落盘时各自量化成每段 1 字节,base64 存进 localStorage,所以 72 条历史也占不了多少地方。

3.5 语义坐标:三个固定随机基

右侧足迹图的坐标,是 signed 与三个固定 ±1 随机基(Rademacher 向量,种子 0x5e6b1a7d,中心化后归一化)点积得到的 3D 方向,取前两维乘 1.18 后 clamp 到 ±1:

const axis = normalize(basis.map(b => dot(signed, b)))
mapX = clamp(axis[0] * 1.18, -1, 1)
mapY = clamp(axis[1] * 1.18, -1, 1)

(上面是等价伪代码,源码里是展开的手写循环、Math.hypot 与 Math.max/min。)这是固定随机投影,不是 UMAP、不是 PCA。它能稳定地把文本差异摊到一个平面上,但没有任何「语义邻近」的保证。配色也来自整句哈希:hashString(text, 0x7c3f91b5) % 6 得到 0..5 的 schemeBase,再由「深空 / 极光 / 日冕」中的一套把该值映射到 4 个色族之一,最终决定旋臂、核心与环境光的颜色。

3.6 Canvas 2D:固定粒子 + 轮廓调制

这是最容易被误解的一环。星云画布在组件挂载时用固定种子 makeRng(0x2f8a1c3d) 一次性生成所有粒子:

  • 140 颗背景星
  • 4 条旋臂 × 220 = 880 个旋臂尘埃粒子
  • 40 个云团、40 个前景散景光点
  • 另有 52 条短弧状 wisps、4 颗沿臂外行的光珠与 96 个 HUD 刻度,按帧循环计算(不占预生成数组)

之后每一帧不再新建粒子,只是按当前 256 段轮廓去调制它们的亮度、大小和径向位置:

// NebulaCanvas.tsx(节选)
const pv = profile[particle.band]!                    // 该粒子绑定的轮廓段
const theta = particle.arm * (TAU / 4) + particle.t * WIND
            + pv * WIND_WOBBLE + particle.jitter + angle * 0.8
const r = radius * (0.06 + particle.t * 1.02) * (0.92 + pv * 0.16)
ctx.globalAlpha = (0.024 + pv * 0.09) * fade
ctx.drawImage(sprites.get(color), x - size / 2, y - size / 2, size, size)

轮廓值 pv 影响的是旋臂的螺距(WIND_WOBBLE)、半径、粒子透明度和尺寸。换句话说:轮廓是乐谱,粒子是乐手;提示词决定谱子怎么起伏,不决定台上有多少乐手。

绘制上用了 globalCompositeOperation = 'lighter' 做加色混合,配合 SpriteCache 把每种颜色的 64×64 柔光精灵缓存成位图——给上千个粒子逐个 createRadialGradient 是性能灾难,缓存后只是 drawImage。旋臂脊线用 5 层由宽到窄、带径向渐隐的描边叠加,避免宽笔刷在外缘留下一道被剪断的平直丝带;核心是「宽而暗的光晕 + 小亮核 + 四道朝尖端收窄的菱形光芒」,各层都压在加色合成的饱和阈值以下,读起来是一颗星而不是一块白盘。最后一张 128² 噪声贴图以 overlay 合成,既给暗部一点胶片质感,也顺手压掉 8 bit 渐变的色带。

这些氛围层刻意控制在「每帧最多几次额外绘制」:颗粒一次图案填充、流星一条渐变线、涟漪两条圆弧、光点四个精灵。实测 520 × 352 画布稳定 60 fps。窗口不可见或页面隐藏时,IntersectionObserver 与 visibilitychange 会直接停掉 requestAnimationFrame。

3.7 工程形态

插件分宿主半与客户端半。宿主半 src/index.ts 的 apply() 是空的——它存在的唯一理由,是让 bundle 能被列出、让 dsh.client 元数据被模块加载器发现。package.json 里两处声明各管一段:

{
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": {
      "platform": "web",
      "inject": [
        "@deepseek-ai/dsh-client-ui-sidebar-right",
        "@deepseek-ai/dsh-api-session-controller"
      ]
    }
  }
}

dsh.bundle.patch 让插件可被安装,dsh.client.inject 声明客户端要借用哪些宿主服务(右侧栏挂载点与会话控制器);真正让宿主加载它的,是 cordis.patch.yml 里的 insert 条目。esbuild 把客户端打成单个 lib/client.js,不带任何运行时依赖。

自转方向与旋臂缠绕是硬约束,仓库里有一个离线物理回归:把真实组件挂到空白页渲染,从画面反算「整圈角向均值相关」得到角速度、按「四阶角向谐波相位随半径的斜率」得到缠绕方向,断言两者同向。不需要起服务,也不需要 token。


四、8192 维,是不是 8192 个光点?

不是。 这是最常见的一个误会,而且情有可原——「高维向量」听起来就该有很多点。但在这套实现里,三者根本不在同一层:

层数量是什么由谁决定
哈希频谱8192(可切 5120)稀疏脉冲经高斯平滑后的连续向量,本质是坐标桶提示词文本
亮度轮廓256 段每 32 维聚成一段 RMS,再 gamma、模糊、归一化8192 维向量
画布光点约 1100 个 + 装饰层挂载时一次性生成的静态粒子,被轮廓逐帧调制固定随机种子 + 轮廓

哪三点?

第一,维度被压缩了 32 倍。 8192 个桶按每 32 个一组折成 256 段。段内只留能量(RMS)和均值。再经 gamma 0.72 和三点模糊,落到画面上又量化成每段 1 字节。到这里,维度早成了一条连续曲线的采样点,不再是「一个个可数的东西」。

第二,光点数量是常数。 880 个旋臂粒子、140 颗背景星这些数字,在你提交任何一句话之前就由固定随机种子定好了。换一句 prompt,不会多出一颗星,也不会少一颗;变的是它们各自被点亮的程度、旋臂的螺距和半径、以及整体配色。没有提交时,画布走的是一段内联的、随时间缓慢起伏的合成轮廓,让空面板也能呼吸——那片星系不属于任何一句话。

第三,「8192 / 5120」这个设置改的不是几何。 这一点连代码注释都专门写明了:analyze(text, _dims) 忽略传入的维度,永远用完整的 8192 点频谱算轮廓和坐标;dims 只在渲染时决定粒子密度:

const density = state.settings.dims === 8192 ? 1 : 0.62
const armCount = Math.floor(arms.length * density)   // 880 → 545

仓库的 scripts/test-engine.mjs 直接断言了这件事:对同一批中英文 prompt,8192 与 5120 两档算出的 axis、mapX/mapY、schemeBase、magnitude 必须逐项相等(误差 < 1e-9)。所以「5120 流畅」是砍粒子密度换帧率,不是把星系换了种形状。

一句话总结:维度不是光点,维度是控制信号。 8192 个桶负责让不同文本产生足够可分辨的频谱,256 段轮廓负责把频谱翻译成起伏,粒子场负责把起伏画成好看的光。


五、每张星图都是独一无二的吗?

这个问题要分三层回答,直接说「是」其实不够诚实。

5.1 同一句话,一定是同一张图

这套算法是确定性的:NFKC 归一化 → 固定权重特征 → 固定哈希 → 固定高斯核 → 固定随机基 → 固定配色族。没有 Math.random() 参与星云本体,也没有模型推理的浮点不确定性。同一句 prompt(大小写和空白归一化后等价)在任何一台机器、任何一次会话里,都会得到完全相同的 256 段轮廓、同一个配色族、同一个足迹坐标。空字符串也有专门的 empty 特征兜底,不会掉进随机分支。

这意味着它是可复现的:你上周写下的那句话,今天再敲一遍,星系还是那片星系。

5.2 不同句子,几乎不会长出同一片星系

敏感度的来源有几处:

  • 任意一个字符变化,都会改变特征集合(汉字二元组、词内三元组都会跟着变);
  • 每个特征撒 7 个脉冲,最多 640 个特征,哈希经过三轮雪崩,微小输入差异会扩散到整条 8192 点频谱;
  • 高斯平滑之后还要叠一个由整句哈希定相位的低频倾斜,保证大尺度轮廓也跟着变;
  • 长文本另有首尾各一段整句指纹,避免局部 n-gram 撞车;
  • 配色族、语义方向、HUD 上的 Σ 强度读数同样来自文本哈希。

test-engine.mjs 里放了四条差异很大的 prompt(中文口语、英文重构需求、Rust 向量检索、四个字的短句),断言它们的 3D 方向两两不同,跑出来是 PASS distinct axes 4 / 4:第一句落在 axis=[0.342, -0.864, -0.368],「今天天气不错」落在 axis=[-0.457, 0.454, 0.764],方向拉得很开。同一脚本还验证了第一句能拆出 22 帧漫游序列。实际用下来,改一个标点、加个「吧」字,星云的亮度起伏和落点就会肉眼可见地变。

5.3 但「独一无二」不是数学意义上的

得把边界说清楚,否则就是吹牛了:

  • 信息有损:8192 → 256 → 每段 1 字节量化,再加 gamma 和三点模糊,细节被主动抹平。两条不同文本理论上可能得到视觉上几乎一致的轮廓。

  • 32 位哈希存在理论碰撞:脉冲索引是 & 8191,特征哈希是 32 位,鸽笼原理摆在那儿。

  • 配色是有限集合:整句哈希只落在 0..5 共 6 个 schemeBase 值,每套配色再把它映射到 4 个色族之一,三套配色合计最多 12 种用色组合,颜色这一维必然会重复。

  • 逐帧画面本来就不一样:自转角速度、呼吸相位、流星、颗粒、指针视差都是时间驱动的;地图视图的背景装饰星甚至用了 Math.random(),每次挂载都不同。所以「逐像素唯一」这个命题从一开始就不成立。

  • 身份不由画面保证:store 里真正的去重依据是宿主的 requestId / message id,画面只负责好看。

所以准确的说法是:

每条提示词拥有一份稳定、可复现、视觉上几乎不会与他人重复的几何指纹。 它是「你的这句话留下的痕迹」,不是加密级的唯一编号。

这个区别其实挺重要。如果哪天真需要「一句话对应一个绝对唯一的标识」,应该拿文本哈希或消息 id 去做,不该让一个装饰性画布扛这个责任。


六、坦白:这不是语义模型

原始设想里的 bge-small-zh-v1.5-int8.onnx 本地嵌入与预计算 UMAP 地形,在当前交付里被换成了确定性哈希嵌入和固定随机投影。因此:

  • 它不具备真正的语义 grouping。「把登录模块重构一下」和「重构一下登录模块」在哈希眼里是两片不同的星系,尽管人知道它们是一个意思;
  • 右侧足迹图上的「距离」没有语义含义,只是固定投影下的方向差异;
  • 它也不读取、不上传任何内容,所有计算都在浏览器本地完成。

换来的是零网络、零模型文件、约 130 KB 的体积、毫秒级的单次分析,以及一条能一眼审计到底的隐私边界。对一个「不提高任何效率」的装饰来说,我认为这笔交易划算。

好在这个替换点被刻意收在 engine.ts 的 analyze() 里:哪天想接真嵌入,只改这一个函数,视图层、粒子场、持久化格式都不用动。


结语

做完之后我最喜欢的时刻,不是它转起来的那一刻,是关掉面板、只剩那枚悬浮球的时候。球体和光环会染上最近一条提示词的语义色,有一道高光极缓慢地绕环一周。

它确实什么忙都帮不上。但每天跟 AI 说的那些话,本来也大多不会留下什么。现在它们至少在天上,各有一颗慢慢转的星。

项目地址:github.com/songzhou666… ,MIT 协议。


文章标签:DeepSeek Harness DSH 插件 Canvas 2D 数据可视化 前端性能 哈希嵌入 FFT TypeScript